diff --git a/.memory/worklog.json b/.memory/worklog.json
index 2bab4e6..11dc02a 100644
--- a/.memory/worklog.json
+++ b/.memory/worklog.json
@@ -1,26 +1,5 @@
{
"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,
"hash": "7665d63",
@@ -3293,6 +3272,25 @@
"message": "auto-save 2026-05-14 12:09 (+4, ~6)",
"hash": "04679b0",
"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
}
]
}
diff --git a/api/main.py b/api/main.py
index 8576a82..0d81386 100644
--- a/api/main.py
+++ b/api/main.py
@@ -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 "
f"from 0 to {duration:.2f} seconds."
)
- resp = llm().chat.completions.create(
- model=ASR_FALLBACK_MODEL,
- messages=[{"role": "user", "content": [
- {"type": "text", "text": prompt},
- {"type": "input_audio", "input_audio": {"data": audio_b64, "format": "wav"}},
- ]}],
- temperature=0,
- )
- content = (resp.choices[0].message.content or "").strip()
- return _parse_asr_segments(content, duration)
+ last_error: Exception | None = None
+ for attempt in range(3):
+ try:
+ resp = llm().chat.completions.create(
+ model=ASR_FALLBACK_MODEL,
+ messages=[{"role": "user", "content": [
+ {"type": "text", "text": prompt},
+ {"type": "input_audio", "input_audio": {"data": audio_b64, "format": "wav"}},
+ ]}],
+ 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]:
@@ -1710,7 +1718,17 @@ def pipeline_transcribe(job_id: str, manage_job_status: bool = True) -> None:
# 1) whisper ASR
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:
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]:
return [
- "透明骨架人双手拿起 SKG 颈部按摩仪,准备戴到脖子上,镜头轻微推近产品。",
- "透明骨架人把 SKG 按摩仪贴合到肩颈位置,手部轻轻调整两侧机身角度。",
- "透明骨架人坐在场景中轻按侧边控制区,产品保持真实比例并清晰可见。",
- "透明骨架人闭眼放松,肩颈从紧绷变舒展,产品佩戴位置稳定不漂移。",
- "镜头靠近展示 SKG 产品材质、按键和内侧触点,透明骨架人的手部不要遮挡产品主体。",
- "使用后的放松状态收尾,透明骨架人自然抬头,产品仍保持白色 U 形外观和真实比例。",
+ "清晨卧室柔光里,透明骨架人把白色 SKG 颈部按摩仪轻戴到后颈,微微闭眼露出放松微笑。",
+ "现代客厅沙发旁,透明骨架人双手扶住 SKG 机身两侧,肩线慢慢放低,表情从紧绷变舒适。",
+ "居家办公桌前,透明骨架人轻按 SKG 侧边控制键,颈部骨架区域清晰可见,神情安静享受。",
+ "暖色卧室床边,透明骨架人佩戴 SKG 后轻轻仰头,白色骨架与透明外壳干净明亮,画面高级。",
+ "落地窗自然光下,透明骨架人坐姿端正,SKG 产品贴合后颈,嘴角微扬呈现轻松舒缓状态。",
+ "简洁浴室镜前,透明骨架人用双手调整 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}未填")
shot_lines.append(f"{i}. 首帧={first};尾帧={last};产品角度={products[0]} / {products[1]} / {products[2]} / {products[3]};已有描述={shot.action_text or '空'}")
prompt = (
- "你是 SKG 产品短视频分镜导演。请为 6 条产品融合镜头各写一条中文动作描述,"
- "每条 20-45 字,必须说明透明骨架人在做什么、产品如何佩戴/展示、动作如何从首帧自然过渡到尾帧。"
+ "你是 SKG 产品短视频分镜导演。请写 20 条中文产品融合动作描述,"
+ "每条 35-70 字,必须说明透明骨架人在什么场景下使用产品、产品如何佩戴/展示、脸部如何舒适享受。"
"产品是 SKG 白色 U 形颈部/肩颈按摩仪,四张产品角度图是同一产品的身份真源;不要写医疗治疗承诺,不要出现竞品。"
"输出 JSON:{\"descriptions\":[\"...\", \"...\"]}。\n\n"
+ "\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 ""
data = json.loads(text)
descriptions = [str(x).strip() for x in data.get("descriptions", []) if str(x).strip()]
- if len(descriptions) < 6:
- descriptions = (descriptions + fallback)[:6]
- return {"descriptions": descriptions[:6], "mode": "llm"}
+ if len(descriptions) < 20:
+ descriptions = (descriptions + fallback)[:20]
+ return {"descriptions": descriptions[:20], "mode": "llm"}
except Exception:
return {"descriptions": fallback, "mode": "fallback"}
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index e9162ae..76d8b7d 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -629,7 +629,7 @@ api/main.py
你看到的区域关键帧素材审核面板
-
主要源码FrameLightbox;按“原图/清洗、主体资产、首尾帧、产品融合、审核”五个页签组织;左侧只放主图/框选画布,但主体资产页左侧改为全部已清洗/已选参考帧网格,首尾帧页左侧显示全部关键帧并可勾选人物/机位参考。主体识别页会显示透明骨架人目标和 Vision 验收分数。清洗页右侧支持一键清洗未处理帧、单张替换清洗版和一键替换全部待应用清洗版;批量替换顺序调用 applyCleanedFrame,不新增后端接口。产品融合页左侧是纵向 6 行镜头工作表:每行直接显示首帧、尾帧、同一产品 4 个角度图、描述词、秒数和单条生成按钮,便于一次看完 6 条视频。产品融合槽位的“粘贴”优先使用应用内 clipboard,也支持选中槽位后 Cmd+V 粘贴系统图片。右侧保留 GPT Image 2 / Seedance 固定模型、当前镜头状态、桌面四角度一键填充、AI 描述草稿、批量排队和产品图库选用;产品图库选中后会填入当前镜头下一个产品角度槽。主体资产页只确认一个统一主体,后端按参考重绘六张纯背景、占满画面的标准站立透明骨架人资产图;首尾帧页通过地点、风格、参考要素和可编辑 prompt 做文字生图,生成结果写入 scene_assets 但以 asset_role=first_frame/last_frame 标记,并自动传入当前产品融合镜头。相关接口包括 cleanupFrame、applyCleanedFrame、addElement、generateSubjectAssets、generateSceneAsset、listProductLibrary、copyProductLibraryAsset 和 generateProductFusionDescriptions。
+
主要源码FrameLightbox;按“原图/清洗、主体资产、首尾帧、产品融合、审核”五个页签组织;左侧只放主图/框选画布,但主体资产页左侧改为全部已清洗/已选参考帧网格,首尾帧页左侧显示全部关键帧并可勾选人物/机位参考。主体识别页会显示透明骨架人目标和 Vision 验收分数。清洗页右侧支持一键清洗未处理帧、单张替换清洗版和一键替换全部待应用清洗版;批量替换顺序调用 applyCleanedFrame,不新增后端接口。产品融合页左侧是纵向 6 行镜头工作表:每行直接显示首帧、尾帧、同一产品 4 个角度图、描述词、秒数和单条生成按钮,便于一次看完 6 条视频。产品融合槽位的“粘贴”优先使用应用内 clipboard,也支持选中槽位后 Cmd+V 粘贴系统图片。右侧保留 GPT Image 2 / Seedance 固定模型、当前镜头状态、桌面四角度一键填充、20 条产品使用描述模板、批量排队和产品图库选用;产品图库选中后会填入当前镜头下一个产品角度槽。主体资产页只确认一个统一主体,后端按参考重绘六张纯背景、占满画面的标准站立透明骨架人资产图;首尾帧页通过地点、风格、参考要素和可编辑 prompt 做文字生图,生成结果写入 scene_assets 但以 asset_role=first_frame/last_frame 标记,并自动传入当前产品融合镜头。相关接口包括 cleanupFrame、applyCleanedFrame、addElement、generateSubjectAssets、generateSceneAsset、listProductLibrary、copyProductLibraryAsset 和 generateProductFusionDescriptions。
适合怎么描述“这一组关键帧如何共同生成一个统一主体包;某张关键帧的水印、去主体场景图、产品融合镜头组和质量风险应该如何审核”。
@@ -806,7 +806,7 @@ SubjectAsset {
| 产品图库 | GET /product-library/skg | listProductLibrary | 读取内置 SKG 白底图库 manifest,返回产品标题、品类、尺寸、白底评分和预览图 URL。 |
| 产品图入库到 job | POST /jobs/{id}/assets/product-library | copyProductLibraryAsset | 把一个内置产品图库条目复制为当前 job 的普通 asset,返回 ImageRef(kind="asset"),用于画面工作台产品融合和分镜产品参考组。 |
| 产品融合引导图 | POST /jobs/{id}/product-fusion/guide | createProductFusionGuide | 旧流程兼容接口:读取产品图和白底人物图,按 product_region 合成位置引导图。当前首尾帧流程不再主动调用它。 |
-
| 产品融合描述词 | POST /jobs/{id}/product-fusion/descriptions | generateProductFusionDescriptions | 为 6 行产品融合镜头生成动作描述草稿;输入重点变为首帧、尾帧和四张产品角度图,有 LLM 配置时用 REWRITE_MODEL 生成 JSON,无配置或失败时回退到本地镜头模板。 |
+
| 产品融合描述词 | POST /jobs/{id}/product-fusion/descriptions | generateProductFusionDescriptions | 生成 20 条产品融合动作描述库,前端每次按 6 条轮换套用到 6 行镜头;输入重点变为首帧、尾帧和四张产品角度图,有 LLM 配置时用 REWRITE_MODEL 生成 JSON,无配置或失败时回退到本地 20 条精准模板。 |
| 分镜保存 | PUT /frames/{idx}/storyboard | updateStoryboard | 保存 4 图槽、时长和改造说明。 |
| 生图 | POST /frames/{idx}/generate | generateImage | 基于关键帧或已选生成图做 image-to-image,目前可用。 |
@@ -917,6 +917,19 @@ SubjectAsset {
变更记录
这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。
+
+
+ 2026-05-14 · 产品融合描述词扩成 20 条精准模板
+ 产品融合
+ Prompt
+
+
+
问题:产品融合视频的动作描述不能泛泛写“人物使用产品”,需要稳定表达透明骨架人在具体场景中佩戴 SKG 产品,并呈现舒适享受状态。
+
改动:前端内置 20 条产品使用描述模板,覆盖卧室、客厅、办公、浴室、阳台、影棚、阅读角等场景;“AI 草拟 6 条”每次从 20 条中按 6 条轮换套用,便于多次生成不同镜头组。
+
后端:generateProductFusionDescriptions 的兜底模板同步扩为 20 条,LLM 提示也改为生成 20 条 35-70 字描述,要求包含场景、佩戴/展示动作和舒适表情,同时排除医疗治疗承诺。
+
影响:api/main.py、web/components/lightbox.tsx、docs/source-analysis.html。
+
+
2026-05-14 · 产品融合改为首尾帧加四产品角度垫图
diff --git a/web/components/lightbox.tsx b/web/components/lightbox.tsx
index 9c3862f..9cdaba4 100644
--- a/web/components/lightbox.tsx
+++ b/web/components/lightbox.tsx
@@ -120,6 +120,28 @@ type FusionUploadTarget = {
}
type FusionFrameRole = "first_image" | "last_image"
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[] =>
Array.from({ length: FUSION_SHOT_COUNT }, (_, i) => ({
@@ -178,6 +200,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const [fusionGenerating, setFusionGenerating] = useState(null)
const [fusionSaving, setFusionSaving] = useState(false)
const [fusionFillingProducts, setFusionFillingProducts] = useState<"current" | "all" | null>(null)
+ const [fusionDraftPage, setFusionDraftPage] = useState(0)
const [editingElement, setEditingElement] = useState<{
frameIndex: number
id: string
@@ -386,28 +409,26 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
}
const draftFusionDescriptions = async () => {
- const actions = [
- "透明骨架人双手拿起 SKG 颈部按摩仪,准备戴到脖子上,镜头轻微推近产品。",
- "透明骨架人把 SKG 按摩仪贴合到肩颈位置,手部轻轻调整两侧机身角度。",
- "透明骨架人坐在场景中轻按侧边控制区,产品保持真实比例并清晰可见。",
- "透明骨架人闭眼放松,肩颈从紧绷变舒展,产品佩戴位置稳定不漂移。",
- "镜头靠近展示 SKG 产品材质、按键和内侧触点,透明骨架人的手部不要遮挡产品主体。",
- "使用后的放松状态收尾,透明骨架人自然抬头,产品仍保持白色 U 形外观和真实比例。",
- ]
+ const actions = PRODUCT_FUSION_DESCRIPTION_PRESETS
let descriptions = actions
try {
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) {
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) => ({
...shot,
- action_text: shot.action_text?.trim() || descriptions[i] || actions[i],
+ action_text: selectedDescriptions[i] || shot.action_text || actions[i],
}))
setFusionShots(next)
+ setFusionDraftPage((prev) => prev + 1)
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") => {