diff --git a/RULES.md b/RULES.md
index 4feb71d..4930db5 100644
--- a/RULES.md
+++ b/RULES.md
@@ -11,7 +11,7 @@
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`)
- 第一冲刺:步骤 1-4(下载 / 拆轨 / 关键帧 / ASR+翻译)
-- 当前产品方向(2026-05-17 再确认):先解决信息流广告快速复刻的第一步,不再沿用“开始后自动抽帧、分镜、元素生成、合成”的默认做法。主界面为“左侧素材输入列 + 右侧音频解析工作表”。用户粘贴 TK 链接或上传视频后点击“开始”,系统自动下载源视频;下载完成后优先提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效。抽帧、分镜规划、产品融入、相似主体高清视图包(最多 10 张,含肩颈/后背特写)和视频合成暂作为后续能力保留,不在当前第一步自动触发。
+- 当前产品方向(2026-05-18 再确认):先解决信息流广告快速复刻的第一步,不再沿用“开始后自动抽帧、分镜、元素生成、合成”的默认做法。主界面为“左侧素材输入列 + 右侧音频解析工作表”。用户粘贴 TK 链接或上传视频后点击“开始”,系统自动下载源视频;下载完成后优先提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效。分镜工作台按逐句时间轴规划新口播、镜头类型、首帧/尾帧、人物需求和产品出现方式;不是所有分镜都必须是“人物 + 产品”,单条生成会按该行规划决定是否传产品图和相似主体参考图。
## 部署事实
- 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik)
diff --git a/api/main.py b/api/main.py
index 6ccd9fd..674993e 100644
--- a/api/main.py
+++ b/api/main.py
@@ -331,6 +331,12 @@ class StoryboardScene(BaseModel):
last_image: dict | None = None
product_images: list[dict] = Field(default_factory=list)
product_fusion_shots: list[dict] = Field(default_factory=list)
+ visual_mode: Literal["person_only", "person_product", "product_only", "environment"] = "person_product"
+ needs_product: bool = True
+ needs_subject: bool = True
+ first_frame_plan: str = ""
+ last_frame_plan: str = ""
+ product_placement: str = ""
# 4 图槽:dict 含 {kind, frame_idx, element_id?, cutout_id?, label}
subject_image: dict | None = None
scene_image: dict | None = None
@@ -4379,6 +4385,12 @@ class UpdateStoryboardReq(BaseModel):
last_image: dict | None = None
product_images: list[dict] = Field(default_factory=list)
product_fusion_shots: list[dict] = Field(default_factory=list)
+ visual_mode: Literal["person_only", "person_product", "product_only", "environment"] = "person_product"
+ needs_product: bool = True
+ needs_subject: bool = True
+ first_frame_plan: str = ""
+ last_frame_plan: str = ""
+ product_placement: str = ""
subject_image: dict | None = None
scene_image: dict | None = None
product_image: dict | None = None
@@ -5548,6 +5560,12 @@ def update_storyboard(job_id: str, idx: int, req: UpdateStoryboardReq) -> Job:
last_image=req.last_image,
product_images=list(req.product_images),
product_fusion_shots=list(req.product_fusion_shots),
+ visual_mode=req.visual_mode,
+ needs_product=bool(req.needs_product),
+ needs_subject=bool(req.needs_subject),
+ first_frame_plan=req.first_frame_plan.strip(),
+ last_frame_plan=req.last_frame_plan.strip(),
+ product_placement=req.product_placement.strip(),
subject_image=req.subject_image,
scene_image=req.scene_image,
product_image=req.product_image,
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index 58b4b88..5dabd0b 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -575,7 +575,7 @@
你看到的区域 旧深度素材面板(当前不作为主路径)
@@ -850,6 +850,15 @@ ProductRefStateItem {
分镜编排结果,不是复刻说明。它把参考图和 SKG 改造方向绑定到一个分镜上。
StoryboardScene {
duration,
+ visual_mode: person_only | person_product | product_only | environment,
+ needs_product,
+ needs_subject,
+ first_frame_plan,
+ last_frame_plan,
+ product_placement,
+ first_image,
+ last_image,
+ product_images[],
subject_image,
scene_image,
product_image,
@@ -900,7 +909,7 @@ ProductRefStateItem {
角色图入库到 job POST /jobs/{id}/assets/character-librarycopyCharacterLibraryAssets把所选角色的 7 张参考图复制为当前 job asset,返回 subject_images,产品融合生成视频时作为人物身份参考图提交。
产品融合引导图 POST /jobs/{id}/product-fusion/guidecreateProductFusionGuide旧流程兼容接口:读取产品图和白底人物图,按 product_region 合成位置引导图。当前内置角色 + 产品 + 描述流程不再主动调用它。
产品融合描述词 POST /jobs/{id}/product-fusion/descriptionsgenerateProductFusionDescriptions兼容接口:可生成产品融合动作描述库。当前前端默认直接用本地 36 条镜头语言模板预填 6 行镜头,并通过“换一组”按钮按 6 条一组轮换。
- 分镜保存 PUT /frames/{idx}/storyboardupdateStoryboard保存 4 图槽、时长和改造说明。
+ 分镜保存 PUT /frames/{idx}/storyboardupdateStoryboard保存 4 图槽、时长、改造说明,以及当前主工作表的镜头类型、人物/产品开关、首帧规划、尾帧规划和产品出现方式。
生图 POST /frames/{idx}/generategenerateImage基于关键帧或已选生成图做 image-to-image,目前可用。
@@ -1005,6 +1014,19 @@ ProductRefStateItem {
变更记录
这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。
+
+
+ 2026-05-18 · 分镜画面规划加入首尾帧和人物/产品开关
+ UI
+ API
+ Workflow
+
+
+
问题: “画面规划 / 产品融入”默认把每句都理解成产品 + 人物视频,导致痛点铺垫、场景过渡和产品特写无法按信息流广告真实节奏拆开。
+
改动: AudioStoryboardPlanPanel 每行新增镜头类型、人物/产品开关、首帧规划、尾帧规划和产品出现方式;默认按角色把开场/痛点设为人物情绪、利益证明设为人物+产品、转化收口设为产品特写。StoryboardScene 新增 visual_mode、needs_product、needs_subject、first_frame_plan、last_frame_plan 和 product_placement,后端保存到 state.json。
+
影响: web/components/ad-recreation-board.tsx、web/app/page.tsx、web/lib/api.ts、api/main.py、RULES.md、docs/source-analysis.html。单条生成时,只有勾选产品才会自动选最多 6 张产品图;只有勾选人物才会传相似主体参考图,避免不需要产品/人物的镜头被硬塞错内容。
+
+
2026-05-18 · 生图网关增加显式代理和网络错误提示
diff --git a/web/app/page.tsx b/web/app/page.tsx
index 6c1d538..7277b61 100644
--- a/web/app/page.tsx
+++ b/web/app/page.tsx
@@ -84,6 +84,20 @@ const PRODUCT_FUSION_NEGATIVE_PROMPT = [
"no product passing through the neck, no product inside the transparent body, no x-ray blending, no transparent product, no product becoming bones or skin, no product fused with spine/ribs/throat, no clipping through shoulders, no floating device, no melted device, no deformed U-shape, no wrong body part, no necklace/scarf/headphones/brace, no random replacement product.",
].join("\n")
+function storyboardNeedsProduct(scene: StoryboardScene) {
+ if (scene.needs_product === false) return false
+ if (scene.needs_product === true) return true
+ const text = `${scene.visual_mode ?? ""} ${scene.product ?? ""} ${scene.product_placement ?? ""}`.toLowerCase()
+ return !/(不出现产品|不露产品|无需产品|不需要产品|无产品|no product|environment|person_only)/.test(text)
+}
+
+function storyboardNeedsSubject(scene: StoryboardScene) {
+ if (scene.needs_subject === false) return false
+ if (scene.needs_subject === true) return true
+ const text = `${scene.visual_mode ?? ""} ${scene.subject ?? ""}`.toLowerCase()
+ return !/(不需要人物|无人物|不出现人物|no person|product_only|environment)/.test(text)
+}
+
// 合并 input + download + split 为一个节点
// 分叉:上路 input → visual lab ↘
// 下路 input → audio ──────────────────────────→ compose
@@ -565,8 +579,10 @@ export default function Home() {
: null
const firstRef = scene.first_image ?? keyframeRef
const lastRef = scene.last_image ?? defaultLastRef
- let productRefs = (scene.product_images?.length ? scene.product_images : scene.product_image ? [scene.product_image] : [])
- if (productRefs.length === 0) {
+ const needsProduct = storyboardNeedsProduct(scene)
+ const needsSubject = storyboardNeedsSubject(scene)
+ let productRefs = needsProduct ? (scene.product_images?.length ? scene.product_images : scene.product_image ? [scene.product_image] : []) : []
+ if (needsProduct && productRefs.length === 0) {
try {
productRefs = await ensureDefaultProductRefs(job.id)
} catch (e) {
@@ -574,7 +590,7 @@ export default function Home() {
return
}
}
- const subjectRefs: ImageRef[] = (frame.elements ?? [])
+ const subjectRefs: ImageRef[] = needsSubject ? (frame.elements ?? [])
.flatMap((element) => element.subject_assets ?? [])
.slice(0, 6)
.map((asset) => ({
@@ -583,8 +599,8 @@ export default function Home() {
element_id: asset.id,
cutout_id: asset.id,
label: asset.label,
- }))
- const primarySubjectRef = subjectRefs[0] ?? firstRef
+ })) : []
+ const primarySubjectRef = needsSubject ? (subjectRefs[0] ?? firstRef) : null
const duration = scene.duration && scene.duration > 0 ? scene.duration : 5
const sourceScene = frame.description?.scene ? `参考画面识别:${frame.description.scene}` : ""
const sourceStyle = frame.description?.style ? `参考风格:${frame.description.style}` : ""
@@ -607,31 +623,47 @@ export default function Home() {
].join("\n")
const prompt = [
`竖屏 9:16,${duration.toFixed(1)} 秒,SKG 产品短视频广告。`,
- productNature,
- productRefs.length
+ needsProduct
+ ? productNature
+ : "本条分镜规划为非产品主镜头:可以只拍人物状态、场景过渡、情绪停点或节奏承接。不要硬插 SKG 产品、白底产品图、包装或任何随机商品。",
+ needsProduct && productRefs.length
? `已上传 ${productRefs.length} 张 SKG 真实产品参考图。产品参考图是唯一产品真源:视频中出现的产品必须严格匹配这些图的外观、颜色、材质、结构比例和关键细节。`
- : "未上传产品图时,仍需生成一个干净高级的 SKG 产品广告画面,但不得保留原视频里的竞品包装或平台元素。",
- "首帧和尾帧只用于控制画面起止、构图、场景和动作方向;如果首尾帧里有竞品、文字包装或非 SKG 产品,必须替换为上传的 SKG 产品参考。",
+ : needsProduct
+ ? "未上传产品图时,仍需生成一个干净高级的 SKG 产品广告画面,但不得保留原视频里的竞品包装或平台元素。"
+ : "本条不传产品参考图;如首尾帧里出现竞品、包装或非 SKG 商品,应弱化、移除或作为模糊背景,不要替换成 SKG 产品。",
+ needsProduct
+ ? "首帧和尾帧只用于控制画面起止、构图、场景和动作方向;如果首尾帧里有竞品、文字包装或非 SKG 产品,必须替换为上传的 SKG 产品参考。"
+ : "首帧和尾帧用于控制画面起止、构图、场景和动作方向;本条没有产品任务,不要因为广告语而自动添加产品。",
"使用首帧和尾帧生成连续过渡视频:首帧必须严格作为视频开始画面,尾帧必须作为视频结束目标画面,中间只做自然运动补间。",
"生成一段单镜头连续视频,一镜到底,从首帧平滑过渡到尾帧;不要跳切,不要突然换场景,不要突然换主体,不要蒙太奇,不要多镜头拼接。",
"如果提供了原视频链接,把它只作为节奏、镜头运动、动作顺序和画面调度参考;不要照搬原视频里的品牌、文字、水印、竞品产品或具体人物。",
"时间线:0%-15% 锁住首帧构图并轻微启动;15%-85% 做平滑连续运动;85%-100% 缓慢贴近尾帧并稳定收住。",
- TRANSPARENT_HUMAN_VIDEO_PROMPT,
+ `镜头类型:${scene.visual_mode ?? "未标注"};需要人物=${needsSubject ? "是" : "否"};需要产品=${needsProduct ? "是" : "否"}。`,
+ scene.first_frame_plan ? `首帧规划:${scene.first_frame_plan}` : "",
+ scene.last_frame_plan ? `尾帧规划:${scene.last_frame_plan}` : "",
+ scene.product_placement ? `产品出现方式:${scene.product_placement}` : "",
+ needsSubject
+ ? TRANSPARENT_HUMAN_VIDEO_PROMPT
+ : "本条不传人物主体参考图;如果画面需要人物,只能作为背景、手部局部或模糊生活方式元素,不要生成主角式透明骨架人。",
`主体改造:${subjectDirection}`,
- `产品替换:${productDirection} 产品必须作为颈部/肩颈按摩仪被正确佩戴或展示,不要放在脸上、手臂上、桌面当摆件,也不要变成瓶子、面霜、医疗设备或食品。`,
+ needsProduct
+ ? `产品替换:${productDirection} 产品必须作为颈部/肩颈按摩仪被正确佩戴或展示,不要放在脸上、手臂上、桌面当摆件,也不要变成瓶子、面霜、医疗设备或食品。`
+ : `产品处理:${productDirection} 本条不需要露出 SKG 产品,不要硬插产品、包装、瓶罐、医疗器械或随机商品。`,
`场景改造:${sceneDirection}`,
`连续动作和镜头:${actionDirection}`,
`首帧:${labelOf(firstRef, "当前分镜关键帧")}`,
`尾帧:${labelOf(lastRef, "未指定,按首帧小幅自然运动收尾")}`,
- `SKG 产品参考:${productRefs.length ? productRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "SKG 产品角度")}`).join(";") : "SKG 产品视觉主角"}`,
- subjectRefs.length ? `关键元素 6 视图参考:${subjectRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "元素视图")}`).join(";")}` : "如果该分镜还没有关键元素 6 视图,优先使用首帧主体关系生成。",
+ needsProduct ? `SKG 产品参考:${productRefs.length ? productRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "SKG 产品角度")}`).join(";") : "SKG 产品视觉主角"}` : "SKG 产品参考:本条不使用产品参考图。",
+ needsSubject
+ ? (subjectRefs.length ? `关键元素 6 视图参考:${subjectRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "元素视图")}`).join(";")}` : "如果该分镜还没有关键元素 6 视图,优先使用首帧主体关系生成。")
+ : "关键元素 6 视图参考:本条不使用人物主体参考图。",
sourceScene,
sourceStyle,
sourceObjects,
- "产品一致性要求:整个视频只能出现同一个白色 U 形 SKG 颈部按摩仪或同一套包装;不要生成第二种产品,不要改变 U 形机身、金属按摩触点、侧边按键、白色材质和整体比例,不要凭空增加屏幕、线缆、文字标签或说明书。",
- "产品呈现要求:至少一次让产品在脖子/肩颈位置清晰占据视觉中心,边缘清楚、材质真实、比例可信;手部接触产品时不要遮挡关键外观,产品不能融化、扭曲、穿帮或漂移。",
- "状态改善要求:画面应形成明确的使用前后感受变化:使用前可以是低头久坐、揉脖子、肩颈疲惫或紧绷;使用后变为肩颈放松、抬头、动作舒展、精神更好。人形骷髅也可以表现为从僵硬难受变轻松放松。表达舒缓和放松,不要承诺治疗。",
- "运动要求:动作幅度小而连续,速度均匀,手部和产品位置前后一致,产品外形不变形,人物表情和姿态不漂移,背景只允许轻微景深和光影变化。",
+ needsProduct ? "产品一致性要求:整个视频只能出现同一个白色 U 形 SKG 颈部按摩仪或同一套包装;不要生成第二种产品,不要改变 U 形机身、金属按摩触点、侧边按键、白色材质和整体比例,不要凭空增加屏幕、线缆、文字标签或说明书。" : "",
+ needsProduct ? "产品呈现要求:至少一次让产品在脖子/肩颈位置清晰占据视觉中心,边缘清楚、材质真实、比例可信;手部接触产品时不要遮挡关键外观,产品不能融化、扭曲、穿帮或漂移。" : "",
+ needsSubject || needsProduct ? "状态改善要求:画面应形成明确的使用前后感受变化:使用前可以是低头久坐、揉脖子、肩颈疲惫或紧绷;使用后变为肩颈放松、抬头、动作舒展、精神更好。人形骷髅也可以表现为从僵硬难受变轻松放松。表达舒缓和放松,不要承诺治疗。" : "节奏要求:作为过渡镜头时只负责情绪、空间和节奏承接,不承诺疗效,不强行展示使用动作。",
+ needsProduct ? "运动要求:动作幅度小而连续,速度均匀,手部和产品位置前后一致,产品外形不变形,人物表情和姿态不漂移,背景只允许轻微景深和光影变化。" : "运动要求:动作幅度小而连续,速度均匀,构图从首帧自然过渡到尾帧,不突然添加人物或产品。",
"商业质感:真实拍摄感,干净高级,柔和稳定打光,产品边缘清晰,材质真实,画面无抖动、无拉伸、无闪烁。",
"禁止:字幕、文字、平台 UI、TikTok 水印、logo 水印、免责声明、竞品包装、随机新物体、非 SKG 产品、医学骨架、夸张病症画面、恐怖元素、画面撕裂、人物或产品突然变形。",
TRANSPARENT_HUMAN_NEGATIVE_PROMPT,
@@ -649,7 +681,7 @@ export default function Home() {
subject_image: primarySubjectRef,
subject_images: subjectRefs,
scene_image: null,
- product_image: productRefs[0] ?? null,
+ product_image: needsProduct ? (productRefs[0] ?? null) : null,
action_image: null,
source_ref: sourceUrl ? { kind: "source_video", url: sourceUrl } : null,
model,
diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx
index 712e87e..37a0217 100644
--- a/web/components/ad-recreation-board.tsx
+++ b/web/components/ad-recreation-board.tsx
@@ -91,15 +91,30 @@ type AudioStoryboardRow = {
end: number
source: string
role: string
+ visualMode: StoryboardVisualMode
+ needsProduct: boolean
+ needsSubject: boolean
skgCopy: string
visualPlan: string
+ firstFramePlan: string
+ lastFramePlan: string
referencePlan: string
keyElements: string
productIntegration: string
+ productPlacement: string
}
type ProductRefItem = ProductRefStateItem
type SubjectStyleMode = "transparent_human" | "source_actor"
+type StoryboardVisualMode = NonNullable
+type RowPlanPatch = Partial>
+
+const VISUAL_MODE_OPTIONS: Array<{ value: StoryboardVisualMode; label: string; description: string }> = [
+ { value: "person_only", label: "人物/情绪", description: "只拍人物、状态、痛点或口播,不强制露产品。" },
+ { value: "person_product", label: "人物+产品", description: "人物佩戴、拿起、调整或使用 SKG 产品。" },
+ { value: "product_only", label: "产品特写", description: "只拍产品、包装、功能细节或 hero packshot。" },
+ { value: "environment", label: "场景过渡", description: "只做空间、生活方式、转场或情绪氛围。" },
+]
const SUBJECT_ASSET_VIEWS = [
{ value: "front", label: "正面" },
@@ -526,22 +541,84 @@ function buildVisualPlan(role: string) {
return "保持原片同类构图和运镜,把画面内容替换成 SKG 肩颈放松场景。"
}
+function visualModeDefaults(mode: StoryboardVisualMode) {
+ if (mode === "person_only") {
+ return {
+ needsProduct: false,
+ needsSubject: true,
+ productPlacement: "本条不出现产品,只用人物状态、痛点或口播承接节奏;不要硬插 SKG 产品。",
+ }
+ }
+ if (mode === "product_only") {
+ return {
+ needsProduct: true,
+ needsSubject: false,
+ productPlacement: "只展示 SKG 肩颈按摩仪本体、佩戴角度或功能细节;不要强行加入人物。",
+ }
+ }
+ if (mode === "environment") {
+ return {
+ needsProduct: false,
+ needsSubject: false,
+ productPlacement: "本条作为场景/情绪/节奏过渡,不出现产品和人物主体;只保留空间、光线和运动节奏。",
+ }
+ }
+ return {
+ needsProduct: true,
+ needsSubject: true,
+ productPlacement: "SKG 肩颈按摩仪作为外置佩戴产品出现,围绕拿起、佩戴、调整、按键或放松状态展开。",
+ }
+}
+
+function visualModeForRole(role: string): StoryboardVisualMode {
+ if (role === "开场钩子" || role === "痛点推进") return "person_only"
+ if (role === "转化收口") return "product_only"
+ if (role === "节奏承接") return "environment"
+ return "person_product"
+}
+
+function buildFirstFramePlan(role: string) {
+ if (role === "开场钩子") return "人物近景看向镜头或低头办公,手轻扶后颈,画面先不露产品。"
+ if (role === "痛点推进") return "保留原片人物动作节奏,肩颈紧绷、低头、揉脖子或久坐状态明确。"
+ if (role === "利益证明") return "人物拿起或准备佩戴 SKG 肩颈按摩仪,产品位置清晰但动作刚开始。"
+ if (role === "方案过渡") return "人物从痛点状态切到拿起产品/靠近肩颈,准备进入使用动作。"
+ if (role === "转化收口") return "产品干净特写或佩戴完成后的稳定画面,留出转化收口的视觉焦点。"
+ return "按原视频当前句的构图启动,先承接节奏,不强行改变镜头主体。"
+}
+
+function buildLastFramePlan(role: string) {
+ if (role === "开场钩子") return "人物抬头或表情更集中,给下一镜产品或方案进入留出空间。"
+ if (role === "痛点推进") return "紧绷状态被放大到一个明确停点,准备切入产品解决方案。"
+ if (role === "利益证明") return "产品已正确佩戴在后颈/肩颈位置,人物放松,产品比例稳定。"
+ if (role === "方案过渡") return "产品贴合肩颈,手部调整完成,画面自然进入功能细节或放松状态。"
+ if (role === "转化收口") return "产品或佩戴状态稳定收住,画面干净,适合后续接购买/行动号召。"
+ return "动作小幅推进并稳定停住,保留与下一句衔接的方向感。"
+}
+
function buildAudioStoryboardRows(job: Job | null): AudioStoryboardRow[] {
if (!job?.transcript.length) return []
return job.transcript.map((segment, index) => {
const source = segment.zh?.trim() || segment.en?.trim() || "原音频文案待补充"
const role = classifyAudioRole(`${segment.en} ${segment.zh}`, index, job.transcript.length)
+ const visualMode = visualModeForRole(role)
+ const defaults = visualModeDefaults(visualMode)
return {
index: segment.index,
start: segment.start,
end: segment.end,
source,
role,
+ visualMode,
+ needsProduct: defaults.needsProduct,
+ needsSubject: defaults.needsSubject,
skgCopy: buildSkgCopy(role, index),
visualPlan: buildVisualPlan(role),
+ firstFramePlan: buildFirstFramePlan(role),
+ lastFramePlan: buildLastFramePlan(role),
referencePlan: `从原视频 ${segment.start.toFixed(1)}-${segment.end.toFixed(1)}s 定向抽 1-2 张参考帧。`,
keyElements: role === "利益证明" ? "佩戴动作、产品位置、手部按键、放松表情" : "口播构图、人物动作、表情节奏、场景光线",
productIntegration: "把原片产品/道具语境替换为 SKG 白色 U 形颈部按摩仪,产品必须外置佩戴在肩颈位置。",
+ productPlacement: defaults.productPlacement,
}
})
}
@@ -742,6 +819,35 @@ function productReferenceNotes(items: ProductRefItem[]) {
.join(";")
}
+function savedScenePatch(scene?: StoryboardScene | null): RowPlanPatch {
+ if (!scene) return {}
+ return {
+ visualMode: scene.visual_mode,
+ needsProduct: scene.needs_product,
+ needsSubject: scene.needs_subject,
+ visualPlan: scene.scene?.split("\n").find((line) => line.trim() && !line.startsWith("镜头类型") && !line.startsWith("首帧规划") && !line.startsWith("尾帧规划") && !line.startsWith("原音频依据"))?.trim(),
+ firstFramePlan: scene.first_frame_plan,
+ lastFramePlan: scene.last_frame_plan,
+ productIntegration: scene.product?.split("\n").find((line) => line.trim() && !line.startsWith("产品需求") && !line.startsWith("产品出现方式") && !line.startsWith("产品素材池") && !line.startsWith("未上传产品图") && !line.startsWith("本条规划"))?.trim(),
+ productPlacement: scene.product_placement,
+ }
+}
+
+function applyPlanPatch(row: AudioStoryboardRow, patch?: RowPlanPatch): AudioStoryboardRow {
+ if (!patch) return row
+ return {
+ ...row,
+ visualMode: patch.visualMode ?? row.visualMode,
+ needsProduct: patch.needsProduct ?? row.needsProduct,
+ needsSubject: patch.needsSubject ?? row.needsSubject,
+ visualPlan: patch.visualPlan ?? row.visualPlan,
+ firstFramePlan: patch.firstFramePlan ?? row.firstFramePlan,
+ lastFramePlan: patch.lastFramePlan ?? row.lastFramePlan,
+ productIntegration: patch.productIntegration ?? row.productIntegration,
+ productPlacement: patch.productPlacement ?? row.productPlacement,
+ }
+}
+
function productPriorityForRow(row: AudioStoryboardRow) {
const viewPriorityByRole: Record = {
"开场钩子": ["front", "left_45", "right_45", "side_thickness"],
@@ -817,22 +923,30 @@ function selectProductItemsForRow(row: AudioStoryboardRow, items: ProductRefItem
}
function buildStoryboardSceneFromAudioRow(row: AudioStoryboardRow, frame: KeyFrame, nextFrame?: KeyFrame | null, productItems: ProductRefItem[] = []): StoryboardScene {
- const selectedProductItems = selectProductItemsForRow(row, productItems)
+ const selectedProductItems = row.needsProduct ? selectProductItemsForRow(row, productItems) : []
const productRefs = selectedProductItems.map((item) => item.ref)
const notes = productReferenceNotes(selectedProductItems)
- const productGuidance = productItems.length
+ const productGuidance = !row.needsProduct
+ ? "本条规划为不露出产品或不把产品作为画面主体;视频生成时不要硬插 SKG 产品、包装、白底图或错误商品。"
+ : productItems.length
? `产品素材池共有 ${productItems.length} 张,本条只选用 ${selectedProductItems.length} 张最相关参考图,不要把未选素材混入本条画面。产品硬定义:这是套在脖子上的 U 形肩颈按摩仪,不是耳机、头戴设备或护颈枕。坐标系硬规则:左/右按佩戴者身体左右,不能按图片左右;上=靠近下巴/脸/颈部上沿,下=靠近锁骨/肩部下沿;内侧=贴颈皮肤/按摩触点,外侧=外壳/按键/Logo。所选图片只作为产品结构、角度、比例和细节参考,不要照搬参考图的白底/黑底/棚拍背景。视角标注:${notes}。保留左右非对称细节,不要把两边做成镜像对称;肩颈产品大小必须贴近真实佩戴比例,不能缩成耳机,也不能放大成护颈枕。`
: "未上传产品图时使用默认 SKG 产品图;生成前建议先建立同一产品素材池,锁定左右差异、厚度和佩戴比例。"
return {
duration: Number(Math.max(3.2, Math.min(6.5, row.end - row.start || 4.5)).toFixed(1)),
first_image: { kind: "keyframe", frame_idx: frame.index, label: `分镜 ${row.index + 1} 参考帧` },
last_image: nextFrame ? { kind: "keyframe", frame_idx: nextFrame.index, label: `分镜 ${row.index + 1} 尾帧` } : null,
+ visual_mode: row.visualMode,
+ needs_product: row.needsProduct,
+ needs_subject: row.needsSubject,
+ first_frame_plan: row.firstFramePlan,
+ last_frame_plan: row.lastFramePlan,
+ product_placement: row.productPlacement,
product_images: productRefs,
product_image: productRefs[0] ?? null,
- subject: row.keyElements,
- scene: `${row.visualPlan}\n原音频依据:${row.source}`,
- product: `${row.productIntegration}\n${productGuidance}`,
- action: row.skgCopy,
+ subject: row.needsSubject ? row.keyElements : "本条不需要人物主体或相似主体参考;如画面里出现人物,只作为背景或局部,不作为主角。",
+ scene: `镜头类型:${VISUAL_MODE_OPTIONS.find((item) => item.value === row.visualMode)?.label ?? row.visualMode}\n${row.visualPlan}\n首帧规划:${row.firstFramePlan}\n尾帧规划:${row.lastFramePlan}\n原音频依据:${row.source}`,
+ product: `产品需求:${row.needsProduct ? "需要产品参考" : "本条不需要产品"}\n产品出现方式:${row.productPlacement}\n${row.needsProduct ? row.productIntegration : "本条以情绪、人物状态、空间或节奏过渡为主,不露出产品。"}\n${productGuidance}`,
+ action: `${row.skgCopy}\n连续动作:从首帧规划自然过渡到尾帧规划,镜头类型和产品/人物需求不能中途改变。`,
reference_ids: [],
}
}
@@ -2039,6 +2153,7 @@ function AudioStoryboardPlanPanel({
const [productAnalyzing, setProductAnalyzing] = useState(false)
const [productAngleBusy, setProductAngleBusy] = useState(null)
const [copyOverrides, setCopyOverrides] = useState>({})
+ const [planOverrides, setPlanOverrides] = useState>({})
const [authorIntent, setAuthorIntent] = useState("")
const [scriptRewriteBusy, setScriptRewriteBusy] = useState<"all" | number | null>(null)
const productFileRef = useRef(null)
@@ -2054,6 +2169,7 @@ function AudioStoryboardPlanPanel({
useEffect(() => {
setProductItems((job?.product_refs ?? []).map(normalizeStoredProductItem))
setCopyOverrides({})
+ setPlanOverrides({})
setAuthorIntent("")
setScriptRewriteBusy(null)
}, [job?.id])
@@ -2080,6 +2196,23 @@ function AudioStoryboardPlanPanel({
setCopyOverrides((prev) => ({ ...prev, [rowIndex]: value }))
}
+ const patchRowPlan = (rowIndex: number, patch: RowPlanPatch) => {
+ setPlanOverrides((prev) => ({ ...prev, [rowIndex]: { ...(prev[rowIndex] ?? {}), ...patch } }))
+ }
+
+ const applyVisualMode = (rowIndex: number, mode: StoryboardVisualMode) => {
+ const defaults = visualModeDefaults(mode)
+ patchRowPlan(rowIndex, {
+ visualMode: mode,
+ needsProduct: defaults.needsProduct,
+ needsSubject: defaults.needsSubject,
+ productPlacement: defaults.productPlacement,
+ })
+ }
+
+ const planForRow = (row: AudioStoryboardRow, frame: KeyFrame | null) =>
+ applyPlanPatch(applyPlanPatch(row, savedScenePatch(frame?.storyboard)), planOverrides[row.index])
+
const rewriteSegmentForRow = (row: AudioStoryboardRow): StoryboardScriptRewriteSegment => ({
index: row.index,
start: row.start,
@@ -2313,7 +2446,8 @@ function AudioStoryboardPlanPanel({
const generateRowVideo = async (row: AudioStoryboardRow, frame: KeyFrame | null) => {
if (!job || !frame || !onGenerateVideo) return
const nextFrame = orderedFrames.find((item) => item.timestamp > frame.timestamp) ?? null
- const scene = buildStoryboardSceneFromAudioRow({ ...row, skgCopy: copyForRow(row) }, frame, nextFrame, productItems)
+ const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row) }
+ const scene = buildStoryboardSceneFromAudioRow(plannedRow, frame, nextFrame, productItems)
setVideoBusyRow(row.index)
try {
const updated = await updateStoryboard(job.id, frame.index, scene)
@@ -2449,9 +2583,11 @@ function AudioStoryboardPlanPanel({
{rows.map((row) => {
const referenceFrame = referenceFrameForRow(row)
+ const plannedRow = planForRow(row, referenceFrame)
const rowVideos = videosForFrame(referenceFrame)
const generating = videoBusyRow === row.index
const copyText = copyForRow(row)
+ const selectedProductCount = plannedRow.needsProduct ? selectProductItemsForRow(plannedRow, productItems).length : 0
return (
- {row.visualPlan}
-
-
- {row.productIntegration}
-
+
@@ -2504,7 +2703,7 @@ function AudioStoryboardPlanPanel({
generateRowVideo(row, referenceFrame)}
+ onClick={() => generateRowVideo(plannedRow, referenceFrame)}
disabled={!referenceFrame || !onGenerateVideo || generating}
className="mt-1.5 inline-flex h-8 w-full items-center justify-center gap-1 rounded-md bg-white px-2 text-[11px] font-semibold text-black transition hover:bg-white/90 disabled:cursor-not-allowed disabled:opacity-40"
>
diff --git a/web/lib/api.ts b/web/lib/api.ts
index b02ae30..10acf44 100644
--- a/web/lib/api.ts
+++ b/web/lib/api.ts
@@ -118,6 +118,12 @@ export interface StoryboardScene {
last_image?: ImageRef | null
product_images?: ImageRef[]
product_fusion_shots?: ProductFusionShot[]
+ visual_mode?: "person_only" | "person_product" | "product_only" | "environment"
+ needs_product?: boolean
+ needs_subject?: boolean
+ first_frame_plan?: string
+ last_frame_plan?: string
+ product_placement?: string
subject_image?: ImageRef | null
scene_image?: ImageRef | null
product_image?: ImageRef | null
@@ -559,7 +565,7 @@ export interface ProductRefStateItem {
landmarks: string[]
note: string
risk: string
- source: "upload" | "ai"
+ source: "upload" | "ai" | "library"
assetMeta?: ImageRef["asset_meta"]
confidence?: number
}