feat: plan storyboard frame endpoints
This commit is contained in:
2
RULES.md
2
RULES.md
@@ -11,7 +11,7 @@
|
|||||||
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
|
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
|
||||||
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`)
|
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`)
|
||||||
- 第一冲刺:步骤 1-4(下载 / 拆轨 / 关键帧 / ASR+翻译)
|
- 第一冲刺:步骤 1-4(下载 / 拆轨 / 关键帧 / ASR+翻译)
|
||||||
- 当前产品方向(2026-05-17 再确认):先解决信息流广告快速复刻的第一步,不再沿用“开始后自动抽帧、分镜、元素生成、合成”的默认做法。主界面为“左侧素材输入列 + 右侧音频解析工作表”。用户粘贴 TK 链接或上传视频后点击“开始”,系统自动下载源视频;下载完成后优先提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效。抽帧、分镜规划、产品融入、相似主体高清视图包(最多 10 张,含肩颈/后背特写)和视频合成暂作为后续能力保留,不在当前第一步自动触发。
|
- 当前产品方向(2026-05-18 再确认):先解决信息流广告快速复刻的第一步,不再沿用“开始后自动抽帧、分镜、元素生成、合成”的默认做法。主界面为“左侧素材输入列 + 右侧音频解析工作表”。用户粘贴 TK 链接或上传视频后点击“开始”,系统自动下载源视频;下载完成后优先提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效。分镜工作台按逐句时间轴规划新口播、镜头类型、首帧/尾帧、人物需求和产品出现方式;不是所有分镜都必须是“人物 + 产品”,单条生成会按该行规划决定是否传产品图和相似主体参考图。
|
||||||
|
|
||||||
## 部署事实
|
## 部署事实
|
||||||
- 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik)
|
- 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik)
|
||||||
|
|||||||
18
api/main.py
18
api/main.py
@@ -331,6 +331,12 @@ class StoryboardScene(BaseModel):
|
|||||||
last_image: dict | None = None
|
last_image: dict | None = None
|
||||||
product_images: list[dict] = Field(default_factory=list)
|
product_images: list[dict] = Field(default_factory=list)
|
||||||
product_fusion_shots: 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}
|
# 4 图槽:dict 含 {kind, frame_idx, element_id?, cutout_id?, label}
|
||||||
subject_image: dict | None = None
|
subject_image: dict | None = None
|
||||||
scene_image: dict | None = None
|
scene_image: dict | None = None
|
||||||
@@ -4379,6 +4385,12 @@ class UpdateStoryboardReq(BaseModel):
|
|||||||
last_image: dict | None = None
|
last_image: dict | None = None
|
||||||
product_images: list[dict] = Field(default_factory=list)
|
product_images: list[dict] = Field(default_factory=list)
|
||||||
product_fusion_shots: 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
|
subject_image: dict | None = None
|
||||||
scene_image: dict | None = None
|
scene_image: dict | None = None
|
||||||
product_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,
|
last_image=req.last_image,
|
||||||
product_images=list(req.product_images),
|
product_images=list(req.product_images),
|
||||||
product_fusion_shots=list(req.product_fusion_shots),
|
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,
|
subject_image=req.subject_image,
|
||||||
scene_image=req.scene_image,
|
scene_image=req.scene_image,
|
||||||
product_image=req.product_image,
|
product_image=req.product_image,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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.",
|
"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")
|
].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 + download + split 为一个节点
|
||||||
// 分叉:上路 input → visual lab ↘
|
// 分叉:上路 input → visual lab ↘
|
||||||
// 下路 input → audio ──────────────────────────→ compose
|
// 下路 input → audio ──────────────────────────→ compose
|
||||||
@@ -565,8 +579,10 @@ export default function Home() {
|
|||||||
: null
|
: null
|
||||||
const firstRef = scene.first_image ?? keyframeRef
|
const firstRef = scene.first_image ?? keyframeRef
|
||||||
const lastRef = scene.last_image ?? defaultLastRef
|
const lastRef = scene.last_image ?? defaultLastRef
|
||||||
let productRefs = (scene.product_images?.length ? scene.product_images : scene.product_image ? [scene.product_image] : [])
|
const needsProduct = storyboardNeedsProduct(scene)
|
||||||
if (productRefs.length === 0) {
|
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 {
|
try {
|
||||||
productRefs = await ensureDefaultProductRefs(job.id)
|
productRefs = await ensureDefaultProductRefs(job.id)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -574,7 +590,7 @@ export default function Home() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const subjectRefs: ImageRef[] = (frame.elements ?? [])
|
const subjectRefs: ImageRef[] = needsSubject ? (frame.elements ?? [])
|
||||||
.flatMap((element) => element.subject_assets ?? [])
|
.flatMap((element) => element.subject_assets ?? [])
|
||||||
.slice(0, 6)
|
.slice(0, 6)
|
||||||
.map((asset) => ({
|
.map((asset) => ({
|
||||||
@@ -583,8 +599,8 @@ export default function Home() {
|
|||||||
element_id: asset.id,
|
element_id: asset.id,
|
||||||
cutout_id: asset.id,
|
cutout_id: asset.id,
|
||||||
label: asset.label,
|
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 duration = scene.duration && scene.duration > 0 ? scene.duration : 5
|
||||||
const sourceScene = frame.description?.scene ? `参考画面识别:${frame.description.scene}` : ""
|
const sourceScene = frame.description?.scene ? `参考画面识别:${frame.description.scene}` : ""
|
||||||
const sourceStyle = frame.description?.style ? `参考风格:${frame.description.style}` : ""
|
const sourceStyle = frame.description?.style ? `参考风格:${frame.description.style}` : ""
|
||||||
@@ -607,31 +623,47 @@ export default function Home() {
|
|||||||
].join("\n")
|
].join("\n")
|
||||||
const prompt = [
|
const prompt = [
|
||||||
`竖屏 9:16,${duration.toFixed(1)} 秒,SKG 产品短视频广告。`,
|
`竖屏 9:16,${duration.toFixed(1)} 秒,SKG 产品短视频广告。`,
|
||||||
productNature,
|
needsProduct
|
||||||
productRefs.length
|
? productNature
|
||||||
|
: "本条分镜规划为非产品主镜头:可以只拍人物状态、场景过渡、情绪停点或节奏承接。不要硬插 SKG 产品、白底产品图、包装或任何随机商品。",
|
||||||
|
needsProduct && productRefs.length
|
||||||
? `已上传 ${productRefs.length} 张 SKG 真实产品参考图。产品参考图是唯一产品真源:视频中出现的产品必须严格匹配这些图的外观、颜色、材质、结构比例和关键细节。`
|
? `已上传 ${productRefs.length} 张 SKG 真实产品参考图。产品参考图是唯一产品真源:视频中出现的产品必须严格匹配这些图的外观、颜色、材质、结构比例和关键细节。`
|
||||||
: "未上传产品图时,仍需生成一个干净高级的 SKG 产品广告画面,但不得保留原视频里的竞品包装或平台元素。",
|
: needsProduct
|
||||||
"首帧和尾帧只用于控制画面起止、构图、场景和动作方向;如果首尾帧里有竞品、文字包装或非 SKG 产品,必须替换为上传的 SKG 产品参考。",
|
? "未上传产品图时,仍需生成一个干净高级的 SKG 产品广告画面,但不得保留原视频里的竞品包装或平台元素。"
|
||||||
|
: "本条不传产品参考图;如首尾帧里出现竞品、包装或非 SKG 商品,应弱化、移除或作为模糊背景,不要替换成 SKG 产品。",
|
||||||
|
needsProduct
|
||||||
|
? "首帧和尾帧只用于控制画面起止、构图、场景和动作方向;如果首尾帧里有竞品、文字包装或非 SKG 产品,必须替换为上传的 SKG 产品参考。"
|
||||||
|
: "首帧和尾帧用于控制画面起止、构图、场景和动作方向;本条没有产品任务,不要因为广告语而自动添加产品。",
|
||||||
"使用首帧和尾帧生成连续过渡视频:首帧必须严格作为视频开始画面,尾帧必须作为视频结束目标画面,中间只做自然运动补间。",
|
"使用首帧和尾帧生成连续过渡视频:首帧必须严格作为视频开始画面,尾帧必须作为视频结束目标画面,中间只做自然运动补间。",
|
||||||
"生成一段单镜头连续视频,一镜到底,从首帧平滑过渡到尾帧;不要跳切,不要突然换场景,不要突然换主体,不要蒙太奇,不要多镜头拼接。",
|
"生成一段单镜头连续视频,一镜到底,从首帧平滑过渡到尾帧;不要跳切,不要突然换场景,不要突然换主体,不要蒙太奇,不要多镜头拼接。",
|
||||||
"如果提供了原视频链接,把它只作为节奏、镜头运动、动作顺序和画面调度参考;不要照搬原视频里的品牌、文字、水印、竞品产品或具体人物。",
|
"如果提供了原视频链接,把它只作为节奏、镜头运动、动作顺序和画面调度参考;不要照搬原视频里的品牌、文字、水印、竞品产品或具体人物。",
|
||||||
"时间线:0%-15% 锁住首帧构图并轻微启动;15%-85% 做平滑连续运动;85%-100% 缓慢贴近尾帧并稳定收住。",
|
"时间线: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}`,
|
`主体改造:${subjectDirection}`,
|
||||||
`产品替换:${productDirection} 产品必须作为颈部/肩颈按摩仪被正确佩戴或展示,不要放在脸上、手臂上、桌面当摆件,也不要变成瓶子、面霜、医疗设备或食品。`,
|
needsProduct
|
||||||
|
? `产品替换:${productDirection} 产品必须作为颈部/肩颈按摩仪被正确佩戴或展示,不要放在脸上、手臂上、桌面当摆件,也不要变成瓶子、面霜、医疗设备或食品。`
|
||||||
|
: `产品处理:${productDirection} 本条不需要露出 SKG 产品,不要硬插产品、包装、瓶罐、医疗器械或随机商品。`,
|
||||||
`场景改造:${sceneDirection}`,
|
`场景改造:${sceneDirection}`,
|
||||||
`连续动作和镜头:${actionDirection}`,
|
`连续动作和镜头:${actionDirection}`,
|
||||||
`首帧:${labelOf(firstRef, "当前分镜关键帧")}`,
|
`首帧:${labelOf(firstRef, "当前分镜关键帧")}`,
|
||||||
`尾帧:${labelOf(lastRef, "未指定,按首帧小幅自然运动收尾")}`,
|
`尾帧:${labelOf(lastRef, "未指定,按首帧小幅自然运动收尾")}`,
|
||||||
`SKG 产品参考:${productRefs.length ? productRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "SKG 产品角度")}`).join(";") : "SKG 产品视觉主角"}`,
|
needsProduct ? `SKG 产品参考:${productRefs.length ? productRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "SKG 产品角度")}`).join(";") : "SKG 产品视觉主角"}` : "SKG 产品参考:本条不使用产品参考图。",
|
||||||
subjectRefs.length ? `关键元素 6 视图参考:${subjectRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "元素视图")}`).join(";")}` : "如果该分镜还没有关键元素 6 视图,优先使用首帧主体关系生成。",
|
needsSubject
|
||||||
|
? (subjectRefs.length ? `关键元素 6 视图参考:${subjectRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "元素视图")}`).join(";")}` : "如果该分镜还没有关键元素 6 视图,优先使用首帧主体关系生成。")
|
||||||
|
: "关键元素 6 视图参考:本条不使用人物主体参考图。",
|
||||||
sourceScene,
|
sourceScene,
|
||||||
sourceStyle,
|
sourceStyle,
|
||||||
sourceObjects,
|
sourceObjects,
|
||||||
"产品一致性要求:整个视频只能出现同一个白色 U 形 SKG 颈部按摩仪或同一套包装;不要生成第二种产品,不要改变 U 形机身、金属按摩触点、侧边按键、白色材质和整体比例,不要凭空增加屏幕、线缆、文字标签或说明书。",
|
needsProduct ? "产品一致性要求:整个视频只能出现同一个白色 U 形 SKG 颈部按摩仪或同一套包装;不要生成第二种产品,不要改变 U 形机身、金属按摩触点、侧边按键、白色材质和整体比例,不要凭空增加屏幕、线缆、文字标签或说明书。" : "",
|
||||||
"产品呈现要求:至少一次让产品在脖子/肩颈位置清晰占据视觉中心,边缘清楚、材质真实、比例可信;手部接触产品时不要遮挡关键外观,产品不能融化、扭曲、穿帮或漂移。",
|
needsProduct ? "产品呈现要求:至少一次让产品在脖子/肩颈位置清晰占据视觉中心,边缘清楚、材质真实、比例可信;手部接触产品时不要遮挡关键外观,产品不能融化、扭曲、穿帮或漂移。" : "",
|
||||||
"状态改善要求:画面应形成明确的使用前后感受变化:使用前可以是低头久坐、揉脖子、肩颈疲惫或紧绷;使用后变为肩颈放松、抬头、动作舒展、精神更好。人形骷髅也可以表现为从僵硬难受变轻松放松。表达舒缓和放松,不要承诺治疗。",
|
needsSubject || needsProduct ? "状态改善要求:画面应形成明确的使用前后感受变化:使用前可以是低头久坐、揉脖子、肩颈疲惫或紧绷;使用后变为肩颈放松、抬头、动作舒展、精神更好。人形骷髅也可以表现为从僵硬难受变轻松放松。表达舒缓和放松,不要承诺治疗。" : "节奏要求:作为过渡镜头时只负责情绪、空间和节奏承接,不承诺疗效,不强行展示使用动作。",
|
||||||
"运动要求:动作幅度小而连续,速度均匀,手部和产品位置前后一致,产品外形不变形,人物表情和姿态不漂移,背景只允许轻微景深和光影变化。",
|
needsProduct ? "运动要求:动作幅度小而连续,速度均匀,手部和产品位置前后一致,产品外形不变形,人物表情和姿态不漂移,背景只允许轻微景深和光影变化。" : "运动要求:动作幅度小而连续,速度均匀,构图从首帧自然过渡到尾帧,不突然添加人物或产品。",
|
||||||
"商业质感:真实拍摄感,干净高级,柔和稳定打光,产品边缘清晰,材质真实,画面无抖动、无拉伸、无闪烁。",
|
"商业质感:真实拍摄感,干净高级,柔和稳定打光,产品边缘清晰,材质真实,画面无抖动、无拉伸、无闪烁。",
|
||||||
"禁止:字幕、文字、平台 UI、TikTok 水印、logo 水印、免责声明、竞品包装、随机新物体、非 SKG 产品、医学骨架、夸张病症画面、恐怖元素、画面撕裂、人物或产品突然变形。",
|
"禁止:字幕、文字、平台 UI、TikTok 水印、logo 水印、免责声明、竞品包装、随机新物体、非 SKG 产品、医学骨架、夸张病症画面、恐怖元素、画面撕裂、人物或产品突然变形。",
|
||||||
TRANSPARENT_HUMAN_NEGATIVE_PROMPT,
|
TRANSPARENT_HUMAN_NEGATIVE_PROMPT,
|
||||||
@@ -649,7 +681,7 @@ export default function Home() {
|
|||||||
subject_image: primarySubjectRef,
|
subject_image: primarySubjectRef,
|
||||||
subject_images: subjectRefs,
|
subject_images: subjectRefs,
|
||||||
scene_image: null,
|
scene_image: null,
|
||||||
product_image: productRefs[0] ?? null,
|
product_image: needsProduct ? (productRefs[0] ?? null) : null,
|
||||||
action_image: null,
|
action_image: null,
|
||||||
source_ref: sourceUrl ? { kind: "source_video", url: sourceUrl } : null,
|
source_ref: sourceUrl ? { kind: "source_video", url: sourceUrl } : null,
|
||||||
model,
|
model,
|
||||||
|
|||||||
@@ -91,15 +91,30 @@ type AudioStoryboardRow = {
|
|||||||
end: number
|
end: number
|
||||||
source: string
|
source: string
|
||||||
role: string
|
role: string
|
||||||
|
visualMode: StoryboardVisualMode
|
||||||
|
needsProduct: boolean
|
||||||
|
needsSubject: boolean
|
||||||
skgCopy: string
|
skgCopy: string
|
||||||
visualPlan: string
|
visualPlan: string
|
||||||
|
firstFramePlan: string
|
||||||
|
lastFramePlan: string
|
||||||
referencePlan: string
|
referencePlan: string
|
||||||
keyElements: string
|
keyElements: string
|
||||||
productIntegration: string
|
productIntegration: string
|
||||||
|
productPlacement: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProductRefItem = ProductRefStateItem
|
type ProductRefItem = ProductRefStateItem
|
||||||
type SubjectStyleMode = "transparent_human" | "source_actor"
|
type SubjectStyleMode = "transparent_human" | "source_actor"
|
||||||
|
type StoryboardVisualMode = NonNullable<StoryboardScene["visual_mode"]>
|
||||||
|
type RowPlanPatch = Partial<Pick<AudioStoryboardRow, "visualMode" | "needsProduct" | "needsSubject" | "visualPlan" | "firstFramePlan" | "lastFramePlan" | "productIntegration" | "productPlacement">>
|
||||||
|
|
||||||
|
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 = [
|
const SUBJECT_ASSET_VIEWS = [
|
||||||
{ value: "front", label: "正面" },
|
{ value: "front", label: "正面" },
|
||||||
@@ -526,22 +541,84 @@ function buildVisualPlan(role: string) {
|
|||||||
return "保持原片同类构图和运镜,把画面内容替换成 SKG 肩颈放松场景。"
|
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[] {
|
function buildAudioStoryboardRows(job: Job | null): AudioStoryboardRow[] {
|
||||||
if (!job?.transcript.length) return []
|
if (!job?.transcript.length) return []
|
||||||
return job.transcript.map((segment, index) => {
|
return job.transcript.map((segment, index) => {
|
||||||
const source = segment.zh?.trim() || segment.en?.trim() || "原音频文案待补充"
|
const source = segment.zh?.trim() || segment.en?.trim() || "原音频文案待补充"
|
||||||
const role = classifyAudioRole(`${segment.en} ${segment.zh}`, index, job.transcript.length)
|
const role = classifyAudioRole(`${segment.en} ${segment.zh}`, index, job.transcript.length)
|
||||||
|
const visualMode = visualModeForRole(role)
|
||||||
|
const defaults = visualModeDefaults(visualMode)
|
||||||
return {
|
return {
|
||||||
index: segment.index,
|
index: segment.index,
|
||||||
start: segment.start,
|
start: segment.start,
|
||||||
end: segment.end,
|
end: segment.end,
|
||||||
source,
|
source,
|
||||||
role,
|
role,
|
||||||
|
visualMode,
|
||||||
|
needsProduct: defaults.needsProduct,
|
||||||
|
needsSubject: defaults.needsSubject,
|
||||||
skgCopy: buildSkgCopy(role, index),
|
skgCopy: buildSkgCopy(role, index),
|
||||||
visualPlan: buildVisualPlan(role),
|
visualPlan: buildVisualPlan(role),
|
||||||
|
firstFramePlan: buildFirstFramePlan(role),
|
||||||
|
lastFramePlan: buildLastFramePlan(role),
|
||||||
referencePlan: `从原视频 ${segment.start.toFixed(1)}-${segment.end.toFixed(1)}s 定向抽 1-2 张参考帧。`,
|
referencePlan: `从原视频 ${segment.start.toFixed(1)}-${segment.end.toFixed(1)}s 定向抽 1-2 张参考帧。`,
|
||||||
keyElements: role === "利益证明" ? "佩戴动作、产品位置、手部按键、放松表情" : "口播构图、人物动作、表情节奏、场景光线",
|
keyElements: role === "利益证明" ? "佩戴动作、产品位置、手部按键、放松表情" : "口播构图、人物动作、表情节奏、场景光线",
|
||||||
productIntegration: "把原片产品/道具语境替换为 SKG 白色 U 形颈部按摩仪,产品必须外置佩戴在肩颈位置。",
|
productIntegration: "把原片产品/道具语境替换为 SKG 白色 U 形颈部按摩仪,产品必须外置佩戴在肩颈位置。",
|
||||||
|
productPlacement: defaults.productPlacement,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -742,6 +819,35 @@ function productReferenceNotes(items: ProductRefItem[]) {
|
|||||||
.join(";")
|
.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) {
|
function productPriorityForRow(row: AudioStoryboardRow) {
|
||||||
const viewPriorityByRole: Record<string, string[]> = {
|
const viewPriorityByRole: Record<string, string[]> = {
|
||||||
"开场钩子": ["front", "left_45", "right_45", "side_thickness"],
|
"开场钩子": ["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 {
|
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 productRefs = selectedProductItems.map((item) => item.ref)
|
||||||
const notes = productReferenceNotes(selectedProductItems)
|
const notes = productReferenceNotes(selectedProductItems)
|
||||||
const productGuidance = productItems.length
|
const productGuidance = !row.needsProduct
|
||||||
|
? "本条规划为不露出产品或不把产品作为画面主体;视频生成时不要硬插 SKG 产品、包装、白底图或错误商品。"
|
||||||
|
: productItems.length
|
||||||
? `产品素材池共有 ${productItems.length} 张,本条只选用 ${selectedProductItems.length} 张最相关参考图,不要把未选素材混入本条画面。产品硬定义:这是套在脖子上的 U 形肩颈按摩仪,不是耳机、头戴设备或护颈枕。坐标系硬规则:左/右按佩戴者身体左右,不能按图片左右;上=靠近下巴/脸/颈部上沿,下=靠近锁骨/肩部下沿;内侧=贴颈皮肤/按摩触点,外侧=外壳/按键/Logo。所选图片只作为产品结构、角度、比例和细节参考,不要照搬参考图的白底/黑底/棚拍背景。视角标注:${notes}。保留左右非对称细节,不要把两边做成镜像对称;肩颈产品大小必须贴近真实佩戴比例,不能缩成耳机,也不能放大成护颈枕。`
|
? `产品素材池共有 ${productItems.length} 张,本条只选用 ${selectedProductItems.length} 张最相关参考图,不要把未选素材混入本条画面。产品硬定义:这是套在脖子上的 U 形肩颈按摩仪,不是耳机、头戴设备或护颈枕。坐标系硬规则:左/右按佩戴者身体左右,不能按图片左右;上=靠近下巴/脸/颈部上沿,下=靠近锁骨/肩部下沿;内侧=贴颈皮肤/按摩触点,外侧=外壳/按键/Logo。所选图片只作为产品结构、角度、比例和细节参考,不要照搬参考图的白底/黑底/棚拍背景。视角标注:${notes}。保留左右非对称细节,不要把两边做成镜像对称;肩颈产品大小必须贴近真实佩戴比例,不能缩成耳机,也不能放大成护颈枕。`
|
||||||
: "未上传产品图时使用默认 SKG 产品图;生成前建议先建立同一产品素材池,锁定左右差异、厚度和佩戴比例。"
|
: "未上传产品图时使用默认 SKG 产品图;生成前建议先建立同一产品素材池,锁定左右差异、厚度和佩戴比例。"
|
||||||
return {
|
return {
|
||||||
duration: Number(Math.max(3.2, Math.min(6.5, row.end - row.start || 4.5)).toFixed(1)),
|
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} 参考帧` },
|
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,
|
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_images: productRefs,
|
||||||
product_image: productRefs[0] ?? null,
|
product_image: productRefs[0] ?? null,
|
||||||
subject: row.keyElements,
|
subject: row.needsSubject ? row.keyElements : "本条不需要人物主体或相似主体参考;如画面里出现人物,只作为背景或局部,不作为主角。",
|
||||||
scene: `${row.visualPlan}\n原音频依据:${row.source}`,
|
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.productIntegration}\n${productGuidance}`,
|
product: `产品需求:${row.needsProduct ? "需要产品参考" : "本条不需要产品"}\n产品出现方式:${row.productPlacement}\n${row.needsProduct ? row.productIntegration : "本条以情绪、人物状态、空间或节奏过渡为主,不露出产品。"}\n${productGuidance}`,
|
||||||
action: row.skgCopy,
|
action: `${row.skgCopy}\n连续动作:从首帧规划自然过渡到尾帧规划,镜头类型和产品/人物需求不能中途改变。`,
|
||||||
reference_ids: [],
|
reference_ids: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2039,6 +2153,7 @@ function AudioStoryboardPlanPanel({
|
|||||||
const [productAnalyzing, setProductAnalyzing] = useState(false)
|
const [productAnalyzing, setProductAnalyzing] = useState(false)
|
||||||
const [productAngleBusy, setProductAngleBusy] = useState<string | null>(null)
|
const [productAngleBusy, setProductAngleBusy] = useState<string | null>(null)
|
||||||
const [copyOverrides, setCopyOverrides] = useState<Record<number, string>>({})
|
const [copyOverrides, setCopyOverrides] = useState<Record<number, string>>({})
|
||||||
|
const [planOverrides, setPlanOverrides] = useState<Record<number, RowPlanPatch>>({})
|
||||||
const [authorIntent, setAuthorIntent] = useState("")
|
const [authorIntent, setAuthorIntent] = useState("")
|
||||||
const [scriptRewriteBusy, setScriptRewriteBusy] = useState<"all" | number | null>(null)
|
const [scriptRewriteBusy, setScriptRewriteBusy] = useState<"all" | number | null>(null)
|
||||||
const productFileRef = useRef<HTMLInputElement | null>(null)
|
const productFileRef = useRef<HTMLInputElement | null>(null)
|
||||||
@@ -2054,6 +2169,7 @@ function AudioStoryboardPlanPanel({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setProductItems((job?.product_refs ?? []).map(normalizeStoredProductItem))
|
setProductItems((job?.product_refs ?? []).map(normalizeStoredProductItem))
|
||||||
setCopyOverrides({})
|
setCopyOverrides({})
|
||||||
|
setPlanOverrides({})
|
||||||
setAuthorIntent("")
|
setAuthorIntent("")
|
||||||
setScriptRewriteBusy(null)
|
setScriptRewriteBusy(null)
|
||||||
}, [job?.id])
|
}, [job?.id])
|
||||||
@@ -2080,6 +2196,23 @@ function AudioStoryboardPlanPanel({
|
|||||||
setCopyOverrides((prev) => ({ ...prev, [rowIndex]: value }))
|
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 => ({
|
const rewriteSegmentForRow = (row: AudioStoryboardRow): StoryboardScriptRewriteSegment => ({
|
||||||
index: row.index,
|
index: row.index,
|
||||||
start: row.start,
|
start: row.start,
|
||||||
@@ -2313,7 +2446,8 @@ function AudioStoryboardPlanPanel({
|
|||||||
const generateRowVideo = async (row: AudioStoryboardRow, frame: KeyFrame | null) => {
|
const generateRowVideo = async (row: AudioStoryboardRow, frame: KeyFrame | null) => {
|
||||||
if (!job || !frame || !onGenerateVideo) return
|
if (!job || !frame || !onGenerateVideo) return
|
||||||
const nextFrame = orderedFrames.find((item) => item.timestamp > frame.timestamp) ?? null
|
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)
|
setVideoBusyRow(row.index)
|
||||||
try {
|
try {
|
||||||
const updated = await updateStoryboard(job.id, frame.index, scene)
|
const updated = await updateStoryboard(job.id, frame.index, scene)
|
||||||
@@ -2449,9 +2583,11 @@ function AudioStoryboardPlanPanel({
|
|||||||
<div className="max-h-[560px] space-y-2 overflow-y-auto pr-1">
|
<div className="max-h-[560px] space-y-2 overflow-y-auto pr-1">
|
||||||
{rows.map((row) => {
|
{rows.map((row) => {
|
||||||
const referenceFrame = referenceFrameForRow(row)
|
const referenceFrame = referenceFrameForRow(row)
|
||||||
|
const plannedRow = planForRow(row, referenceFrame)
|
||||||
const rowVideos = videosForFrame(referenceFrame)
|
const rowVideos = videosForFrame(referenceFrame)
|
||||||
const generating = videoBusyRow === row.index
|
const generating = videoBusyRow === row.index
|
||||||
const copyText = copyForRow(row)
|
const copyText = copyForRow(row)
|
||||||
|
const selectedProductCount = plannedRow.needsProduct ? selectProductItemsForRow(plannedRow, productItems).length : 0
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
key={row.index}
|
key={row.index}
|
||||||
@@ -2486,11 +2622,74 @@ function AudioStoryboardPlanPanel({
|
|||||||
</StoryboardPlanCell>
|
</StoryboardPlanCell>
|
||||||
|
|
||||||
<StoryboardPlanCell label="画面规划 / 产品融入">
|
<StoryboardPlanCell label="画面规划 / 产品融入">
|
||||||
<p className="line-clamp-2" title={row.visualPlan}>{row.visualPlan}</p>
|
<div className="grid gap-1.5">
|
||||||
<p className="mt-1 line-clamp-3 text-white/45" title={row.productIntegration}>
|
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-1.5">
|
||||||
<Package className="mr-1 inline h-3 w-3 text-rose-200/75" />
|
<select
|
||||||
{row.productIntegration}
|
value={plannedRow.visualMode}
|
||||||
</p>
|
onChange={(event) => applyVisualMode(row.index, event.target.value as StoryboardVisualMode)}
|
||||||
|
className="h-7 min-w-0 rounded border border-white/10 bg-black/45 px-1.5 text-[10.5px] text-white/76 outline-none focus:border-cyan-300/50"
|
||||||
|
title={VISUAL_MODE_OPTIONS.find((item) => item.value === plannedRow.visualMode)?.description}
|
||||||
|
>
|
||||||
|
{VISUAL_MODE_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label className="inline-flex h-7 items-center gap-1 rounded border border-white/10 bg-white/[0.045] px-1.5 text-[10px] text-white/55">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={plannedRow.needsSubject}
|
||||||
|
onChange={(event) => patchRowPlan(row.index, { needsSubject: event.target.checked })}
|
||||||
|
className="h-3 w-3 accent-cyan-300"
|
||||||
|
/>
|
||||||
|
人物
|
||||||
|
</label>
|
||||||
|
<label className="inline-flex h-7 items-center gap-1 rounded border border-white/10 bg-white/[0.045] px-1.5 text-[10px] text-white/55">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={plannedRow.needsProduct}
|
||||||
|
onChange={(event) => patchRowPlan(row.index, { needsProduct: event.target.checked })}
|
||||||
|
className="h-3 w-3 accent-cyan-300"
|
||||||
|
/>
|
||||||
|
产品
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={plannedRow.visualPlan}
|
||||||
|
onChange={(event) => patchRowPlan(row.index, { visualPlan: event.target.value })}
|
||||||
|
placeholder="画面规划"
|
||||||
|
className="min-h-[42px] w-full resize-y rounded border border-white/10 bg-black/32 px-2 py-1.5 text-[10.5px] leading-snug text-white/76 outline-none placeholder:text-white/25 focus:border-cyan-300/50"
|
||||||
|
/>
|
||||||
|
<div className="grid gap-1 md:grid-cols-2">
|
||||||
|
<textarea
|
||||||
|
value={plannedRow.firstFramePlan}
|
||||||
|
onChange={(event) => patchRowPlan(row.index, { firstFramePlan: event.target.value })}
|
||||||
|
placeholder="首帧:视频开始画面"
|
||||||
|
className="min-h-[48px] w-full resize-y rounded border border-emerald-300/12 bg-emerald-300/[0.04] px-2 py-1.5 text-[10.5px] leading-snug text-emerald-50/78 outline-none placeholder:text-white/25 focus:border-emerald-300/50"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={plannedRow.lastFramePlan}
|
||||||
|
onChange={(event) => patchRowPlan(row.index, { lastFramePlan: event.target.value })}
|
||||||
|
placeholder="尾帧:视频结束画面"
|
||||||
|
className="min-h-[48px] w-full resize-y rounded border border-cyan-300/12 bg-cyan-300/[0.04] px-2 py-1.5 text-[10.5px] leading-snug text-cyan-50/78 outline-none placeholder:text-white/25 focus:border-cyan-300/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={plannedRow.productPlacement}
|
||||||
|
onChange={(event) => patchRowPlan(row.index, { productPlacement: event.target.value })}
|
||||||
|
placeholder="产品出现方式:不出现 / 首帧出现 / 尾帧出现 / 全程佩戴 / 产品特写"
|
||||||
|
className="min-h-[38px] w-full resize-y rounded border border-white/10 bg-black/32 px-2 py-1.5 text-[10.5px] leading-snug text-white/68 outline-none placeholder:text-white/25 focus:border-rose-300/45"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between gap-2 text-[10px] text-white/34">
|
||||||
|
<span>{plannedRow.needsProduct ? `将自动挑选 ${selectedProductCount || 0} 张产品参考图` : "本条不传产品图"}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => patchRowPlan(row.index, visualModeDefaults(plannedRow.visualMode))}
|
||||||
|
className="rounded border border-white/10 px-1.5 py-0.5 text-white/42 transition hover:border-white/25 hover:text-white/72"
|
||||||
|
>
|
||||||
|
重置类型
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</StoryboardPlanCell>
|
</StoryboardPlanCell>
|
||||||
|
|
||||||
<StoryboardPlanCell label="生成视频" className="xl:border-r-0">
|
<StoryboardPlanCell label="生成视频" className="xl:border-r-0">
|
||||||
@@ -2504,7 +2703,7 @@ function AudioStoryboardPlanPanel({
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => generateRowVideo(row, referenceFrame)}
|
onClick={() => generateRowVideo(plannedRow, referenceFrame)}
|
||||||
disabled={!referenceFrame || !onGenerateVideo || generating}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -118,6 +118,12 @@ export interface StoryboardScene {
|
|||||||
last_image?: ImageRef | null
|
last_image?: ImageRef | null
|
||||||
product_images?: ImageRef[]
|
product_images?: ImageRef[]
|
||||||
product_fusion_shots?: ProductFusionShot[]
|
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
|
subject_image?: ImageRef | null
|
||||||
scene_image?: ImageRef | null
|
scene_image?: ImageRef | null
|
||||||
product_image?: ImageRef | null
|
product_image?: ImageRef | null
|
||||||
@@ -559,7 +565,7 @@ export interface ProductRefStateItem {
|
|||||||
landmarks: string[]
|
landmarks: string[]
|
||||||
note: string
|
note: string
|
||||||
risk: string
|
risk: string
|
||||||
source: "upload" | "ai"
|
source: "upload" | "ai" | "library"
|
||||||
assetMeta?: ImageRef["asset_meta"]
|
assetMeta?: ImageRef["asset_meta"]
|
||||||
confidence?: number
|
confidence?: number
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user