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

This commit is contained in:
2026-05-14 12:20:57 +08:00
parent e9e0ca8f42
commit df6f0c3bc4
4 changed files with 120 additions and 56 deletions

View File

@@ -1,26 +1,5 @@
{ {
"entries": [ "entries": [
{
"files_changed": 1,
"hash": "9700e2a",
"message": "auto-save 2026-05-13 05:04 (~1)",
"ts": "2026-05-13T05:05:02+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "6e9b33b",
"message": "auto-save 2026-05-13 05:10 (~1)",
"ts": "2026-05-13T05:10:55+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "840f833",
"message": "auto-save 2026-05-13 05:16 (~1)",
"ts": "2026-05-13T05:16:50+08:00",
"type": "commit"
},
{ {
"files_changed": 1, "files_changed": 1,
"hash": "7665d63", "hash": "7665d63",
@@ -3293,6 +3272,25 @@
"message": "auto-save 2026-05-14 12:09 (+4, ~6)", "message": "auto-save 2026-05-14 12:09 (+4, ~6)",
"hash": "04679b0", "hash": "04679b0",
"files_changed": 10 "files_changed": 10
},
{
"ts": "2026-05-14T12:15:27+08:00",
"type": "commit",
"message": "auto-save 2026-05-14 12:15 (~2)",
"hash": "e9e0ca8",
"files_changed": 2
},
{
"ts": "2026-05-14T04:16:10Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 1 项未提交变更 · 最近提交auto-save 2026-05-14 12:15 (~2)",
"files_changed": 1
},
{
"ts": "2026-05-14T04:18:39Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 3 项未提交变更 · 最近提交auto-save 2026-05-14 12:15 (~2)",
"files_changed": 3
} }
] ]
} }

View File

@@ -1436,16 +1436,24 @@ def _transcribe_gemini_sync(wav: Path) -> list[dict]:
"Use English for the transcript. If exact timestamps are uncertain, return one segment " "Use English for the transcript. If exact timestamps are uncertain, return one segment "
f"from 0 to {duration:.2f} seconds." f"from 0 to {duration:.2f} seconds."
) )
resp = llm().chat.completions.create( last_error: Exception | None = None
model=ASR_FALLBACK_MODEL, for attempt in range(3):
messages=[{"role": "user", "content": [ try:
{"type": "text", "text": prompt}, resp = llm().chat.completions.create(
{"type": "input_audio", "input_audio": {"data": audio_b64, "format": "wav"}}, model=ASR_FALLBACK_MODEL,
]}], messages=[{"role": "user", "content": [
temperature=0, {"type": "text", "text": prompt},
) {"type": "input_audio", "input_audio": {"data": audio_b64, "format": "wav"}},
content = (resp.choices[0].message.content or "").strip() ]}],
return _parse_asr_segments(content, duration) temperature=0,
)
content = (resp.choices[0].message.content or "").strip()
return _parse_asr_segments(content, duration)
except Exception as e:
last_error = e
if attempt < 2:
time.sleep(1.0)
raise last_error or RuntimeError("Gemini audio transcription failed")
def _transcribe_sync(wav: Path) -> list[dict]: def _transcribe_sync(wav: Path) -> list[dict]:
@@ -1710,7 +1718,17 @@ def pipeline_transcribe(job_id: str, manage_job_status: bool = True) -> None:
# 1) whisper ASR # 1) whisper ASR
progress(f"{ASR_MODEL} 转录中…", 78) progress(f"{ASR_MODEL} 转录中…", 78)
segments = _transcribe_sync(wav) try:
segments = _transcribe_sync(wav)
except Exception:
if job.transcript:
segments = [
{"start": seg.start, "end": seg.end, "text": seg.en}
for seg in job.transcript
if seg.en.strip()
]
else:
raise
if not segments: if not segments:
raise RuntimeError("ASR 返回 0 段(可能无人声 / 格式问题)") raise RuntimeError("ASR 返回 0 段(可能无人声 / 格式问题)")
@@ -3699,12 +3717,26 @@ def create_product_fusion_guide(job_id: str, req: ProductFusionShot) -> dict:
def fallback_product_fusion_descriptions() -> list[str]: def fallback_product_fusion_descriptions() -> list[str]:
return [ return [
"透明骨架人双手拿起 SKG 颈部按摩仪,准备戴到脖子上,镜头轻微推近产品", "清晨卧室柔光里,透明骨架人把白色 SKG 颈部按摩仪轻戴到后颈,微微闭眼露出放松微笑",
"透明骨架人 SKG 按摩仪贴合到肩颈位置,手部轻轻调整两侧机身角度", "现代客厅沙发旁,透明骨架人双手扶住 SKG 机身两侧,肩线慢慢放低,表情从紧绷变舒适",
"透明骨架人坐在场景中轻按侧边控制区,产品保持真实比例并清晰可见", "居家办公桌前,透明骨架人轻按 SKG 侧边控制键,颈部骨架区域清晰可见,神情安静享受",
"透明骨架人闭眼放松,肩颈从紧绷变舒展,产品佩戴位置稳定不漂移", "暖色卧室床边,透明骨架人佩戴 SKG 后轻轻仰头,白色骨架与透明外壳干净明亮,画面高级",
"镜头靠近展示 SKG 产品材质、按键和内侧触点,透明骨架人的手部不要遮挡产品主体", "落地窗自然光下透明骨架人坐姿端正SKG 产品贴合后颈,嘴角微扬呈现轻松舒缓状态",
"使用后的放松状态收尾,透明骨架人自然抬头,产品仍保持白色 U 形外观和真实比例", "简洁浴室镜前,透明骨架人用双手调整 SKG 贴合角度,眼神柔和,产品白色机身清楚可辨",
"午后阳台休息区,透明骨架人戴着 SKG 慢慢侧头伸展,肩颈线条舒展,表情舒适而不夸张。",
"高端影棚白色背景中,透明骨架人平稳转身展示 SKG 佩戴效果,产品比例真实,轮廓清晰。",
"健身后休息长椅上,透明骨架人把 SKG 放上肩颈,呼吸放慢,脸上出现明显放松感。",
"办公会议间隙,透明骨架人靠在椅背上佩戴 SKG轻轻闭眼画面传达短暂恢复和舒适休息。",
"夜晚卧室暖灯下,透明骨架人坐在床沿使用 SKG肩颈骨架被柔和光线照亮神情安稳享受。",
"城市公寓客厅里,透明骨架人一边看向窗外一边使用 SKG动作自然产品贴合不漂移。",
"极简桌面场景中,透明骨架人拿起 SKG 靠近颈部,镜头轻推展示产品材质和佩戴准备动作。",
"木质休闲椅上,透明骨架人佩戴 SKG 后轻轻呼气,肩部下沉,脸部呈现舒缓满足的微笑。",
"白色商业摄影场景里,透明骨架人用指尖轻触 SKG 按键,产品细节清晰,人物状态轻松专业。",
"温暖客厅地毯旁透明骨架人坐姿放松SKG 稳定贴合后颈,闭眼感受舒适放松的瞬间。",
"窗边阅读角落中,透明骨架人戴着 SKG 翻开书页,动作慢而自然,表情平和享受。",
"办公室午休场景里,透明骨架人把 SKG 戴稳后靠回椅背,眼睛半闭,颈肩明显放松。",
"干净产品广告场景中,透明骨架人轻扶 SKG 两端展示佩戴贴合度,微笑自然,产品不变形。",
"收尾特写镜头里,透明骨架人佩戴 SKG 后缓慢抬头微笑,白色骨架清楚,整体干净高级。",
] ]
@@ -3728,8 +3760,8 @@ def generate_product_fusion_descriptions(job_id: str, req: ProductFusionDescript
products.append(f"产品角度{len(products) + 1}未填") products.append(f"产品角度{len(products) + 1}未填")
shot_lines.append(f"{i}. 首帧={first};尾帧={last};产品角度={products[0]} / {products[1]} / {products[2]} / {products[3]};已有描述={shot.action_text or ''}") shot_lines.append(f"{i}. 首帧={first};尾帧={last};产品角度={products[0]} / {products[1]} / {products[2]} / {products[3]};已有描述={shot.action_text or ''}")
prompt = ( prompt = (
"你是 SKG 产品短视频分镜导演。请为 6 条产品融合镜头各写一条中文动作描述," "你是 SKG 产品短视频分镜导演。请写 20 条中文产品融合动作描述,"
"每条 20-45 字,必须说明透明骨架人在什么、产品如何佩戴/展示、动作如何从首帧自然过渡到尾帧" "每条 35-70 字,必须说明透明骨架人在什么场景下使用产品、产品如何佩戴/展示、脸部如何舒适享受"
"产品是 SKG 白色 U 形颈部/肩颈按摩仪,四张产品角度图是同一产品的身份真源;不要写医疗治疗承诺,不要出现竞品。" "产品是 SKG 白色 U 形颈部/肩颈按摩仪,四张产品角度图是同一产品的身份真源;不要写医疗治疗承诺,不要出现竞品。"
"输出 JSON{\"descriptions\":[\"...\", \"...\"]}。\n\n" "输出 JSON{\"descriptions\":[\"...\", \"...\"]}。\n\n"
+ "\n".join(shot_lines) + "\n".join(shot_lines)
@@ -3746,9 +3778,9 @@ def generate_product_fusion_descriptions(job_id: str, req: ProductFusionDescript
text = resp.choices[0].message.content or "" text = resp.choices[0].message.content or ""
data = json.loads(text) data = json.loads(text)
descriptions = [str(x).strip() for x in data.get("descriptions", []) if str(x).strip()] descriptions = [str(x).strip() for x in data.get("descriptions", []) if str(x).strip()]
if len(descriptions) < 6: if len(descriptions) < 20:
descriptions = (descriptions + fallback)[:6] descriptions = (descriptions + fallback)[:20]
return {"descriptions": descriptions[:6], "mode": "llm"} return {"descriptions": descriptions[:20], "mode": "llm"}
except Exception: except Exception:
return {"descriptions": fallback, "mode": "fallback"} return {"descriptions": fallback, "mode": "fallback"}

View File

@@ -629,7 +629,7 @@ api/main.py
</div> </div>
<div class="flow-row"> <div class="flow-row">
<div><strong>你看到的区域</strong><span>关键帧素材审核面板</span></div> <div><strong>你看到的区域</strong><span>关键帧素材审核面板</span></div>
<div><strong>主要源码</strong><span><code>FrameLightbox</code>;按“原图/清洗、主体资产、首尾帧、产品融合、审核”五个页签组织;左侧只放主图/框选画布,但主体资产页左侧改为全部已清洗/已选参考帧网格,首尾帧页左侧显示全部关键帧并可勾选人物/机位参考。主体识别页会显示透明骨架人目标和 Vision 验收分数。清洗页右侧支持一键清洗未处理帧、单张替换清洗版和一键替换全部待应用清洗版;批量替换顺序调用 <code>applyCleanedFrame</code>,不新增后端接口。产品融合页左侧是纵向 6 行镜头工作表:每行直接显示首帧、尾帧、同一产品 4 个角度图、描述词、秒数和单条生成按钮,便于一次看完 6 条视频。产品融合槽位的“粘贴”优先使用应用内 <code>clipboard</code>,也支持选中槽位后 Cmd+V 粘贴系统图片。右侧保留 GPT Image 2 / Seedance 固定模型、当前镜头状态、桌面四角度一键填充、AI 描述草稿、批量排队和产品图库选用;产品图库选中后会填入当前镜头下一个产品角度槽。主体资产页只确认一个统一主体,后端按参考重绘六张纯背景、占满画面的标准站立透明骨架人资产图;首尾帧页通过地点、风格、参考要素和可编辑 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>listProductLibrary</code><code>copyProductLibraryAsset</code><code>generateProductFusionDescriptions</code></span></div> <div><strong>主要源码</strong><span><code>FrameLightbox</code>;按“原图/清洗、主体资产、首尾帧、产品融合、审核”五个页签组织;左侧只放主图/框选画布,但主体资产页左侧改为全部已清洗/已选参考帧网格,首尾帧页左侧显示全部关键帧并可勾选人物/机位参考。主体识别页会显示透明骨架人目标和 Vision 验收分数。清洗页右侧支持一键清洗未处理帧、单张替换清洗版和一键替换全部待应用清洗版;批量替换顺序调用 <code>applyCleanedFrame</code>,不新增后端接口。产品融合页左侧是纵向 6 行镜头工作表:每行直接显示首帧、尾帧、同一产品 4 个角度图、描述词、秒数和单条生成按钮,便于一次看完 6 条视频。产品融合槽位的“粘贴”优先使用应用内 <code>clipboard</code>,也支持选中槽位后 Cmd+V 粘贴系统图片。右侧保留 GPT Image 2 / Seedance 固定模型、当前镜头状态、桌面四角度一键填充、20 条产品使用描述模板、批量排队和产品图库选用;产品图库选中后会填入当前镜头下一个产品角度槽。主体资产页只确认一个统一主体,后端按参考重绘六张纯背景、占满画面的标准站立透明骨架人资产图;首尾帧页通过地点、风格、参考要素和可编辑 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>listProductLibrary</code><code>copyProductLibraryAsset</code><code>generateProductFusionDescriptions</code></span></div>
<div><strong>适合怎么描述</strong><span>“这一组关键帧如何共同生成一个统一主体包;某张关键帧的水印、去主体场景图、产品融合镜头组和质量风险应该如何审核”。</span></div> <div><strong>适合怎么描述</strong><span>“这一组关键帧如何共同生成一个统一主体包;某张关键帧的水印、去主体场景图、产品融合镜头组和质量风险应该如何审核”。</span></div>
</div> </div>
<div class="flow-row"> <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>产品图库</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>产品图入库到 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/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>为 6 行产品融合镜头生成动作描述草稿;输入重点变为首帧、尾帧和四张产品角度图,有 LLM 配置时用 <code>REWRITE_MODEL</code> 生成 JSON无配置或失败时回退到本地镜头模板。</td></tr> <tr><td>产品融合描述词</td><td><code>POST /jobs/{id}/product-fusion/descriptions</code></td><td><code>generateProductFusionDescriptions</code></td><td>生成 20 条产品融合动作描述库,前端每次按 6 条轮换套用到 6 行镜头;输入重点变为首帧、尾帧和四张产品角度图,有 LLM 配置时用 <code>REWRITE_MODEL</code> 生成 JSON无配置或失败时回退到本地 20 条精准模板。</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>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> <tr><td>生图</td><td><code>POST /frames/{idx}/generate</code></td><td><code>generateImage</code></td><td>基于关键帧或已选生成图做 image-to-image目前可用。</td></tr>
</tbody> </tbody>
@@ -917,6 +917,19 @@ SubjectAsset {
<h2>变更记录</h2> <h2>变更记录</h2>
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p> <p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
<div class="changelog"> <div class="changelog">
<article class="change">
<header>
<h3>2026-05-14 · 产品融合描述词扩成 20 条精准模板</h3>
<span class="tag orange">产品融合</span>
<span class="tag blue">Prompt</span>
</header>
<div class="body">
<p><strong>问题:</strong>产品融合视频的动作描述不能泛泛写“人物使用产品”,需要稳定表达透明骨架人在具体场景中佩戴 SKG 产品,并呈现舒适享受状态。</p>
<p><strong>改动:</strong>前端内置 20 条产品使用描述模板覆盖卧室、客厅、办公、浴室、阳台、影棚、阅读角等场景“AI 草拟 6 条”每次从 20 条中按 6 条轮换套用,便于多次生成不同镜头组。</p>
<p><strong>后端:</strong><code>generateProductFusionDescriptions</code> 的兜底模板同步扩为 20 条LLM 提示也改为生成 20 条 35-70 字描述,要求包含场景、佩戴/展示动作和舒适表情,同时排除医疗治疗承诺。</p>
<p><strong>影响:</strong><code>api/main.py</code><code>web/components/lightbox.tsx</code><code>docs/source-analysis.html</code></p>
</div>
</article>
<article class="change"> <article class="change">
<header> <header>
<h3>2026-05-14 · 产品融合改为首尾帧加四产品角度垫图</h3> <h3>2026-05-14 · 产品融合改为首尾帧加四产品角度垫图</h3>

View File

@@ -120,6 +120,28 @@ type FusionUploadTarget = {
} }
type FusionFrameRole = "first_image" | "last_image" type FusionFrameRole = "first_image" | "last_image"
const PRODUCT_ANGLE_LABELS = ["产品角度 1", "产品角度 2", "产品角度 3", "产品角度 4"] const PRODUCT_ANGLE_LABELS = ["产品角度 1", "产品角度 2", "产品角度 3", "产品角度 4"]
const PRODUCT_FUSION_DESCRIPTION_PRESETS = [
"清晨卧室柔光里,透明骨架人把白色 SKG 颈部按摩仪轻戴到后颈,微微闭眼露出放松微笑。",
"现代客厅沙发旁,透明骨架人双手扶住 SKG 机身两侧,肩线慢慢放低,表情从紧绷变舒适。",
"居家办公桌前,透明骨架人轻按 SKG 侧边控制键,颈部骨架区域清晰可见,神情安静享受。",
"暖色卧室床边,透明骨架人佩戴 SKG 后轻轻仰头,白色骨架与透明外壳干净明亮,画面高级。",
"落地窗自然光下透明骨架人坐姿端正SKG 产品贴合后颈,嘴角微扬呈现轻松舒缓状态。",
"简洁浴室镜前,透明骨架人用双手调整 SKG 贴合角度,眼神柔和,产品白色机身清楚可辨。",
"午后阳台休息区,透明骨架人戴着 SKG 慢慢侧头伸展,肩颈线条舒展,表情舒适而不夸张。",
"高端影棚白色背景中,透明骨架人平稳转身展示 SKG 佩戴效果,产品比例真实,轮廓清晰。",
"健身后休息长椅上,透明骨架人把 SKG 放上肩颈,呼吸放慢,脸上出现明显放松感。",
"办公会议间隙,透明骨架人靠在椅背上佩戴 SKG轻轻闭眼画面传达短暂恢复和舒适休息。",
"夜晚卧室暖灯下,透明骨架人坐在床沿使用 SKG肩颈骨架被柔和光线照亮神情安稳享受。",
"城市公寓客厅里,透明骨架人一边看向窗外一边使用 SKG动作自然产品贴合不漂移。",
"极简桌面场景中,透明骨架人拿起 SKG 靠近颈部,镜头轻推展示产品材质和佩戴准备动作。",
"木质休闲椅上,透明骨架人佩戴 SKG 后轻轻呼气,肩部下沉,脸部呈现舒缓满足的微笑。",
"白色商业摄影场景里,透明骨架人用指尖轻触 SKG 按键,产品细节清晰,人物状态轻松专业。",
"温暖客厅地毯旁透明骨架人坐姿放松SKG 稳定贴合后颈,闭眼感受舒适放松的瞬间。",
"窗边阅读角落中,透明骨架人戴着 SKG 翻开书页,动作慢而自然,表情平和享受。",
"办公室午休场景里,透明骨架人把 SKG 戴稳后靠回椅背,眼睛半闭,颈肩明显放松。",
"干净产品广告场景中,透明骨架人轻扶 SKG 两端展示佩戴贴合度,微笑自然,产品不变形。",
"收尾特写镜头里,透明骨架人佩戴 SKG 后缓慢抬头微笑,白色骨架清楚,整体干净高级。",
]
const createFusionShots = (): ProductFusionShot[] => const createFusionShots = (): ProductFusionShot[] =>
Array.from({ length: FUSION_SHOT_COUNT }, (_, i) => ({ Array.from({ length: FUSION_SHOT_COUNT }, (_, i) => ({
@@ -178,6 +200,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const [fusionGenerating, setFusionGenerating] = useState<number | "all" | null>(null) const [fusionGenerating, setFusionGenerating] = useState<number | "all" | null>(null)
const [fusionSaving, setFusionSaving] = useState(false) const [fusionSaving, setFusionSaving] = useState(false)
const [fusionFillingProducts, setFusionFillingProducts] = useState<"current" | "all" | null>(null) const [fusionFillingProducts, setFusionFillingProducts] = useState<"current" | "all" | null>(null)
const [fusionDraftPage, setFusionDraftPage] = useState(0)
const [editingElement, setEditingElement] = useState<{ const [editingElement, setEditingElement] = useState<{
frameIndex: number frameIndex: number
id: string id: string
@@ -386,28 +409,26 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
} }
const draftFusionDescriptions = async () => { const draftFusionDescriptions = async () => {
const actions = [ const actions = PRODUCT_FUSION_DESCRIPTION_PRESETS
"透明骨架人双手拿起 SKG 颈部按摩仪,准备戴到脖子上,镜头轻微推近产品。",
"透明骨架人把 SKG 按摩仪贴合到肩颈位置,手部轻轻调整两侧机身角度。",
"透明骨架人坐在场景中轻按侧边控制区,产品保持真实比例并清晰可见。",
"透明骨架人闭眼放松,肩颈从紧绷变舒展,产品佩戴位置稳定不漂移。",
"镜头靠近展示 SKG 产品材质、按键和内侧触点,透明骨架人的手部不要遮挡产品主体。",
"使用后的放松状态收尾,透明骨架人自然抬头,产品仍保持白色 U 形外观和真实比例。",
]
let descriptions = actions let descriptions = actions
try { try {
const result = await generateProductFusionDescriptions(jobId, fusionShots) const result = await generateProductFusionDescriptions(jobId, fusionShots)
descriptions = result.descriptions.length ? result.descriptions : actions descriptions = result.descriptions.length >= PRODUCT_FUSION_DESCRIPTION_PRESETS.length ? result.descriptions : actions
} catch (e) { } catch (e) {
toast.error("AI 描述生成失败,已使用本地草稿") toast.error("AI 描述生成失败,已使用本地草稿")
} }
const start = (fusionDraftPage * FUSION_SHOT_COUNT) % descriptions.length
const selectedDescriptions = Array.from({ length: FUSION_SHOT_COUNT }, (_, i) => (
descriptions[(start + i) % descriptions.length] || actions[i]
))
const next = fusionShots.map((shot, i) => ({ const next = fusionShots.map((shot, i) => ({
...shot, ...shot,
action_text: shot.action_text?.trim() || descriptions[i] || actions[i], action_text: selectedDescriptions[i] || shot.action_text || actions[i],
})) }))
setFusionShots(next) setFusionShots(next)
setFusionDraftPage((prev) => prev + 1)
void persistFusionShots(next) void persistFusionShots(next)
toast.success("已生成 6 条动作描述草稿,可继续手工修改") toast.success(`已套用 6 条动作描述 · 模板 ${start + 1}-${Math.min(start + FUSION_SHOT_COUNT, descriptions.length)}`)
} }
const fillDesktopProductAngles = async (scope: "current" | "all") => { const fillDesktopProductAngles = async (scope: "current" | "all") => {