diff --git a/backend/internal/handler/user_data.go b/backend/internal/handler/user_data.go index 9174d971b..ab822d131 100644 --- a/backend/internal/handler/user_data.go +++ b/backend/internal/handler/user_data.go @@ -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") diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 9c67221a6..e7e9c00ac 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -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)无横向溢出,全景类型卡片换为单列;选图器覆盖层可独立关闭,不误关整个弹窗。 diff --git a/web/src/components/canvas/canvas-camera-control-popover.tsx b/web/src/components/canvas/canvas-camera-control-popover.tsx index 466ea2fbd..85b68d3ff 100644 --- a/web/src/components/canvas/canvas-camera-control-popover.tsx +++ b/web/src/components/canvas/canvas-camera-control-popover.tsx @@ -34,7 +34,7 @@ export function CanvasCameraControlPopover({ cameraControl, onCameraControlChang {!compact ? 摄像机 : null} {open ? ( - setOpen(false)}> + setOpen(false)}> setOpen(false)} diff --git a/web/src/components/canvas/canvas-node-content.tsx b/web/src/components/canvas/canvas-node-content.tsx index ad5b9844f..f40888f16 100644 --- a/web/src/components/canvas/canvas-node-content.tsx +++ b/web/src/components/canvas/canvas-node-content.tsx @@ -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); }); diff --git a/web/src/components/canvas/canvas-node-lighting-dialog.tsx b/web/src/components/canvas/canvas-node-lighting-dialog.tsx index 71f93612e..0f9e330ba 100644 --- a/web/src/components/canvas/canvas-node-lighting-dialog.tsx +++ b/web/src/components/canvas/canvas-node-lighting-dialog.tsx @@ -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()} > - -
- {/* Left col: view tabs + light sphere */} -
-
+
+

打光效果

+ +
+
+
+
{(["perspective", "front"] as const).map((mode) => (
- {/* Middle col: global controls */} -
+
全局 updateOption("smartMode", checked)} theme={theme} />
-
-
- 亮度 -
updateOption("brightness", value)} tooltip={{ formatter: (value) => `${value}%` }} />
-
- - {options.brightness}% -
+
+ 亮度 +
updateOption("brightness", value)} tooltip={{ formatter: (value) => `${value}%` }} />
+
+ + {options.brightness}%
-
- 颜色 -
- - {options.lightColor === "#ffffff" ? ( -
-
-
- ) : null} +
+
+ 颜色 + updateOption("lightColor", value)} />
+ updateOption("rimLight", checked)} theme={theme} />
@@ -292,45 +287,39 @@ export function CanvasNodeLightingPanel({ dataUrl, onClose, onConfirm }: { dataU ))}
- -
- 轮廓光 - updateOption("rimLight", checked)} theme={theme} /> -
+
- {/* Right col: smart description + presets */} -
- 智能模式 -