auto-save 2026-05-14 12:48 (~4)

This commit is contained in:
2026-05-14 12:48:34 +08:00
parent 2d1a89f03e
commit 9e8088e436
4 changed files with 118 additions and 25 deletions

View File

@@ -1,19 +1,5 @@
{
"entries": [
{
"files_changed": 1,
"hash": "169951b",
"message": "auto-save 2026-05-13 06:09 (~1)",
"ts": "2026-05-13T06:09:56+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "d0b73fd",
"message": "auto-save 2026-05-13 06:15 (~1)",
"ts": "2026-05-13T06:15:50+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "1dd2c67",
@@ -3287,6 +3273,19 @@
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 1 项未提交变更 · 最近提交auto-save 2026-05-14 12:37 (~6)",
"files_changed": 1
},
{
"ts": "2026-05-14T12:43:03+08:00",
"type": "commit",
"message": "auto-save 2026-05-14 12:42 (~9)",
"hash": "2d1a89f",
"files_changed": 9
},
{
"ts": "2026-05-14T04:46:11Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 4 项未提交变更 · 最近提交auto-save 2026-05-14 12:42 (~9)",
"files_changed": 4
}
]
}

View File

@@ -556,8 +556,8 @@
<div class="step"><div class="num">3</div><h3>清洗水印</h3><p>对关键帧做全图或区域清洗,清洗版先进入待审核状态;确认后可单张替换,也可一键替换全部待应用清洗版。</p></div>
<div class="step"><div class="num">4</div><h3>主体识别</h3><p>识别场景和主体候选,只是候选,不应锁死。</p></div>
<div class="step"><div class="num">5</div><h3>素材准备</h3><p>清洗关键帧,把多张关键帧作为同一主体的参考,先重绘六张标准站立主体资产图,再按关键帧生成多个去主体、相似或换风格场景图。</p></div>
<div class="step"><div class="num">6</div><h3>分镜改造</h3><p>把参考主体、场景、动作和 SKG 产品放入分镜结构;产品融合使用纵向 6 行镜头工作表,每行绑定产品图、白底人物图、产品区域、场景图、描述词秒数和单条生成入口</p></div>
<div class="step"><div class="num">7</div><h3>生成视频</h3><p>普通分镜可调用 Seedance / Kling / Veo 3产品融合固定用 GPT Image 2 生成位置引导图,再用 Seedance 按秒数生成视频,结果回写到画面工作台节点</p></div>
<div class="step"><div class="num">6</div><h3>分镜改造</h3><p>把参考主体、场景、动作和 SKG 产品放入分镜结构;产品融合使用纵向 6 行镜头工作表,只补人物首帧、尾帧、描述词秒数,产品图固定内置</p></div>
<div class="step"><div class="num">7</div><h3>生成视频</h3><p>普通分镜可调用 Seedance / Kling / Veo 3产品融合自动传入固定 4 张 SKG 产品图和每行首尾帧,用 Seedance 按秒数生成视频,结果回写到对应行</p></div>
<div class="step"><div class="num">8</div><h3>声音文案</h3><p>音频轨独立处理:提取原音频并按实际秒数生成 SKG 英文产品介绍 voice-overASR/翻译只作为改前对照和节奏参考;配置 MiniMax 后从男声、女声、成熟声池随机生成自然英文配音 mp3。底部音频条播放原音频时指针会按时间走过字幕节点。</p></div>
<div class="step"><div class="num">9</div><h3>合成成品</h3><p>片段、字幕、配音、转场合成最终 mp4。当前未实现。</p></div>
</div>
@@ -629,7 +629,7 @@ api/main.py
</div>
<div class="flow-row">
<div><strong>你看到的区域</strong><span>关键帧素材审核面板</span></div>
<div><strong>主要源码</strong><span><code>FrameLightbox</code>;按“原图/清洗、主体资产、首尾帧、产品融合、审核”五个页签组织;左侧只放主图/框选画布,但主体资产页左侧改为全部已清洗/已选参考帧网格,首尾帧页左侧显示全部关键帧并可勾选人物/机位参考。主体识别页会显示透明骨架人目标和 Vision 验收分数。清洗页右侧支持一键清洗未处理帧、单张替换清洗版和一键替换全部待应用清洗版;批量替换顺序调用 <code>applyCleanedFrame</code>,不新增后端接口。产品融合页左侧是纵向 6 行镜头工作表:每行只显示首帧、尾帧、已预填动作描述、秒数、生成按钮和对应视频结果;四张桌面 SKG 产品图作为固定产品参考,生成时通过 <code>copyProductLibraryAsset</code> 自动写入镜头,不再暴露产品角度槽、产品融合辅助栏或产品图库选择器。产品融合槽位的“粘贴”优先使用应用内 <code>clipboard</code>,也支持选中槽位后 Cmd+V 粘贴系统图片。主体资产页只确认一个统一主体,后端按参考重绘六张纯背景、占满画面的标准站立透明骨架人资产图;首尾帧页通过地点、风格、参考要素和可编辑 prompt 做文字生图,生成结果写入 <code>scene_assets</code> 但以 <code>asset_role=first_frame/last_frame</code> 标记,并自动传入当前产品融合镜头。相关接口包括 <code>cleanupFrame</code><code>applyCleanedFrame</code><code>addElement</code><code>generateSubjectAssets</code><code>generateSceneAsset</code><code>copyProductLibraryAsset</code></span></div>
<div><strong>主要源码</strong><span><code>FrameLightbox</code>;按“原图/清洗、主体资产、首尾帧、产品融合、审核”五个页签组织;左侧只放主图/框选画布,但主体资产页左侧改为全部已清洗/已选参考帧网格,首尾帧页左侧显示全部关键帧并可勾选人物/机位参考。主体识别页会显示透明骨架人目标和 Vision 验收分数。清洗页右侧支持一键清洗未处理帧、单张替换清洗版和一键替换全部待应用清洗版;批量替换顺序调用 <code>applyCleanedFrame</code>,不新增后端接口。产品融合页左侧是纵向 6 行镜头工作表:每行只显示首帧、尾帧、已预填动作描述、秒数、生成按钮和对应视频结果;描述词内置 36 条镜头语言模板,按“建立出场、产品入画、佩戴贴合、使用感受、生活延展、收尾记忆”排列,点击“换一组”只刷新 6 行描述词。四张桌面 SKG 产品图作为固定产品参考,生成时通过 <code>copyProductLibraryAsset</code> 自动写入镜头,不再暴露产品角度槽、产品融合辅助栏或产品图库选择器。产品融合槽位的“粘贴”优先使用应用内 <code>clipboard</code>,也支持选中槽位后 Cmd+V 粘贴系统图片。主体资产页只确认一个统一主体,后端按参考重绘六张纯背景、占满画面的标准站立透明骨架人资产图;首尾帧页通过地点、风格、参考要素和可编辑 prompt 做文字生图,生成结果写入 <code>scene_assets</code> 但以 <code>asset_role=first_frame/last_frame</code> 标记,并自动传入当前产品融合镜头。相关接口包括 <code>cleanupFrame</code><code>applyCleanedFrame</code><code>addElement</code><code>generateSubjectAssets</code><code>generateSceneAsset</code><code>copyProductLibraryAsset</code></span></div>
<div><strong>适合怎么描述</strong><span>“这一组关键帧如何共同生成一个统一主体包;某张关键帧的水印、去主体场景图、产品融合镜头组和质量风险应该如何审核”。</span></div>
</div>
<div class="flow-row">
@@ -806,7 +806,7 @@ SubjectAsset {
<tr><td>产品图库</td><td><code>GET /product-library/skg</code></td><td><code>listProductLibrary</code></td><td>读取内置 SKG 白底图库 manifest返回产品标题、品类、尺寸、白底评分和预览图 URL。</td></tr>
<tr><td>产品图入库到 job</td><td><code>POST /jobs/{id}/assets/product-library</code></td><td><code>copyProductLibraryAsset</code></td><td>把一个内置产品图库条目复制为当前 job 的普通 asset返回 <code>ImageRef(kind="asset")</code>,用于画面工作台产品融合和分镜产品参考组。</td></tr>
<tr><td>产品融合引导图</td><td><code>POST /jobs/{id}/product-fusion/guide</code></td><td><code>createProductFusionGuide</code></td><td>旧流程兼容接口:读取产品图和白底人物图,按 <code>product_region</code> 合成位置引导图。当前首尾帧流程不再主动调用它。</td></tr>
<tr><td>产品融合描述词</td><td><code>POST /jobs/{id}/product-fusion/descriptions</code></td><td><code>generateProductFusionDescriptions</code></td><td>兼容接口:可生成 20 条产品融合动作描述库。当前前端默认直接用本地 20 条精准模板预填 6 行镜头,不再显示单独的 AI 草拟入口</td></tr>
<tr><td>产品融合描述词</td><td><code>POST /jobs/{id}/product-fusion/descriptions</code></td><td><code>generateProductFusionDescriptions</code></td><td>兼容接口:可生成产品融合动作描述库。当前前端默认直接用本地 36 条镜头语言模板预填 6 行镜头,并通过“换一组”按钮按 6 条一组轮换</td></tr>
<tr><td>分镜保存</td><td><code>PUT /frames/{idx}/storyboard</code></td><td><code>updateStoryboard</code></td><td>保存 4 图槽、时长和改造说明。</td></tr>
<tr><td>生图</td><td><code>POST /frames/{idx}/generate</code></td><td><code>generateImage</code></td><td>基于关键帧或已选生成图做 image-to-image目前可用。</td></tr>
</tbody>
@@ -917,6 +917,18 @@ SubjectAsset {
<h2>变更记录</h2>
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
<div class="changelog">
<article class="change">
<header>
<h3>2026-05-14 · 音频提取直接生成英文产品口播</h3>
<span class="tag gray">Audio</span>
<span class="tag green">MiniMax</span>
</header>
<div class="body">
<p><strong>问题:</strong>“提取音频”不能只做原音频转文字再改写,用户需要点击后直接得到介绍 SKG 产品的英文文案和配音,长度尽量贴近原音频,并且声音不能生硬。</p>
<p><strong>改动:</strong><code>pipeline_transcribe</code> 提取 <code>audio.wav</code> 后读取原音频时长,用该时长估算英文口播词数;<code>_rewrite_audio_script_sync</code> 改为生成自然、有趣、可直接 TTS 的 SKG 英文产品介绍。ASR/翻译保留为对照和节奏参考ASR 不可用时仍继续生成产品口播。MiniMax voice_id 改为从 <code>MINIMAX_TTS_VOICE_POOL</code> 随机选择男声、女声或成熟声。</p>
<p><strong>影响:</strong><code>api/main.py</code><code>api/.env.example</code><code>api/README.md</code><code>RULES.md</code><code>web/components/nodes/index.tsx</code><code>web/components/audio-strip.tsx</code><code>web/components/dashboard.tsx</code><code>docs/source-analysis.html</code></p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-14 · 产品融合描述词扩成 20 条精准模板</h3>
@@ -1042,13 +1054,13 @@ SubjectAsset {
</article>
<article class="change">
<header>
<h3>2026-05-14 · 音频处理接入 SKG 英文口播改写与 MiniMax 配音</h3>
<h3>2026-05-14 · 音频处理接入 SKG 英文产品口播与 MiniMax 配音</h3>
<span class="tag gray">Audio</span>
<span class="tag green">MiniMax</span>
</header>
<div class="body">
<p><strong>问题:</strong>音频处理节点之前只说明“音轨 → ASR → 翻译 → 改写”,没有真实改写产物,也没有配音输出;用户无法直接拿到符合 SKG 产品语境的英文口播。</p>
<p><strong>改动:</strong><code>Job</code> 新增 <code>audio_script</code><code>pipeline_transcribe</code> 在 ASR 和翻译后生成 SKG 英文改写文案,并在配置 <code>MINIMAX_API_KEY</code> 时调用 MiniMax T2A 输出 <code>/jobs/{id}/audio-script.mp3</code>。前端 <code>AudioNode</code> 和侧栏 Rewrite 区显示模型链路、英文改写文案和配音播放器。</p>
<p><strong>问题:</strong>音频处理节点之前只说明“音轨 → ASR → 翻译 → 改写”,没有按原音频时长生成的产品介绍产物,也没有配音输出;用户无法直接拿到符合 SKG 产品语境的英文口播。</p>
<p><strong>改动:</strong><code>Job</code> 新增 <code>audio_script</code><code>pipeline_transcribe</code> 提取 <code>audio.wav</code> 后按原音频秒数生成 SKG 英文产品介绍文案,并在配置 <code>MINIMAX_API_KEY</code> 时调用 MiniMax T2A 输出 <code>/jobs/{id}/audio-script.mp3</code>MiniMax voice_id 从英文男声、女声、成熟声池随机选择;前端 <code>AudioNode</code> 和侧栏 Rewrite 区显示模型链路、英文产品文案和配音播放器。</p>
<p><strong>边界:</strong>MiniMax 官方 Speech API 当前接入的是 TTS 配音,不替代 ASR原始音频文案提取仍走现有 OpenAI-compatible audio transcription 入口。</p>
<p><strong>影响:</strong><code>api/main.py</code><code>api/.env.example</code><code>api/README.md</code><code>web/lib/api.ts</code><code>web/components/nodes/index.tsx</code><code>web/components/dashboard.tsx</code><code>web/app/page.tsx</code><code>docs/source-analysis.html</code></p>
</div>

View File

@@ -539,9 +539,12 @@ export default function Home() {
`产品角度图 2${labelOf(productRefs[1], "SKG 产品侧面/斜侧视角")}`,
`产品角度图 3${labelOf(productRefs[2], "SKG 产品背面/细节视角")}`,
`产品角度图 4${labelOf(productRefs[3], "SKG 产品补充/底部或佩戴视角")}`,
"产品使用部位:这是颈部/肩颈按摩仪,只能自然佩戴或贴合在脖子、后颈、颈肩交界处;不要放到手臂、腰、腿、胸口、眼部或背景里。",
"比例尺寸产品应符合真实颈部按摩仪大小U 形结构环绕后颈但不能巨大化、缩小成饰品、嵌入身体、悬浮或穿透透明人体。",
"镜头语言:严格按动作描述里的出场方式、景别、运镜、产品进入方式、佩戴贴合动作和收尾方式执行。",
`动作描述:${shot.action_text.trim()}`,
TRANSPARENT_HUMAN_VIDEO_PROMPT,
"融合要求:产品必须自然出现在透明骨架人动作中,尺寸可信,透视一致,贴合身体/手部/使用区域,不能悬浮、漂移、融化、扭曲或变成其他物体。",
"融合要求:产品必须自然出现在透明骨架人动作中,尺寸可信,透视一致,贴合手部拿取和后颈/颈肩使用区域,不能悬浮、漂移、融化、扭曲或变成其他物体。",
"首尾连续性:镜头从首帧自然运动到尾帧,中间不要跳切,不换角色,不换产品,不突然改变场景。",
"产品一致性:严格保持 SKG 产品外观、颜色、材质、U 形结构、按摩触点、按键和比例;四张产品角度图是产品身份真源。",
"场景要求:背景、空间、光线和阴影要自然统一,不要出现水印、平台 UI、字幕或竞品包装。",

View File

@@ -120,7 +120,7 @@ type FusionUploadTarget = {
}
type FusionFrameRole = "first_image" | "last_image"
const FUSION_PROMPT_MARKER_PREFIX = "产品融合镜头ID"
const PRODUCT_FUSION_DESCRIPTION_PRESETS = [
const LEGACY_PRODUCT_FUSION_DESCRIPTION_PRESETS = [
"清晨卧室柔光里,透明骨架人把白色 SKG 颈部按摩仪轻戴到后颈,微微闭眼露出放松微笑。",
"现代客厅沙发旁,透明骨架人双手扶住 SKG 机身两侧,肩线慢慢放低,表情从紧绷变舒适。",
"居家办公桌前,透明骨架人轻按 SKG 侧边控制键,颈部骨架区域清晰可见,神情安静享受。",
@@ -143,6 +143,60 @@ const PRODUCT_FUSION_DESCRIPTION_PRESETS = [
"收尾特写镜头里,透明骨架人佩戴 SKG 后缓慢抬头微笑,白色骨架清楚,整体干净高级。",
]
const PRODUCT_FUSION_LENS_STAGES = [
"01 建立出场",
"02 产品入画",
"03 佩戴贴合",
"04 使用感受",
"05 生活延展",
"06 收尾记忆",
]
const PRODUCT_FUSION_DESCRIPTION_PRESETS = [
"镜头01建立出场半身中景透明骨架人先自然出现在清晨卧室柔光里SKG 白色产品放在桌面或手边;镜头慢慢推近,让人物透明外壳和白色骨架先成立,结尾手准备伸向产品。",
"镜头02产品入画从桌面产品近景开始透明骨架人的手把 SKG 产品拿起带入画面;镜头从产品轻移到人物肩颈,产品尺寸真实,不能漂浮,结尾靠近后颈。",
"镜头03佩戴贴合肩颈侧面近景透明骨架人双手扶住 SKG 两端贴合后颈并微调角度;镜头轻微环绕展示 U 形结构、触点和颈椎位置,结尾产品稳定贴合。",
"镜头04使用感受半身近景产品已佩戴透明骨架人闭眼呼吸放慢、肩线下沉、嘴角微笑镜头慢推不换场景突出舒适享受但不要医疗治疗暗示。",
"镜头05生活延展现代客厅、办公桌或窗边休息场景透明骨架人保持佩戴 SKG 做轻松阅读或休息动作;镜头横移或轻绕,产品位置稳定,人物透明身体和骨架清晰。",
"镜头06收尾记忆产品和人物肩颈半身特写透明骨架人缓慢抬头微笑定格SKG 产品轮廓清楚可辨;镜头停在干净高级的广告收尾,不出现文字和 logo 字幕。",
"备用01镜中出场浴室或卧室镜前透明骨架人先在镜面中出现手边放着 SKG 产品;镜头从镜中人物拉到真实人物,最后拿起产品准备佩戴。",
"备用02手部带入产品先在白色桌面上清楚出现透明骨架人的手进入画面拿起 SKG镜头跟随手部移动到肩颈区域强调产品被真实拿起而不是凭空出现。",
"备用03侧面贴合45 度侧面近景,透明骨架人把 SKG 产品从颈侧滑入正确位置,轻轻按压贴合;镜头短距离环绕,确保产品透视、比例和身体接触真实。",
"备用04按键反馈产品佩戴后透明骨架人用指尖轻按侧边按键肩颈骨架区域被柔和光线照亮镜头从按键细节回到人物放松表情。",
"备用05办公舒缓居家办公桌前透明骨架人佩戴 SKG 靠回椅背,手从键盘移开,肩部慢慢放松;镜头从电脑桌面横移到人物半身。",
"备用06沙发休息现代客厅沙发上透明骨架人戴着 SKG 闭眼休息,呼吸节奏变慢;镜头轻微推近产品和肩颈,最后停在舒适表情。",
"备用07窗边阅读窗边阅读角中透明骨架人一边翻书一边稳定佩戴 SKG镜头从书页过渡到产品再到透明骨架人的平和微笑。",
"备用08影棚展示高端白色影棚里透明骨架人佩戴 SKG 缓慢转身展示正面和侧面贴合效果;镜头平稳环绕,产品外观不能变形。",
"备用09床边放松暖色卧室床边透明骨架人坐下后把 SKG 戴稳,肩线下沉,脸部从疲惫转为舒适;镜头慢慢推近收住。",
"备用10阳台伸展午后阳台休息区透明骨架人戴着 SKG 缓慢侧头伸展,颈椎白骨清楚可见;镜头横移跟随动作,产品不漂移。",
"备用11产品特写转人开场为 SKG 产品白底特写,随后自然切到透明骨架人佩戴后的半身画面;镜头语言干净商业,强调产品身份一致。",
"备用12拿起到佩戴一镜到底透明骨架人从桌面拿起 SKG抬手、对准后颈、轻轻戴上一镜到底完成动作产品始终保持真实尺寸和方向。",
"备用13舒适反应特写肩颈近景转脸部特写透明骨架人闭眼微笑骨架和透明外壳保持同一角色镜头稳定不夸张表演。",
"备用14最终定格透明骨架人佩戴 SKG 面向镜头轻轻微笑,产品清晰贴合后颈,背景干净高级;最后 1 秒稳定定格作为广告收尾。",
"备用15门口入场透明骨架人从现代公寓门口走入画面肩颈略显紧绷SKG 产品放在玄关台面;镜头中景跟随,结尾视线落到产品。",
"备用16从包中取出透明骨架人坐到办公椅上从随身包里拿出白色 SKG 产品;镜头从包内产品切到人物手部,强调产品自然进入生活场景。",
"备用17正面佩戴正面半身镜头透明骨架人双手把 SKG 从胸前抬到后颈,动作慢而准确;产品贴合后颈时停顿,比例和接触关系真实。",
"备用18颈肩舒展产品已佩戴透明骨架人缓慢转动脖颈、肩部下沉脸部露出轻松表情镜头轻推近肩颈不出现夸张疗效表达。",
"备用19移动生活镜头透明骨架人佩戴 SKG 在客厅和窗边之间轻松移动,透明身体和白色骨架始终清楚;镜头平稳横移,产品位置不变。",
"备用20产品轮廓收束收尾以肩颈侧面特写呈现 SKG 轮廓,透明骨架人微笑停住,背景柔和虚化;画面干净高级,无文字和水印。",
"备用21影棚人物建立高端白色影棚中透明骨架人站立转向镜头白色骨架清晰可见SKG 产品置于旁边展台;镜头缓慢推近建立商业感。",
"备用22产品旋转展示白底产品图感的 SKG 产品在人物手中被轻轻转动展示正侧面,随后靠近透明骨架人的后颈;镜头跟随产品,不让产品变形。",
"备用23侧后方落位从人物侧后方观察 SKG 落到后颈位置,透明皮肤包裹白色颈椎骨架,双手轻调两端;镜头短距离环绕确认贴合。",
"备用24安静闭眼透明骨架人佩戴后闭眼停留呼吸放慢、肩部放松产品与颈部阴影自然镜头不切换只做细微推进。",
"备用25场景转为日常镜头从肩颈近景拉开到完整生活场景人物继续佩戴 SKG 阅读、休息或看窗外;产品清楚但不抢走人物主体。",
"备用26广告式收束透明骨架人面对镜头轻轻微笑手放下不再遮挡产品SKG 稳定贴合后颈;镜头保持半身构图作为干净收尾。",
"备用27疲惫到放松开场透明骨架人坐在办公桌前揉肩桌面有 SKG 产品;镜头从疲惫状态慢慢推近,结尾人物准备拿起产品。",
"备用28桌面到肩颈产品从桌面被拿起镜头沿手部轨迹移动到肩颈透明骨架人的颈椎和肋骨清晰产品必须跟随手部运动。",
"备用29双手校准透明骨架人用双手对称扶住 SKG 两端,微调到后颈正确位置;镜头正侧之间轻微移动,强调贴合和尺寸可信。",
"备用30舒适微表情佩戴稳定后透明骨架人眼神变柔和、嘴角轻扬肩颈线条放松镜头从产品细节回到脸部保持广告级质感。",
]
const legacyFusionDescriptionSet = new Set(LEGACY_PRODUCT_FUSION_DESCRIPTION_PRESETS)
const shouldUseDefaultFusionDescription = (value?: string | null) => {
const text = value?.trim()
return !text || legacyFusionDescriptionSet.has(text)
}
const createFusionShots = (): ProductFusionShot[] =>
Array.from({ length: FUSION_SHOT_COUNT }, (_, i) => ({
id: `shot-${i + 1}`,
@@ -169,7 +223,7 @@ const normalizeFusionShots = (shots?: ProductFusionShot[] | null): ProductFusion
...item,
...shot,
product_images: shot.product_images?.slice(0, PRODUCT_ANGLE_COUNT) ?? [],
action_text: shot.action_text?.trim() || item.action_text,
action_text: shouldUseDefaultFusionDescription(shot.action_text) ? item.action_text : shot.action_text,
id: shot.id || item.id,
}
})
@@ -200,6 +254,7 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
const [fusionUploadTarget, setFusionUploadTarget] = useState<FusionUploadTarget | null>(null)
const [fusionGenerating, setFusionGenerating] = useState<number | "all" | null>(null)
const [fusionSaving, setFusionSaving] = useState(false)
const [fusionPresetPage, setFusionPresetPage] = useState(0)
const [editingElement, setEditingElement] = useState<{
frameIndex: number
id: string
@@ -415,6 +470,19 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
}
}
const rotateFusionDescriptions = () => {
const page = fusionPresetPage + 1
const start = (page * FUSION_SHOT_COUNT) % PRODUCT_FUSION_DESCRIPTION_PRESETS.length
const next = fusionShots.map((shot, i) => ({
...shot,
action_text: PRODUCT_FUSION_DESCRIPTION_PRESETS[(start + i) % PRODUCT_FUSION_DESCRIPTION_PRESETS.length] || shot.action_text,
}))
setFusionPresetPage(page)
setFusionShots(next)
void persistFusionShots(next)
toast.success(`已换第 ${Math.floor(start / FUSION_SHOT_COUNT) + 1} 组镜头语言`)
}
const runFusionVideo = async (index: number) => {
const shot = fusionShots[index]
if (!shot?.first_image || !shot.last_image || !shot.action_text?.trim()) {
@@ -989,6 +1057,16 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
<span className="rounded bg-black/35 px-1.5 py-0.5 text-[9.5px] font-mono text-white/55">
{fusionReadyCount}/6
</span>
<button
type="button"
onClick={rotateFusionDescriptions}
disabled={!!fusionGenerating}
className="inline-flex h-6 items-center justify-center gap-1 rounded bg-white/10 px-2 text-[9.5px] font-medium text-white/65 transition hover:bg-white/18 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
title="换一组内置镜头语言,不改变首帧和尾帧"
>
<RefreshCw className="h-3 w-3" />
</button>
<button
type="button"
onClick={() => void runAllFusionVideos()}
@@ -1014,6 +1092,7 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
const latestVideoUrl = latestShotVideo?.url ? apiAssetUrl(latestShotVideo.url) : ""
const ready = !!(shot.first_image && shot.last_image && shot.action_text?.trim())
const busy = fusionGenerating === i || fusionGenerating === "all"
const lensStageLabel = PRODUCT_FUSION_LENS_STAGES[i] ?? `镜头 ${i + 1}`
const pasteIntoSlot = (target: FusionUploadTarget, label: string) => {
setActiveFusionShot(i)
if (clipboard) {
@@ -1146,7 +1225,7 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
<label className="block">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-[9px] text-white/38"> · </span>
<span className="text-[9px] text-amber-100/65">{lensStageLabel}</span>
<span className="text-[8.5px] text-white/30">#{i + 1}</span>
</div>
<textarea