From c7c7301c135446dd4b84893f945818f44c54525e Mon Sep 17 00:00:00 2001 From: kang Date: Mon, 18 May 2026 11:57:46 +0800 Subject: [PATCH] feat: standardize media asset tiles --- AGENTS.md | 8 + RULES.md | 1 + docs/source-analysis.html | 17 +- web/components/ad-recreation-board.tsx | 403 ++++++++++--------------- web/components/media-asset-tile.tsx | 234 ++++++++++++++ 5 files changed, 412 insertions(+), 251 deletions(-) create mode 100644 web/components/media-asset-tile.tsx diff --git a/AGENTS.md b/AGENTS.md index 9d30b3c..e8a4d19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,3 +37,11 @@ - 任何改动只要影响产品理解、节点职责、界面行为、数据模型、API、运行方式或用户操作路径,必须在同一次任务里更新 `docs/source-analysis.html` - 更新时至少补充“变更记录”,必要时同步更新源码结构地图、界面区域到源码、数据模型、接口地图、节点职责边界 - 不要把源码解析页接入主应用路由,除非用户明确要求;它默认是项目内独立 HTML 文档 + +## Media Asset UI Contract + +- 任何图片 / 视频 / 抽帧 / 产品图 / 生成图 / 首尾帧 / 视频候选缩略图,不允许临时手写一套孤立交互;当前工作台新增媒体展示默认复用 `web/components/media-asset-tile.tsx` +- 所有媒体缩略图默认支持鼠标停留放大预览;预览层必须挂到顶层固定浮层,不能被滚动容器、面板或表格裁切 +- 可删除的媒体素材必须显示删除入口;删除按钮、重新生成按钮、状态遮罩和悬停预览交互要在全项目保持一致 +- 缩略图尺寸可按区域调整,但图片 / 视频必须可完整查看;需要裁切时必须仍能通过悬停预览看到完整素材 +- 新增媒体板块验收时必须检查:悬停放大、删除入口、预览不被遮挡、图片完整性、视频预览、模型 / 状态标注是否与已有板块一致 diff --git a/RULES.md b/RULES.md index 89647a8..08e63a5 100644 --- a/RULES.md +++ b/RULES.md @@ -79,6 +79,7 @@ - 没有公网地址时,`.project.json.urls` 保持空数组 - 任何部署或域名变化,都要先改元数据,再视为任务完成 - 用户给到源码 / 下载包 / 参考实现时,默认优先按源码实现和复刻,不先自创“类似效果”;如果因安全、依赖、性能或部署限制必须改写,必须先说明差异和原因。 +- 媒体素材交互为项目基底规则:任何图片、视频、抽帧、产品图、AI 生成图、首尾帧和视频候选缩略图,默认复用 `web/components/media-asset-tile.tsx`;必须支持鼠标停留顶层放大预览,可删除素材必须有删除按钮,预览不能被面板或滚动容器遮挡。 ## 注意事项 - 项目内源码解析页:`docs/source-analysis.html` diff --git a/docs/source-analysis.html b/docs/source-analysis.html index e05433c..4871667 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -590,6 +590,7 @@ web/app/globals.css全局主题变量、登录页视觉样式、ReactFlow 样式引用,以及本地开发态 nextjs-portal 遮挡隐藏规则。 web/app/page.tsx产品工作台主状态:jobs、activeJobId、生成任务状态;主渲染为全屏素材输入列 + 信息流广告复刻工作表;“开始分析”会把 job 放入并行素材分析队列,下载完成后触发 triggerTranscribe 解析音频,并触发 analyzeJob 自动抽 12 张参考帧,形成“音频文案路 + 视频视觉路”同步推进;底部吸附音频条不再从主界面渲染。 web/components/ad-recreation-board.tsx信息流广告复刻工作表:左侧素材输入只负责链接/上传和任务切换,不再重复放横版原视频预览;右侧顶部用“音频文案路、视频视觉路、主体资产、产品资产”四个状态条显示后台并行进度。源视频工作区展示视频下载状态和默认折叠的文案依据。音频解析结果改成默认折叠的辅助信息,展开后同一行看讲话人/节奏/背景音;主工作区左侧是按 9:16 显示的竖版原视频播放器,播放器内覆盖“当前点抽帧”,按当前播放秒数手动补参考帧;右侧上方是音频波形 / 切点参考,下方是逐句时间轴;下一行铺开“关键帧 / 相似主体”。音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点,顶部同时显示当前播放秒数、总时长和鼠标指针停点秒数。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。关键帧区的主入口是“自动抽帧 12 张”,一键按动作峰值目标重新抽取 12 张源视频参考帧,优先抓手势、表情变化、节奏点和镜头变化,缩略图按竖版完整比例显示不裁切并用更多列紧凑铺开,鼠标停留会通过固定浮层放大展示完整帧。“生成 10 张高清图”放在相似主体白底视图区,不和抽参考按钮平齐;如果用户没有勾选帧,默认把全部关键帧作为主体参考,勾选后只传已选帧;生成区可在“透明骨架 / 普通真人”之间切换,可选择桌面导入的 5 套内置形象作为创意方向,并可填写统一主体方向,例如年轻女性、更运动、更高级。关键帧和相似主体白底视图都用更小的竖版缩略图密排;白底视图只展示每个 view 的最新一张,缩略图上提供“重新生成这一张”和“删除这一张”,单张重生会用 replace_views=true 替换同一视角。前端调用 generateSubjectAssets 时按主体类型传 subject_style=transparent_humansource_actor,按需传 character_id,并使用 reconstruction_mode=similar;后端会把关键帧和内置形象视为同一个主体的创意证据,并锁定同一性别表现、年龄段、体型、材质、风格和视觉身份,同时生成全身多视角 + 肩颈正/左右近景 + 后颈肩背特写,避免整套图出现男女性别、老少年龄或样式混杂。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px,但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写,分镜时间和原内容列压缩为窄摘要列,新口播列进一步收窄,把横向空间留给画面规划和首尾帧。每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入和历史候选视频槽;画面规划区先选择镜头类型(人物/情绪、人物+产品、产品特写、场景过渡),再用人物/产品开关、首帧规划、尾帧规划和产品出现方式决定这一条到底需不需要产品图或相似主体参考。当前主流程暂停直接调用视频模型,不再提供“生成本条 · Seedance”或“一键提交全部”视频入口;行内新增“首尾帧闸门”,分别显示/生成首帧和尾帧,旧 keyframe 类型首尾帧会被忽略,只认真正的 asset 首尾帧。生成首尾帧时调用 generateSceneAsset,传入相似主体白底视图 subject_images 和该行自动挑选的产品图 product_images;关键帧只作为前置主体重构证据和行数据承载位置,不再作为后续视频首尾帧参考。视频候选槽只展示历史候选和待生成占位,按钮改为“保存本条规划 / 保存全部规划”。只有该行勾选“产品”时,首尾帧生成才会从产品素材池按分镜角色、视角优先级、用途标签、置信度和风险自动挑选最多 6 张相关产品图;未勾选产品时不会把产品图提交给首尾帧/后续生视频模型。只有该行勾选“人物”时,才会传相似主体参考图;否则 prompt 会明确禁止强行添加主角式透明骨架人。ModelTrace 会在音频解析、产品识别/补图、相似主体高清视图包、脚本改写等入口旁直接展示模型名;所有生图入口都显示并使用 gpt-image-2,没有其他图片模型 fallback;点击后用固定浮层展示模型链路、输入输出和回退逻辑。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 + web/components/media-asset-tile.tsx项目内媒体素材缩略图基底组件:图片、视频、抽帧、产品图、相似主体图、首尾帧和视频候选默认从这里获得统一交互。组件负责缩略图显示、顶层固定浮层 hover 放大、删除按钮、重新生成等操作按钮、忙碌遮罩和图片/视频共用预览,避免每个新板块重复手写不同的媒体交互。 web/app/login/page.tsx生产登录页:访问账号/访问密钥表单、保持登录、错误/成功状态;当前只在原版 Digital Oasis 动态背景上叠加一个组合登录框,桌面端左侧是动态角色,右侧是图标化登录表单;面板左上角展示官网 SKG 字标和中文“营销内容工作台”系统标识。 web/app/login/layout.tsx登录路由专属 layout:覆盖全站默认网页标题和描述为空,避免 /login 继承工作台 metadata 后在页面源码里继续出现登录界面文字以外的文案。 web/components/login/oasis-canvas.tsx登录页全屏动态视觉层:用 iframe 直接承载下载包 web/public/oasis-source/index.html 的原 WebGPU / Three.js 草场源码;父级登录页只覆盖自己的文案和表单,并在捕获阶段把全局鼠标坐标同时用原生事件和 postMessage 转发给 iframe,避免登录面板或输入框遮挡时草地失去鼠标响应。 @@ -650,12 +651,12 @@ api/main.py
你看到的区域音频解析结果表
-
主要源码AudioIntakePanel / SourceReferenceBuildPanel in web/components/ad-recreation-board.tsx;复用 triggerTranscribeAudioScriptanalyzeJobaddManualFramedeleteFramegenerateSubjectAssets
+
主要源码AudioIntakePanel / SourceReferenceBuildPanel in web/components/ad-recreation-board.tsx;关键帧和相似主体缩略图复用 MediaAssetTile;后端复用 triggerTranscribeAudioScriptanalyzeJobaddManualFramedeleteFramegenerateSubjectAssets
适合怎么描述“竖版原视频尺寸、播放器内当前播放点手动抽帧、自动抽帧 12 张入口、关键帧删除、相似主体高清视图包、内置形象选择、透明骨架/普通真人主体类型、连续响度波形、逐句时间轴滚动、高亮和跳转联动还需要怎么调整”。
你看到的区域信息流复刻分镜工作台
-
主要源码AudioStoryboardPlanPanelProductReferenceCardMissingProductViewSlotbuildAudioStoryboardRowsselectProductItemsForRowsubjectAssetRefsForPlanningendpointAssetRefbuildEndpointFramePromptbuildStoryboardSceneFromAudioRowgenerateEndpointFrameForRowsaveRowStoryboardDraftsaveAllStoryboardDraftsEndpointFrameSlotStoryboardVideoSlots in web/components/ad-recreation-board.tsx;产品白底图上传复用 uploadStoryboardAsset,视角自动识别调用 analyzeProductViews,缺角度自动补图调用 generateProductAngleAsset。当前单条/批量按钮只保存规划;首尾帧按钮调用 generateSceneAsset,把相似主体白底视图和产品素材写入 subject_images/product_images,再用 PUT /frames/{idx}/storyboard 保存 asset 首尾帧引用。web/app/page.tsx 的视频提交回调有暂停保护,旧入口误触也不会请求 /storyboard/video
+
主要源码AudioStoryboardPlanPanelProductReferenceCardMissingProductViewSlotbuildAudioStoryboardRowsselectProductItemsForRowsubjectAssetRefsForPlanningendpointAssetRefbuildEndpointFramePromptbuildStoryboardSceneFromAudioRowgenerateEndpointFrameForRowsaveRowStoryboardDraftsaveAllStoryboardDraftsEndpointFrameSlotStoryboardVideoSlots in web/components/ad-recreation-board.tsx;产品图、首尾帧和视频候选缩略图统一复用 MediaAssetTile,包括顶层 hover 放大和删除入口。产品白底图上传复用 uploadStoryboardAsset,视角自动识别调用 analyzeProductViews,缺角度自动补图调用 generateProductAngleAsset。当前单条/批量按钮只保存规划;首尾帧按钮调用 generateSceneAsset,把相似主体白底视图和产品素材写入 subject_images/product_images,再用 PUT /frames/{idx}/storyboard 保存 asset 首尾帧引用;首尾帧删除只移除本条规划中的引用,避免继续误用旧资产。web/app/page.tsx 的视频提交回调有暂停保护,旧入口误触也不会请求 /storyboard/video
适合怎么描述“按音频逐句生成产品分镜、每行怎样改写口播、哪几句不需要产品或人物、首帧/尾帧该怎么停、首尾帧是否已经生成并准确、产品素材池识别/补图后的备注是否准确、哪些分镜后续才值得进入单条视频候选”。
@@ -1015,6 +1016,18 @@ ProductRefStateItem {

变更记录

这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。

+
+
+

2026-05-18 · 媒体素材交互收口为统一组件

+ UI + Contract +
+
+

问题:每新增图片/视频板块都临时写缩略图,导致 hover 放大、删除按钮、预览浮层层级和图片完整显示规则反复不一致。

+

改动:新增 web/components/media-asset-tile.tsx 作为项目内媒体素材基底组件,并把当前主路径的关键帧、相似主体图、产品素材图、首尾帧和历史视频候选接入同一套缩略图、顶层 hover 预览、删除/重生按钮和忙碌遮罩逻辑;AGENTS.mdRULES.md 同步新增媒体素材 UI Contract。

+

影响:以后当前项目凡是图片、视频、抽帧、产品图、AI 生成图、首尾帧或视频候选,默认不能再手写孤立交互;只允许在统一组件参数上调整尺寸和显示内容。

+
+

2026-05-18 · 暂停直接视频提交,改为首尾帧闸门

diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx index 6188cb1..1cf8d52 100644 --- a/web/components/ad-recreation-board.tsx +++ b/web/components/ad-recreation-board.tsx @@ -1,6 +1,6 @@ "use client" -import { type MouseEvent as ReactMouseEvent, type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react" +import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react" import { createPortal } from "react-dom" import { AlertTriangle, Check, ChevronDown, Circle, Film, FileText, Image as ImageIcon, Info, Link2, Loader2, @@ -49,6 +49,7 @@ import { videoUrl, } from "@/lib/api" import { type NodeData } from "@/components/nodes" +import { MediaAssetTile } from "@/components/media-asset-tile" const TARGETS: Array<{ value: FrameExtractTarget; label: string }> = [ { value: "balanced", label: "综合" }, @@ -942,8 +943,11 @@ function subjectAssetRefsForPlanning(source: { frame: KeyFrame; element: KeyElem function endpointAssetRef(frame: KeyFrame | null, role: "first_frame" | "last_frame"): ImageRef | null { if (!frame) return null - const saved = role === "first_frame" ? frame.storyboard?.first_image : frame.storyboard?.last_image - if (saved && saved.kind !== "keyframe") return saved + const key = role === "first_frame" ? "first_image" : "last_image" + if (frame.storyboard && Object.prototype.hasOwnProperty.call(frame.storyboard, key)) { + const saved = role === "first_frame" ? frame.storyboard.first_image : frame.storyboard.last_image + return saved && saved.kind !== "keyframe" ? saved : null + } const asset = [...(frame.scene_assets ?? [])].reverse().find((item) => item.asset_role === role) if (!asset) return null return { @@ -1320,6 +1324,7 @@ export function AdRecreationBoard({ job={job} selectedFrames={data.selectedFrames} onJobUpdate={data.onJobUpdate} + onDeleteVideo={data.onDeleteVideo} runtimeModels={runtimeModels} />
@@ -1718,8 +1723,6 @@ function SourceReferenceBuildPanel({ const [subjectBusy, setSubjectBusy] = useState(false) const [subjectAssetBusy, setSubjectAssetBusy] = useState(null) const [deletingFrame, setDeletingFrame] = useState(null) - const [framePreview, setFramePreview] = useState<{ index: number; left: number; top: number } | null>(null) - const [subjectAssetPreview, setSubjectAssetPreview] = useState<{ id: string; left: number; top: number } | null>(null) const [subjectStyle, setSubjectStyle] = useState("transparent_human") const [subjectDirection, setSubjectDirection] = useState("") const [characterLibrary, setCharacterLibrary] = useState([]) @@ -1755,8 +1758,6 @@ function SourceReferenceBuildPanel({ return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi) }) }, [actorAssets]) - const previewFrame = framePreview ? frames.find((frame) => frame.index === framePreview.index) ?? null : null - const previewSubjectAsset = subjectAssetPreview ? visibleActorAssets.find((asset) => asset.id === subjectAssetPreview.id) ?? null : null const referenceCountLabel = selectedReferenceFrames.length ? `使用已选 ${selectedReferenceFrames.length} 张` : frames.length @@ -1894,85 +1895,8 @@ function SourceReferenceBuildPanel({ } } - const updateFramePreviewPosition = (event: ReactMouseEvent, frameIndex: number) => { - const margin = 16 - const previewWidth = Math.min(340, window.innerWidth - margin * 2) - const previewHeight = previewWidth * 16 / 9 + 44 - let left = event.clientX + 18 - let top = event.clientY + 18 - if (left + previewWidth > window.innerWidth - margin) { - left = event.clientX - previewWidth - 18 - } - if (top + previewHeight > window.innerHeight - margin) { - top = window.innerHeight - previewHeight - margin - } - setFramePreview({ - index: frameIndex, - left: Math.max(margin, left), - top: Math.max(margin, top), - }) - } - - const updateSubjectAssetPreviewPosition = (event: ReactMouseEvent, assetId: string) => { - const margin = 16 - const previewWidth = Math.min(420, window.innerWidth - margin * 2) - const previewHeight = Math.min(720, window.innerHeight - margin * 2) - let left = event.clientX + 18 - let top = event.clientY + 18 - if (left + previewWidth > window.innerWidth - margin) { - left = event.clientX - previewWidth - 18 - } - if (top + previewHeight > window.innerHeight - margin) { - top = window.innerHeight - previewHeight - margin - } - setSubjectAssetPreview({ - id: assetId, - left: Math.max(margin, left), - top: Math.max(margin, top), - }) - } - - const framePreviewPortal = framePreview && previewFrame && typeof document !== "undefined" - ? createPortal( -
- -
- 参考帧 {String(frames.findIndex((frame) => frame.index === previewFrame.index) + 1).padStart(2, "0")} - {previewFrame.timestamp.toFixed(1)}s -
-
, - document.body, - ) - : null - const subjectAssetPreviewPortal = subjectAssetPreview && previewSubjectAsset && typeof document !== "undefined" - ? createPortal( -
-
- -
-
- {previewSubjectAsset.label || previewSubjectAsset.view || "主体视图预览"} - {previewSubjectAsset.width}x{previewSubjectAsset.height} -
-
, - document.body, - ) - : null - return (
- {framePreviewPortal} - {subjectAssetPreviewPortal}
} title="关键帧 / 相似主体" />
@@ -2001,43 +1925,23 @@ function SourceReferenceBuildPanel({ {frames.map((frame, index) => { const selected = selectedFrames.has(frame.index) return ( -
updateFramePreviewPosition(event, frame.index)} - onMouseMove={(event) => updateFramePreviewPosition(event, frame.index)} - onMouseLeave={() => setFramePreview(null)} - className={`group relative aspect-[9/16] overflow-hidden rounded border bg-black transition ${ - selected ? "border-emerald-300/70" : "border-white/10 hover:border-cyan-300/40" - }`} + src={effectiveFrameUrl(job.id, frame)} + alt={`关键帧 ${index + 1}`} + label={`参考帧 ${String(index + 1).padStart(2, "0")}`} + meta={`${frame.timestamp.toFixed(1)}s`} + className="aspect-[9/16]" + objectFit="contain" + selected={selected} title={`关键帧 ${index + 1} · ${frame.timestamp.toFixed(1)}s`} - > - - {String(index + 1).padStart(2, "0")} - - {selected ? : } - - {onDeleteFrame && ( - - )} -
+ onClick={() => onToggleFrame(frame.index)} + topLeft={{String(index + 1).padStart(2, "0")}} + topRight={{selected ? : }} + onDelete={onDeleteFrame ? () => void deleteReferenceFrame(frame.index) : undefined} + deleting={deletingFrame === frame.index} + deleteLabel={`删除关键帧 ${index + 1}`} + /> ) })} {!frames.length && ( @@ -2151,53 +2055,30 @@ function SourceReferenceBuildPanel({ {visibleActorAssets.map((asset) => { const busyMode = subjectAssetBusy?.endsWith(asset.id) ? subjectAssetBusy.split(":")[0] : "" return ( -
updateSubjectAssetPreviewPosition(event, asset.id)} - onMouseMove={(event) => updateSubjectAssetPreviewPosition(event, asset.id)} - onMouseLeave={() => setSubjectAssetPreview(null)} - className="group relative aspect-[9/16] w-12 overflow-hidden rounded border border-white/10 bg-white transition hover:border-cyan-200/70 2xl:w-14" - title={asset.label || asset.view} - > - - {asset.label - -
- - -
-
+ alt={asset.label || asset.view} + label={asset.label || asset.view || "主体视图预览"} + meta={asset.width && asset.height ? `${asset.width}x${asset.height}` : undefined} + className="aspect-[9/16] w-12 bg-white 2xl:w-14" + objectFit="contain" + title={asset.label || asset.view} + actions={[{ + key: "regen", + label: "重新生成这一张", + icon: , + tone: "cyan", + busy: busyMode === "regen", + disabled: !!subjectAssetBusy, + onClick: () => void regenerateSubjectAsset(asset), + }]} + onDelete={() => void deleteActorAsset(asset)} + deleting={busyMode === "delete"} + deleteDisabled={!!subjectAssetBusy} + deleteLabel="删除这一张" + /> ) })}
@@ -2216,11 +2097,13 @@ function AudioStoryboardPlanPanel({ job, selectedFrames, onJobUpdate, + onDeleteVideo, runtimeModels, }: { job: Job | null selectedFrames: Set onJobUpdate?: (job: Job) => void + onDeleteVideo?: (videoId: string) => void runtimeModels?: RuntimeModels }) { const [storyboardSaveBusyRow, setStoryboardSaveBusyRow] = useState(null) @@ -2579,6 +2462,26 @@ function AudioStoryboardPlanPanel({ } } + const clearEndpointFrameForRow = async (row: AudioStoryboardRow, frame: KeyFrame | null, role: "first_frame" | "last_frame") => { + if (!job || !frame) return + const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row) } + const busyKey = `${row.index}:clear_${role}` + setEndpointFrameBusy(busyKey) + try { + const scene = buildStoryboardSceneFromAudioRow(plannedRow, frame, productItems, subjectRefs, { + firstImage: role === "first_frame" ? null : endpointAssetRef(frame, "first_frame"), + lastImage: role === "last_frame" ? null : endpointAssetRef(frame, "last_frame"), + }) + const updated = await updateStoryboard(job.id, frame.index, scene) + onJobUpdate?.(updated) + toast.success(`${role === "first_frame" ? "首帧" : "尾帧"}已从本条规划移除`) + } catch (e) { + toast.error("移除首尾帧失败:" + (e instanceof Error ? e.message : String(e))) + } finally { + setEndpointFrameBusy(null) + } + } + const saveSingleRowStoryboardDraft = async (row: AudioStoryboardRow, frame: KeyFrame | null) => { if (!job || !frame) return setStoryboardSaveBusyRow(row.index) @@ -2868,16 +2771,20 @@ function AudioStoryboardPlanPanel({ frame={referenceFrame} role="first_frame" busy={endpointFrameBusy === `${row.index}:first_frame`} + deleting={endpointFrameBusy === `${row.index}:clear_first_frame`} disabled={!referenceFrame || (plannedRow.needsSubject && !subjectRefs.length) || (plannedRow.needsProduct && !productItems.length)} onGenerate={() => void generateEndpointFrameForRow(plannedRow, referenceFrame, "first_frame")} + onDelete={() => void clearEndpointFrameForRow(plannedRow, referenceFrame, "first_frame")} /> void generateEndpointFrameForRow(plannedRow, referenceFrame, "last_frame")} + onDelete={() => void clearEndpointFrameForRow(plannedRow, referenceFrame, "last_frame")} />
@@ -2898,6 +2805,7 @@ function AudioStoryboardPlanPanel({ job={job} videos={rowVideos} enabled={!!endpointAssetRef(referenceFrame, "first_frame") && !!endpointAssetRef(referenceFrame, "last_frame")} + onDeleteVideo={onDeleteVideo} />
{endpointAssetRef(referenceFrame, "first_frame") && endpointAssetRef(referenceFrame, "last_frame") @@ -2949,59 +2857,30 @@ function ProductReferenceCard({ const assetWarnings = item.assetMeta?.warnings ?? [] const assetActions = item.assetMeta?.actions ?? [] const orientationText = formatProductOrientation(item.orientation) - const [previewPos, setPreviewPos] = useState<{ left: number; top: number } | null>(null) - - function updatePreviewPosition(event: ReactMouseEvent) { - const margin = 16 - const previewWidth = Math.min(380, window.innerWidth - margin * 2) - const previewHeight = previewWidth + 118 - let left = event.clientX + 18 - let top = event.clientY + 18 - if (left + previewWidth > window.innerWidth - margin) { - left = event.clientX - previewWidth - 18 - } - if (top + previewHeight > window.innerHeight - margin) { - top = window.innerHeight - previewHeight - margin - } - setPreviewPos({ - left: Math.max(margin, left), - top: Math.max(margin, top), - }) - } - - const preview = previewPos && typeof document !== "undefined" - ? createPortal( -
- -
- {productViewLabel(item.view)} · {productBackgroundLabel(item.background)} · {tagLabels.join(" / ")} -
- {item.note} - {orientationText ? <>
方向:{orientationText} : null} - {item.landmarks.length ? <>
结构:{item.landmarks.join(" / ")} : null} - {item.risk ? <>
风险:{item.risk} : null} - {assetWarnings.length ? <>
规格:{assetWarnings.join(";")} : null} -
-
, - document.body, - ) - : null + const previewDetail = ( + <> + {productViewLabel(item.view)} · {productBackgroundLabel(item.background)} · {tagLabels.join(" / ") || "用途待标注"} +
+ {item.note || "无备注"} + {orientationText ? <>
方向:{orientationText} : null} + {item.landmarks.length ? <>
结构:{item.landmarks.join(" / ")} : null} + {item.risk ? <>
风险:{item.risk} : null} + {assetWarnings.length ? <>
规格:{assetWarnings.join(";")} : null} + + ) return (
-
setPreviewPos(null)} - > - {productViewLabel(item.view)} - {preview} - {item.source === "ai" ? "AI" : "图"} -
+ {item.source === "ai" ? "AI" : "图"}} + />
{productViewLabel(item.view)} @@ -3085,14 +2964,30 @@ function StoryboardPlanCell({ label, children, className = "" }: { label: string ) } -function StoryboardVideoSlots({ job, videos, enabled }: { job: Job; videos: GeneratedVideo[]; enabled: boolean }) { +function StoryboardVideoSlots({ + job, + videos, + enabled, + onDeleteVideo, +}: { + job: Job + videos: GeneratedVideo[] + enabled: boolean + onDeleteVideo?: (videoId: string) => void +}) { const visible = videos.slice(0, 6) const emptyCount = Math.max(0, 6 - visible.length) return (
{visible.map((video) => ( - + onDeleteVideo(video.id) : undefined} + /> ))} {Array.from({ length: emptyCount }).map((_, index) => (
@@ -3112,35 +3007,38 @@ function EndpointFrameSlot({ frame, role, busy, + deleting, disabled, onGenerate, + onDelete, }: { job: Job frame: KeyFrame | null role: "first_frame" | "last_frame" busy: boolean + deleting?: boolean disabled: boolean onGenerate: () => void + onDelete?: () => void }) { const ref = endpointAssetRef(frame, role) const src = ref ? resolveImageRefUrl(job.id, ref) : "" const label = role === "first_frame" ? "首帧" : "尾帧" return (
-
- {src ? ( - - {`${label}资产`} - - ) : ( -
先生成{label}
- )} - {busy && ( -
- -
- )} -
+ + ) : ( +
{media}
+ ) + + const preview = position && canPreview && typeof document !== "undefined" + ? createPortal( +
+
+ {kind === "video" && src ? ( +
+ {(label || meta || previewDetail) && ( +
+ {(label || meta) && ( +
+ {label} + {meta ? {meta} : null} +
+ )} + {previewDetail ?
{previewDetail}
: null} +
+ )} +
, + document.body, + ) + : null + + return ( +
setPosition(null)} + > + {body} + {preview} + {topLeft ?
{topLeft}
: null} + {topRight ?
{topRight}
: null} + {bottom ?
{bottom}
: null} + {(actions.length || onDelete) ? ( +
+ {actions.map((action) => ( + + ))} + {onDelete ? ( + + ) : null} +
+ ) : null} + {busy ? ( +
+ +
+ ) : null} +
+ ) +}