feat: simplify storyboard video card flow

This commit is contained in:
2026-05-19 11:05:57 +08:00
parent ff7bf00f6d
commit 3462758585
4 changed files with 1133 additions and 80 deletions

View File

@@ -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(),

File diff suppressed because one or more lines are too long

View File

@@ -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<StoryboardScene["visual_mode"]>
type RowPlanPatch = Partial<Pick<AudioStoryboardRow, "visualMode" | "needsProduct" | "needsSubject" | "subjectDescription" | "subjectDescriptionZh" | "visualPlan" | "visualPlanZh" | "firstFramePlan" | "firstFramePlanZh" | "lastFramePlan" | "lastFramePlanZh" | "productIntegration" | "productIntegrationZh" | "productPlacement" | "productPlacementZh">>
type RowPlanPatch = Partial<Pick<AudioStoryboardRow, "visualMode" | "needsProduct" | "needsSubject" | "subjectDescription" | "subjectDescriptionZh" | "skgCopy" | "skgCopyZh" | "sceneOneLine" | "sceneOneLineZh" | "actionOneLine" | "actionOneLineZh" | "visualPlan" | "visualPlanZh" | "firstFramePlan" | "firstFramePlanZh" | "lastFramePlan" | "lastFramePlanZh" | "productIntegration" | "productIntegrationZh" | "productPlacement" | "productPlacementZh">>
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<number | null>(null)
const [batchCardBusy, setBatchCardBusy] = useState(false)
const [advancedRows, setAdvancedRows] = useState<Set<number>>(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<RefineStoryboardResult["items"] | null>(null)
const productFileRef = useRef<HTMLInputElement | null>(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" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={() => void batchDrawAllRows()}
disabled={batchCardBusy || batchStoryboardSaveBusy || !rows.length || !orderedFrames.length}
className="skg-primary-action inline-flex h-9 items-center justify-center gap-1 px-2.5 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
>
{batchCardBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
{rows.length}×4
</button>
<button
type="button"
onClick={() => {
@@ -3953,7 +4220,7 @@ function AudioStoryboardPlanPanel({
className="skg-primary-action inline-flex h-9 items-center justify-center gap-1 px-2.5 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
>
{batchStoryboardSaveBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
</button>
</div>
</div>
@@ -3970,8 +4237,110 @@ function AudioStoryboardPlanPanel({
return (
<article
key={row.index}
className="grid overflow-hidden rounded-md border border-white/10 bg-black/24 text-[11px] leading-snug text-white/64 xl:grid-cols-[54px_120px_minmax(170px,0.48fr)_minmax(420px,1.2fr)_360px] 2xl:grid-cols-[56px_140px_280px_minmax(560px,1fr)_420px]"
className="overflow-hidden rounded-md border border-white/10 bg-black/24 text-[11px] leading-snug text-white/64"
>
<div className="border-b border-white/8 p-2.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-white/82"> {row.index + 1}</span>
<span className="font-mono text-[10.5px] text-white/42">{row.start.toFixed(1)}-{row.end.toFixed(1)}s</span>
<span className="rounded-md border border-emerald-300/15 bg-emerald-300/[0.08] px-1.5 py-0.5 text-[10px] leading-tight text-emerald-100/80">
{ROLE_LABELS_ZH[row.role]}
</span>
{referenceFrame ? (
<span className="rounded-md border border-white/10 bg-white/[0.04] px-1.5 py-0.5 text-[10px] text-white/38"> {referenceFrame.index + 1}</span>
) : (
<span className="rounded-md border border-amber-300/18 bg-amber-300/[0.07] px-1.5 py-0.5 text-[10px] text-amber-100/70"></span>
)}
</div>
<p className="mt-1 line-clamp-1 text-[10.5px] text-white/32" title={row.source}>{row.source}</p>
</div>
<div className="flex flex-wrap items-center justify-end gap-1.5">
<button
type="button"
onClick={() => openRefineForRow(plannedRow, referenceFrame)}
disabled={!referenceFrame}
className="inline-flex h-8 items-center justify-center gap-1 rounded-md border border-violet-300/18 bg-violet-300/[0.07] px-2 text-[10.5px] font-semibold text-violet-100/75 transition hover:border-violet-300/45 hover:text-violet-50 disabled:cursor-not-allowed disabled:opacity-35"
>
<Wand2 className="h-3.5 w-3.5" />
AI
</button>
<button
type="button"
onClick={() => void drawVideosForRow(plannedRow, referenceFrame, 4)}
disabled={!referenceFrame || quickVideoBusyRow !== null}
className="skg-primary-action inline-flex h-8 items-center justify-center gap-1 px-2 text-[10.5px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
>
{quickVideoBusyRow === row.index ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
4
</button>
<button
type="button"
onClick={() => setAdvancedRows((prev) => {
const next = new Set(prev)
if (next.has(row.index)) next.delete(row.index)
else next.add(row.index)
return next
})}
className="inline-flex h-8 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.045] px-2 text-[10.5px] font-semibold text-white/58 transition hover:border-white/25 hover:text-white/82"
>
<ChevronDown className={`h-3.5 w-3.5 transition ${advancedRows.has(row.index) ? "rotate-180" : ""}`} />
</button>
</div>
</div>
<div className="mt-2 grid gap-2 lg:grid-cols-3">
<CompactStoryboardField
label="文案"
value={copyText}
zhValue={copyZhText}
showChinese={showChineseMirror}
onChange={(value) => patchRowCopy(row.index, value)}
onChangeZh={(value) => patchRowCopyZh(row.index, value)}
onSave={() => void savePromptToLibrary("skg_script", `分镜 ${row.index + 1} 文案`, copyText, copyZhText)}
onPick={() => toast.info("从右侧资源库选用提示词后,可粘贴到当前字段。")}
/>
<CompactStoryboardField
label="场景一句话"
value={plannedRow.sceneOneLine}
zhValue={plannedRow.sceneOneLineZh}
showChinese={showChineseMirror}
onChange={(value) => 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("从右侧资源库选用提示词后,可粘贴到当前字段。")}
/>
<CompactStoryboardField
label="人物 + 产品 + 动作"
value={plannedRow.actionOneLine}
zhValue={plannedRow.actionOneLineZh}
showChinese={showChineseMirror}
onChange={(value) => 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("从右侧资源库选用提示词后,可粘贴到当前字段。")}
/>
</div>
<StoryboardVideoSlots
job={job}
videos={rowVideos}
enabled={!!referenceFrame}
selectedVideoId={referenceFrame?.storyboard?.selected_video_id ?? ""}
busy={quickVideoBusyRow === row.index}
onDraw={() => 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}
/>
</div>
{advancedRows.has(row.index) ? (
<div className="grid xl:grid-cols-[54px_120px_minmax(170px,0.48fr)_minmax(420px,1.2fr)_360px] 2xl:grid-cols-[56px_140px_280px_minmax(560px,1fr)_420px]">
<StoryboardPlanCell label="分镜">
<div className="font-mono text-[11px] text-white/40">{row.start.toFixed(1)}-{row.end.toFixed(1)}s</div>
<div className="mt-1.5 inline-flex max-w-full rounded-md border border-emerald-300/15 bg-emerald-300/[0.08] px-1.5 py-0.5 text-[10px] leading-tight text-emerald-100/80">
@@ -4182,20 +4551,22 @@ function AudioStoryboardPlanPanel({
<StoryboardVideoSlots
job={job}
videos={rowVideos}
enabled={!!endpointAssetRef(referenceFrame, "first_frame") && !!endpointAssetRef(referenceFrame, "last_frame")}
enabled={!!referenceFrame}
selectedVideoId={referenceFrame?.storyboard?.selected_video_id ?? ""}
busy={quickVideoBusyRow === row.index}
onDraw={() => 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}
/>
<div className="mt-1 truncate text-[10px] text-white/34" title="视频生成已暂停,首尾帧确认后再开放单条提交">
{endpointAssetRef(referenceFrame, "first_frame") && endpointAssetRef(referenceFrame, "last_frame")
? "首尾帧已就绪 · 待开放单条视频提交"
: "先生成并确认首帧 / 尾帧"}
</div>
<div className="mt-1 flex items-center justify-between gap-2">
<span className="text-[10px] text-white/34"></span>
<span className="rounded border border-amber-300/18 bg-amber-300/[0.07] px-1.5 py-0.5 text-[10px] text-amber-100/70"></span>
<span className="text-[10px] text-white/34"></span>
<span className="rounded border border-emerald-300/18 bg-emerald-300/[0.07] px-1.5 py-0.5 text-[10px] text-emerald-100/70"></span>
</div>
<div className="mt-1 rounded border border-amber-300/12 bg-amber-300/[0.045] px-2 py-1 text-[10px] leading-snug text-amber-100/62">
SKG /
<div className="mt-1 rounded border border-cyan-300/12 bg-cyan-300/[0.045] px-2 py-1 text-[10px] leading-snug text-cyan-100/62">
/ prompt
</div>
<button
type="button"
@@ -4207,13 +4578,112 @@ function AudioStoryboardPlanPanel({
</button>
</StoryboardPlanCell>
</div>
) : null}
</article>
)
})}
</div>
{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 (
<div className="fixed inset-0 z-[9000] flex items-center justify-center bg-black/72 p-4">
<div className="w-full max-w-3xl rounded-lg border border-white/14 bg-[#080b0f] p-3 shadow-[0_26px_90px_rgba(0,0,0,0.72)]">
<div className="mb-2 flex items-center justify-between gap-3">
<div>
<div className="text-[14px] font-semibold text-white"> AI </div>
<div className="mt-0.5 text-[11px] text-white/42"> {dialogRow.index + 1} · </div>
</div>
<button type="button" onClick={closeRefineDialog} className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/48 transition hover:text-white">
</button>
</div>
<textarea
value={refineFeedback}
onChange={(event) => setRefineFeedback(event.target.value)}
placeholder="你想怎么改?例如:让这条更兴奋一点,但产品露出更自然。"
className="min-h-[72px] w-full resize-y rounded-md border border-white/10 bg-black/35 px-2.5 py-2 text-[12px] leading-relaxed text-white outline-none placeholder:text-white/25 focus:border-cyan-300/50"
/>
<div className="mt-2 flex flex-wrap gap-1.5">
{quickButtons.map((item) => (
<button
key={item}
type="button"
onClick={() => setRefineFeedback((prev) => prev ? `${prev}${item}` : item)}
className="rounded-md border border-white/10 bg-white/[0.045] px-2 py-1 text-[10.5px] text-white/55 transition hover:border-cyan-300/35 hover:text-cyan-100"
>
{item}
</button>
))}
</div>
{refinePreview ? (
<div className="mt-3 grid gap-2 md:grid-cols-2">
<div className="rounded-md border border-white/10 bg-black/24 p-2">
<div className="mb-1 text-[11px] font-semibold text-white/58"></div>
<p className="text-[11px] leading-snug text-white/60">{plannedDialogRow.skgCopy}</p>
<p className="mt-1 text-[11px] leading-snug text-white/44">{plannedDialogRow.sceneOneLine}</p>
<p className="mt-1 text-[11px] leading-snug text-white/44">{plannedDialogRow.actionOneLine}</p>
</div>
<div className="rounded-md border border-emerald-300/18 bg-emerald-300/[0.06] p-2">
<div className="mb-1 text-[11px] font-semibold text-emerald-100/72"></div>
<p className="text-[11px] leading-snug text-white/76">{refinePreview.skg_copy_en}</p>
<p className="mt-1 text-[11px] leading-snug text-white/56">{refinePreview.scene_one_line_en}</p>
<p className="mt-1 text-[11px] leading-snug text-white/56">{refinePreview.action_one_line_en}</p>
</div>
</div>
) : null}
<div className="mt-3 flex items-center justify-end gap-2">
<button type="button" onClick={closeRefineDialog} className="skg-secondary-action inline-flex h-8 items-center px-3 text-[11px] font-semibold">
</button>
{refinePreview ? (
<>
<button
type="button"
onClick={() => void submitRefine()}
disabled={refineBusy}
className="skg-secondary-action inline-flex h-8 items-center gap-1 px-3 text-[11px] font-semibold disabled:cursor-not-allowed disabled:opacity-40"
>
{refineBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={() => {
applyRefineItems(dialogRow.index, refinePreview)
closeRefineDialog()
}}
className="skg-primary-action inline-flex h-8 items-center gap-1 px-3 text-[11px] font-semibold"
>
<Check className="h-3.5 w-3.5" />
</button>
</>
) : (
<button
type="button"
onClick={() => void submitRefine()}
disabled={refineBusy}
className="skg-primary-action inline-flex h-8 items-center gap-1 px-3 text-[11px] font-semibold disabled:cursor-not-allowed disabled:opacity-40"
>
{refineBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
AI
</button>
)}
</div>
</div>
</div>
)
})() : null}
</>
) : (
<EmptyState text="音频解析完成后,这里会按逐句时间轴生成信息流复刻分镜工作台。先抽帧并生成相似主体,再逐条规划首尾帧。" />
<EmptyState text="音频解析完成后,这里会按逐句时间轴生成三字段分镜,并支持单条或整片一键抽 4 张视频候选。" />
)}
</section>
)
@@ -4372,40 +4842,144 @@ function StoryboardPlanCell({ label, children, className = "" }: { label: string
)
}
function CompactStoryboardField({
label,
value,
zhValue,
showChinese,
onChange,
onChangeZh,
onSave,
onPick,
}: {
label: string
value: string
zhValue?: string
showChinese: boolean
onChange: (value: string) => void
onChangeZh?: (value: string) => void
onSave?: () => void
onPick?: () => void
}) {
return (
<div className="min-w-0 rounded-md border border-white/10 bg-black/28 p-2">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-[11px] font-semibold text-white/68">{label}</span>
<span className="flex items-center gap-1">
<button
type="button"
onClick={onSave}
disabled={!value.trim()}
className="inline-flex h-6 w-6 items-center justify-center rounded border border-[#d6b36a]/18 bg-[#d6b36a]/[0.06] text-[#f1d78e]/70 transition hover:border-[#d6b36a]/45 hover:text-[#f1d78e] disabled:cursor-not-allowed disabled:opacity-30"
title="保存到提示词库"
aria-label="保存到提示词库"
>
<BookOpen className="h-3 w-3" />
</button>
<button
type="button"
onClick={onPick}
className="inline-flex h-6 w-6 items-center justify-center rounded border border-white/10 bg-white/[0.045] text-white/45 transition hover:border-cyan-300/35 hover:text-cyan-100"
title="从提示词库选用"
aria-label="从提示词库选用"
>
<PanelRight className="h-3 w-3" />
</button>
</span>
</div>
<textarea
value={value}
onChange={(event) => onChange(event.target.value)}
className="min-h-[48px] w-full resize-y rounded border border-white/10 bg-black/34 px-2 py-1.5 text-[11px] leading-snug text-white/82 outline-none placeholder:text-white/25 focus:border-cyan-300/50"
/>
{showChinese ? (
<textarea
value={zhValue ?? ""}
onChange={(event) => onChangeZh?.(event.target.value)}
placeholder="中文镜像"
className="mt-1 min-h-[30px] w-full resize-none rounded border border-white/8 bg-black/22 px-2 py-1 text-[10px] leading-snug text-white/42 outline-none placeholder:text-white/22 focus:border-cyan-300/35"
/>
) : null}
</div>
)
}
function StoryboardVideoSlots({
job,
videos,
enabled,
selectedVideoId = "",
busy = false,
onDraw,
onReroll,
onRegenerate,
onClear,
onSelect,
onDeleteVideo,
}: {
job: Job
videos: GeneratedVideo[]
enabled: boolean
selectedVideoId?: string
busy?: boolean
onDraw?: () => void
onReroll?: () => void
onRegenerate?: () => void
onClear?: () => void
onSelect?: (videoId: string) => void
onDeleteVideo?: (videoId: string) => void
}) {
const visible = videos.slice(0, 6)
const emptyCount = Math.max(0, 6 - visible.length)
const visible = videos
const slotCount = Math.max(4, Math.ceil(Math.max(visible.length, 1) / 4) * 4)
const emptyCount = Math.max(0, slotCount - visible.length)
return (
<div>
<div className="grid grid-cols-6 gap-1.5">
<div className="mt-2 rounded-md border border-white/10 bg-black/24 p-2">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Film className="h-3.5 w-3.5 text-cyan-100/65" />
<span className="text-[11px] font-semibold text-white/66">4 </span>
{videos.length ? <span className="text-[10px] text-white/34">{videos.length} </span> : null}
</div>
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
onClick={videos.length ? onReroll : onDraw}
disabled={!enabled || busy}
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-cyan-300/20 bg-cyan-300/[0.07] px-2 text-[10px] font-semibold text-cyan-100/70 transition hover:border-cyan-300/45 hover:text-cyan-50 disabled:cursor-not-allowed disabled:opacity-35"
>
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
{videos.length ? "再抽 4 张" : "抽 4 张"}
</button>
<button
type="button"
onClick={onClear}
disabled={!videos.length}
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.04] px-2 text-[10px] font-semibold text-white/46 transition hover:border-rose-300/35 hover:text-rose-100 disabled:cursor-not-allowed disabled:opacity-30"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-1.5 sm:grid-cols-4">
{visible.map((video) => (
<StoryboardVideoPreview
key={video.id}
job={job}
video={video}
className="aspect-[9/16] min-h-[86px] w-full"
selected={selectedVideoId === video.id}
className="aspect-[9/16] min-h-[118px] w-full"
onSelect={onSelect ? () => onSelect(video.id) : undefined}
onRegenerate={onRegenerate}
onDelete={onDeleteVideo ? () => onDeleteVideo(video.id) : undefined}
/>
))}
{Array.from({ length: emptyCount }).map((_, index) => (
<div key={`empty-video-${index}`} className="flex aspect-[9/16] min-h-[86px] min-w-0 items-center justify-center rounded border border-dashed border-white/12 bg-black/25 px-1 text-center text-[9.5px] leading-tight text-white/26">
{enabled ? `候选 ${visible.length + index + 1}` : "待首尾帧"}
<div key={`empty-video-${index}`} className="flex aspect-[9/16] min-h-[118px] min-w-0 items-center justify-center rounded border border-dashed border-white/12 bg-black/25 px-1 text-center text-[9.5px] leading-tight text-white/26">
{enabled ? `候选 ${visible.length + index + 1}` : "待帧"}
</div>
))}
</div>
{videos.length > 6 && (
<div className="mt-1 text-[10px] text-white/34"> {videos.length - 6} </div>
)}
</div>
)
}
@@ -4503,11 +5077,17 @@ function StoryboardVideoPreview({
job,
video,
className = "h-20 w-12",
selected = false,
onSelect,
onRegenerate,
onDelete,
}: {
job: Job
video: GeneratedVideo
className?: string
selected?: boolean
onSelect?: () => void
onRegenerate?: () => void
onDelete?: () => void
}) {
const src = videoSrc(video)
@@ -4518,15 +5098,19 @@ function StoryboardVideoPreview({
kind="video"
src={src && video.status === "completed" ? src : undefined}
poster={poster}
href={src || undefined}
href={onSelect ? undefined : src || undefined}
alt={`片段 ${shortId(video.id)}`}
label={`${shortId(video.id)} · ${video.model}`}
meta={video.status}
className={`shrink-0 bg-black/45 ${className}`}
objectFit="cover"
selected={selected}
onClick={onSelect}
title={`${video.model} · ${video.status}`}
bottom={<span className="block truncate rounded bg-black/70 px-1 py-0.5 text-center font-mono text-[9px] text-white/62">{running ? "生成中" : video.status === "failed" ? "失败" : shortId(video.id)}</span>}
topLeft={selected ? <span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-emerald-400 text-black"><Check className="h-3 w-3" /></span> : undefined}
topRight={running ? <Loader2 className="h-3 w-3 animate-spin text-cyan-100" /> : undefined}
actions={onRegenerate ? [{ key: "regen", label: "重生一个候选", icon: <RefreshCw className="h-3 w-3" />, onClick: onRegenerate, tone: "cyan" }] : []}
onDelete={onDelete}
deleteLabel="删除这个视频候选"
/>

View File

@@ -188,6 +188,13 @@ export interface StoryboardScene {
needs_product?: boolean
needs_subject?: boolean
subject_brief?: string
skg_copy_en?: string
skg_copy_zh?: string
scene_one_line_en?: string
scene_one_line_zh?: string
action_one_line_en?: string
action_one_line_zh?: string
selected_video_id?: string
first_frame_plan?: string
last_frame_plan?: string
product_placement?: string
@@ -203,6 +210,33 @@ export interface StoryboardScene {
reference_ids?: string[]
}
export interface QuickStoryboardPlanInput {
skg_copy_en?: string
skg_copy_zh?: string
scene_one_line_en?: string
scene_one_line_zh?: string
action_one_line_en?: string
action_one_line_zh?: string
subject_brief?: string
duration?: number
visual_mode?: StoryboardScene["visual_mode"]
needs_product?: boolean
needs_subject?: boolean
}
export interface RefineStoryboardResult {
items: {
skg_copy_en: string
skg_copy_zh: string
scene_one_line_en: string
scene_one_line_zh: string
action_one_line_en: string
action_one_line_zh: string
}
model: string
error?: string
}
export interface GeneratedVideo {
id: string
provider_id?: string
@@ -1180,12 +1214,64 @@ export async function updateStoryboard(
return res.json()
}
export async function quickPlanStoryboard(
jobId: string,
frameIdx: number,
body: QuickStoryboardPlanInput,
): Promise<StoryboardScene> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/storyboard/quick-plan`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`quickPlanStoryboard ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function refineStoryboard(
jobId: string,
frameIdx: number,
body: { current_plan: QuickStoryboardPlanInput; user_feedback: string },
): Promise<RefineStoryboardResult> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/storyboard/refine`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`refineStoryboard ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function batchGenerateAll(
jobId: string,
body: { count_per_row?: number; concurrency?: number; model?: string; size?: string },
): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/storyboard/batch-generate-all`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`batchGenerateAll ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function generateStoryboardVideo(
jobId: string,
frameIdx: number,
body: {
prompt: string
duration?: number
count?: number
seed?: number | null
first_image?: ImageRef | null
last_image?: ImageRef | null
product_images?: ImageRef[]