diff --git a/api/main.py b/api/main.py index 8336097..93f188a 100644 --- a/api/main.py +++ b/api/main.py @@ -376,6 +376,13 @@ class StoryboardScene(BaseModel): needs_product: bool = True needs_subject: bool = True subject_brief: str = "" + skg_copy_en: str = "" + skg_copy_zh: str = "" + scene_one_line_en: str = "" + scene_one_line_zh: str = "" + action_one_line_en: str = "" + action_one_line_zh: str = "" + selected_video_id: str = "" first_frame_plan: str = "" last_frame_plan: str = "" product_placement: str = "" @@ -5599,6 +5606,14 @@ class UpdateStoryboardReq(BaseModel): visual_mode: Literal["person_only", "person_product", "product_only", "environment"] = "person_product" needs_product: bool = True needs_subject: bool = True + subject_brief: str = "" + skg_copy_en: str = "" + skg_copy_zh: str = "" + scene_one_line_en: str = "" + scene_one_line_zh: str = "" + action_one_line_en: str = "" + action_one_line_zh: str = "" + selected_video_id: str = "" first_frame_plan: str = "" last_frame_plan: str = "" product_placement: str = "" @@ -5615,8 +5630,10 @@ class UpdateStoryboardReq(BaseModel): class GenerateStoryboardVideoReq(BaseModel): - prompt: str + prompt: str = "" duration: float = 4 + count: int = 1 + seed: int | None = None first_image: dict | None = None last_image: dict | None = None product_images: list[dict] = Field(default_factory=list) @@ -5630,6 +5647,175 @@ class GenerateStoryboardVideoReq(BaseModel): size: str = "720x1280" +class QuickStoryboardPlanReq(BaseModel): + skg_copy_en: str = "" + skg_copy_zh: str = "" + scene_one_line_en: str = "" + scene_one_line_zh: str = "" + action_one_line_en: str = "" + action_one_line_zh: str = "" + subject_brief: str = "" + duration: float = 4 + visual_mode: Literal["person_only", "person_product", "product_only", "environment"] = "person_product" + needs_product: bool = True + needs_subject: bool = True + + +class RefineStoryboardReq(BaseModel): + current_plan: QuickStoryboardPlanReq = Field(default_factory=QuickStoryboardPlanReq) + user_feedback: str = "" + + +class BatchGenerateStoryboardReq(BaseModel): + count_per_row: int = 4 + concurrency: int = 4 + model: str = "" + size: str = "720x1280" + + +def _quick_field_en(en: str, zh: str) -> str: + text = (en or "").strip() + if text: + return _ensure_english(text) + return _ensure_english((zh or "").strip()) + + +def _subject_brief_for_frame(frame: KeyFrame | None) -> str: + if not frame: + return "" + briefs = [ + (element.subject_consensus_brief or element.subject_consensus_brief_zh or "").strip() + for element in (frame.elements or []) + if (element.subject_consensus_brief or element.subject_consensus_brief_zh or "").strip() + ] + return "\n".join(briefs[:3]) + + +def _fallback_quick_storyboard_plan(req: QuickStoryboardPlanReq, frame: KeyFrame | None = None) -> StoryboardScene: + copy_en = _quick_field_en(req.skg_copy_en, req.skg_copy_zh) or "Show the SKG massage product as a natural upgrade in this short-video beat." + scene_en = _quick_field_en(req.scene_one_line_en, req.scene_one_line_zh) or "Clean vertical short-video scene with premium wellness lighting." + action_en = _quick_field_en(req.action_one_line_en, req.action_one_line_zh) or "A natural creator-style subject introduces and uses the SKG neck-and-shoulder massager." + subject_brief = (req.subject_brief or _subject_brief_for_frame(frame)).strip() + product_placement = ( + "Show the SKG white U-shaped neck-and-shoulder massager worn externally around the neck and shoulders; " + "preserve realistic scale, contact pads, button placement, side thickness, and left-right asymmetry." + if req.needs_product + else "Do not show the SKG product in this beat unless it is only a subtle background context." + ) + return StoryboardScene( + duration=max(3.2, min(8.0, float(req.duration or 4))), + visual_mode=req.visual_mode, + needs_product=bool(req.needs_product), + needs_subject=bool(req.needs_subject), + subject_brief=subject_brief, + skg_copy_en=copy_en, + skg_copy_zh=(req.skg_copy_zh or "").strip(), + scene_one_line_en=scene_en, + scene_one_line_zh=(req.scene_one_line_zh or "").strip(), + action_one_line_en=action_en, + action_one_line_zh=(req.action_one_line_zh or "").strip(), + first_frame_plan=f"First frame: {scene_en}. Establish the subject state and visual problem clearly. {action_en}", + last_frame_plan=f"Last frame: continue from the first frame and land on a clearer SKG product benefit moment. {action_en}", + product_placement=product_placement, + subject=subject_brief or ("Use a consistent similar commercial subject with clear neck and shoulder area." if req.needs_subject else "No main character required."), + scene=f"{scene_en}\nVoice-over reference: {copy_en}", + product=product_placement, + action=f"{action_en}\nEnglish voice-over: {copy_en}", + reference_ids=[], + ) + + +def _quick_storyboard_plan_sync(req: QuickStoryboardPlanReq, frame: KeyFrame | None = None) -> StoryboardScene: + fallback = _fallback_quick_storyboard_plan(req, frame) + if not LLM_API_KEY: + return fallback + subject_brief = (req.subject_brief or _subject_brief_for_frame(frame)).strip() + payload = { + "skg_copy_en": _quick_field_en(req.skg_copy_en, req.skg_copy_zh), + "skg_copy_zh": req.skg_copy_zh, + "scene_one_line_en": _quick_field_en(req.scene_one_line_en, req.scene_one_line_zh), + "scene_one_line_zh": req.scene_one_line_zh, + "action_one_line_en": _quick_field_en(req.action_one_line_en, req.action_one_line_zh), + "action_one_line_zh": req.action_one_line_zh, + "subject_brief": subject_brief, + "duration": req.duration, + "visual_mode": req.visual_mode, + "needs_product": req.needs_product, + "needs_subject": req.needs_subject, + } + prompt = ( + "Expand this compact SKG TikTok recreation row into a complete video generation storyboard plan. " + "Return strict JSON only. All English fields must be English. Chinese mirror fields may be Simplified Chinese.\n" + "Schema: {\"visual_mode\":\"person_only|person_product|product_only|environment\"," + "\"needs_product\":true,\"needs_subject\":true," + "\"skg_copy_en\":\"...\",\"skg_copy_zh\":\"...\"," + "\"scene_one_line_en\":\"...\",\"scene_one_line_zh\":\"...\"," + "\"action_one_line_en\":\"...\",\"action_one_line_zh\":\"...\"," + "\"subject_brief\":\"...\",\"first_frame_plan\":\"...\",\"last_frame_plan\":\"...\"," + "\"product_placement\":\"...\",\"subject\":\"...\",\"scene\":\"...\",\"product\":\"...\",\"action\":\"...\"}.\n" + "Rules: keep the row compact semantics; do not add medical treatment claims; product is an SKG white U-shaped neck-and-shoulder wearable massager; " + "the final video prompt must be usable without user-visible first/last frame steps.\n\n" + f"Input:\n{json.dumps(payload, ensure_ascii=False)}" + ) + try: + resp = llm().chat.completions.create( + model=REWRITE_MODEL, + messages=[ + {"role": "system", "content": "Return valid JSON only. No markdown. No commentary."}, + {"role": "user", "content": prompt}, + ], + response_format={"type": "json_object"}, + temperature=0.35, + max_tokens=1400, + ) + raw = (resp.choices[0].message.content or "").strip() + if raw.startswith("```"): + match = re.search(r"\{[\s\S]*\}", raw) + raw = match.group(0) if match else raw + data = json.loads(raw) + return StoryboardScene( + duration=max(3.2, min(8.0, float(req.duration or 4))), + visual_mode=data.get("visual_mode") if data.get("visual_mode") in {"person_only", "person_product", "product_only", "environment"} else fallback.visual_mode, + needs_product=bool(data.get("needs_product", fallback.needs_product)), + needs_subject=bool(data.get("needs_subject", fallback.needs_subject)), + subject_brief=str(data.get("subject_brief") or fallback.subject_brief).strip(), + skg_copy_en=_ensure_english(str(data.get("skg_copy_en") or fallback.skg_copy_en).strip()), + skg_copy_zh=str(data.get("skg_copy_zh") or fallback.skg_copy_zh).strip(), + scene_one_line_en=_ensure_english(str(data.get("scene_one_line_en") or fallback.scene_one_line_en).strip()), + scene_one_line_zh=str(data.get("scene_one_line_zh") or fallback.scene_one_line_zh).strip(), + action_one_line_en=_ensure_english(str(data.get("action_one_line_en") or fallback.action_one_line_en).strip()), + action_one_line_zh=str(data.get("action_one_line_zh") or fallback.action_one_line_zh).strip(), + first_frame_plan=_ensure_english(str(data.get("first_frame_plan") or fallback.first_frame_plan).strip()), + last_frame_plan=_ensure_english(str(data.get("last_frame_plan") or fallback.last_frame_plan).strip()), + product_placement=_ensure_english(str(data.get("product_placement") or fallback.product_placement).strip()), + subject=_ensure_english(str(data.get("subject") or fallback.subject).strip()), + scene=_ensure_english(str(data.get("scene") or fallback.scene).strip()), + product=_ensure_english(str(data.get("product") or fallback.product).strip()), + action=_ensure_english(str(data.get("action") or fallback.action).strip()), + reference_ids=[], + ) + except Exception as e: + print(f"[quick storyboard fallback] {e}", flush=True) + return fallback + + +def _storyboard_video_prompt(scene: StoryboardScene, seed: int | None = None) -> str: + parts = [ + "Create one vertical 9:16 short-form ad video clip for SKG.", + f"English voice-over line: {_ensure_english(scene.skg_copy_en or scene.action or '')}", + f"Scene: {_ensure_english(scene.scene_one_line_en or scene.scene or '')}", + f"Subject + product + action: {_ensure_english(scene.action_one_line_en or scene.action or '')}", + f"First frame intent: {_ensure_english(scene.first_frame_plan or '')}", + f"Last frame intent: {_ensure_english(scene.last_frame_plan or '')}", + f"Product placement: {_ensure_english(scene.product_placement or scene.product or '')}", + f"Subject brief: {_ensure_english(scene.subject_brief or scene.subject or '')}", + "Keep motion natural, creator-ad style, premium clean wellness lighting, no subtitles, no platform UI, no watermark, no medical treatment claims.", + ] + if seed is not None: + parts.append(f"Creative variation seed: {seed}.") + return "\n".join([p for p in parts if p.strip()]) + + class ProductFusionDescriptionReq(BaseModel): shots: list[ProductFusionShot] = Field(default_factory=list) @@ -5901,29 +6087,93 @@ def render_storyboard_video( update_generated_video(job_id, local_id, status="failed", error=str(e)[:500]) -@app.post("/jobs/{job_id}/frames/{idx}/storyboard/video", response_model=Job) -def generate_storyboard_video(job_id: str, idx: int, req: GenerateStoryboardVideoReq, bg: BackgroundTasks) -> Job: +@app.post("/jobs/{job_id}/frames/{idx}/storyboard/quick-plan", response_model=StoryboardScene) +def quick_plan_storyboard(job_id: str, idx: int, req: QuickStoryboardPlanReq) -> StoryboardScene: job = JOBS.get(job_id) if not job: raise HTTPException(404, "job not found") frame = next((f for f in job.frames if f.index == idx), None) if not frame: raise HTTPException(404, "frame not found") + return _quick_storyboard_plan_sync(req, frame) + + +@app.post("/jobs/{job_id}/frames/{idx}/storyboard/refine") +def refine_storyboard(job_id: str, idx: int, req: RefineStoryboardReq) -> dict: + job = JOBS.get(job_id) + if not job: + raise HTTPException(404, "job not found") + frame = next((f for f in job.frames if f.index == idx), None) + if not frame: + raise HTTPException(404, "frame not found") + current = req.current_plan + feedback = req.user_feedback.strip() + if not feedback: + raise HTTPException(400, "user_feedback required") + fallback = { + "skg_copy_en": _quick_field_en(current.skg_copy_en, current.skg_copy_zh), + "skg_copy_zh": current.skg_copy_zh.strip(), + "scene_one_line_en": _quick_field_en(current.scene_one_line_en, current.scene_one_line_zh), + "scene_one_line_zh": current.scene_one_line_zh.strip(), + "action_one_line_en": _quick_field_en(current.action_one_line_en, current.action_one_line_zh), + "action_one_line_zh": current.action_one_line_zh.strip(), + } + if not LLM_API_KEY: + return {"items": fallback, "model": "fallback"} + prompt = ( + "Rewrite this compact SKG storyboard row according to user feedback. " + "Keep meaning and timing, improve clarity and video-generation usefulness. " + "Return strict JSON only with exactly these fields: " + "skg_copy_en, skg_copy_zh, scene_one_line_en, scene_one_line_zh, action_one_line_en, action_one_line_zh. " + "English fields must be English; Chinese fields must be Simplified Chinese. " + "No medical treatment claims.\n\n" + f"Current:\n{json.dumps(fallback, ensure_ascii=False)}\n\n" + f"User feedback:\n{feedback}" + ) + try: + resp = llm().chat.completions.create( + model=REWRITE_MODEL, + messages=[ + {"role": "system", "content": "Return valid JSON only. No markdown. No explanation."}, + {"role": "user", "content": prompt}, + ], + response_format={"type": "json_object"}, + temperature=0.55, + max_tokens=900, + ) + data = json.loads((resp.choices[0].message.content or "{}").strip()) + out = { + "skg_copy_en": _ensure_english(str(data.get("skg_copy_en") or fallback["skg_copy_en"]).strip()), + "skg_copy_zh": str(data.get("skg_copy_zh") or fallback["skg_copy_zh"]).strip(), + "scene_one_line_en": _ensure_english(str(data.get("scene_one_line_en") or fallback["scene_one_line_en"]).strip()), + "scene_one_line_zh": str(data.get("scene_one_line_zh") or fallback["scene_one_line_zh"]).strip(), + "action_one_line_en": _ensure_english(str(data.get("action_one_line_en") or fallback["action_one_line_en"]).strip()), + "action_one_line_zh": str(data.get("action_one_line_zh") or fallback["action_one_line_zh"]).strip(), + } + return {"items": out, "model": REWRITE_MODEL} + except Exception as e: + return {"items": fallback, "model": "fallback", "error": str(e)[:300]} + + +def _enqueue_storyboard_videos(job: Job, frame: KeyFrame, req: GenerateStoryboardVideoReq, bg: BackgroundTasks | None = None) -> list[str]: ensure_video_api_configured() - prompt = req.prompt.strip() + prompt = _ensure_english(req.prompt.strip()) + if not prompt and frame.storyboard: + prompt = _storyboard_video_prompt(frame.storyboard, req.seed) if not prompt: raise HTTPException(400, "prompt required") + count = max(1, min(12, int(req.count or 1))) ref = req.first_image or req.subject_image or req.product_image or req.scene_image or req.action_image primary_role = "first_frame" if req.first_image else "reference_image" - ref_path = storyboard_ref_path(job_id, ref) or (job_dir(job_id) / "frames" / f"{idx:03d}.jpg") + ref_path = storyboard_ref_path(job.id, ref) or (job_dir(job.id) / "frames" / f"{frame.index:03d}.jpg") if not ref_path.exists(): raise HTTPException(404, "reference image missing") - poster = storyboard_ref_url(job_id, ref) or f"/jobs/{job_id}/frames/{idx}.jpg" - last_ref_path = storyboard_ref_path(job_id, req.last_image) + poster = storyboard_ref_url(job.id, ref) or f"/jobs/{job.id}/frames/{frame.index}.jpg" + last_ref_path = storyboard_ref_path(job.id, req.last_image) raw_product_refs = req.product_images[:6] if req.product_images else ([req.product_image] if req.product_image else []) - product_ref_paths = [p for p in (storyboard_ref_path(job_id, r) for r in raw_product_refs) if p] - subject_ref_paths = [p for p in (storyboard_ref_path(job_id, r) for r in req.subject_images[:8]) if p] + product_ref_paths = [p for p in (storyboard_ref_path(job.id, r) for r in raw_product_refs) if p] + subject_ref_paths = [p for p in (storyboard_ref_path(job.id, r) for r in req.subject_images[:8]) if p] reference_ref_paths = [] seen_ref_paths: set[str] = {str(ref_path)} # Product fusion is sensitive to object drift. Send product references before @@ -5934,27 +6184,117 @@ def generate_storyboard_video(job_id: str, idx: int, req: GenerateStoryboardVide reference_ref_paths.append(p) seen_ref_paths.add(key) - local_id = uuid.uuid4().hex[:12] model = resolve_video_model(req.model) seconds = video_seconds(float(req.duration or 4)) - item = GeneratedVideo( - id=local_id, - provider_id="", - frame_idx=idx, - prompt=prompt, - model=model, - status="queued", - url="", - poster_url=poster, - duration=float(seconds), - progress=0, - created_at=time.time(), - ) - update(job, generated_videos=[item] + job.generated_videos, message=f"视频生成已提交 · 分镜 {idx + 1}") source_ref = req.source_ref if source_ref and source_ref.kind == "source_video" and not source_ref.url: source_ref = None - bg.add_task(render_storyboard_video, job_id, local_id, "", ref_path, prompt, model, seconds, req.size, source_ref, last_ref_path, reference_ref_paths, primary_role) + items: list[GeneratedVideo] = [] + ids: list[str] = [] + for i in range(count): + local_id = uuid.uuid4().hex[:12] + ids.append(local_id) + variant_seed = (req.seed + i) if req.seed is not None else random.randint(100000, 999999) + variant_prompt = _ensure_english(f"{prompt}\n\nCreate variation {i + 1} of {count}. Variation seed: {variant_seed}. Keep the same compact row meaning but vary camera motion, gesture timing, and composition.") + items.append(GeneratedVideo( + id=local_id, + provider_id="", + frame_idx=frame.index, + prompt=variant_prompt, + model=model, + status="queued", + url="", + poster_url=poster, + duration=float(seconds), + progress=0, + created_at=time.time(), + )) + task_args = (job.id, local_id, "", ref_path, variant_prompt, model, seconds, req.size, source_ref, last_ref_path, reference_ref_paths, primary_role) + if bg is not None: + bg.add_task(render_storyboard_video, *task_args) + else: + threading.Thread(target=render_storyboard_video, args=task_args, daemon=True).start() + update(job, generated_videos=items + job.generated_videos, message=f"视频抽卡已提交 · 分镜 {frame.index + 1} · {count} 张") + return ids + + +@app.post("/jobs/{job_id}/frames/{idx}/storyboard/video", response_model=Job) +def generate_storyboard_video(job_id: str, idx: int, req: GenerateStoryboardVideoReq, bg: BackgroundTasks) -> Job: + job = JOBS.get(job_id) + if not job: + raise HTTPException(404, "job not found") + frame = next((f for f in job.frames if f.index == idx), None) + if not frame: + raise HTTPException(404, "frame not found") + _enqueue_storyboard_videos(job, frame, req, bg) + return job + + +def _batch_generate_worker(job_id: str, req: BatchGenerateStoryboardReq) -> None: + from concurrent.futures import ThreadPoolExecutor, wait + + job = JOBS.get(job_id) + if not job: + return + count = max(1, min(12, int(req.count_per_row or 4))) + concurrency = max(1, min(8, int(req.concurrency or 4))) + frames = list(job.frames) + update(job, message=f"整片一键抽卡已启动 · 0/{len(frames)} 条", error="") + done = 0 + + def submit_one(frame: KeyFrame) -> None: + nonlocal done + try: + scene = frame.storyboard + if scene is None: + quick_req = QuickStoryboardPlanReq( + scene_one_line_en=(frame.description or {}).get("scene", "") if isinstance(frame.description, dict) else "", + action_one_line_en="Use the source beat as a compact SKG product ad action with a clear subject, product, and motion.", + subject_brief=_subject_brief_for_frame(frame), + duration=5, + ) + scene = _quick_storyboard_plan_sync(quick_req, frame) + frame.storyboard = scene + update(job, frames=job.frames) + prompt = _storyboard_video_prompt(scene) + video_req = GenerateStoryboardVideoReq( + prompt=prompt, + duration=scene.duration or 4, + count=count, + first_image=scene.first_image, + last_image=scene.last_image, + product_images=scene.product_images, + subject_images=scene.subject_images, + subject_image=scene.subject_image, + scene_image=scene.scene_image, + product_image=scene.product_image, + action_image=scene.action_image, + model=req.model, + size=req.size, + ) + _enqueue_storyboard_videos(job, frame, video_req, None) + except Exception as e: + update(job, error=f"分镜 {frame.index + 1} 抽卡失败:{str(e)[:220]}") + finally: + done += 1 + update(job, message=f"整片一键抽卡进行中 · {done}/{len(frames)} 条") + + with ThreadPoolExecutor(max_workers=concurrency) as executor: + futures = [executor.submit(submit_one, frame) for frame in frames] + wait(futures) + update(job, message=f"整片一键抽卡已提交 · {len(frames)}/{len(frames)} 条 · 每条 {count} 张") + + +@app.post("/jobs/{job_id}/storyboard/batch-generate-all", response_model=Job) +def batch_generate_all_storyboard(job_id: str, req: BatchGenerateStoryboardReq) -> Job: + job = JOBS.get(job_id) + if not job: + raise HTTPException(404, "job not found") + ensure_video_api_configured() + if not job.frames: + raise HTTPException(400, "no frames to generate") + threading.Thread(target=_batch_generate_worker, args=(job_id, req), daemon=True).start() + update(job, message=f"整片一键抽卡已启动 · {len(job.frames)} 条 · 每条 {max(1, min(12, int(req.count_per_row or 4)))} 张") return job @@ -6901,8 +7241,12 @@ def delete_storyboard_video(job_id: str, video_id: str) -> Job: shutil.rmtree(out_dir) except OSError: pass + if removed: + for frame in job.frames: + if frame.index == removed.frame_idx and frame.storyboard and frame.storyboard.selected_video_id == video_id: + frame.storyboard.selected_video_id = "" msg = f"删除视频任务 · 分镜 {removed.frame_idx + 1}" if removed else "删除视频任务" - update(job, generated_videos=kept, message=msg) + update(job, generated_videos=kept, frames=job.frames, message=msg) return job @@ -6928,6 +7272,14 @@ def update_storyboard(job_id: str, idx: int, req: UpdateStoryboardReq) -> Job: visual_mode=req.visual_mode, needs_product=bool(req.needs_product), needs_subject=bool(req.needs_subject), + subject_brief=req.subject_brief.strip(), + skg_copy_en=req.skg_copy_en.strip(), + skg_copy_zh=req.skg_copy_zh.strip(), + scene_one_line_en=req.scene_one_line_en.strip(), + scene_one_line_zh=req.scene_one_line_zh.strip(), + action_one_line_en=req.action_one_line_en.strip(), + action_one_line_zh=req.action_one_line_zh.strip(), + selected_video_id=req.selected_video_id.strip(), first_frame_plan=req.first_frame_plan.strip(), last_frame_plan=req.last_frame_plan.strip(), product_placement=req.product_placement.strip(), diff --git a/docs/source-analysis.html b/docs/source-analysis.html index f7fcbfc..a296971 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -569,7 +569,7 @@

业务管线

-

当前产品方向已收窄为“信息流广告快速复刻”:主界面左侧是素材输入列,右侧是信息流复刻工作表。顶部固定显示 01-09 流程顺序和每一步的判定依据,编号不再是装饰文本,而是按素材任务、源视频、音频文案、抽帧、主体资产、产品资产、分镜文案、首尾帧和视频候选这些状态解锁。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动音频文案路和视频视觉路。音频文案路提取原音频文案/字幕,分析讲话人、语速节奏、背景音乐/环境声/音效,并为后续新口播和分镜文案提供时间轴;视频视觉路同步抽取参考帧,参考帧只用于人工选择主体并生成相似主体白底视图。产品图上传后独立形成产品资产包:自动识别视角、左右/上下/内外侧、结构点、比例和风险,并补缺角度。最终分镜规划按逐句时间轴把文案、相似主体资产和产品资产汇合;当前暂停直接调视频模型,先逐条生成并审核首帧/尾帧,确认后再决定哪些分镜进入视频候选。

+

当前产品方向已收窄为“信息流广告快速复刻”:主界面左侧是素材输入列,右侧是信息流复刻工作表。顶部固定显示 01-09 流程顺序和每一步的判定依据,编号不再是装饰文本,而是按素材任务、源视频、音频文案、抽帧、主体资产、产品资产、分镜文案、三字段规划和视频候选这些状态解锁。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动音频文案路和视频视觉路。音频文案路提取原音频文案/字幕,分析讲话人、语速节奏、背景音乐/环境声/音效,并为后续新口播和分镜文案提供时间轴;视频视觉路同步抽取参考帧,参考帧只用于人工选择主体并生成相似主体白底视图。产品图上传后独立形成产品资产包:自动识别视角、左右/上下/内外侧、结构点、比例和风险,并补缺角度。最终分镜规划按逐句时间轴把文案、相似主体资产和产品资产汇合;客户默认只看“文案 / 场景一句话 / 人物+产品+动作”三字段,一键为单条抽 4 张视频候选或整片批量抽卡。首尾帧、视觉规划、产品出现方式等细节保留在高级抽屉和后端自动展开逻辑里,不再作为客户默认闸门。

01

素材输入

有当前素材任务即通过;输入框只负责创建或切换任务。

02

源视频下载

job.video_url 存在即通过;created/downloading 视为运行中。TikTok 受限视频可通过 YTDLP_COOKIES_FILEYTDLP_COOKIES_FROM_BROWSER 提供登录态;生产云端使用服务器私有 cookies 文件挂载,失败后可对同一素材重新下载。

@@ -578,8 +578,8 @@
05

相似主体

关键帧里存在 subject_assets 即通过;生成类似创新主体,不复刻原人。

06

产品素材池

product_refs 有记录即通过;不限量上传,后续按分镜最多挑 6 张。

07

分镜文案

逐句时间轴生成后进入分镜;新口播可单段或整片改写。

-
08

画面首尾帧

先定场景+人+产品+动作,再生成 asset 类型首帧/尾帧;旧 keyframe 不算通过。

-
09

视频候选

当前不直接调视频模型;首尾帧审核后才开放单条或批量提交。

+
08

三字段规划

客户默认只编辑文案、场景一句话、人物+产品+动作;高级抽屉保留首尾帧和 6 字段。

+
09

视频候选

单条默认抽 4 张候选;整片一键抽卡后台提交,失败行可重试。

@@ -594,6 +594,7 @@ web/app/globals.css全局主题变量、登录页视觉样式、信息流工作台同源品牌 token、ReactFlow 样式引用,以及本地开发态 nextjs-portal 遮挡隐藏规则。工作台在 skg-board-theme 内定义 --skg-gold-1--skg-gold-2--skg-cream--skg-bg-*--skg-text-*--skg-radius-* 和按钮阴影等变量,并新增 skg-board-brandskg-stat-cardskg-primary-actionskg-secondary-actionskg-empty-state 等样式。暗色工作台复用登录页金色聚焦、米白主按钮和弱暖光氛围;明亮模式通过 skg-board-theme--light 复用同一套结构,改成暖白底、白色 panel、黑底主 CTA 和深色文本,不另起一套界面。 web/app/page.tsx产品工作台主状态:jobs、activeJobId、生成任务状态;主渲染为全屏素材输入列 + 信息流广告复刻工作表;“开始分析”会把 job 放入并行素材分析队列,下载完成后触发 triggerTranscribe 解析音频,并触发 analyzeJob 自动抽 12 张参考帧,形成“音频文案路 + 视频视觉路”同步推进;底部吸附音频条和旧全局浮动主题按钮不再从主界面渲染,避免和工作台内的明暗模式切换重复。 web/components/ad-recreation-board.tsx信息流广告复刻工作表:顶部先展示与登录页连续的 SKG brand strip,包含 SKG 字标、“未来健康 · 营销内容工作台”和“营销内容工作台 · TK 二创”;右侧素材/任务/视频/文案统计改为米白 stat 卡片,主动作按钮统一走 skg-primary-action,次动作走 skg-secondary-action,空状态复用 AnimatedLoginCharacters。顶部由 buildWorkflowSteps 统一生成 01-09 流程顺序、状态和判定依据,WorkflowOrderBar 展示完整顺序,WorkflowStepBadge / PipelineLane / 分镜列标题共用同一套编号。左侧素材输入只负责链接/上传和任务切换,不再重复放横版原视频预览;右侧顶部用“音频文案、抽帧参考、相似主体、产品素材池”四个状态条显示后台并行进度。源视频工作区展示视频下载状态和默认折叠的文案依据。音频解析结果改成默认折叠的辅助信息,展开后同一行看讲话人/节奏/背景音;主工作区左侧放大为按 9:16 显示的竖版原视频播放器,播放器内覆盖“当前点抽帧”,按当前播放秒数手动补参考帧;右侧上方是音频波形 / 切点参考,下方左侧是参考帧池,右侧是逐句时间轴;下一行只保留“相似主体 / 主体模板”。音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点,顶部同时显示当前播放秒数、总时长和鼠标指针停点秒数。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。逐句时间轴左侧参考帧池的主入口是“自动抽帧 12 张”,一键按动作峰值目标重新抽取 12 张源视频参考帧,优先抓手势、表情变化、节奏点和镜头变化;缩略图按竖版完整比例显示不裁切并用更多列紧凑铺开,点选状态直接叠在参考帧池缩略图上,鼠标停留会通过固定浮层放大展示完整帧。“生成 10 张高清图”放在下方相似主体白底视图区,不和抽参考按钮平齐;如果用户没有勾选帧,默认把全部关键帧作为主体参考,勾选后只传已选帧;生成区可在“透明骨架 / 普通真人”之间切换,可选择桌面导入的 5 套内置形象作为创意方向,并可填写统一主体方向,例如年轻女性、更运动、更高级。关键帧和相似主体白底视图都用更小的竖版缩略图密排;白底视图只展示每个 view 的最新一张,缩略图上提供“重新生成这一张”和“删除这一张”,单张重生会用 replace_views=true 替换同一视角。前端调用 generateSubjectAssets 时按主体类型传 subject_style=transparent_humansource_actor,按需传 character_id,并使用 reconstruction_mode=similar;后端会把关键帧和内置形象视为同一个主体的创意证据,并锁定同一性别表现、年龄段、体型、材质、风格和视觉身份,同时生成全身多视角 + 肩颈正/左右近景 + 后颈肩背特写,避免整套图出现男女性别、老少年龄或样式混杂。主体生成完成后会形成 subject_consensus_brief,主体模板保存区可预览/编辑这段 brief。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px,但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写,分镜时间和原内容列压缩为窄摘要列,新口播列进一步收窄,把横向空间留给画面规划和首尾帧。每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入和历史候选视频槽;画面规划区先选择镜头类型(人物/情绪、人物+产品、产品特写、场景过渡),再用人物/产品开关、首帧规划、尾帧规划和产品出现方式决定这一条到底需不需要产品图或相似主体参考。当前主流程暂停直接调用视频模型,不再提供“生成本条 · Seedance”或“一键提交全部”视频入口;行内新增“首尾帧闸门”,分别显示/生成首帧和尾帧,旧 keyframe 类型首尾帧会被忽略,只认真正的 asset 首尾帧。生成首尾帧时调用 generateSceneAsset,主体只传 subject_brief,不再传主体图;产品按端点选择最多 1-2 张硬参考图,默认正面,侧面/后颈/厚度/特写等关键词会额外补一张对应视角。关键帧只作为前置主体重构证据和行数据承载位置,不再作为后续视频首尾帧参考。视频候选槽只展示历史候选和待生成占位,按钮改为“保存本条规划 / 保存全部规划”。只有该行勾选“产品”时,首尾帧生成才会从产品素材池按端点视角策略自动挑选最多 1-2 张相关产品图;未勾选产品时不会把产品图提交给首尾帧/后续生视频模型,并走纯文字首尾帧。只有该行勾选“人物”时,才会把主体 brief 注入 prompt;否则 prompt 会明确禁止强行添加主角式透明骨架人,后端也不会再给产品特写强加透明骨架人约束。ModelTrace 会在音频解析、产品识别/补图、相似主体高清视图包、脚本改写等入口旁直接展示模型名;所有生图入口都显示并使用 gpt-image-2,没有其他图片模型 fallback;点击后用固定浮层展示模型链路、输入输出和回退逻辑。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 + AudioStoryboardPlanPanel 三字段抽卡当前分镜主路径:每行默认只显示 skg_copy_*scene_one_line_*action_one_line_* 三组中英字段,以及 AI 改写、抽 4 张视频和高级抽屉按钮。quickPlanStoryboard 把三字段和主体 brief 展开为完整 StoryboardScenegenerateStoryboardVideo 默认提交 count=4,候选区用 4-grid 展示进度、hover 视频预览、选中、重生、删除和清空。高级抽屉仍展示旧 6 字段、首尾帧 prompt 和首尾帧资产槽,但客户默认不用先处理首尾帧。 web/components/resource-library/library-drawer.tsx全局资源中心浮窗:由工作台顶部“资源库”按钮打开,叠加在工作台上方但不阻塞主界面;尺寸、位置和当前 Tab 写入 localStorage["skg-resource-library-drawer"]。提示词 Tab 固定 5 列(场景描述、视频描述、主体描述、SKG 文案、产品角度),每列先显示 use_count 排名前 5 的“常用”,再按月份倒序分组;提示词节点常驻复制按钮,hover 可选英文/中文/双语复制,并调用 use 接口。素材 Tab 固定 4 列(主体、产品、场景、视频),节点不可拖动,按月份倒序硬编码排列;“应用到当前 job”只调用后端复制接口,得到普通 ImageRef(kind="asset") 后再写入产品素材池或复制 ID。浮窗顶部最近 24 小时横条混合显示提示词和素材;新建提示词、上传素材、删除前查引用、详情侧栏都在该组件内完成。 AdRecreationBoard 主题切换顶部指标区左侧有“明亮/暗色”按钮,使用 Sun / Moon 图标切换 skg-board-theme--light 类名,并把选择写入 localStorage["skg-board-theme"]。暗色仍是默认模式;明亮模式只改变工作台外观,不改变任务、素材、分镜、模型调用或接口数据。 SourceReferenceBuildPanel“相似主体 / 主体模板”当前承担主体资产生成和主体模板复用的前端入口:顶部用 radio 区分“用模板生成”和“不用模板(从源视频关键帧创新)”,源视频相似 不再作为模板卡混进网格。模板库把 GET /subject-templates 数据库模板和 GET /character-library/skg 内置形象合并成 120px 竖排卡片,选中态统一用 SKG 金色;当选择“不用模板”时模板网格会收起,避免把生成按钮和结果缩略图挤到折叠区域之外。保存为主体模板的名称、备注和按钮固定在模板区底部一行。下方“生成主体视图”独立显示模型链路,支持透明骨架/真人、全部 10 / 常用 4 / 自定义视图;同时新增“主体设定”,默认随机组合性别表现、年龄段、着装风格、地域人种、肤色、体型比例、发型和气质场景,也可切到手动指定。随机组合会在点击生成时解析成一套固定 profile 并传给后端 subject_profile,整包视图共用同一人设,不会一张男一张女或一张年轻一张银发。已有生成结果会优先显示在生成区标题下方,再显示控制项,避免用户生成后还要继续向下找图。主体缩略图放大为可单张重生、删除和 hover 放大的媒体卡;生成中会显示本次请求锁定的素材 ID 和主体设定,切换其他模块不会改变已经提交的生成目标。前端仍传 reconstruction_mode=similar,后端先用 VISION_MODEL 把关键帧/模板图转成非身份化文字 brief;如果 brief 失败,则继续用用户方向、模板文字、内置形象 brief 和结构化主体设定。最终主体图只走 gpt-image-2/images/generations 文字生图,不再把原帧或模板图作为强 image-edit 锚点。 @@ -637,9 +638,9 @@ web/app/page.tsx -> 信息流广告复刻工作表:web/components/ad-recreation-board.tsx -> 开始分析:创建/激活 job → 下载完成后并行触发视频视觉路 analyzeJob 与音频文案路 triggerTranscribe - -> WorkflowOrderBar:01 素材输入 → 02 源视频下载 → 03 音频文案 → 04 抽帧参考 → 05 相似主体 → 06 产品素材池 → 07 分镜文案 → 08 画面首尾帧 → 09 视频候选;每步从 buildWorkflowSteps 取判定依据和状态 + -> WorkflowOrderBar:01 素材输入 → 02 源视频下载 → 03 音频文案 → 04 抽帧参考 → 05 相似主体 → 06 产品素材池 → 07 分镜文案 → 08 三字段规划 → 09 视频候选;每步从 buildWorkflowSteps 取判定依据和状态 -> 左侧素材输入列 + 右侧 03-06 状态条 + 默认折叠的文案依据 + 源视频工作区(音频解析结果默认折叠,竖版 9:16 原视频播放器放大并内置当前点抽帧,右侧上方连续响度波形显示当前/总时长/指针停点,右侧下方左侧是参考帧池,右侧是逐句时间轴联动滚动;参考帧池缩略图自身显示是否已选,不再单独重复显示已选关键帧;下方只保留相似主体 / 主体模板和相似主体高清视图包;不勾选帧则默认用全部帧,勾选后只用已选帧,可叠加 5 套内置形象;主体模板区分为模板库与本次生成 / 入库草稿,数据库接口未完成前只允许命名和备注,不提交保存) - -> 信息流复刻分镜工作台:06 同一产品素材池不限量上传 → 自动识别视角 / 背景 / 用途 / 风险 → 人工检查备注 → 07 逐句时间轴 / 原内容 / 新口播文案 → 08 画面规划与产品融入(镜头类型、人物描述、人物/产品开关、首帧、尾帧、产品出现方式)→ 首尾帧闸门:按需求选择主体视角 + 产品素材生成首帧/尾帧 → 保存规划 → 09 历史候选视频槽(当前不直接批量提交视频) + -> 信息流复刻分镜工作台:06 同一产品素材池不限量上传 → 自动识别视角 / 背景 / 用途 / 风险 → 人工检查备注 → 07 逐句时间轴 / 原内容 / 新口播文案 → 08 紧凑三字段(文案、场景一句话、人物+产品+动作)→ quick-plan 自动展开高级字段 → 单条抽 4 张视频 / 再抽追加 / 选中候选 → 09 整片一键抽卡后台批量提交 -> 底部音频条:不再渲染,音频结果集中到右侧工作表 -> 旧节点/深度素材面板:web/components/nodes/index.tsx、web/components/lightbox.tsx、web/components/storyboard-workbench.tsx(底层保留,当前不作为主入口) -> API 契约:web/lib/api.ts @@ -669,6 +670,11 @@ api/main.py
主要源码AudioStoryboardPlanPanelProductReferenceCardMissingProductViewSlotbuildAudioStoryboardRowsselectProductItemsForRowsubjectAssetRefsForPlanningsubjectBriefForEndpointendpointAssetRefbuildEndpointFramePromptbuildStoryboardSceneFromAudioRowgenerateEndpointFrameForRowsaveRowStoryboardDraftsaveAllStoryboardDraftsEndpointFrameSlotStoryboardVideoSlots in web/components/ad-recreation-board.tsx;产品图、首尾帧和视频候选缩略图统一复用 MediaAssetTile,包括顶层 hover 放大和删除入口。产品白底图上传复用 uploadStoryboardAsset,视角自动识别调用 analyzeProductViews,缺角度自动补图调用 generateProductAngleAsset。当前单条/批量按钮只保存规划;首尾帧按钮调用 generateSceneAsset,传 subject_brief 和端点选择后的 1-2 张 product_images,不再传主体图或 contact sheet,再用 PUT /frames/{idx}/storyboard 保存 asset 首尾帧引用;首尾帧删除只移除本条规划中的引用,避免继续误用旧资产。语言策略由 AudioStoryboardRow 的英文主字段 + *Zh 镜像字段承载:role 内部是 hook/pain/proof/solution/cta/bridgebuildEndpointFramePromptStoryboardScene 主值默认英文,中文只用于团队阅读;首尾帧提交前前端 translateText 兜底,后端 _ensure_english 再兜底。web/app/page.tsx 的视频提交回调有暂停保护,旧入口误触也不会请求 /storyboard/video
适合怎么描述“按音频逐句生成产品分镜、每行怎样改写口播、哪几句不需要产品或人物、首帧/尾帧该怎么停、首尾帧是否已经生成并准确、产品素材池识别/补图后的备注是否准确、哪些分镜后续才值得进入单条视频候选”。
+
+
你看到的区域三字段抽卡行 / 视频候选区
+
主要源码AudioStoryboardPlanPanelCompactStoryboardFieldStoryboardVideoSlotsStoryboardVideoPreview in web/components/ad-recreation-board.tsx;前端接口是 quickPlanStoryboardrefineStoryboardbatchGenerateAllgenerateStoryboardVideo(count) in web/lib/api.ts;后端接口和后台线程在 api/main.py
+
适合怎么描述“默认行只露三字段,AI 改写是否先预览、抽 4 张是否追加、哪个候选被选中、整片一键抽卡进度和失败重试、首尾帧细节是否只在高级里出现”。
+
你看到的区域全局资源中心浮窗
主要源码LibraryDrawer in web/components/resource-library/library-drawer.tsx;入口、保存到库和应用回写在 AdRecreationBoardAudioStoryboardPlanPanelProductReferenceCardEndpointFrameSlot;前端接口在 web/lib/api.ts;后端目录扫描、索引、CRUD、删除保护和复制到 job 在 api/main.py
@@ -922,6 +928,13 @@ ProductRefStateItem { needs_product, needs_subject, subject_brief, + skg_copy_en, + skg_copy_zh, + scene_one_line_en, + scene_one_line_zh, + action_one_line_en, + action_one_line_zh, + selected_video_id, first_frame_plan, last_frame_plan, product_placement, @@ -984,7 +997,11 @@ ProductRefStateItem { 角色图入库到 jobPOST /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保存三字段中英镜像、选中视频 ID、4 图槽、时长、改造说明,以及高级抽屉里的镜头类型、人物描述、人物/产品开关、首帧规划、尾帧规划和产品出现方式。 + 三字段自动展开POST /jobs/{job_id}/frames/{idx}/storyboard/quick-planquickPlanStoryboard输入 skg_copy_*scene_one_line_*action_one_line_*subject_brief,用 REWRITE_MODEL 展开为完整 StoryboardScene,只作为视频 prompt 来源,不直接持久化。 + AI 改文案POST /jobs/{job_id}/frames/{idx}/storyboard/refinerefineStoryboard输入当前三字段和中文反馈,返回新的三字段中英镜像。前端必须先弹改前/改后预览,用户点应用后才写入行状态。 + 单条视频抽卡POST /jobs/{job_id}/frames/{idx}/storyboard/videogenerateStoryboardVideo新增 countseed,默认一次创建 4 个 GeneratedVideo 任务并立即返回 job;每个候选独立排队、生成、失败或成功。前端提交 prompt 前用 quick-plan 展开,高级首尾帧存在时继续带上,不存在时后端用参考帧/主体图/产品图透明兜底。 + 整片一键抽卡POST /jobs/{job_id}/storyboard/batch-generate-allbatchGenerateAll输入 count_per_row=4concurrency=4,后台遍历分镜并提交每行候选;job message 用轮询展示进度。单行失败只写 job error,不阻断其他行。 生图POST /frames/{idx}/generategenerateImage基于关键帧或已选生成图做 image-to-image,目前可用。 @@ -1017,8 +1034,8 @@ ProductRefStateItem { 候选片段 - 后续阶段保留的生成视频能力,仍可通过底层接口和旧组件继续演进。 - 不要在第一步入口里露出“生成全部视频”或误导用户认为已进入视频合成。 + 当前分镜主路径的抽卡结果:单条默认 4 张,支持再抽追加、选中最终视频、重生/删除/清空候选;整片一键抽卡后台批量提交。 + 不要要求客户先手动生成首帧/尾帧;不要把 prompt 全文塞进默认抽卡区,除非用户展开高级。 /storyboard/videogenerated_videosAdRecreationBoard @@ -1041,6 +1058,7 @@ ProductRefStateItem {
  • 分镜工作台 4 图槽和改造说明自动保存。
  • 音频文案轨:点击开始或提取音频后提取原文案、中文翻译、讲话人、语速节奏、背景音乐/环境声/音效;结果集中在右侧工作表展示。
  • GPT Image 生图;当前 IMAGE_MODEL 和主体 6 视图链路默认使用 gpt-image-2
  • +
  • 三字段分镜抽卡:默认行只露文案、场景一句话、人物+产品+动作;支持 AI 改写预览、单条抽 4 张视频、再抽追加、选中候选和整片一键抽卡后台提交。
  • 全局资源中心:提示词库和素材库可从顶部“资源库”打开;提示词可复制并计数,素材应用到 job 时会复制成本 job 内普通 asset。
  • @@ -1090,6 +1108,19 @@ ProductRefStateItem {

    变更记录

    这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。

    +
    +
    +

    2026-05-19 · 分镜生成简化为三字段抽卡流

    + API + UI + Storyboard +
    +
    +

    问题:原分镜行把新口播、画面规划、人物描述、首帧、尾帧、产品出现方式和视频槽全部摊开,客户要先理解 6 字段和首尾帧闸门才能生成视频,单条也只能按旧候选逻辑慢慢提交。

    +

    改动:默认行改为三字段:文案、场景一句话、人物+产品+动作;每行提供 AI 改写、抽 4 张视频和高级抽屉。高级抽屉保留原 6 字段、首尾帧 prompt 和首尾帧资产槽。候选区改为 4-grid,支持生成中状态、hover 视频预览、选中、再抽追加、单张重生、删除和清空。顶部新增整片一键抽卡,默认每条 4 张并后台提交。

    +

    影响:StoryboardScene 新增 skg_copy_*scene_one_line_*action_one_line_*selected_video_id。后端新增 quick-planrefinebatch-generate-all,并让 /storyboard/video 支持 count/seed。默认 prompt 仍以英文主值提交,中文只作为镜像显示。

    +
    +

    2026-05-18 · 新增全局资源中心和复制式跨 job 素材库

    diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx index 4d7cc15..68d4361 100644 --- a/web/components/ad-recreation-board.tsx +++ b/web/components/ad-recreation-board.tsx @@ -22,6 +22,8 @@ import { type KeyFrame, type ProductViewAnalysisItem, type ProductRefStateItem, + type QuickStoryboardPlanInput, + type RefineStoryboardResult, type RuntimeModels, type StoryboardScriptRewriteSegment, type StoryboardScene, @@ -32,6 +34,7 @@ import { analyzeJob, analyzeProductViews, apiAssetUrl, + batchGenerateAll, characterLibraryImageUrl, createAssetLibraryItem, createPromptLibraryItem, @@ -41,6 +44,7 @@ import { formatJobError, generateSceneAsset, generateProductAngleAsset, + generateStoryboardVideo, generateSubjectAssets, generatedImageUrl, getJob, @@ -50,6 +54,8 @@ import { listSubjectTemplates, representativeCutoutUrl, resolveImageRefUrl, + refineStoryboard, + quickPlanStoryboard, rewriteStoryboardScript, saveSubjectTemplate, saveProductRefs, @@ -120,6 +126,10 @@ type AudioStoryboardRow = { subjectDescriptionZh: string skgCopy: string skgCopyZh: string + sceneOneLine: string + sceneOneLineZh: string + actionOneLine: string + actionOneLineZh: string visualPlan: string visualPlanZh: string firstFramePlan: string @@ -153,7 +163,7 @@ type ResolvedSubjectProfile = { payload: SubjectProfilePreference } type StoryboardVisualMode = NonNullable -type RowPlanPatch = Partial> +type RowPlanPatch = Partial> type WorkflowStepId = "input" | "source" | "audio" | "visual" | "subject" | "product" | "script" | "scene" | "video" type WorkflowStepStatus = "blocked" | "pending" | "running" | "ready" | "paused" type WorkflowStep = { @@ -395,6 +405,13 @@ const fieldClass = const emptyScene = (): StoryboardScene => ({ duration: 5, + skg_copy_en: "", + skg_copy_zh: "", + scene_one_line_en: "", + scene_one_line_zh: "", + action_one_line_en: "", + action_one_line_zh: "", + selected_video_id: "", subject: "", product: "", scene: "", @@ -686,18 +703,18 @@ function buildWorkflowSteps({ { id: "scene", no: "08", - title: "画面首尾帧", - detail: endpointTargetCount ? `${endpointFramePairCount}/${endpointTargetCount} 组首尾帧` : "待分镜", - judge: "每条分镜先确定场景+人+产品+动作,再生成 asset 类型首帧/尾帧;keyframe 不算通过。", - status: stepStatus({ ready: endpointTargetCount > 0 && endpointFramePairCount >= endpointTargetCount, blocked: !storyboardReady }), + title: "三字段规划", + detail: storyboardReady ? `${transcriptCount} 条紧凑分镜` : "待分镜", + judge: "客户默认只看文案、场景一句话、人物+产品+动作;首尾帧藏在高级模式和后端内部。", + status: stepStatus({ ready: storyboardReady, blocked: !storyboardReady }), }, { id: "video", no: "09", title: "视频候选", - detail: generatedVideoCount ? `${generatedVideoCount} 条历史` : "生成入口暂停", - judge: "当前不直接调视频模型;首尾帧审核后才开放单条或批量提交。", - status: generatedVideoCount > 0 ? "ready" : "paused", + detail: generatedVideoCount ? `${generatedVideoCount} 条候选` : "可抽 4 张", + judge: "单条默认抽 4 张候选;整片一键抽卡后台提交,失败行可单独重试。", + status: generatedVideoCount > 0 ? "ready" : stepStatus({ ready: false, blocked: !storyboardReady }), }, ] } @@ -1150,6 +1167,10 @@ function buildAudioStoryboardRows(job: Job | null): AudioStoryboardRow[] { subjectDescriptionZh: buildSubjectDescriptionZh(role, visualMode), skgCopy: buildSkgCopy(role, index), skgCopyZh: buildSkgCopyZh(role, index), + sceneOneLine: buildVisualPlan(role), + sceneOneLineZh: buildVisualPlanZh(role), + actionOneLine: `${buildSubjectDescription(role, visualMode) || "Product-forward SKG short-video beat."} ${defaults.productPlacement}`, + actionOneLineZh: `${buildSubjectDescriptionZh(role, visualMode) || "以 SKG 产品为主的短视频镜头。"}${defaultsZh.productPlacement ? ` ${defaultsZh.productPlacement}` : ""}`, visualPlan: buildVisualPlan(role), visualPlanZh: buildVisualPlanZh(role), firstFramePlan: buildFirstFramePlan(role), @@ -1369,6 +1390,12 @@ function savedScenePatch(scene?: StoryboardScene | null): RowPlanPatch { visualMode: scene.visual_mode, needsProduct: scene.needs_product, needsSubject: scene.needs_subject, + skgCopy: scene.skg_copy_en, + skgCopyZh: scene.skg_copy_zh, + sceneOneLine: scene.scene_one_line_en, + sceneOneLineZh: scene.scene_one_line_zh, + actionOneLine: scene.action_one_line_en, + actionOneLineZh: scene.action_one_line_zh, subjectDescription: scene.subject?.split("\n").find((line) => line.trim() && !line.startsWith("Subject source") && !line.startsWith("No main subject") && !line.startsWith("主体真源") && !line.startsWith("本条不需要"))?.trim(), visualPlan: scene.scene?.split("\n").find((line) => line.trim() && !line.startsWith("Visual mode") && !line.startsWith("First-frame plan") && !line.startsWith("Last-frame plan") && !line.startsWith("Source audio reference") && !line.startsWith("镜头类型") && !line.startsWith("首帧规划") && !line.startsWith("尾帧规划") && !line.startsWith("原音频依据"))?.trim(), firstFramePlan: scene.first_frame_plan, @@ -1385,6 +1412,12 @@ function applyPlanPatch(row: AudioStoryboardRow, patch?: RowPlanPatch): AudioSto visualMode: patch.visualMode ?? row.visualMode, needsProduct: patch.needsProduct ?? row.needsProduct, needsSubject: patch.needsSubject ?? row.needsSubject, + skgCopy: patch.skgCopy ?? row.skgCopy, + skgCopyZh: patch.skgCopyZh ?? row.skgCopyZh, + sceneOneLine: patch.sceneOneLine ?? row.sceneOneLine, + sceneOneLineZh: patch.sceneOneLineZh ?? row.sceneOneLineZh, + actionOneLine: patch.actionOneLine ?? row.actionOneLine, + actionOneLineZh: patch.actionOneLineZh ?? row.actionOneLineZh, subjectDescription: patch.subjectDescription ?? row.subjectDescription, subjectDescriptionZh: patch.subjectDescriptionZh ?? row.subjectDescriptionZh, visualPlan: patch.visualPlan ?? row.visualPlan, @@ -1677,6 +1710,13 @@ function buildStoryboardSceneFromAudioRow( needs_product: row.needsProduct, needs_subject: row.needsSubject, subject_brief: row.needsSubject ? subjectBrief : "", + skg_copy_en: row.skgCopy, + skg_copy_zh: row.skgCopyZh, + scene_one_line_en: row.sceneOneLine, + scene_one_line_zh: row.sceneOneLineZh, + action_one_line_en: row.actionOneLine, + action_one_line_zh: row.actionOneLineZh, + selected_video_id: frame.storyboard?.selected_video_id ?? "", first_frame_plan: row.firstFramePlan, last_frame_plan: row.lastFramePlan, product_placement: row.productPlacement, @@ -1742,7 +1782,7 @@ export function AdRecreationBoard({ }) const workflow = workflowStepMap(workflowSteps) const statusMessage = job?.message?.startsWith("视频生成已提交") - ? "历史候选视频已保留;当前已暂停直接提交视频,先逐条生成并审核首尾帧。" + ? "视频候选已提交;当前默认按紧凑三字段抽卡,首尾帧细节自动处理。" : job?.message useEffect(() => { @@ -3353,6 +3393,13 @@ function AudioStoryboardPlanPanel({ const [authorIntent, setAuthorIntent] = useState("") const [showChineseMirror, setShowChineseMirror] = useState(true) const [scriptRewriteBusy, setScriptRewriteBusy] = useState<"all" | number | null>(null) + const [quickVideoBusyRow, setQuickVideoBusyRow] = useState(null) + const [batchCardBusy, setBatchCardBusy] = useState(false) + const [advancedRows, setAdvancedRows] = useState>(new Set()) + const [refineDialog, setRefineDialog] = useState<{ rowIndex: number; frameIndex: number | null } | null>(null) + const [refineFeedback, setRefineFeedback] = useState("") + const [refineBusy, setRefineBusy] = useState(false) + const [refinePreview, setRefinePreview] = useState(null) const productFileRef = useRef(null) const productPersistSeq = useRef(0) const rows = useMemo(() => buildAudioStoryboardRows(job), [job]) @@ -3374,6 +3421,12 @@ function AudioStoryboardPlanPanel({ setPlanOverrides({}) setAuthorIntent("") setScriptRewriteBusy(null) + setQuickVideoBusyRow(null) + setBatchCardBusy(false) + setAdvancedRows(new Set()) + setRefineDialog(null) + setRefineFeedback("") + setRefinePreview(null) }, [job?.id]) const persistProductItems = async (items: ProductRefItem[]) => { @@ -3399,6 +3452,10 @@ function AudioStoryboardPlanPanel({ setCopyOverrides((prev) => ({ ...prev, [rowIndex]: value })) } + const patchRowCopyZh = (rowIndex: number, value: string) => { + setCopyZhOverrides((prev) => ({ ...prev, [rowIndex]: value })) + } + const patchRowPlan = (rowIndex: number, patch: RowPlanPatch) => { setPlanOverrides((prev) => ({ ...prev, [rowIndex]: { ...(prev[rowIndex] ?? {}), ...patch } })) } @@ -3464,6 +3521,125 @@ function AudioStoryboardPlanPanel({ return (job?.generated_videos ?? []).filter((video) => video.frame_idx === frame.index) } + const quickInputForRow = (row: AudioStoryboardRow, frame: KeyFrame | null): QuickStoryboardPlanInput => ({ + skg_copy_en: row.skgCopy, + skg_copy_zh: row.skgCopyZh, + scene_one_line_en: row.sceneOneLine, + scene_one_line_zh: row.sceneOneLineZh, + action_one_line_en: row.actionOneLine, + action_one_line_zh: row.actionOneLineZh, + subject_brief: row.needsSubject ? subjectBriefForEndpoint(row, subjectRefs) : "", + duration: Number(Math.max(3.2, Math.min(6.5, row.end - row.start || frame?.storyboard?.duration || 4.5)).toFixed(1)), + visual_mode: row.visualMode, + needs_product: row.needsProduct, + needs_subject: row.needsSubject, + }) + + const buildSceneForPlannedRow = ( + row: AudioStoryboardRow, + frame: KeyFrame, + quickPlan?: StoryboardScene | null, + selectedVideoId?: string, + ): StoryboardScene => { + const selectedSubjectRefs = row.needsSubject ? selectSubjectRefsForRow(row, subjectRefs) : [] + const base = buildStoryboardSceneFromAudioRow(row, frame, productItems, selectedSubjectRefs, { + firstImage: frame.storyboard?.first_image ?? endpointAssetRef(frame, "first_frame"), + lastImage: frame.storyboard?.last_image ?? endpointAssetRef(frame, "last_frame"), + }) + if (!quickPlan) { + return { ...base, selected_video_id: selectedVideoId ?? frame.storyboard?.selected_video_id ?? base.selected_video_id ?? "" } + } + return { + ...base, + duration: quickPlan.duration || base.duration, + visual_mode: quickPlan.visual_mode ?? base.visual_mode, + needs_product: quickPlan.needs_product ?? base.needs_product, + needs_subject: quickPlan.needs_subject ?? base.needs_subject, + subject_brief: quickPlan.subject_brief || base.subject_brief, + skg_copy_en: quickPlan.skg_copy_en || base.skg_copy_en, + skg_copy_zh: quickPlan.skg_copy_zh || base.skg_copy_zh, + scene_one_line_en: quickPlan.scene_one_line_en || base.scene_one_line_en, + scene_one_line_zh: quickPlan.scene_one_line_zh || base.scene_one_line_zh, + action_one_line_en: quickPlan.action_one_line_en || base.action_one_line_en, + action_one_line_zh: quickPlan.action_one_line_zh || base.action_one_line_zh, + first_frame_plan: quickPlan.first_frame_plan || base.first_frame_plan, + last_frame_plan: quickPlan.last_frame_plan || base.last_frame_plan, + product_placement: quickPlan.product_placement || base.product_placement, + subject: quickPlan.subject || base.subject, + scene: quickPlan.scene || base.scene, + product: quickPlan.product || base.product, + action: quickPlan.action || base.action, + selected_video_id: selectedVideoId ?? frame.storyboard?.selected_video_id ?? base.selected_video_id ?? "", + } + } + + const promptForStoryboardScene = (scene: StoryboardScene) => [ + "Create one vertical 9:16 short-form SKG ad video clip.", + `English voice-over line: ${scene.skg_copy_en || scene.action || ""}`, + `Scene: ${scene.scene_one_line_en || scene.scene || ""}`, + `Subject + product + action: ${scene.action_one_line_en || scene.action || ""}`, + `First frame intent: ${scene.first_frame_plan || ""}`, + `Last frame intent: ${scene.last_frame_plan || ""}`, + `Product placement: ${scene.product_placement || scene.product || ""}`, + `Subject brief: ${scene.subject_brief || scene.subject || ""}`, + "Keep motion natural, creator-ad style, premium wellness lighting, no subtitles, no platform UI, no watermark, no medical treatment claims.", + ].filter((line) => line.trim()).join("\n") + + const drawVideosForRow = async (row: AudioStoryboardRow, frame: KeyFrame | null, count = 4) => { + if (!job || !frame) { + toast.warning("这条分镜还没有参考帧,先完成抽帧。") + return + } + const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row), skgCopyZh: copyZhForRow(row) } + setQuickVideoBusyRow(row.index) + try { + const expandedPlan = await quickPlanStoryboard(job.id, frame.index, quickInputForRow(plannedRow, frame)) + const scene = buildSceneForPlannedRow(plannedRow, frame, expandedPlan) + const saved = await updateStoryboard(job.id, frame.index, scene) + onJobUpdate?.(saved) + const updated = await generateStoryboardVideo(job.id, frame.index, { + prompt: promptForStoryboardScene(scene), + duration: scene.duration || 4, + count, + first_image: scene.first_image ?? null, + last_image: scene.last_image ?? null, + product_images: scene.product_images ?? [], + subject_images: scene.subject_images ?? [], + subject_image: scene.subject_image ?? null, + scene_image: scene.scene_image ?? null, + product_image: scene.product_image ?? null, + action_image: scene.action_image ?? null, + model: "seedance", + size: "720x1280", + }) + onJobUpdate?.(updated) + toast.success(`分镜 ${row.index + 1} 已提交 ${count} 张视频候选`) + } catch (e) { + toast.error("视频抽卡失败:" + (e instanceof Error ? e.message : String(e))) + } finally { + setQuickVideoBusyRow(null) + } + } + + const selectVideoForRow = async (row: AudioStoryboardRow, frame: KeyFrame | null, videoId: string) => { + if (!job || !frame) return + const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row), skgCopyZh: copyZhForRow(row) } + try { + const scene = buildSceneForPlannedRow(plannedRow, frame, frame.storyboard, videoId) + const updated = await updateStoryboard(job.id, frame.index, scene) + onJobUpdate?.(updated) + toast.success(`分镜 ${row.index + 1} 已选用该视频`) + } catch (e) { + toast.error("选用视频失败:" + (e instanceof Error ? e.message : String(e))) + } + } + + const clearVideosForRow = (videos: GeneratedVideo[]) => { + if (!videos.length) return + for (const video of videos) onDeleteVideo?.(video.id) + toast.success(`已清空 ${videos.length} 个候选`) + } + const itemSourceForRef = (ref: ImageRef) => productItems.find((item) => sameImageRef(item.ref, ref))?.source ?? "upload" const buildAnalyzedProductItems = (refs: ImageRef[], analysisItems: ProductViewAnalysisItem[] = [], startIndex = 0) => refs.map((ref, index) => { @@ -3686,12 +3862,8 @@ function AudioStoryboardPlanPanel({ const saveRowStoryboardDraft = async (row: AudioStoryboardRow, frame: KeyFrame) => { if (!job) return - const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row) } - const selectedSubjectRefs = plannedRow.needsSubject ? selectSubjectRefsForRow(plannedRow, subjectRefs) : [] - const scene = buildStoryboardSceneFromAudioRow(plannedRow, frame, productItems, selectedSubjectRefs, { - firstImage: endpointAssetRef(frame, "first_frame"), - lastImage: endpointAssetRef(frame, "last_frame"), - }) + const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row), skgCopyZh: copyZhForRow(row) } + const scene = buildSceneForPlannedRow(plannedRow, frame, frame.storyboard) const updated = await updateStoryboard(job.id, frame.index, scene) onJobUpdate?.(updated) } @@ -3765,7 +3937,7 @@ function AudioStoryboardPlanPanel({ setStoryboardSaveBusyRow(row.index) try { await saveRowStoryboardDraft(row, frame) - toast.success("已保存本条分镜规划;视频生成入口已暂停,等待首尾帧资产") + toast.success("已保存本条三字段规划") } catch (e) { toast.error("保存本条规划失败:" + (e instanceof Error ? e.message : String(e))) } finally { @@ -3773,14 +3945,14 @@ function AudioStoryboardPlanPanel({ } } - const saveAllStoryboardDrafts = async () => { - if (!job || !rows.length) return + const saveAllStoryboardDrafts = async (quiet = false) => { + if (!job || !rows.length) return { ok: 0, failed: 0 } const jobsToSubmit = rows .map((row) => ({ row: planForRow(row, referenceFrameForRow(row)), frame: referenceFrameForRow(row) })) .filter((item): item is { row: AudioStoryboardRow; frame: KeyFrame } => !!item.frame) if (!jobsToSubmit.length) { - toast.warning("先完成前置抽帧,让每条分镜有可保存的承载位置") - return + if (!quiet) toast.warning("先完成前置抽帧,让每条分镜有可保存的承载位置") + return { ok: 0, failed: rows.length } } setBatchStoryboardSaveBusy(true) let ok = 0 @@ -3796,12 +3968,98 @@ function AudioStoryboardPlanPanel({ console.warn("批量保存分镜规划失败", item.row.index, e) } } - if (failed) toast.warning(`已保存 ${ok} 条规划,${failed} 条失败`) - else toast.success(`已保存全部 ${ok} 条分镜规划;视频生成入口已暂停`) + if (!quiet) { + if (failed) toast.warning(`已保存 ${ok} 条规划,${failed} 条失败`) + else toast.success(`已保存全部 ${ok} 条分镜规划`) + } } finally { setStoryboardSaveBusyRow(null) setBatchStoryboardSaveBusy(false) } + return { ok, failed } + } + + const batchDrawAllRows = async () => { + if (!job || !rows.length) return + setBatchCardBusy(true) + try { + await saveAllStoryboardDrafts(true) + const updated = await batchGenerateAll(job.id, { + count_per_row: 4, + concurrency: 4, + model: "seedance", + size: "720x1280", + }) + onJobUpdate?.(updated) + toast.success(`整片一键抽卡已启动:${rows.length} 条 × 4 张`) + } catch (e) { + toast.error("整片一键抽卡失败:" + (e instanceof Error ? e.message : String(e))) + } finally { + setBatchCardBusy(false) + } + } + + const openRefineForRow = (row: AudioStoryboardRow, frame: KeyFrame | null) => { + setRefineDialog({ rowIndex: row.index, frameIndex: frame?.index ?? null }) + setRefineFeedback("") + setRefinePreview(null) + } + + const applyRefineItems = (rowIndex: number, items: RefineStoryboardResult["items"]) => { + setCopyOverrides((prev) => ({ ...prev, [rowIndex]: items.skg_copy_en })) + setCopyZhOverrides((prev) => ({ ...prev, [rowIndex]: items.skg_copy_zh })) + patchRowPlan(rowIndex, { + skgCopy: items.skg_copy_en, + skgCopyZh: items.skg_copy_zh, + sceneOneLine: items.scene_one_line_en, + sceneOneLineZh: items.scene_one_line_zh, + actionOneLine: items.action_one_line_en, + actionOneLineZh: items.action_one_line_zh, + }) + } + + const submitRefine = async () => { + if (!job || !refineDialog) return + const row = rows.find((item) => item.index === refineDialog.rowIndex) + const frame = refineDialog.frameIndex == null ? null : job.frames.find((item) => item.index === refineDialog.frameIndex) ?? null + if (!row || !frame) return + const feedback = refineFeedback.trim() + if (!feedback) { + toast.warning("先写一句你想怎么改。") + return + } + const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row), skgCopyZh: copyZhForRow(row) } + const currentPlan = refinePreview + ? { + ...quickInputForRow(plannedRow, frame), + skg_copy_en: refinePreview.skg_copy_en, + skg_copy_zh: refinePreview.skg_copy_zh, + scene_one_line_en: refinePreview.scene_one_line_en, + scene_one_line_zh: refinePreview.scene_one_line_zh, + action_one_line_en: refinePreview.action_one_line_en, + action_one_line_zh: refinePreview.action_one_line_zh, + } + : quickInputForRow(plannedRow, frame) + setRefineBusy(true) + try { + const result = await refineStoryboard(job.id, frame.index, { + current_plan: currentPlan, + user_feedback: feedback, + }) + setRefinePreview(result.items) + if (result.error) toast.warning(`AI 改写已回退:${result.error}`) + } catch (e) { + toast.error("AI 改写失败:" + (e instanceof Error ? e.message : String(e))) + } finally { + setRefineBusy(false) + } + } + + const closeRefineDialog = () => { + setRefineDialog(null) + setRefineFeedback("") + setRefinePreview(null) + setRefineBusy(false) } if (!job) return null @@ -3935,6 +4193,15 @@ function AudioStoryboardPlanPanel({ {scriptRewriteBusy === "all" ? : } 整片改写 +
    @@ -3970,8 +4237,110 @@ function AudioStoryboardPlanPanel({ return (
    +
    +
    +
    +
    + 分镜 {row.index + 1} + {row.start.toFixed(1)}-{row.end.toFixed(1)}s + + {ROLE_LABELS_ZH[row.role]} + + {referenceFrame ? ( + 帧 {referenceFrame.index + 1} + ) : ( + 待抽帧 + )} +
    +

    {row.source}

    +
    +
    + + + +
    +
    + +
    + patchRowCopy(row.index, value)} + onChangeZh={(value) => patchRowCopyZh(row.index, value)} + onSave={() => void savePromptToLibrary("skg_script", `分镜 ${row.index + 1} 文案`, copyText, copyZhText)} + onPick={() => toast.info("从右侧资源库选用提示词后,可粘贴到当前字段。")} + /> + patchRowPlan(row.index, { sceneOneLine: value, visualPlan: value })} + onChangeZh={(value) => patchRowPlan(row.index, { sceneOneLineZh: value, visualPlanZh: value })} + onSave={() => void savePromptToLibrary("scene_desc", `分镜 ${row.index + 1} 场景一句话`, plannedRow.sceneOneLine, plannedRow.sceneOneLineZh)} + onPick={() => toast.info("从右侧资源库选用提示词后,可粘贴到当前字段。")} + /> + patchRowPlan(row.index, { actionOneLine: value, subjectDescription: value })} + onChangeZh={(value) => patchRowPlan(row.index, { actionOneLineZh: value, subjectDescriptionZh: value })} + onSave={() => void savePromptToLibrary("video_desc", `分镜 ${row.index + 1} 人物产品动作`, plannedRow.actionOneLine, plannedRow.actionOneLineZh)} + onPick={() => toast.info("从右侧资源库选用提示词后,可粘贴到当前字段。")} + /> +
    + + void drawVideosForRow(plannedRow, referenceFrame, 4)} + onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, 4)} + onRegenerate={() => void drawVideosForRow(plannedRow, referenceFrame, 1)} + onClear={() => clearVideosForRow(rowVideos)} + onSelect={(videoId) => void selectVideoForRow(plannedRow, referenceFrame, videoId)} + onDeleteVideo={onDeleteVideo} + /> +
    + + {advancedRows.has(row.index) ? ( +
    {row.start.toFixed(1)}-{row.end.toFixed(1)}s
    @@ -4182,20 +4551,22 @@ function AudioStoryboardPlanPanel({ void drawVideosForRow(plannedRow, referenceFrame, 4)} + onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, 4)} + onRegenerate={() => void drawVideosForRow(plannedRow, referenceFrame, 1)} + onClear={() => clearVideosForRow(rowVideos)} + onSelect={(videoId) => void selectVideoForRow(plannedRow, referenceFrame, videoId)} onDeleteVideo={onDeleteVideo} /> -
    - {endpointAssetRef(referenceFrame, "first_frame") && endpointAssetRef(referenceFrame, "last_frame") - ? "首尾帧已就绪 · 待开放单条视频提交" - : "先生成并确认首帧 / 尾帧"} -
    - 视频生成 - 已暂停 + 高级首尾帧 + 视频接口自动兜底
    -
    - 先保存画面规划;等 SKG 首帧/尾帧资产确认后再开放单条视频提交。 +
    + 不需要客户先生成首尾帧;这里保留给老手手动调首帧/尾帧 prompt 或资产。
    + ) : null}
    ) })} + {refineDialog ? (() => { + const dialogRow = rows.find((item) => item.index === refineDialog.rowIndex) + const dialogFrame = refineDialog.frameIndex == null ? null : job.frames.find((item) => item.index === refineDialog.frameIndex) ?? null + const plannedDialogRow = dialogRow && dialogFrame ? { ...planForRow(dialogRow, dialogFrame), skgCopy: copyForRow(dialogRow), skgCopyZh: copyZhForRow(dialogRow) } : null + if (!dialogRow || !dialogFrame || !plannedDialogRow) return null + const quickButtons = ["更兴奋", "更舒缓", "产品再露", "主体更近", "镜头更动", "背景更暗", "节奏更快"] + return ( +
    +
    +
    +
    +
    让 AI 帮你改这条分镜
    +
    分镜 {dialogRow.index + 1} · 先预览,不会直接覆盖。
    +
    + +
    +