feat(*): 创作工作台 - 优化全景打光交互、生成模式与图片缓存

This commit is contained in:
ddCat
2026-09-12 00:12:13 +08:00
parent 2b25d15f9c
commit b413870d04
14 changed files with 546 additions and 658 deletions

View File

@@ -311,9 +311,14 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
if usePlayback {
serveETag = etag + ":pb"
}
// 媒体资源具备不可变资源 ID,允许浏览器在本地磁盘安全长期缓存;
// stale-while-revalidate 允许优先使用本地磁盘缓存秒开展现,并向服务端异步重验。
c.Header("Cache-Control", "private, max-age=2592000, stale-while-revalidate=86400")
// 资源 ID 内容不可变(上传永远生成新 ID,不会原地覆盖):图片可以放心交给浏览器
// 磁盘强缓存 30 天,大画布二次打开零请求直读磁盘缓存。视频/音频涉及转码副本
// 就绪与 Range 语义,保持逐次条件请求(304)。
if strings.HasPrefix(resource.MimeType, "image/") {
c.Header("Cache-Control", "private, max-age=2592000, stale-while-revalidate=86400")
} else {
c.Header("Cache-Control", "private, no-cache")
}
c.Header("ETag", serveETag)
c.Header("Accept-Ranges", "bytes")
c.Header("X-Content-Type-Options", "nosniff")

View File

@@ -5,6 +5,17 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
# 待测试
## 画布图片资源缓存链路优化
消除首屏满屏转圈与"一直在下载":后端图片资源改 30 天磁盘强缓存(`Cache-Control: max-age=2592000`),前端内存防抖 IndexedDB 元数据写入、O(1) 淘汰预检、并发下载 4→16,视口内远程图片直链首帧上屏(走原生磁盘缓存秒开),Blob 缓存后台平滑升级。已编译通过,需浏览器最终确认:
- [ ] 打开含 50+ 张图片的大画布(如无限画布 1):图片不再全屏长时间转圈,视口内图片在 200ms 内显示(浏览器原生缓存或网络秒级完成)。
- [ ] 刷新页面后再次打开同一画布:单张图片耗时 ≤4ms(from disk cache),无网络请求。
- [ ] 监视器 Console Network 选项卡:图片资源响应头为 `Cache-Control: private, max-age=2592000`;HTTP 304 不再出现(本地磁盘命中无需 If-None-Match 验证)。
- [ ] 超大画布(200+ 图片)冷启动后,并发下载队列在 1~2 轮完成(不再串行 20+ 秒排队)。
- [ ] 切换不同画布、打开多个标签页均无 IndexedDB 阻塞卡顿(touchCacheMeta 内存防抖 + evictFor O(1) 预检生效)。
- [ ] 视频资源仍保持 `Cache-Control: private, no-cache`(每次播放仍需校验服务端 ETag)。
## 后端业务域拆包
- `internal/service` 只保留别名(4 个文件)。组合根和未拆出实现在 `internal/app`。已抽出域:`kernel`、`platform`、`skills`、`prompts`、`auth`、`canvas`、`assets`、`outbound`、`generation`。handler 仍只打 `*service.Service`。
@@ -223,8 +234,17 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
- 图片节点支持基于原图的打光与镜头控制;确认操作只生成明确的编辑请求,不修改源节点内容。
- 打光支持光位、俯仰、亮度、光色、轮廓光和预设;镜头控制由声明式参数构建提示词,生成结果保留对应操作元数据。
- 打光弹窗确认后只创建空图片节点并连到原图,不立刻发起生成;提示词、模型和尺寸可在新节点面板里改完再生成。
- [ ] 登录画布后分别执行打光与镜头控制,确认新节点、连线、模型配置、失败状态和窄屏菜单行为正确。
- [ ] 检查明暗主题、键盘焦点、弹窗滚动、复制提示词与自定义模板;确认取消操作不会触发生成。
- [ ] 点击「生成打光效果」后应出现空图片节点和连线,节点面板可改提示词/模型/尺寸;确认不会立刻进入 loading 或打上游。
## 创作页生成类型胶囊切换
- 创作页 Composer 的生成类型从「图片生成」下拉菜单改为 视频 | 图片 | 文本 三段胶囊切换(`creation-mode-capsule`),选中态使用 `--control-selected-bg/fg` 中性高亮;旧下拉 `creation-mode-picker-menu` / `creation-menu-icon` / `creation-chat-control.is-mode` 样式已删除。
- 录音时控制行不换行(`:has(> .voice-recording-inline)` 时 `nowrap`),录音条改为可压缩(`flex: 1 1 236px; min-width: 140px`,波形可缩);≤680px 恢复换行。
- [ ] 创作页(含首页空态和会话态)确认胶囊明暗主题选中/悬停层次正确、高度与其他控件协调,切换视频/图片/文本后模型选择器与参数菜单仍跟随模式变化。
- [ ] 录音时右侧模型、清晰度、时长、清空等控件保持在同一行,波形被压缩但不遮挡;窄屏(≤680px)录音时仍可换行不溢出。
## v1.2.8.rc1 候选版本发布
@@ -1652,3 +1672,11 @@ v2(用户反馈首版难看后重做):菜单改用 antd Dropdown(placeme
- [ ] 视频节点只连图片和文本节点、提示词不写任何 @ 引用时:text 仍只有输入框原文,连线文本不自动拼入(8f20240d 行为保持)。
- [ ] 图片、文本、音频模式生成行为不变;显式 @文本 引用的图片节点重试同样内联文本内容。
- [ ] 引用超长文本(超过模型提示词上限)时提交被拦截并提示具体字符数,不出现静默截断或直接丢弃。
## 全景图生成弹窗重设计(2026-09-11)
按设计系统重写 `canvas-panorama-config-modal.tsx`:由 1120px 双栏(左步骤表单 + 右侧栏)改为 680px 单栏表单结构,分区依次为生成方式(AntD Segmented)、参考图/源图缩略图行、场景描述(AntD TextArea)、全景类型(双选卡片)、高级选项(智能比例 Switch + 复制完整提示词);底部固定操作栏含状态提示、取消与主操作按钮,支持 ⌘↵。视觉遵循「默认无边、选中才有 stroke」:未选中项用 `bg-foreground/5` 表面、无描边,选中项用 accent 环线加浅色填充;选图器内选中缩略图为 2px accent 环 + 角标对勾。修复部分缩略图停留在骨架态的问题:缓存图可能在 onLoad 挂载前已完成,新增 ref 挂载时 `complete && naturalWidth` 补检。功能行为(提示词合成、单/多选约束、配置字段、事件)保持不变。前端 `bun run build` 通过,需浏览器验收:
- [ ] 画布图片节点工具栏打开「全景图」弹窗:明暗主题下各分区层级清晰,仅选中项显示 accent 描边;生成方式 Segmented 切换后参考图标题、描述区、底部状态同步变化。
- [ ] 有画布图片(含视频封面)时从画布添加/搜索/多选参考图,图生模式强制单选;缩略图加载后不再停留骨架态,失败图显示「加载失败」。
- [ ] 确认生成与图片直出两条路径行为与旧版一致(配置字段、连线、提示词),⌘↵ 快捷提交可用;「复制完整提示词」内容正确。
- [ ] 680px 弹窗在窄屏(<768px)无横向溢出,全景类型卡片换为单列;选图器覆盖层可独立关闭,不误关整个弹窗。

View File

@@ -34,7 +34,7 @@ export function CanvasCameraControlPopover({ cameraControl, onCameraControlChang
{!compact ? <span>摄像机</span> : null}
</button>
{open ? (
<AppModal title="摄像机控制" open centered footer={null} width={780} flush onCancel={() => setOpen(false)}>
<AppModal title={null} closable={false} open centered footer={null} width={780} flush onCancel={() => setOpen(false)}>
<CanvasNodeCameraPanel
cameraControl={cameraControl}
onClose={() => setOpen(false)}

View File

@@ -13,7 +13,7 @@ import { buildLibTVImagePreviewUrl, buildLibTVVideoSourceUrl } from "@/lib/canva
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import type { CanvasTheme } from "@/lib/canvas-theme";
import { formatBytes } from "@/lib/image-utils";
import { resourceIdFromStorageKey } from "@/services/api/resources";
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import type { GenerationTask } from "@/services/api/task-center";
import { cacheResourceObjectUrl, getCachedResourceObjectUrl, peekCachedResourceObjectUrl, scheduleResourceBlobCache } from "@/services/resource-blob-cache";
import { resolveMediaUrl } from "@/services/file-storage";
@@ -650,16 +650,17 @@ function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) {
? content
: node.metadata?.previewContent
|| (node.type === CanvasNodeType.Image && node.metadata?.importSource?.provider === "libtv" ? buildLibTVImagePreviewUrl(content) : content);
const isRemoteResource = Boolean(resourceIdFromStorageKey(storageKey));
const resourceId = resourceIdFromStorageKey(storageKey);
const isRemoteResource = Boolean(resourceId);
// 图片内容随资源 ID 不可变且后端允许磁盘强缓存:视口内的远程图片首帧直接上直链,
// 走浏览器原生解码与磁盘缓存;Blob 缓存就绪后再平滑替换,避免刷新后满屏转圈。
const synchronousUrl = eager && isRemoteResource && node.type === CanvasNodeType.Image ? peekCachedResourceObjectUrl(storageKey) || resourceFileUrl(resourceId) : "";
// Inline data URLs are already local, but decoding thousands of them is
// still expensive. Images must wait for the same viewport gate as remote
// resources; otherwise DOM virtualization does not reduce image work.
const isLazyVisual = node.type === CanvasNodeType.Image;
const synchronousUrl = isRemoteResource ? peekCachedResourceObjectUrl(storageKey) : "";
const isHttpUrl = Boolean(fallback && !fallback.startsWith("data:"));
// 优先使用内存中的有效 Blob URL;若已进入视口且有服务端的直接图片地址(非 data: base64),
// 立即作为初始图片呈现,利用浏览器原生 HTTP 磁盘缓存秒开,消除满屏转圈等待。
const initialUrl = synchronousUrl || (eager && isHttpUrl ? fallback : (isRemoteResource || isLazyVisual ? "" : fallback));
const initialUrl = synchronousUrl || (eager && isLazyVisual && isHttpUrl ? fallback : (isRemoteResource || isLazyVisual ? "" : fallback));
const [url, setUrl] = useState(() => initialUrl);
const [loading, setLoading] = useState(() => !initialUrl && isRemoteResource && eager);
@@ -688,7 +689,7 @@ function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) {
if (!cancelled && cached) setUrl(cached);
else if (!cancelled && eager && fallback) setUrl(fallback);
}).catch(() => {
if (!cancelled && eager) setUrl(fallback);
if (!cancelled && eager) setUrl(synchronousUrl || fallback);
}).finally(() => {
if (!cancelled) setLoading(false);
});

View File

@@ -32,6 +32,9 @@ const LIGHT_POSITIONS = [
{ label: "后方", azimuth: 180, elevation: 0 },
];
const LIGHTING_PREVIEW_COLUMN_WIDTH = 300;
const LIGHTING_SPHERE_SIZE = 220;
const STYLE_PRESETS = [
{ id: "overexposed", name: "过曝胶片", color: "#d4b896", image: "/lighting-presets/overexposed.png", prompt: "overexposed film aesthetic, high-key lighting, washed out highlights, soft diffused light, vintage film look" },
{ id: "blueBacklight", name: "蓝色逆光", color: "#1a3a5c", image: "/lighting-presets/blue-backlight.png", prompt: "dramatic backlighting, blue rim light, cool color temperature, silhouette with colored edges, ethereal atmosphere" },
@@ -207,11 +210,21 @@ export function CanvasNodeLightingPanel({ dataUrl, onClose, onConfirm }: { dataU
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="flex p-0">
{/* Left col: view tabs + light sphere */}
<div className="flex flex-col items-center gap-2 border-r px-3 py-3" style={{ width: 185, borderColor: theme.toolbar.border }}>
<div className="mb-1 flex w-full gap-1 rounded-[var(--r-md)] p-1" style={{ background: theme.toolbar.itemHover }}>
<div className="flex h-14 items-center justify-between border-b px-5" style={{ borderColor: theme.toolbar.border }}>
<h2 className="text-[var(--fs-title)] font-semibold leading-none">打光效果</h2>
<button
type="button"
aria-label="关闭打光效果"
className="grid size-8 place-items-center rounded-[var(--r-md)] transition-colors hover:bg-black/5 dark:hover:bg-white/10"
style={{ color: theme.node.muted }}
onClick={onClose}
>
<X className="size-5" />
</button>
</div>
<div className="grid items-stretch" style={{ gridTemplateColumns: `${LIGHTING_PREVIEW_COLUMN_WIDTH}px minmax(0, 1fr)` }}>
<div className="flex shrink-0 flex-col items-center gap-2 border-r px-3 py-3" style={{ width: LIGHTING_PREVIEW_COLUMN_WIDTH, borderColor: theme.toolbar.border }}>
<div className="flex w-full gap-1 rounded-[var(--r-md)] p-1" style={{ background: theme.toolbar.itemHover }}>
{(["perspective", "front"] as const).map((mode) => (
<button
key={mode}
@@ -234,45 +247,27 @@ export function CanvasNodeLightingPanel({ dataUrl, onClose, onConfirm }: { dataU
/>
</div>
{/* Middle col: global controls */}
<div className="flex flex-col gap-3 border-r px-3 py-3" style={{ width: 280, borderColor: theme.toolbar.border }}>
<div className="flex min-w-0 flex-1 flex-col justify-between gap-2.5 px-4 py-3">
<div className="flex items-center justify-between">
<span className="text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.muted }}>全局</span>
<PanelToggle label="智能模式" checked={options.smartMode} onChange={(checked) => updateOption("smartMode", checked)} theme={theme} />
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<span className="w-8 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>亮度</span>
<div className="min-w-0 flex-1"><Slider min={0} max={100} value={options.brightness} onChange={(value) => updateOption("brightness", value)} tooltip={{ formatter: (value) => `${value}%` }} /></div>
<div className="flex items-center gap-1 rounded-[var(--r-md)] border px-2 py-1 text-[11px]" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.itemHover, color: theme.node.muted }}>
<Sun className="size-3" />
<span className="w-8 text-right">{options.brightness}%</span>
</div>
<div className="flex items-center gap-2">
<span className="w-8 shrink-0 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>亮度</span>
<div className="min-w-0 flex-1"><Slider min={0} max={100} value={options.brightness} onChange={(value) => updateOption("brightness", value)} tooltip={{ formatter: (value) => `${value}%` }} /></div>
<div className="flex h-6 items-center gap-1 rounded-[var(--r-md)] border px-1.5 text-[11px]" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.itemHover, color: theme.node.muted }}>
<Sun className="size-3" />
<span className="w-7 text-right tabular-nums">{options.brightness}%</span>
</div>
</div>
<div className="flex items-center gap-2">
<span className="w-8 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>颜色</span>
<div className="relative flex items-center">
<label
className="relative h-6 w-10 cursor-pointer overflow-hidden rounded border"
style={{ borderColor: theme.toolbar.border, background: options.lightColor === "#ffffff" ? "linear-gradient(135deg, #ff0000 0%, #ff9900 25%, #ffff00 50%, transparent 50%, transparent 100%), linear-gradient(45deg, #aaa 25%, transparent 25%, transparent 75%, #aaa 75%)" : options.lightColor }}
>
<input
type="color"
aria-label="光线颜色"
value={options.lightColor}
onChange={(event) => updateOption("lightColor", event.target.value)}
className="absolute left-0 top-full h-0 w-0 cursor-pointer opacity-0"
/>
</label>
{options.lightColor === "#ffffff" ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-px w-full rotate-45 bg-red-500" />
</div>
) : null}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="w-8 shrink-0 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>颜色</span>
<LightColorSwatch value={options.lightColor} theme={theme} onChange={(value) => updateOption("lightColor", value)} />
</div>
<PanelToggle label="轮廓光" checked={options.rimLight} onChange={(checked) => updateOption("rimLight", checked)} theme={theme} />
</div>
<div className="space-y-1.5">
@@ -292,45 +287,39 @@ export function CanvasNodeLightingPanel({ dataUrl, onClose, onConfirm }: { dataU
))}
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>轮廓光</span>
<PanelToggle label="轮廓光" checked={options.rimLight} onChange={(checked) => updateOption("rimLight", checked)} theme={theme} />
</div>
</div>
</div>
{/* Right col: smart description + presets */}
<div className="flex flex-1 flex-col gap-3 px-4 py-3">
<span className="text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.muted }}>智能模式</span>
<textarea
value={smartDesc}
onChange={(event) => setSmartDesc(event.target.value)}
placeholder="简单描述你想要实现的打光效果,或者情绪风格"
disabled={!options.smartMode}
className="h-16 w-full resize-none rounded-[var(--r-lg)] border px-3 py-2 text-xs outline-none transition-colors"
style={{ borderColor: theme.toolbar.border, background: theme.toolbar.itemHover, color: theme.node.text, opacity: options.smartMode ? 1 : 0.55 }}
/>
<span className="text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.muted }}>预设</span>
<div className="grid grid-cols-2 gap-1.5">
{STYLE_PRESETS.map((preset) => (
<button
key={preset.id}
type="button"
aria-pressed={options.stylePreset === preset.id}
onClick={() => updateOption("stylePreset", options.stylePreset === preset.id ? "" : preset.id)}
className={`relative h-[60px] overflow-hidden rounded-[var(--r-lg)] text-left transition-[box-shadow,filter,border-color,background-color] duration-150 ${options.stylePreset === preset.id ? "ring-2 ring-white/60 shadow-[0_0_0_1px_rgba(255,255,255,0.15)]" : "hover:ring-1 hover:ring-white/30"}`}
style={{
backgroundImage: `linear-gradient(to top, rgba(0,0,0,0.78), rgba(0,0,0,0.08)), url(${preset.image})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundColor: preset.color,
}}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_70%_30%,rgba(255,255,255,0.22),transparent_36%)]" />
<span className="absolute bottom-1.5 left-2 right-2 text-[11px] font-medium leading-tight text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.55)]">{preset.name}</span>
</button>
))}
</div>
<div className="flex flex-col gap-3 border-t px-4 py-3" style={{ borderColor: theme.toolbar.border }}>
<span className="text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.muted }}>智能模式</span>
<textarea
value={smartDesc}
onChange={(event) => setSmartDesc(event.target.value)}
placeholder="简单描述你想要实现的打光效果,或者情绪风格"
disabled={!options.smartMode}
className="h-16 w-full resize-none rounded-[var(--r-lg)] border px-3 py-2 text-xs outline-none transition-colors"
style={{ borderColor: theme.toolbar.border, background: theme.toolbar.itemHover, color: theme.node.text, opacity: options.smartMode ? 1 : 0.55 }}
/>
<span className="text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.muted }}>预设</span>
<div className="grid grid-cols-4 gap-1.5">
{STYLE_PRESETS.map((preset) => (
<button
key={preset.id}
type="button"
aria-pressed={options.stylePreset === preset.id}
onClick={() => updateOption("stylePreset", options.stylePreset === preset.id ? "" : preset.id)}
className={`relative h-[60px] overflow-hidden rounded-[var(--r-lg)] text-left transition-[box-shadow,filter,border-color,background-color] duration-150 ${options.stylePreset === preset.id ? "ring-2 ring-white/60 shadow-[0_0_0_1px_rgba(255,255,255,0.15)]" : "hover:ring-1 hover:ring-white/30"}`}
style={{
backgroundImage: `linear-gradient(to top, rgba(0,0,0,0.78), rgba(0,0,0,0.08)), url(${preset.image})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundColor: preset.color,
}}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_70%_30%,rgba(255,255,255,0.22),transparent_36%)]" />
<span className="absolute bottom-1.5 left-2 right-2 text-[11px] font-medium leading-tight text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.55)]">{preset.name}</span>
</button>
))}
</div>
</div>
@@ -345,8 +334,8 @@ export function CanvasNodeLightingPanel({ dataUrl, onClose, onConfirm }: { dataU
type="button"
whileHover={reducedMotion ? undefined : { y: -1 }}
whileTap={reducedMotion ? undefined : { scale: 0.97 }}
className="flex h-8 items-center gap-1.5 rounded-[var(--dock-item-radius)] px-3 text-[var(--fs-label)] font-semibold"
style={{ background: theme.node.activeStroke, color: theme.node.panel }}
className="flex h-8 items-center gap-1.5 rounded-[var(--dock-item-radius)] border px-3 text-[var(--fs-label)] font-semibold transition-colors"
style={{ borderColor: theme.node.activeStroke, color: theme.node.activeStroke, background: "transparent" }}
onClick={handleApply}
>
<Send className="size-3.5" />生成打光效果
@@ -368,6 +357,32 @@ export function CanvasNodeLightingPanel({ dataUrl, onClose, onConfirm }: { dataU
);
}
function LightColorSwatch({ value, theme, onChange }: { value: string; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (value: string) => void }) {
const isWhite = value.toLowerCase() === "#ffffff";
return (
<label
className="relative h-6 w-10 shrink-0 cursor-pointer overflow-hidden rounded border"
style={{
borderColor: theme.toolbar.border,
background: isWhite
? "linear-gradient(45deg, #cfcfcf 25%, transparent 25%, transparent 75%, #cfcfcf 75%), linear-gradient(45deg, #cfcfcf 25%, #ffffff 25%, #ffffff 75%, #cfcfcf 75%)"
: value,
backgroundSize: isWhite ? "8px 8px, 8px 8px" : undefined,
backgroundPosition: isWhite ? "0 0, 4px 4px" : undefined,
}}
>
<input
type="color"
aria-label="光线颜色"
value={value}
onChange={(event) => onChange(event.target.value)}
className="absolute inset-0 cursor-pointer opacity-0"
/>
{isWhite ? <span className="pointer-events-none absolute inset-[-20%] m-auto h-px w-[140%] rotate-45 bg-red-500" /> : null}
</label>
);
}
function PanelToggle({ label, checked, onChange, theme }: { label: string; checked: boolean; onChange: (checked: boolean) => void; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
return (
<div className="flex items-center gap-2">
@@ -542,8 +557,13 @@ function drawLightSphere(ctx: CanvasRenderingContext2D, width: number, height: n
if (previewImageUrl) {
const image = new Image();
image.onload = () => {
const imageWidth = radius * 0.5;
const imageHeight = radius * 0.75;
const sourceWidth = image.naturalWidth || image.width;
const sourceHeight = image.naturalHeight || image.height;
const aspectRatio = sourceWidth > 0 && sourceHeight > 0 ? sourceWidth / sourceHeight : 2 / 3;
const maxImageWidth = radius * 0.92;
const maxImageHeight = radius * 0.96;
const imageWidth = Math.min(maxImageWidth, maxImageHeight * aspectRatio);
const imageHeight = Math.min(maxImageHeight, maxImageWidth / aspectRatio);
ctx.save();
ctx.shadowColor = "rgba(0,0,0,0.8)";
ctx.shadowBlur = 12;
@@ -594,7 +614,7 @@ function LightingSphereControl({ azimuth, elevation, onAngleChange, previewImage
const canvasRef = useRef<HTMLCanvasElement>(null);
const isDraggingRef = useRef(false);
const dragStartRef = useRef<{ x: number; y: number; azimuth: number; elevation: number } | null>(null);
const size = 180;
const size = LIGHTING_SPHERE_SIZE;
const redraw = useCallback(() => {
const canvas = canvasRef.current;

View File

@@ -1,7 +1,7 @@
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
import { App, Button, Dropdown, Input, Modal, Tag, Tooltip } from "antd";
import type { MenuProps } from "antd";
import { Check, ChevronDown, Ellipsis, Images, Plus, SlidersHorizontal, UserRound } from "lucide-react";
import { Camera, Check, ChevronDown, Ellipsis, Images, Plus, SlidersHorizontal, UserRound } from "lucide-react";
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import { ASSET_CATEGORY_OPTIONS } from "@/lib/asset-category";
@@ -281,8 +281,7 @@ export function CanvasNodeToolbar({
const primary = inGroup("primary");
const primaryTools = narrow ? primary.slice(0, 1) : primary;
const portraitTools = compact ? [] : inGroup("portrait");
const viewpointTools = compact ? [] : inGroup("viewpoint");
const lightingTools = compact ? [] : inGroup("lighting");
const viewpointLightingTools = compact ? [] : [...inGroup("viewpoint"), ...inGroup("lighting")];
const panoramaTools = compact ? [] : inGroup("panorama");
const processTools = compact ? [...inGroup("portrait"), ...inGroup("viewpoint"), ...inGroup("lighting"), ...inGroup("panorama"), ...inGroup("process")] : inGroup("process");
const workspaceTools = narrow ? [] : inGroup("workspace");
@@ -319,8 +318,7 @@ export function CanvasNodeToolbar({
{primaryTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
{panoramaTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
{portraitTools.length ? <NodeDockMenuButton menuId="portrait" label="人像调整" icon={<UserRound className="size-3.5" />} tools={portraitTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
{viewpointTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
{lightingTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
{viewpointLightingTools.length ? <NodeDockMenuButton menuId="viewpoint-lighting" label="视角" icon={<Camera className="size-3.5" />} tools={viewpointLightingTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
{processTools.length ? <NodeDockMenuButton menuId="process" label={processMenuLabel} icon={isVideo ? <Images className="size-3.5" /> : <SlidersHorizontal className="size-3.5" />} tools={processTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
{workspaceTools.length ? <span aria-hidden className="aceternity-dock-separator mx-1 h-5 w-px shrink-0" /> : null}
{workspaceTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}

View File

@@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useState } from "react";
import { CheckCircle2, Compass, Copy, Globe, HelpCircle, Image as ImageIcon, Info, Plus, Search, Sparkles, Type, X } from "lucide-react";
import { memo, useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { Button, Input, Switch } from "antd";
import { Check, Compass, Copy, Globe, Image as ImageIcon, Info, Plus, Search, Sparkles, X } from "lucide-react";
import { AppModal } from "@/components/ui/product/app-modal";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
@@ -53,7 +54,6 @@ function buildPanoramaPrompt(sourceMode: PanoramaSourceMode, projection: Panoram
"除非用户另有说明,保持真实摄影质感、电影级光影和自然空间纵深",
].join(", ");
const projectionLabel = projection === "spherical" ? "720度球形" : "360度环绕";
const ratio = projection === "spherical" ? "2:1" : "4:1";
const fallbackText = `生成一张可用于全景查看器的完整沉浸式全景图,画面必须是比例${ratio}的等距柱状投影结构,宽度是高度的${projection === "spherical" ? "2倍" : "4倍"},结合导入图片的主体、材质、色彩和风格,并根据文字描述补全四周环境、天空或天花板、地面或地板,让左右边缘自然衔接。${common}`;
@@ -68,6 +68,7 @@ function buildPanoramaPrompt(sourceMode: PanoramaSourceMode, projection: Panoram
const PanoramaSetupForm = memo(
({
onSubmit,
onCancel,
onCopyPrompt,
previewImageUrl,
initialProjection = "spherical",
@@ -76,6 +77,7 @@ const PanoramaSetupForm = memo(
canvasNodes,
}: {
onSubmit: (composedPrompt: string, config: PanoramaGenerateConfig) => void;
onCancel: () => void;
onCopyPrompt?: (prompt: string) => void;
previewImageUrl?: string | null;
initialProjection?: PanoramaProjection;
@@ -87,11 +89,12 @@ const PanoramaSetupForm = memo(
const [sourceMode, setSourceMode] = useState<PanoramaSourceMode>(normalizeInitialSourceMode(initialSourceMode, Boolean(previewImageUrl)));
const [smartBase, setSmartBase] = useState(initialSmartBase);
const [prompt, setPrompt] = useState("");
const [showSmartHelp, setShowSmartHelp] = useState(false);
const [assetQuery, setAssetQuery] = useState("");
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [draftSelectedIds, setDraftSelectedIds] = useState<string[]>([]);
const [loadedImages, setLoadedImages] = useState<Record<string, boolean>>({});
const [failedImages, setFailedImages] = useState<Record<string, boolean>>({});
const canvasReferences = useMemo<PanoramaReferenceImage[]>(() => {
const list: PanoramaReferenceImage[] = [];
@@ -158,6 +161,8 @@ const PanoramaSetupForm = memo(
const effectivePrompt = sourceMode === "image" ? prompt.trim() : prompt.trim() || defaultAiPrompt;
const composed = sourceMode === "image" ? effectivePrompt : buildPanoramaPrompt(sourceMode, projection, effectivePrompt);
const directReference = selectedReferences[0] ?? null;
const outputRatio = projection === "spherical" ? "2:1" : "4:1";
const canSubmit = sourceMode === "ai" || Boolean(directReference);
const handleSubmit = useCallback(() => {
if (sourceMode === "image" && !directReference) return;
@@ -170,6 +175,32 @@ const PanoramaSetupForm = memo(
});
}, [composed, directReference, onSubmit, projection, selectedReferences, smartBase, sourceMode]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
handleSubmit();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [handleSubmit]);
const markImageLoaded = useCallback((id: string) => {
setLoadedImages((current) => (current[id] ? current : { ...current, [id]: true }));
}, []);
const markImageFailed = useCallback((id: string) => {
setFailedImages((current) => (current[id] ? current : { ...current, [id]: true }));
}, []);
const trackImageReady = useCallback(
(id: string) => (element: HTMLImageElement | null) => {
if (element?.complete && element.naturalWidth > 0) markImageLoaded(id);
},
[markImageLoaded],
);
const toggleDraftReference = (id: string) => {
setDraftSelectedIds((current) => {
if (sourceMode === "image") {
@@ -189,279 +220,276 @@ const PanoramaSetupForm = memo(
setIsAssetPickerOpen(false);
};
const projectionButtons = (
<div className="grid grid-cols-2 gap-2">
const renderImageSurface = (image: PanoramaReferenceImage, surfaceClass: string) => {
const loaded = loadedImages[image.id];
const failed = failedImages[image.id];
return (
<div className={`relative overflow-hidden bg-foreground/5 ${surfaceClass}`}>
<img
src={image.url}
alt={image.label}
draggable={false}
ref={trackImageReady(image.id)}
onLoad={() => markImageLoaded(image.id)}
onError={() => markImageFailed(image.id)}
className={`h-full w-full object-cover transition-opacity duration-300 ${loaded && !failed ? "opacity-100" : "opacity-0"}`}
/>
{!failed && !loaded && <div className="absolute inset-0 animate-pulse bg-foreground/5" />}
{failed && (
<div className="absolute inset-0 grid place-items-center">
<span className="px-2 text-center text-[11px] leading-4 text-foreground/40">加载失败</span>
</div>
)}
</div>
);
};
const thumbSizeClass = sourceMode === "image" ? "w-[140px]" : "w-[100px]";
const selectedThumb = (image: PanoramaReferenceImage, index: number) => (
<div key={image.id} className={`group relative shrink-0 ${thumbSizeClass}`}>
{renderImageSurface(image, "aspect-[4/3] rounded-[var(--r-md)]")}
<span className="pointer-events-none absolute left-1.5 top-1.5 inline-flex items-center gap-1 rounded-full bg-black/55 px-1.5 py-0.5 text-[9px] font-medium text-white backdrop-blur-sm">
<span className="size-1 rounded-full" style={{ backgroundColor: image.color }} />
{sourceMode === "image" ? "源图" : `${index + 1}`}
</span>
<button
type="button"
onClick={() => setProjection("spherical")}
className={`flex items-center justify-center gap-1 rounded-md border px-2 py-1.5 text-xs transition ${projection === "spherical" ? "border-[var(--color-accent)] bg-[var(--color-accent)] text-white" : "border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text)] hover:bg-[var(--color-bg-hover)]"}`}
onClick={() => setSelectedIds((current) => current.filter((id) => id !== image.id))}
aria-label={`移除${image.label}`}
className="absolute right-1 top-1 grid size-5 place-items-center rounded-full bg-black/55 text-white opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
>
<Globe className="h-3 w-3" /> 720°球体
</button>
<button
type="button"
onClick={() => setProjection("cylindrical")}
className={`flex items-center justify-center gap-1 rounded-md border px-2 py-1.5 text-xs transition ${projection === "cylindrical" ? "border-[var(--color-accent)] bg-[var(--color-accent)] text-white" : "border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text)] hover:bg-[var(--color-bg-hover)]"}`}
>
<Compass className="h-3 w-3" /> 360°环绕
<X className="size-3" />
</button>
</div>
);
const modeButtons = (
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setSourceMode("ai")}
className={`flex h-10 items-center justify-center gap-1 rounded-md border px-2 text-xs transition ${sourceMode !== "image" ? "border-[var(--color-accent)] bg-[var(--color-accent)] text-white" : "border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text)] hover:bg-[var(--color-bg-hover)]"}`}
>
<Type className="h-3 w-3" /> AI生成全景图
</button>
<button
type="button"
onClick={() => setSourceMode("image")}
className={`flex h-10 items-center justify-center gap-1 rounded-md border px-2 text-xs transition ${sourceMode === "image" ? "border-[var(--color-accent)] bg-[var(--color-accent)] text-white" : "border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text)] hover:bg-[var(--color-bg-hover)]"}`}
>
<ImageIcon className="h-3 w-3" /> 图生全景图
</button>
</div>
);
const smartControl = (
<div className="relative flex items-center gap-1.5">
<button
type="button"
onClick={() => setSmartBase((current) => !current)}
className={`inline-flex h-8 items-center gap-1 whitespace-nowrap rounded-md border px-2.5 text-xs transition-colors ${
smartBase ? "border-[var(--color-accent)]/60 bg-[var(--color-accent)]/15 text-[var(--color-accent)]" : "border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)]"
}`}
>
{smartBase && <CheckCircle2 className="h-3 w-3" />}
智能比例合成
</button>
<button
type="button"
onClick={() => setShowSmartHelp((current) => !current)}
className="flex h-8 w-8 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]"
>
<HelpCircle className="h-3.5 w-3.5" />
</button>
{showSmartHelp && (
<div className="absolute bottom-full right-0 z-30 mb-2 w-[340px] rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] p-3 text-[11px] leading-5 text-[var(--color-text-muted)] shadow-2xl">
AI生成全景图会优先提交目标比例;如果供应商没有 2:1 / 4:1,会选择最接近的宽比例生成,再裁切、羽化并归一化。
</div>
)}
</div>
);
const body = (
<div className="grid min-h-[560px] grid-cols-[270px_minmax(0,1fr)_330px]">
<aside className="flex flex-col border-r border-[var(--color-border)] bg-[var(--color-bg-subtle)]">
<div className="border-b border-[var(--color-border)] px-3 py-2">
<div className="text-xs font-semibold text-[var(--color-text)]">{sourceMode === "image" ? "全景源图(单张)" : "全景素材"}</div>
<div className="mt-0.5 text-[10px] text-[var(--color-text-muted)]">
{sourceMode === "image" ? (selectedReferences.length > 0 ? "已选 1 张源图" : "请选择 1 张源图") : `${selectedReferences.length} / ${allReferences.length} 张已加入`}
</div>
</div>
<div className="ui-scrollbar nowheel flex-1 overflow-y-auto p-2">
<div className="space-y-2">
{selectedReferences.map((image, index) => (
<div key={image.id} className="rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] p-1.5">
<div className="aspect-video overflow-hidden rounded bg-[var(--color-bg-subtle)]">
<img src={image.url} alt={image.label} className="h-full w-full object-cover" draggable={false} />
</div>
<div className="mt-1 flex items-center gap-1">
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: image.color }} />
<div className="min-w-0 flex-1 truncate text-[10px] text-[var(--color-text)]">
{sourceMode === "image" ? "源图" : `图${index + 1}`} · {image.label}
</div>
<button
type="button"
onClick={() => setSelectedIds((current) => current.filter((id) => id !== image.id))}
className="rounded px-1 text-[10px] text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]"
>
×
</button>
</div>
</div>
))}
{(sourceMode !== "image" || selectedReferences.length === 0) && (
<button
type="button"
onClick={openAssetPicker}
className="flex aspect-video w-full items-center justify-center rounded-lg border border-dashed border-[var(--color-border)] bg-[var(--color-bg-subtle)] text-[var(--color-text-muted)] hover:border-[var(--color-text-muted)] hover:text-[var(--color-text)]"
title={sourceMode === "image" ? "选择全景源图" : "从资产加入图片"}
>
<Plus className="h-6 w-6" />
</button>
)}
{sourceMode === "image" && selectedReferences.length > 0 && (
<button
type="button"
onClick={openAssetPicker}
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-2 text-[11px] text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)]"
title="替换源图"
>
替换源图
</button>
)}
</div>
</div>
</aside>
<main className="flex min-w-0 flex-col gap-4 p-6">
<div className="relative">
<textarea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={sourceMode === "image" ? "可选备注:使用现成图片作为全景源,不会提交给 AI" : "输入全景场景描述;文字+图片+内置全景提示词会一起提交"}
className="nodrag nowheel h-40 w-full resize-none rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-4 py-3 text-sm leading-6 text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)]/70 focus:border-[var(--color-accent)]"
/>
</div>
{sourceMode === "ai" ? (
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-3 py-2 text-[11px] leading-5 text-[var(--color-text-muted)]">
AI生成全景图会把文字、导入图片和内置全景提示词一起提交,目标是先得到 2:1 全景底图,再进入全景查看器。
</div>
) : (
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-3 py-2 text-[11px] leading-5 text-[var(--color-text-muted)]">
图生全景图只使用第一张源图作为全景底图。图片接近 {projection === "spherical" ? "2:1" : "4:1"} 时直接进入全景查看器;不符合时在本地裁切、羽化并归一化,不会提交给 AI。
</div>
)}
{sourceMode !== "image" && (
<details className="text-[11px] text-[var(--color-text-muted)]">
<summary className="cursor-pointer hover:text-[var(--color-text)]">查看最终提示词</summary>
<pre className="mt-2 max-h-36 overflow-y-auto whitespace-pre-wrap rounded-lg bg-[var(--color-bg-subtle)] p-2 text-[11px] text-[var(--color-text-muted)]">{composed}</pre>
</details>
)}
<div className="mt-auto flex items-center justify-between gap-3 border-t border-[var(--color-border)] pt-4">
{sourceMode !== "image" ? (
<div className="text-[11px] leading-5 text-[var(--color-text-muted)]">使用当前配置生成全景图。</div>
) : (
<div className="text-[11px] leading-5 text-[var(--color-text-muted)]">使用现成图片作为全景源,比例处理在本地完成。</div>
)}
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleSubmit}
disabled={sourceMode === "image" && !directReference}
className="flex h-8 items-center gap-1 whitespace-nowrap rounded-md bg-[var(--color-accent)] px-3 text-xs text-white hover:bg-[var(--color-accent)]/90 disabled:cursor-not-allowed disabled:opacity-40"
>
{sourceMode === "image" ? <ImageIcon className="h-3 w-3" /> : <Sparkles className="h-3 w-3" />}
{sourceMode === "image" ? "创建全景图" : "生成全景图"}
</button>
</div>
</div>
</main>
<aside className="flex flex-col gap-4 border-l border-[var(--color-border)] bg-[var(--color-bg-subtle)] p-4">
<section className="flex flex-col gap-2">
<div className="text-[10px] uppercase tracking-wider text-[var(--color-text-muted)]">生成方式</div>
{modeButtons}
</section>
<section className="flex flex-col gap-2">
<div className="text-[10px] uppercase tracking-wider text-[var(--color-text-muted)]">投影方式</div>
{projectionButtons}
</section>
{projection === "cylindrical" && (
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-2.5 py-1.5 text-[11px] leading-5 text-amber-700 dark:text-amber-200/85">360° 环绕更适合横向街景/长廊;普通全景建议使用 720° 球体。</div>
)}
<section className="flex flex-col gap-2">
<div className="text-[10px] uppercase tracking-wider text-[var(--color-text-muted)]">比例兜底</div>
<div className="flex items-start gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-[11px] leading-5 text-[var(--color-text-muted)]">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[var(--color-text-muted)]" />
<span>
{sourceMode === "image"
? `本地检查源图比例;符合 ${projection === "spherical" ? "2:1" : "4:1"} 时直接使用,不符合时本地裁切、羽化并归一化。`
: `会优先请求 ${projection === "spherical" ? "2:1" : "4:1"} 全景比例;如果当前供应商不支持,会自动改用最接近的宽比例,并在生成后走裁切/归一化兜底。`}
</span>
</div>
{sourceMode !== "image" && (
<div className="flex flex-col gap-2 pt-1">
{smartControl}
<button
type="button"
onClick={() => onCopyPrompt?.(composed)}
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 text-xs text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-bg-hover)]"
title="复制完整提示词到剪贴板"
>
<Copy className="h-3 w-3" /> 复制提示词
</button>
</div>
)}
</section>
</aside>
</div>
const addTile = (
<button
type="button"
onClick={openAssetPicker}
className={`flex shrink-0 flex-col items-center justify-center gap-1.5 rounded-[var(--r-md)] bg-foreground/5 text-[10px] font-medium text-foreground/45 transition-colors hover:bg-foreground/10 hover:text-foreground/65 ${thumbSizeClass} aspect-[4/3]`}
>
<Plus className="size-4" strokeWidth={1.8} />
<span>添加</span>
</button>
);
return (
<>
{body}
{isAssetPickerOpen && (
<div className="absolute inset-0 z-[260] flex items-center justify-center bg-black/35 backdrop-blur-sm">
<div className="w-[560px] max-w-[calc(100%-32px)] rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] shadow-2xl">
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-4 py-3">
<div>
<div className="text-sm font-semibold text-[var(--color-text)]">{sourceMode === "image" ? "选择要转换的全景源图" : "选择全景参考图"}</div>
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">{sourceMode === "image" ? "图生全景图只使用一张现成图片作为全景源,不会提交给 AI" : "AI生成全景图支持多选,可作为风格 / 主体 / 元素参考"}</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background text-foreground">
<header className="flex shrink-0 items-center gap-2.5 border-b border-border px-5 py-3">
<span className="grid size-7 shrink-0 place-items-center rounded-[var(--r-md)] bg-foreground/5 text-foreground/70">
<Globe className="size-3.5" strokeWidth={1.8} />
</span>
<div className="min-w-0">
<h2 className="text-[13px] font-semibold leading-tight">全景图生成</h2>
<p className="text-[10px] text-foreground/45">AI 补全环境或直接转换</p>
</div>
</header>
<main className="ui-scrollbar nowheel min-h-0 min-w-0 flex-1 overflow-y-auto px-5 py-3">
<div className="space-y-3">
{/* 生成方式 */}
<div>
<div className="mb-1.5 text-[11px] font-semibold text-foreground">生成方式</div>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
aria-pressed={sourceMode === "ai"}
onClick={() => setSourceMode("ai")}
className={`flex items-center gap-2 rounded-[var(--r-md)] px-2.5 py-2 text-left transition-all ${
sourceMode === "ai"
? "bg-accent text-white"
: "bg-foreground/5 text-foreground hover:bg-foreground/10"
}`}
>
<Sparkles className="size-4 shrink-0" />
<div className="min-w-0">
<div className="text-[11px] font-semibold">AI 生成</div>
<div className="text-[9px] leading-tight opacity-75">补全环境</div>
</div>
</button>
<button
type="button"
aria-pressed={sourceMode === "image"}
onClick={() => setSourceMode("image")}
className={`flex items-center gap-2 rounded-[var(--r-md)] px-2.5 py-2 text-left transition-all ${
sourceMode === "image"
? "bg-accent text-white"
: "bg-foreground/5 text-foreground hover:bg-foreground/10"
}`}
>
<ImageIcon className="size-4 shrink-0" />
<div className="min-w-0">
<div className="text-[11px] font-semibold">图片直出</div>
<div className="text-[9px] leading-tight opacity-75">直接转换</div>
</div>
</button>
</div>
<button type="button" onClick={() => setIsAssetPickerOpen(false)} className="rounded-md p-1 text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]">
<X className="h-4 w-4" />
</div>
{/* 参考图 / 源图 */}
<div>
<div className="mb-1.5 flex items-center justify-between gap-2">
<div className="text-[11px] font-semibold text-foreground">{sourceMode === "image" ? "全景源图" : "参考图"}</div>
<Button size="small" type="text" icon={<Plus className="size-3.5" />} onClick={openAssetPicker} className="!h-6 !px-2 !text-[10px]">
{sourceMode === "image" && selectedReferences.length > 0 ? "替换" : "添加"}
</Button>
</div>
<div className="flex flex-wrap gap-1.5">
{selectedReferences.map((image, index) => selectedThumb(image, index))}
{sourceMode !== "image" && addTile}
{sourceMode === "image" && selectedReferences.length === 0 && addTile}
</div>
</div>
{/* 场景描述 */}
{sourceMode === "ai" && (
<div>
<div className="mb-1.5 text-[11px] font-semibold text-foreground">场景描述</div>
<Input.TextArea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="例如:黄昏的海边木屋,能看到天空、地面和四周环境"
autoSize={{ minRows: 2, maxRows: 4 }}
/>
</div>
)}
{/* 全景类型 */}
<div>
<div className="mb-1.5 text-[11px] font-semibold text-foreground">全景类型</div>
<div className="grid grid-cols-2 gap-2">
{(
[
{ value: "spherical" as const, icon: Globe, title: "球形全景", desc: "720° 沉浸式", ratio: "2:1" },
{ value: "cylindrical" as const, icon: Compass, title: "环绕全景", desc: "360° 街景", ratio: "4:1" },
] as const
).map((option) => {
const selected = projection === option.value;
const Icon = option.icon;
return (
<button
key={option.value}
type="button"
aria-pressed={selected}
onClick={() => setProjection(option.value)}
className={`flex items-center gap-2 rounded-[var(--r-md)] px-2.5 py-2 text-left transition-all ${
selected
? "bg-accent text-white"
: "bg-foreground/5 text-foreground hover:bg-foreground/10"
}`}
>
<Icon className="size-4 shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-semibold">{option.title}</span>
<span className={`rounded-sm px-1 py-px text-[9px] font-medium ${selected ? "bg-white/20" : "bg-foreground/8 text-foreground/55"}`}>{option.ratio}</span>
</div>
<div className={`text-[9px] leading-tight ${selected ? "opacity-80" : "text-foreground/50"}`}>{option.desc}</div>
</div>
</button>
);
})}
</div>
</div>
{/* 高级选项 */}
<div>
<div className="mb-1.5 text-[11px] font-semibold text-foreground">高级选项</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3 rounded-[var(--r-md)] bg-foreground/5 px-2.5 py-1.5">
<div className="min-w-0">
<div className="text-[11px] font-semibold text-foreground">智能比例</div>
<div className="text-[9px] leading-tight text-foreground/50">模型不支持时自动裁切</div>
</div>
<Switch size="small" checked={smartBase} onChange={setSmartBase} />
</div>
{sourceMode === "ai" && (
<Button size="small" type="text" icon={<Copy className="size-3.5" />} onClick={() => onCopyPrompt?.(composed)} className="!h-6 !px-2 !text-[10px]">
复制完整提示词
</Button>
)}
</div>
</div>
</div>
</main>
<footer className="flex shrink-0 items-center justify-between gap-3 border-t border-border px-5 py-2.5">
<span className={`truncate text-[10px] ${canSubmit ? "text-foreground/45" : "text-amber-500"}`}>
{sourceMode === "image" ? (directReference ? "已选源图" : "请选择源图") : selectedReferences.length > 0 ? `${selectedReferences.length} 张参考图` : "无参考图"}
</span>
<div className="flex shrink-0 items-center gap-2">
<Button size="small" onClick={onCancel}>
取消
</Button>
<Button size="small" type="primary" icon={sourceMode === "image" ? <ImageIcon className="size-3.5" /> : <Sparkles className="size-3.5" />} disabled={!canSubmit} onClick={handleSubmit}>
{sourceMode === "image" ? "创建全景图" : "生成全景图"}
</Button>
</div>
</footer>
</div>
{/* 选图面板 */}
{isAssetPickerOpen && (
<div className="absolute inset-0 z-[260] flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm">
<div className="flex max-h-full w-[500px] max-w-full flex-col overflow-hidden rounded-[var(--r-lg)] border border-border bg-background shadow-2xl">
<div className="flex items-start justify-between gap-3 border-b border-border px-5 py-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-foreground">{sourceMode === "image" ? "选择源图" : "选择参考图"}</div>
<div className="mt-0.5 text-[11px] text-foreground/45">
{sourceMode === "image" ? "只选一张作为全景源" : `已选 ${draftSelectedIds.length} 张`}
</div>
</div>
<button
type="button"
onClick={() => setIsAssetPickerOpen(false)}
aria-label="关闭"
className="grid size-6 shrink-0 place-items-center rounded-[var(--r-md)] text-foreground/45 transition-colors hover:bg-foreground/5 hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
<div className="border-b border-[var(--color-border)] px-4 py-2">
<label className="flex h-8 items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-2">
<Search className="h-3.5 w-3.5 text-[var(--color-text-muted)]" />
<input
value={assetQuery}
onChange={(event) => setAssetQuery(event.target.value)}
placeholder="搜索图片名称"
className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)]/70"
/>
</label>
<div className="border-b border-border px-5 py-2">
<Input size="small" allowClear value={assetQuery} onChange={(event) => setAssetQuery(event.target.value)} placeholder="搜索图片名称" prefix={<Search className="size-3.5 text-foreground/35" />} />
</div>
<div className="ui-scrollbar max-h-[380px] overflow-y-auto p-4">
<div className="ui-scrollbar min-h-0 flex-1 overflow-y-auto p-4">
{filteredReferences.length === 0 ? (
<div className="rounded-lg border border-dashed border-[var(--color-border)] px-4 py-10 text-center text-xs text-[var(--color-text-muted)]">没有匹配的图片资产</div>
<div className="rounded-[var(--r-md)] bg-foreground/5 px-4 py-8 text-center text-[11px] text-foreground/45">没有匹配的图片</div>
) : (
<div className="grid grid-cols-4 gap-2">
<div className="grid grid-cols-5 gap-2">
{filteredReferences.map((image) => {
const selected = draftSelectedIds.includes(image.id);
const failed = failedImages[image.id];
const loaded = loadedImages[image.id];
return (
<button
key={image.id}
type="button"
onClick={() => toggleDraftReference(image.id)}
className={`relative overflow-hidden rounded-lg border bg-[var(--color-bg)] text-left transition-colors ${
selected ? "border-[var(--color-accent)]/80 ring-1 ring-[var(--color-accent)]/50" : "border-[var(--color-border)] hover:border-[var(--color-text-muted)]"
}`}
aria-pressed={selected}
className={`group relative overflow-hidden rounded-[var(--r-md)] transition-shadow ${selected ? "ring-2 ring-accent" : ""}`}
>
<div className="aspect-square bg-[var(--color-bg-subtle)]">
<img src={image.url} alt={image.label} className="h-full w-full object-cover" draggable={false} />
</div>
<div className="truncate px-1.5 py-1 text-[10px] text-[var(--color-text)]">{image.label}</div>
{selected && <span className="absolute right-1 top-1 rounded bg-[var(--color-accent)] px-1 py-0.5 text-[9px] text-white">已选</span>}
{renderImageSurface(image, "aspect-square w-full")}
{selected && (
<span className="absolute right-0.5 top-0.5 grid size-4 place-items-center rounded-full bg-accent text-white">
<Check className="size-2.5" />
</span>
)}
</button>
);
})}
</div>
)}
</div>
<div className="flex justify-end gap-2 border-t border-[var(--color-border)] px-4 py-3">
<button
type="button"
onClick={() => setIsAssetPickerOpen(false)}
className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-1.5 text-xs text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)]"
>
<div className="flex items-center justify-end gap-2 border-t border-border px-5 py-3">
<Button size="small" onClick={() => setIsAssetPickerOpen(false)}>
取消
</button>
<button
type="button"
onClick={confirmAssetPicker}
disabled={sourceMode === "image" && draftSelectedIds.length === 0}
className="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs text-white hover:bg-[var(--color-accent)]/90 disabled:cursor-not-allowed disabled:opacity-45"
>
{sourceMode === "image" ? (draftSelectedIds.length > 0 ? "使用此图" : "请选择一张源图") : `加入 ${draftSelectedIds.length} 张`}
</button>
</Button>
<Button size="small" type="primary" disabled={sourceMode === "image" && draftSelectedIds.length === 0} onClick={confirmAssetPicker}>
{sourceMode === "image" ? "使用此图" : `加入 ${draftSelectedIds.length}`}
</Button>
</div>
</div>
</div>
@@ -475,11 +503,12 @@ PanoramaSetupForm.displayName = "PanoramaSetupForm";
export function CanvasPanoramaConfigModal({ open, onCancel, onConfirm, onCopyPrompt, previewImageUrl, initialProjection, initialSourceMode, initialSmartBase, nodes }: CanvasPanoramaConfigModalProps) {
return (
<AppModal title="全景图生成" open={open} centered footer={null} width={980} flush onCancel={onCancel}>
<div className="flex flex-col">
<div className="relative">
<AppModal open={open} centered footer={null} width={680} flush onCancel={onCancel}>
<div className="flex min-h-0 flex-col overflow-hidden" style={{ maxHeight: "min(640px, calc(100vh - 100px))" }}>
<div className="relative flex min-h-0 flex-1">
<PanoramaSetupForm
onSubmit={onConfirm}
onCancel={onCancel}
onCopyPrompt={onCopyPrompt}
previewImageUrl={previewImageUrl}
initialProjection={initialProjection}

View File

@@ -11,28 +11,7 @@ import { canvasNodeMentionToken, canvasResourceMentionToken, type CanvasResource
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { scopedLocalStorage } from "@/lib/user-scope";
import type { GenerationTask } from "@/services/api/task-center";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type CanvasWorkspaceMode, type ConnectionHandle, type Position, type StoryboardColumn, type StoryboardRow } from "@/types/canvas";
const CANVAS_WORKSPACE_MODE_STORAGE_KEY = "canvas-workspace-mode-v1";
export function readCanvasWorkspaceMode(): CanvasWorkspaceMode {
if (typeof window === "undefined") return "professional";
try {
return scopedLocalStorage.getItem(CANVAS_WORKSPACE_MODE_STORAGE_KEY) === "simple" ? "simple" : "professional";
} catch (error) {
console.warn("读取画布工作模式失败,已使用专业模式", error);
return "professional";
}
}
export function persistCanvasWorkspaceMode(mode: CanvasWorkspaceMode) {
try {
scopedLocalStorage.setItem(CANVAS_WORKSPACE_MODE_STORAGE_KEY, mode);
} catch (error) {
console.warn("保存画布工作模式失败", error);
}
}
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type ConnectionHandle, type Position, type StoryboardColumn, type StoryboardRow } from "@/types/canvas";
export function createCanvasNode(type: CanvasNodeTypeId, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData {
const builtinSpec = type in NODE_DEFAULT_SIZE ? getNodeSpec(type as CanvasNodeType) : undefined;

View File

@@ -1,18 +1,16 @@
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Link } from "react-router";
import { Bot, Check, ChevronDown, Clapperboard, CloudDownload, Coins, CopyPlus, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Settings2, Share2, Sparkles, Trash2, Undo2, Upload } from "lucide-react";
import { Bot, Clapperboard, CloudDownload, Coins, CopyPlus, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Share2, Trash2, Undo2, Upload } from "lucide-react";
import { Button, Dropdown, Tooltip } from "antd";
import { useWalletBalance } from "@/hooks/use-wallet-balance";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import type { CanvasContextSummary } from "@/lib/canvas/canvas-context-summary";
import type { CanvasShortDramaProgress } from "@/lib/canvas/canvas-short-drama";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import type { CanvasMediaPerformanceMode, CanvasWorkspaceMode } from "@/types/canvas";
import type { CanvasMediaPerformanceMode } from "@/types/canvas";
import { CanvasShortcutsModal } from "./canvas-shortcuts-modal";
type CanvasTopBarProps = {
@@ -282,82 +280,6 @@ export function CanvasTopBar({
);
}
export function CanvasWorkspaceModeSwitch({ mode, onChange }: { mode: CanvasWorkspaceMode; onChange: (mode: CanvasWorkspaceMode) => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const reducedMotion = useReducedMotion();
const simple = mode === "simple";
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open) return;
const closeOnOutsidePress = (event: PointerEvent) => {
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) setOpen(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("pointerdown", closeOnOutsidePress);
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("pointerdown", closeOnOutsidePress);
document.removeEventListener("keydown", closeOnEscape);
};
}, [open]);
const selectMode = (nextMode: CanvasWorkspaceMode) => {
if (nextMode !== mode) onChange(nextMode);
setOpen(false);
};
return (
<div ref={rootRef} className="aceternity-mode-switch pointer-events-auto relative z-[var(--dock-z-popover)]">
<motion.button
type="button"
whileHover={reducedMotion ? undefined : { y: -1 }}
whileTap={reducedMotion ? undefined : { scale: 0.97 }}
transition={aceternityMotion.spring.dock}
className="canvas-mode-switch-trigger flex h-10 min-w-28 items-center gap-2 rounded-xl px-2.5 text-left outline-none backdrop-blur-2xl focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
style={{ ...canvasDockStyle(theme, theme.node.text), background: open ? "var(--dock-command-active)" : "var(--dock-surface)", outlineColor: theme.accent.primary }}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={`当前为${simple ? "简洁" : "专业"}模式,点击切换`}
onClick={() => setOpen((value) => !value)}
>
<span className="grid size-6 shrink-0 place-items-center rounded-full" style={{ background: theme.toolbar.itemHover, color: theme.accent.primary }}>
{simple ? <Sparkles className="size-3" /> : <Settings2 className="size-3" />}
</span>
<span className="min-w-0 flex-1 whitespace-nowrap text-[var(--fs-caption)] font-semibold leading-none">{simple ? "简洁模式" : "专业模式"}</span>
<motion.span animate={{ rotate: open ? 180 : 0 }} transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.dock} className="grid size-4 place-items-center" style={{ color: theme.node.muted }}>
<ChevronDown className="size-3" />
</motion.span>
</motion.button>
<div className="absolute bottom-11 right-0 w-72" style={{ maxWidth: "calc(100vw - var(--space-8))" }}>
<AnimatePresence>
{open ? (
<motion.div
role="listbox"
aria-label="选择画布工作模式"
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.92 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.95 }}
transition={aceternityMotion.spring.panel}
className="canvas-mode-switch-menu aceternity-floating-panel w-full overflow-hidden rounded-[var(--r-lg)] p-1.5 backdrop-blur-2xl"
style={{ ...canvasDockStyle(theme, theme.node.text), background: "var(--dock-surface)" }}
>
<ModeOption active={simple} motionEnabled={!reducedMotion} icon={<Sparkles className="size-4" />} title="简洁模式" description="保留核心创作路径,降低参数密度" theme={theme} onClick={() => selectMode("simple")} />
<ModeOption active={!simple} motionEnabled={!reducedMotion} icon={<Settings2 className="size-4" />} title="专业模式" description="显示完整节点、导演台与生成控制" theme={theme} onClick={() => selectMode("professional")} />
</motion.div>
) : null}
</AnimatePresence>
</div>
</div>
);
}
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
// 顶栏是 --z-toolbar 上的绝对定位浮层,会为子元素建立层叠上下文。行内绝对定位的提示
// 无论 z-index 多高都无法越过它,会被顶栏下方 --z-panel-floating 的生成任务面板盖住,
// 因此提示必须走 portal 的浮层层级。
@@ -369,25 +291,6 @@ function CanvasTopBarTooltip({ label, children }: { label: string; children: Rea
);
}
function ModeOption({ active, motionEnabled, icon, title, description, theme, onClick }: { active: boolean; motionEnabled: boolean; icon: ReactNode; title: string; description: string; theme: CanvasTheme; onClick: () => void }) {
return (
<motion.button
type="button"
role="option"
aria-selected={active}
whileTap={motionEnabled ? { scale: 0.98 } : undefined}
transition={aceternityMotion.spring.dock}
className="canvas-mode-switch-option group flex min-h-14 w-full items-center gap-3 rounded-[var(--r-md)] px-3 py-2 text-left outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-0"
style={{ background: active ? theme.accent.primarySoft : "transparent", color: theme.node.text, outlineColor: theme.accent.primary }}
onClick={onClick}
>
<span className="grid size-8 shrink-0 place-items-center rounded-[var(--dock-item-radius)] [&_svg]:size-3.5" style={{ background: theme.spatial.surface, color: active ? theme.accent.primary : theme.node.muted }}>{icon}</span>
<span className="min-w-0 flex-1"><span className="block text-[var(--fs-body)] font-semibold leading-none">{title}</span><span className="mt-1.5 block text-[var(--fs-caption)] leading-5" style={{ color: theme.node.muted }}>{description}</span></span>
<span className="grid size-5 shrink-0 place-items-center" style={{ color: theme.accent.primary, opacity: active ? 1 : 0 }}><Check className="size-3.5" /></span>
</motion.button>
);
}
function MenuLabel({ text, shortcut }: { text: string; shortcut: string }) {
return (
<span className="flex min-w-36 items-center justify-between gap-8">

View File

@@ -82,7 +82,7 @@ import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-gra
import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry";
import { resolveCanvasEmptyStateKind } from "@/lib/canvas/canvas-starter";
import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "@/lib/canvas/canvas-image-batch-retry";
import { createCanvasNode, getInputSummary, isHiddenBatchChild, persistCanvasWorkspaceMode, readCanvasWorkspaceMode } from "@/lib/canvas/canvas-project-domain";
import { createCanvasNode, getInputSummary, isHiddenBatchChild } from "@/lib/canvas/canvas-project-domain";
import { stampCanvasNodeChanges, updateCanvasNode, updateCanvasNodes } from "@/lib/canvas/canvas-node-timestamps";
import { canvasAssetHandoffAttempt, finalizeCanvasAssetHandoff, uninsertedCanvasAssetHandoffPayloads } from "@/lib/canvas/canvas-asset-handoff";
import { batchSourceRestriction } from "@/lib/canvas/canvas-batch-connection";
@@ -90,7 +90,7 @@ import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard
import { CanvasAgentChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback";
import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation";
import { cancelGenerationTask } from "@/services/api/task-center";
import { CanvasTopBar, CanvasWorkspaceModeSwitch } from "./canvas-project-top-bar";
import { CanvasTopBar } from "./canvas-project-top-bar";
import { LibTVImportDialog } from "./components/libtv-import-dialog";
import { TapNowImportDialog } from "./components/tapnow-import-dialog";
import { CanvasFocusModeBar } from "@/components/canvas/canvas-focus-mode-bar";
@@ -295,7 +295,7 @@ function InfiniteCanvasPage() {
const [canvasTool, setCanvasTool] = useState<CanvasToolMode>("box-select");
const [mediaPerformanceMode, setMediaPerformanceMode] = useState<CanvasMediaPerformanceMode>(readCanvasMediaPerformanceMode);
const [projectLoaded, setProjectLoaded] = useState(false);
const [workspaceMode, setWorkspaceMode] = useState<CanvasWorkspaceMode>(readCanvasWorkspaceMode);
const workspaceMode: CanvasWorkspaceMode = "professional";
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
const [shareModalOpen, setShareModalOpen] = useState(false);
const [tapNowImportOpen, setTapNowImportOpen] = useState(false);
@@ -352,10 +352,6 @@ function InfiniteCanvasPage() {
const { focusMode, enterFocusMode, exitFocusMode, toggleFocusMode } = useFocusMode();
const [focusDockRevealed, setFocusDockRevealed] = useState(false);
useEffect(() => {
persistCanvasWorkspaceMode(workspaceMode);
}, [workspaceMode]);
useEffect(() => {
persistCanvasMediaPerformanceMode(mediaPerformanceMode);
}, [mediaPerformanceMode]);
@@ -1115,6 +1111,9 @@ function InfiniteCanvasPage() {
setArtCritiqueNodeId(node.id);
} else if (node.type === CanvasNodeType.MediaConversion) {
setDialogNodeId(null);
} else if (node.type === CanvasNodeType.Panorama) {
// 全景节点是纯查看器,没有可编辑提示词,不弹提示词面板。
setDialogNodeId(null);
} else {
// 选择参考媒体时保留当前工作流配置面板,避免点击图片后配置“返回/消失”。
// 没有工作流配置面板时,媒体节点仍按原逻辑打开自己的面板。
@@ -1132,7 +1131,7 @@ function InfiniteCanvasPage() {
const handleNodeDragEnd = useCallback((nodeId: string) => {
const node = nodesRef.current.find((item) => item.id === nodeId);
if (!node || node.type === CanvasNodeType.Script || node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.MediaConversion || node.type === PORTRAIT_CLEARANCE_NODE_TYPE || node.type === ART_CRITIQUE_NODE_TYPE) {
if (!node || node.type === CanvasNodeType.Script || node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.MediaConversion || node.type === CanvasNodeType.Panorama || node.type === PORTRAIT_CLEARANCE_NODE_TYPE || node.type === ART_CRITIQUE_NODE_TYPE) {
setDialogNodeId(null);
return;
}
@@ -2323,19 +2322,6 @@ function InfiniteCanvasPage() {
/>
) : null}
{!focusMode ? (
<div
data-canvas-no-zoom
className="pointer-events-none absolute bottom-[calc(var(--canvas-inset-y)+var(--space-16))] z-[var(--z-toolbar)] transition-[right,bottom] duration-300 lg:bottom-[var(--canvas-inset-y)]"
style={{ right: assistantMounted ? `calc(var(--canvas-inset-x) + ${assistantWidth}px + var(--space-3))` : "var(--canvas-inset-x)" }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
<CanvasWorkspaceModeSwitch mode={workspaceMode} onChange={setWorkspaceMode} />
</div>
) : null}
<CanvasNodeSearchModal
open={nodeSearchOpen}
nodes={nodes}
@@ -2613,16 +2599,17 @@ function InfiniteCanvasPage() {
flush
open
centered
title="打光效果"
title={null}
closable={false}
footer={null}
width={900}
width={720}
onCancel={() => setLightingNodeId(null)}
>
<CanvasNodeLightingPanel
dataUrl={lightingNode.metadata.content}
onClose={() => setLightingNodeId(null)}
onConfirm={(options, prompt) => {
void generateLightingNode(lightingNode, options, prompt);
generateLightingNode(lightingNode, options, prompt);
}}
/>
</AppModal>
@@ -2642,7 +2629,7 @@ function InfiniteCanvasPage() {
/>
) : null}
{dialogNode && !isCanvasImageSourceNode(dialogNode) && !dialogNode.metadata?.fileUpload && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && !selectionBox && !isCanvasNodeMoving ? (
{dialogNode && !isCanvasImageSourceNode(dialogNode) && !dialogNode.metadata?.fileUpload && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && dialogNode.type !== CanvasNodeType.Panorama && !selectionBox && !isCanvasNodeMoving ? (
<CanvasNodePanelOverlay
node={dialogNode}
viewport={viewport}

View File

@@ -588,7 +588,8 @@ export function useCanvasMediaTools({
});
setSelectedNodeIds(new Set([childId]));
setSelectedConnectionId(null);
setDialogNodeId(childId);
// 全景节点是纯查看器,创建后不弹提示词面板。
setDialogNodeId(null);
setPanoramaConfigNodeId(null);
message.success(config.sourceMode === "image" ? "已创建全景查看节点" : "已创建全景生成节点");
}, [message, setConnections, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
@@ -863,49 +864,35 @@ export function useCanvasMediaTools({
}
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, nodesRef, persistMediaNodes, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedNodeIds, startGenerationRequest]);
const generateLightingNode = useCallback(async (node: CanvasNodeData, options: CanvasImageLightingOptions, prompt: string) => {
const generateLightingNode = useCallback((node: CanvasNodeData, options: CanvasImageLightingOptions, prompt: string) => {
if (!node.metadata?.content) return;
const generationConfig = { ...buildGenerationConfig(effectiveConfig, node, "image"), count: "1" };
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
navigateToSettings({ continueCreation: true });
return;
}
const childId = nanoid();
const imageSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
const title = buildLightingLabel(options);
const source = nodeReferenceImage(node);
if (!source) return;
const styleExecution = resolveImageEditStyle(node, prompt, generationConfig);
if (!styleExecution) return;
const { prompt: effectivePrompt, metadata: styleMetadata } = styleExecution;
const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, 1, [source]);
setLightingNodeId(null);
setRunningNodeId(childId);
setNodes((current) => [...current, { id: childId, type: CanvasNodeType.Image, title, position: { x: node.position.x + node.width + 96, y: node.position.y }, width: imageSpec.width, height: imageSpec.height, metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, ...generationMetadata, ...styleMetadata } }]);
setNodes((current) => [...current, {
id: childId,
type: CanvasNodeType.Image,
title,
position: { x: node.position.x + node.width + 96, y: node.position.y },
width: imageSpec.width,
height: imageSpec.height,
metadata: {
prompt,
status: NODE_STATUS_IDLE,
generationMode: "image",
...generationMetadata,
},
}]);
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]);
setSelectedNodeIds(new Set([childId]));
setSelectedConnectionId(null);
setDialogNodeId(childId);
const controller = startGenerationRequest(childId, node.id, childId);
try {
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: childId, mode: "image", prompt: effectivePrompt, config: generationConfig, referenceImages: [source], signal: controller.signal, metadata: { sourceNodeId: node.id, edit: "lighting", lighting: options, ...styleMetadata }, onTaskCreated: (task) => bindGenerationTask(childId, task) });
const image = result.images?.[0];
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
const uploaded = await uploadImage(image.dataUrl);
const size = fitNodeSize(uploaded.width, uploaded.height, imageSpec.width, imageSpec.height);
const currentNode = nodesRef.current.find((item) => item.id === childId);
if (!currentNode) throw new Error("打光生成节点已被删除");
const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: { ...currentNode.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata } };
setNodes((current) => current.map((item) => item.id === childId ? finalizedNode : item));
await persistMediaNodes([finalizedNode]);
} catch (error) {
if (isGenerationCanceled(error)) return;
const details = generationErrorMessage(error);
setNodes((current) => current.map((item) => item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: details } } : item));
} finally {
finishGenerationRequest(childId, controller);
setRunningNodeId(null);
}
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, nodesRef, persistMediaNodes, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedNodeIds, startGenerationRequest]);
}, [effectiveConfig, setConnections, setDialogNodeId, setLightingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
const generateEmotionNode = useCallback(async (node: CanvasNodeData, payload: CanvasImageEmotionPayload) => {
if (!node.metadata?.content) return;

View File

@@ -6,7 +6,7 @@ import { AppDrawer } from "@/components/ui/product/app-drawer";
import { AppModal } from "@/components/ui/product/app-modal";
import { Tooltip } from "@/components/ui/base/tooltip";
import { Reorder } from "motion/react";
import { ArrowDown, ArrowUp, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Copy, Download, FileText, Film, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Minimize2, MoreHorizontal, Music2, Pencil, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, UserRound, WandSparkles, Waves, X } from "lucide-react";
import { ArrowDown, ArrowUp, Brain, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Copy, Download, FileText, Film, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Minimize2, MoreHorizontal, Music2, Pencil, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, UserRound, WandSparkles, Waves, X } from "lucide-react";
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
import { GenerationToolCard, type GenerationToolStatus } from "@/components/ai/generation-tool-card";
@@ -624,16 +624,19 @@ export function CreationComposer(props: ComposerProps) {
}
function ModePicker({ mode, onModeChange }: { mode: CreationMode; onModeChange: (mode: CreationMode) => void }) {
const [open, setOpen] = useState(false);
const items: { mode: CreationMode; icon: ReactNode; label: string }[] = [
{ mode: "video", icon: <Film />, label: "视频生成" },
{ mode: "image", icon: <ImageIcon />, label: "图片生成" },
{ mode: "text", icon: <MessageSquareText />, label: "文本创作" },
{ mode: "video", icon: <Film />, label: "视频" },
{ mode: "image", icon: <ImageIcon />, label: "图片" },
{ mode: "text", icon: <MessageSquareText />, label: "文本" },
];
const current = items.find((item) => item.mode === mode) || items[0];
return <Popover open={open} onOpenChange={setOpen} trigger="click" placement="bottomLeft" arrow={false} classNames={{ root: "creation-control-popover", container: "creation-control-popover-surface", content: "creation-control-popover-content" }} content={<div className="creation-mode-picker-menu" role="listbox" aria-label="选择生成类型">{items.map((item) => <button key={item.mode} type="button" role="option" aria-selected={item.mode === mode} className={item.mode === mode ? "is-selected" : ""} onClick={() => { onModeChange(item.mode); setOpen(false); }}><span className="creation-menu-icon">{item.icon}</span><span>{item.label}</span>{item.mode === mode ? <Check /> : null}</button>)}</div>}>
<button type="button" className="creation-chat-control is-mode" aria-label={`生成类型:${current.label}`}>{current.icon}<span>{current.label}</span><ChevronDown className={open ? "is-open" : ""} /></button>
</Popover>;
return <div className="creation-mode-capsule" role="group" aria-label="生成类型">
{items.map((item) => (
<button key={item.mode} type="button" className="creation-chat-control" aria-pressed={item.mode === mode} aria-label={`${item.label}生成`} onClick={() => onModeChange(item.mode)}>
{item.icon}
<span>{item.label}</span>
</button>
))}
</div>;
}
function GenerationSettingsMenu(props: ComposerProps) {

View File

@@ -20,6 +20,7 @@ const sessionBlobs = new Map<string, Blob>();
const inFlight = new Map<string, Promise<string>>();
const scheduled = new Set<string>();
const cacheMetaTouchWarnings = new Set<string>();
const metaTouchedAt = new Map<string, number>();
const downloadQueue: Array<() => void> = [];
let activeDownloads = 0;
let persistQueue: Promise<void> = Promise.resolve();
@@ -28,17 +29,9 @@ const FALLBACK_CACHE_BYTES = 512 * 1024 * 1024;
const MIN_CACHE_BYTES = 64 * 1024 * 1024;
const MAX_CACHE_ENTRIES = 500;
const TOUCH_INTERVAL_MS = 10 * 60 * 1000;
const BUDGET_REFRESH_MS = 5 * 60 * 1000;
// 现代浏览器对同源 HTTP/2 连接多路复用;上限给到 16 让大画布冷启动在 1~2 轮内完成并发拉取。
const MAX_CONCURRENT_DOWNLOADS = 16;
const recentlyTouched = new Set<string>();
export function peekCachedResourceObjectUrl(storageKey: string): string {
const resourceId = resourceIdFromStorageKey(storageKey);
if (!resourceId) return "";
const userScope = getActiveUserScope();
if (userScope === "guest") return "";
const key = `${userScope}:${resourceId}:file`;
return objectUrls.get(key) || "";
}
export async function getCachedResourceObjectUrl(storageKey: string) {
const target = await cacheTarget(storageKey);
@@ -46,6 +39,15 @@ export async function getCachedResourceObjectUrl(storageKey: string) {
return readCachedObjectUrl(target);
}
/** 同步读取本会话内存中的 Blob URL,命中时节点首帧即可上屏,不必等 IndexedDB 异步读取。 */
export function peekCachedResourceObjectUrl(storageKey: string) {
const resourceId = resourceIdFromStorageKey(storageKey);
if (!resourceId) return "";
const userScope = getActiveUserScope();
if (userScope === "guest") return "";
return objectUrls.get(`${userScope}:${resourceId}:file`) || "";
}
export async function cacheResourceObjectUrl(storageKey: string) {
const target = await cacheTarget(storageKey);
if (!target) return "";
@@ -169,6 +171,7 @@ async function persistBlob(target: ResourceCacheMeta, blob: Blob) {
try {
await write();
invalidateCacheStats();
return;
} catch (firstError) {
// 第一次失败通常意味着浏览器配额不足;激进淘汰后只允许再尝试一次,
@@ -176,6 +179,7 @@ async function persistBlob(target: ResourceCacheMeta, blob: Blob) {
await evictFor(blob.size, target.key, true);
try {
await write();
invalidateCacheStats();
return;
} catch (retryError) {
const cleanupResults = await Promise.allSettled([blobStore.removeItem(target.key), metaStore.removeItem(target.key)]);
@@ -226,11 +230,15 @@ async function cacheTarget(storageKey: string): Promise<ResourceCacheMeta | null
}
async function touchCacheMeta(target: ResourceCacheMeta) {
if (recentlyTouched.has(target.key)) return;
recentlyTouched.add(target.key);
const now = Date.now();
// 同一会话内重复访问不落 IndexedDB:淘汰扫描在写入时才需要元数据,
// 内存时间戳足够让高频读取跳过跨进程 I/O。
const lastTouchedAt = metaTouchedAt.get(target.key);
if (lastTouchedAt && now - lastTouchedAt < TOUCH_INTERVAL_MS) return;
metaTouchedAt.set(target.key, now);
const current = await metaStore.getItem<ResourceCacheMeta>(target.key);
if (!current || Date.now() - current.lastAccessedAt < TOUCH_INTERVAL_MS) return;
await metaStore.setItem(target.key, { ...current, lastAccessedAt: Date.now() });
if (!current || now - current.lastAccessedAt < TOUCH_INTERVAL_MS) return;
await metaStore.setItem(target.key, { ...current, lastAccessedAt: now });
}
@@ -243,12 +251,20 @@ function touchCacheMetaSafely(target: ResourceCacheMeta) {
});
}
// 条目数/字节数与存储配额在内存中跟踪,常规写入只做 O(1) 检查,
// 只有接近预算才执行全量 LRU 扫描,避免每张图持久化都 iterate 全部元数据。
let cacheStats: { count: number; bytes: number } | null = null;
let cachedBudget: { value: number; at: number } | null = null;
async function evictFor(incomingBytes: number, protectedKey: string, aggressive = false) {
const entryCount = await metaStore.length().catch(() => 0);
const budget = aggressive ? Math.max(MIN_CACHE_BYTES, (await cacheBudget()) / 2) : await cacheBudget();
// 只有当条目数接近上限或需要激进淘汰时,才进行全量迭代,避免写入每个文件都跑全表扫描
if (!aggressive && entryCount < MAX_CACHE_ENTRIES - 10 && incomingBytes < budget / 10) return;
// 常规写入只在统计可信或明显未接近阈值时跳过全表扫描;写入会使内存统计失效,
// 因此不会用可能过时的字节数做永久放行。
if (!aggressive) {
if (cacheStats && cacheStats.count < MAX_CACHE_ENTRIES && cacheStats.bytes + incomingBytes <= budget) return;
if (!cacheStats && entryCount < MAX_CACHE_ENTRIES - 10 && incomingBytes < budget / 10) return;
}
const metas: ResourceCacheMeta[] = [];
await metaStore.iterate<ResourceCacheMeta, void>((value) => {
if (value?.key) metas.push(value);
@@ -263,6 +279,11 @@ async function evictFor(incomingBytes: number, protectedKey: string, aggressive
total -= Math.max(0, candidate.size || 0);
count -= 1;
}
cacheStats = { count, bytes: total };
}
function invalidateCacheStats() {
cacheStats = null;
}
async function removeCacheEntry(meta: ResourceCacheMeta) {
@@ -274,10 +295,15 @@ async function removeCacheEntry(meta: ResourceCacheMeta) {
}
async function cacheBudget() {
if (!navigator.storage?.estimate) return FALLBACK_CACHE_BYTES;
const estimate = await navigator.storage.estimate().catch(() => null);
if (!estimate?.quota) return FALLBACK_CACHE_BYTES;
return Math.min(MAX_CACHE_BYTES, Math.max(MIN_CACHE_BYTES, Math.floor(estimate.quota * 0.2)));
// 配额估计是跨进程调用且会话内基本稳定,短暂缓存避免每张图一次 IPC 往返。
if (cachedBudget && Date.now() - cachedBudget.at < BUDGET_REFRESH_MS) return cachedBudget.value;
let value = FALLBACK_CACHE_BYTES;
if (navigator.storage?.estimate) {
const estimate = await navigator.storage.estimate().catch(() => null);
if (estimate?.quota) value = Math.min(MAX_CACHE_BYTES, Math.max(MIN_CACHE_BYTES, Math.floor(estimate.quota * 0.2)));
}
cachedBudget = { value, at: Date.now() };
return value;
}
function objectUrl(key: string, blob: Blob) {

View File

@@ -1694,26 +1694,6 @@
background: var(--dock-command-hover) !important;
}
.canvas-mode-switch-trigger {
border: 0;
box-shadow: 0 14px 34px color-mix(in srgb, var(--cn-spatial-shadow) 72%, transparent), inset 0 1px 0 rgba(255,255,255,.045);
transition: background-color var(--motion-instant) ease-out, color var(--motion-instant) ease-out, box-shadow var(--motion-instant) ease-out;
}
.canvas-mode-switch-trigger:hover:not(:disabled) {
background: var(--dock-command-hover) !important;
}
.canvas-mode-switch-menu {
border: 0;
box-shadow: 0 22px 56px color-mix(in srgb, var(--cn-spatial-shadow) 80%, transparent), inset 0 1px 0 rgba(255,255,255,.04);
transform-origin: bottom right;
}
.canvas-mode-switch-option:hover:not([aria-selected="true"]) {
background: var(--dock-command-hover) !important;
}
.canvas-topbar-project-cluster {
width: max-content;
max-width: min(360px, calc(50vw - 88px));
@@ -2966,54 +2946,6 @@
padding: 0 !important;
}
.creation-mode-picker-menu {
width: 176px;
padding: 6px;
color: #17191d;
}
.dark .creation-mode-picker-menu {
color: #f2f4f6;
}
.creation-mode-picker-menu button {
display: flex;
width: 100%;
height: 40px;
align-items: center;
gap: 10px;
border-radius: 8px;
padding: 0 8px;
color: inherit;
font-size: 13px;
text-align: left;
transition: background-color 150ms ease;
}
.creation-mode-picker-menu button > svg {
width: 15px;
height: 15px;
margin-left: auto;
}
.creation-menu-icon {
display: grid;
width: 26px;
height: 26px;
place-items: center;
border: 1px solid #e5e7eb;
border-radius: 7px;
}
.dark .creation-menu-icon {
border-color: #30343b;
}
.creation-menu-icon svg {
width: 15px;
height: 15px;
}
.creation-settings-panel {
width: min(400px, calc(100vw - 24px));
padding: 16px;
@@ -3747,7 +3679,6 @@
.creation-chat-composer .creation-model-picker.canvas-composer-model-picker:hover,
.creation-chat-composer .creation-model-picker.canvas-composer-model-picker[aria-expanded="true"] { border-color: rgba(255, 255, 255, .12) !important; background: #272b32 !important; transform: translateY(-1px); }
.creation-chat-control:active { transform: scale(.98); }
.creation-chat-control.is-mode { border-color: rgba(6, 182, 212, .36) !important; background: rgba(5, 80, 98, .24) !important; color: #17c4e2 !important; }
.creation-chat-control > svg { width: 15px; height: 15px; flex: 0 0 auto; }
.creation-chat-control > svg:last-child { width: 14px; height: 14px; color: var(--creation-muted); transition: transform 160ms var(--motion-ease-out); }
.creation-chat-control > svg:last-child.is-open { transform: rotate(180deg); }
@@ -7606,24 +7537,12 @@
0 14px 34px rgba(15, 23, 42, 0.1);
}
@media (max-width: 1024px) {
.aceternity-mode-switch {
top: 56px;
}
}
.dark .canvas-floating-panel {
box-shadow:
0 1px 1px rgba(0, 0, 0, 0.28),
0 14px 34px rgba(0, 0, 0, 0.34);
}
.canvas-mode-switch-trigger {
border: 0 !important;
border-color: transparent !important;
box-shadow: 0 14px 34px color-mix(in srgb, var(--cn-spatial-shadow) 72%, transparent), inset 0 1px 0 rgba(255,255,255,.045);
}
.canvas-tool-button,
.canvas-node-tool-button {
transform: translate3d(0, 0, 0) scale(1);
@@ -10532,6 +10451,9 @@ html:not(.dark) .creation-chat-reference:hover:not(:disabled) { background: rgba
gap: 2px;
}
/* 录音条自身可压缩,避免波形 + 胶囊同时出现时把右侧控件挤到第二行。 */
.creation-chat-controls:has(> .voice-recording-inline) { flex-wrap: nowrap; }
.creation-chat-control,
.creation-chat-composer .creation-model-picker.canvas-composer-model-picker {
display: inline-flex;
@@ -10560,11 +10482,6 @@ html:not(.dark) .creation-chat-reference:hover:not(:disabled) { background: rgba
transform: none;
}
.creation-chat-control.is-mode {
background: rgba(255, 255, 255, .075) !important;
color: var(--creation-text) !important;
}
.creation-chat-control[aria-pressed="true"],
.creation-chat-control[aria-pressed="true"]:hover {
background: rgba(6, 182, 212, .16) !important;
@@ -10581,10 +10498,31 @@ html:not(.dark) .creation-chat-reference:hover:not(:disabled) { background: rgba
html:not(.dark) .creation-chat-control,
html:not(.dark) .creation-chat-composer .creation-model-picker.canvas-composer-model-picker { background: transparent !important; color: var(--creation-muted) !important; }
html:not(.dark) .creation-chat-control.is-mode,
html:not(.dark) .creation-chat-control:hover,
html:not(.dark) .creation-chat-composer .creation-model-picker.canvas-composer-model-picker:hover { background: rgba(29, 33, 37, .06) !important; color: var(--creation-text) !important; }
.creation-mode-capsule {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 2px;
border-radius: 999px;
background: rgba(255, 255, 255, .06);
padding: 2px;
}
.creation-mode-capsule .creation-chat-control { height: 24px !important; border-radius: 999px !important; }
.creation-mode-capsule .creation-chat-control:hover,
.creation-home .creation-mode-capsule .creation-chat-control:hover { background: rgba(255, 255, 255, .09) !important; color: var(--creation-text) !important; }
.creation-mode-capsule .creation-chat-control[aria-pressed="true"],
.creation-mode-capsule .creation-chat-control[aria-pressed="true"]:hover,
.creation-home .creation-mode-capsule .creation-chat-control[aria-pressed="true"]:hover { background: var(--control-selected-bg) !important; color: var(--control-selected-fg) !important; }
.creation-mode-capsule .creation-chat-control[aria-pressed="true"] > svg { color: var(--control-selected-fg) !important; }
html:not(.dark) .creation-mode-capsule { background: rgba(28, 32, 37, .05); }
html:not(.dark) .creation-mode-capsule .creation-chat-control:hover { background: rgba(28, 32, 37, .07) !important; }
html:not(.dark) .creation-mode-capsule .creation-chat-control[aria-pressed="true"],
html:not(.dark) .creation-mode-capsule .creation-chat-control[aria-pressed="true"]:hover { background: var(--control-selected-bg) !important; color: var(--control-selected-fg) !important; }
html:not(.dark) .creation-mode-capsule .creation-chat-control[aria-pressed="true"] > svg { color: var(--control-selected-fg) !important; }
.creation-chat-control:disabled {
opacity: .42 !important;
cursor: not-allowed;
@@ -10654,14 +10592,6 @@ html:not(.dark) .creation-chat-control[aria-pressed="true"] > svg { color: #0e74
.creation-range-wrap input::-moz-range-thumb { width: 6px; height: 14px; border-radius: 4px; }
.creation-range-labels { margin-top: 2px; color: var(--creation-faint); font-size: 9px; }
.creation-mode-picker-menu { width: 158px; padding: 5px; color: #f0f1f2; }
.creation-mode-picker-menu button { display: flex; width: 100%; height: 34px; align-items: center; gap: 8px; border: 0; border-radius: 6px; background: transparent; color: inherit; padding: 0 7px; font-size: 11px; text-align: left; }
.creation-mode-picker-menu button:hover { background: var(--control-selected-bg); }
.creation-mode-picker-menu button.is-selected { background: var(--control-selected-bg); color: var(--control-selected-fg); }
.creation-mode-picker-menu button > svg { width: 13px; height: 13px; margin-left: auto; }
.creation-menu-icon { display: grid; width: 19px; height: 19px; place-items: center; border: 0; border-radius: 5px; background: rgba(255, 255, 255, .07); }
.creation-menu-icon svg { width: 12px; height: 12px; }
.creation-model-picker-menu { width: min(332px, calc(100vw - 24px)) !important; max-height: min(420px, calc(100vh - 24px)); padding: 7px !important; background: transparent !important; color: #f0f1f2 !important; }
.creation-model-picker-heading { display: flex; align-items: center; gap: 6px; padding: 4px 7px 7px; color: #8e949d; font-size: 10px; }
.creation-model-picker-heading strong { color: #e6e8eb; font-weight: 500; }
@@ -10688,13 +10618,11 @@ html:not(.dark) .creation-control-popover-surface,
html:not(.dark) .creation-model-picker-surface { background: rgba(255, 255, 255, .94) !important; box-shadow: 0 16px 38px rgba(28, 32, 37, .12) !important; }
html:not(.dark) .creation-parameter-section h3,
html:not(.dark) .creation-duration-menu > div:first-child,
html:not(.dark) .creation-mode-picker-menu,
html:not(.dark) .creation-model-picker-menu { color: #727980 !important; }
html:not(.dark) .creation-choice-grid button { color: #737a82; }
html:not(.dark) .creation-choice-grid button:hover { background: rgba(28, 32, 37, .06); color: #25292e; }
html:not(.dark) .creation-duration-menu strong,
html:not(.dark) .creation-model-picker-heading strong { color: #20242a; }
html:not(.dark) .creation-mode-picker-menu button:hover,
html:not(.dark) .creation-model-picker-menu .canvas-model-picker-option > span:first-child > span:first-child { background: rgba(28, 32, 37, .06) !important; }
@keyframes creation-composer-arrive {
@@ -10707,6 +10635,7 @@ html:not(.dark) .creation-model-picker-menu .canvas-model-picker-option > span:f
.creation-chat-writing-surface, .creation-chat-composer.is-empty .creation-chat-writing-surface { grid-template-columns: 30px minmax(0, 1fr); gap: 8px; padding: 14px 12px 5px; }
.creation-chat-dock { align-items: flex-end; padding: 4px 7px 8px 8px; }
.creation-chat-controls { max-width: none; }
.creation-chat-controls:has(> .voice-recording-inline) { flex-wrap: wrap; }
.creation-chat-control, .creation-chat-composer .creation-model-picker.canvas-composer-model-picker { height: 29px !important; padding: 0 6px !important; font-size: 10px; }
.creation-chat-composer .creation-model-picker.canvas-composer-model-picker { width: min(136px, 42vw) !important; }
.creation-thread-stage, .creation-thread-composer { width: calc(100% - 28px); }
@@ -11591,8 +11520,7 @@ html:not(.dark) .creation-duration-choices button:hover { background: rgba(28, 3
.creation-home .creation-chat-dock { min-height: 48px; padding: 4px 12px 12px 16px; }
.creation-home .creation-chat-control,
.creation-home .creation-chat-composer .creation-model-picker.canvas-composer-model-picker,
.creation-home .creation-chat-control.is-mode {
.creation-home .creation-chat-composer .creation-model-picker.canvas-composer-model-picker {
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
@@ -11609,16 +11537,14 @@ html:not(.dark) .creation-duration-choices button:hover { background: rgba(28, 3
transform: none;
}
.creation-home .creation-chat-control.is-mode { color: var(--creation-text) !important; }
.creation-home .creation-chat-control.is-mode > svg:first-child { color: var(--creation-text); }
html:not(.dark) .creation-home .creation-chat-control:hover,
html:not(.dark) .creation-home .creation-chat-control[aria-expanded="true"],
html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.canvas-composer-model-picker:hover { background: rgba(28, 32, 37, .06) !important; }
.creation-home .creation-chat-controls > .voice-recording-inline {
width: min(236px, calc(100vw - 120px));
max-width: 236px;
flex: 0 1 236px;
flex: 1 1 236px;
min-width: 140px;
min-height: 30px;
height: 30px;
overflow: hidden;
@@ -11630,6 +11556,7 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
.creation-home .creation-chat-controls > .voice-recording-inline > svg {
flex: 0 1 160px;
max-width: 160px;
min-width: 0;
}
.creation-chat-intro h1 > .creation-intro-emphasis {
@@ -12861,8 +12788,7 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
/* 旧创作规则位于 utilities 层且使用 !important;必须在同层覆盖。 */
@layer utilities {
.creation-home .creation-chat-control,
.creation-home .creation-chat-composer .creation-model-picker.canvas-composer-model-picker,
.creation-home .creation-chat-control.is-mode {
.creation-home .creation-chat-composer .creation-model-picker.canvas-composer-model-picker {
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
@@ -12879,8 +12805,6 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
transform: none;
}
.creation-home .creation-chat-control.is-mode { color: var(--creation-text) !important; }
.creation-parameter-menu {
width: 304px;
padding: 13px 14px 14px;
@@ -14590,8 +14514,6 @@ html:not(.dark) .creation-history-drawer-root {
.skill-install-modal .ant-modal-title { font-size: var(--fs-heading-lg); font-weight: 620; }
.skill-install-modal .ant-upload-wrapper .ant-upload-drag { border-color: color-mix(in srgb, var(--foreground) 14%, transparent); border-radius: var(--r-lg); background: color-mix(in srgb, var(--foreground) 2.5%, transparent); }
.skill-install-modal .ant-upload-wrapper .ant-upload-drag:hover { border-color: color-mix(in srgb, var(--foreground) 28%, transparent); }
.skill-install-mode { padding: 3px; }
.skill-install-mode [role="radio"] { min-height: 34px; }
.skill-install-modal .skill-install-form { margin-top: 16px; }
.skill-package-modal .ant-modal-container { border: 1px solid color-mix(in srgb, var(--foreground) 11%, transparent); border-radius: var(--r-xl); background: var(--popover); box-shadow: var(--elevation-panel); }