884 lines
39 KiB
Python
884 lines
39 KiB
Python
"""One-shot generator: emit the dynamic-shot meta-short-drama SKILL.md.
|
||
|
||
Hand-writing 10 shot slots × 6 step types each is error-prone. This
|
||
script composes the per-shot YAML blocks from a template and prints
|
||
the full SKILL.md to stdout. Pipe to the bundled SKILL.md path:
|
||
|
||
python scripts/_gen_meta_short_drama.py > \
|
||
src/opensquilla/skills/bundled/meta-short-drama/SKILL.md
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
MAX_SHOTS = 10 # 1..MAX_SHOTS slots emitted in the DAG
|
||
SLUG_TMPL = "{{ inputs.workspace_dir }}/meta_short_drama/{{ inputs.user_message | slugify | truncate(40) }}"
|
||
|
||
HEAD = '''---
|
||
name: meta-short-drama
|
||
description: "Use this meta-skill instead of answering directly when the current user asks to generate an AI short-drama or 短剧 from a topic. The workflow infers render style, character identity, and shot count (1-10, default 5) from the request (filling in conservative defaults when missing), drafts a strict shot-by-shot shooting script, pauses for one free-form review (the user can approve, adjust render style / character / shot count / shot details, or cancel in plain language), optionally re-drafts the script with the user's adjustments, generates one universal full-cast identity-reference image plus per-shot composition images, then per-shot video clips (each video anchored to BOTH the universal reference image and its own composition image so the character identity AND scene layout stay consistent), bookends them with a title card and an ending card, burns subtitles in the user's language, and saves the script alongside the final MP4. Do not use it for slide decks, document-decision analysis, single-image generation, isolated script writing, or pasted historical short-drama examples."
|
||
kind: meta
|
||
meta_priority: 75
|
||
always: false
|
||
final_text_mode: "step:deliver"
|
||
triggers:
|
||
- "生成短剧"
|
||
- "生成一个短剧"
|
||
- "生成一段短剧"
|
||
- "做一个AI短剧"
|
||
- "帮我做一个短剧"
|
||
- "三分镜短剧"
|
||
- "短视频分镜成片"
|
||
- "分镜成片"
|
||
- "generate a short drama"
|
||
- "generate short drama"
|
||
- "make a short drama from"
|
||
- "topic to short drama mp4"
|
||
- "shot list to final mp4"
|
||
provenance:
|
||
origin: opensquilla-original
|
||
license: Apache-2.0
|
||
metadata:
|
||
opensquilla:
|
||
risk: high
|
||
capabilities: [network-read, filesystem-write, process-control]
|
||
composition_skills:
|
||
- ai-video-script
|
||
- nano-banana-pro
|
||
- seedance-2-prompt
|
||
- video-still-animator
|
||
- video-merger
|
||
- srt-from-script
|
||
- subtitle-burner
|
||
- title-card-image
|
||
- text-file-read
|
||
composition:
|
||
steps:
|
||
# =========================================================================
|
||
# 1. Best-effort intake — extract RENDER_STYLE / IDENTITY_ANCHOR / N_SHOTS
|
||
# from the user message, or fill in conservative defaults. Never asks
|
||
# the user here; the user gets one combined chance to adjust after
|
||
# seeing the actual script in step 3.
|
||
# =========================================================================
|
||
- id: intake_extract
|
||
kind: llm_chat
|
||
with:
|
||
system: "Extract or invent a short-drama intake contract. Match the user's language for RENDER_STYLE / IDENTITY_ANCHOR. Be conservative — pick safe defaults rather than asking the user."
|
||
task: |
|
||
Read the request and emit exactly this 7-line block, in this
|
||
order, with no extra commentary:
|
||
|
||
TOPIC: <one short line — the actual story/product topic>
|
||
RENDER_STYLE: <render aesthetic, one line in user's language>
|
||
AUTO_FILLED_RENDER_STYLE: <yes|no>
|
||
IDENTITY_ANCHOR: <one line in user's language describing main character(s)>
|
||
AUTO_FILLED_IDENTITY_ANCHOR: <yes|no>
|
||
N_SHOTS: <integer 1..10, default 5>
|
||
AUTO_FILLED_N_SHOTS: <yes|no>
|
||
|
||
Rules:
|
||
- Detect dominant language of the request. Use that language for
|
||
RENDER_STYLE and IDENTITY_ANCHOR. Downstream models accept
|
||
Chinese natively (seedance is Chinese-first).
|
||
- If user named a render style verbatim → copy it, AUTO_FILLED_RENDER_STYLE: no.
|
||
- Else INFER a render style from the TOPIC's genre, era, and
|
||
tone — DO NOT default to anime. Pick whichever of these
|
||
best fits the story you just read; fall through to a fresh
|
||
descriptor if none match exactly. Use the user's language.
|
||
* 现代职场 / 都市爽剧 / 商战 / 反转 / corporate drama →
|
||
电影级写实, 真实摄影, 戏剧化强光对比, 高对比度色调
|
||
/ Cinematic realism, dramatic high-contrast lighting
|
||
* 古风 / 武侠 / 仙侠 / 宫廷 / wuxia / xianxia →
|
||
水墨风, 中国传统工笔画, 柔和留白构图
|
||
/ Ink-wash painting, traditional Chinese gongbi style
|
||
* 校园 / 青春 / 恋爱 / 治愈 / slice-of-life / romance →
|
||
日系胶片质感, 柔和自然光, 浅景深, 温暖调色
|
||
/ Japanese film aesthetic, soft natural light, warm grade
|
||
* 科幻 / 赛博朋克 / 未来 / sci-fi / cyberpunk →
|
||
赛博朋克霓虹, 体积光雾气, 高对比反射, 未来感
|
||
/ Cyberpunk neon, volumetric haze, future-noir
|
||
* 恐怖 / 悬疑 / 惊悚 / horror / thriller / noir →
|
||
低调照明, 高反差暗调, 电影黑色风格
|
||
/ Low-key lighting, high-contrast noir, cinematic shadow
|
||
* 童话 / 绘本 / 儿童 / fairytale / picture-book / kids →
|
||
水彩绘本插画, 柔和纸面纹理, 暖色调
|
||
/ Watercolour storybook, soft paper texture, warm palette
|
||
* 商品 / 广告 / 带货 / product / commercial →
|
||
影棚布光, 浅景深产品特写, 干净背景
|
||
/ Studio lighting, hero-product close-up, clean background
|
||
* 美食 / 烹饪 / food / cooking →
|
||
顶光美食摄影, 自然质感, 浅景深
|
||
/ Top-down food photography, natural texture, shallow DOF
|
||
* 科普 / 教学 / 信息图 / explainer / educational →
|
||
扁平信息图风格, 简洁配色, 平面构图
|
||
/ Flat infographic style, clean palette, geometric layout
|
||
* 卡通 / 动画 / 二次元 / 萌系 — only when the user really
|
||
wants anime → 2D 动漫插画, 扁平上色, 柔和赛璐璐阴影
|
||
/ 2D anime illustration, flat cel-shading
|
||
* none of the above → write ONE descriptive line that
|
||
matches the topic's mood (NOT anime by default). Examples:
|
||
documentary realism / oil-painting cinematic / vintage
|
||
super-8 grain / minimalist black-and-white photography.
|
||
AUTO_FILLED_RENDER_STYLE: yes
|
||
- If user described main character(s) with at least
|
||
ethnicity + age + hair + outfit → summarise ≤40 words,
|
||
AUTO_FILLED_IDENTITY_ANCHOR: no.
|
||
- Else invent ONE or TWO original characters fitting the TOPIC.
|
||
- If user named shot count (3 个分镜 / "5 shots" / etc.) → use it
|
||
clamped 1..10, AUTO_FILLED_N_SHOTS: no.
|
||
- Else default N_SHOTS: 5, AUTO_FILLED_N_SHOTS: yes.
|
||
- Never ask the user a question. The user reviews in step 3.
|
||
|
||
User request:
|
||
{{ inputs.user_message | xml_escape | truncate(1500) }}
|
||
|
||
# =========================================================================
|
||
# 2. Draft the script with whatever values we have. Free (LLM only).
|
||
# =========================================================================
|
||
- id: script_draft
|
||
kind: agent
|
||
skill: ai-video-script
|
||
depends_on: [intake_extract]
|
||
with:
|
||
task: |
|
||
Generate a strict-format short-drama shooting script following
|
||
ai-video-script's SKILL.md OUTPUT FORMAT section. Use the
|
||
N_SHOTS value from the intake contract below (clamp 1..10).
|
||
Default DURATION_S total: 50 (~10s per shot for the default 5
|
||
shots). ASPECT_RATIO: 9:16.
|
||
|
||
Output style: plain text only. No emoji, no decorative symbols.
|
||
|
||
Language: match the user's request language for every field.
|
||
Both downstream models accept CJK natively — do NOT translate
|
||
Chinese stories into English.
|
||
|
||
IDENTITY_ANCHOR and RENDER_STYLE below are caller-supplied —
|
||
paste them byte-for-byte into every shot's IMAGE_PROMPT and
|
||
VIDEO_PROMPT. Do not paraphrase or invent alternates.
|
||
|
||
Intake contract:
|
||
{{ outputs.intake_extract | truncate(1500) }}
|
||
|
||
User original request:
|
||
{{ inputs.user_message | xml_escape | truncate(1200) }}
|
||
|
||
Emit OVERVIEW.IDENTITY_ANCHOR, OVERVIEW.RENDER_STYLE, and
|
||
OVERVIEW.N_SHOTS lines so downstream steps can re-extract them.
|
||
|
||
# =========================================================================
|
||
# 2b. Persist the draft to disk BEFORE the review pause so the user
|
||
# can hand-edit the file directly while reviewing. The next step
|
||
# reads it back so manual edits propagate even when the user's
|
||
# reply doesn't mention them.
|
||
# =========================================================================
|
||
- id: script_save_draft
|
||
kind: tool_call
|
||
tool: write_file
|
||
tool_allowlist: [write_file]
|
||
depends_on: [script_draft]
|
||
tool_args:
|
||
path: "<<SLUG>>/script.txt"
|
||
content: "{{ outputs.script_draft }}"
|
||
|
||
# =========================================================================
|
||
# 3. ONE combined review gate — free-form. The user can approve,
|
||
# rewrite anything, or cancel.
|
||
# =========================================================================
|
||
- id: review_gate
|
||
kind: user_input
|
||
depends_on: [script_save_draft, script_draft, intake_extract]
|
||
clarify:
|
||
mode: form
|
||
intro: |
|
||
脚本就绪。下面是脚本预览 + 我对风格/角色/分镜数做的假设
|
||
(标 AUTO_FILLED: yes 的项是我替你填的,你可以改)。
|
||
|
||
脚本草稿已存到本次运行目录的 script.txt —— 想直接改文件也行,
|
||
下一步会重新读盘,你的手动编辑会一起带进去。
|
||
|
||
你怎么回都行 —— 不用按固定格式:
|
||
- 满意就直接说 "ok" / "继续" / "proceed"
|
||
- 想换风格 → 写一句新的 RENDER_STYLE
|
||
- 想换角色 → 写新的 IDENTITY_ANCHOR
|
||
- 想改分镜数 → 直接说 "5 个分镜" / "改成 7 镜头"
|
||
- 想改某镜内容 → 直接说 "镜头2节奏快点" / "shot 3 换成屋顶场景"
|
||
- 不想做了 → 说 "取消" / "cancel" / "停"
|
||
|
||
预估成本(选继续才会发生):
|
||
- N 张镜头图 + 1 张全角色参考图 (nano-banana-pro) ≈ N × $0.05 + $0.05-$0.10
|
||
- N 段视频 (seedance-2.0) ≈ $0.15/s × 总时长
|
||
(脚本里每镜 DURATION_S 决定时长)
|
||
- 封面 + 结尾卡 (本地 Pillow + ffmpeg,免费)
|
||
- ffmpeg 拼接 + 烧字幕
|
||
合计随 N_SHOTS 与总时长缩放。
|
||
|
||
=== 我做的假设 ===
|
||
{{ outputs.intake_extract | truncate(800) }}
|
||
|
||
=== 脚本草稿 ===
|
||
{{ outputs.script_draft | truncate(3500) }}
|
||
nl_extract: true
|
||
fields:
|
||
- name: review
|
||
type: string
|
||
required: true
|
||
prompt: |
|
||
用户对脚本草稿的整段回复 — 直接把用户说的所有文字原样
|
||
放进这个字段,不要总结、不要重写、不要解释。这是一个
|
||
catch-all 字段:任何同意/拒绝/修改意见/吐槽/闲聊都属于这里。
|
||
The user's verbatim reply about the script draft. Copy the
|
||
user's entire reply text into this single field — do not
|
||
summarise, paraphrase, translate, or split it. This is a
|
||
catch-all: approvals, rejections, edits, off-topic remarks
|
||
all belong here. If the user's reply is empty or pure
|
||
whitespace, emit "(empty)" so the field always has a value.
|
||
max_chars: 4000
|
||
cancel_keywords: ["cancel", "取消", "算了", "停止", "stop", "abort"]
|
||
timeout_hours: 24
|
||
|
||
# =========================================================================
|
||
# 4. Parse the free-form review.
|
||
# =========================================================================
|
||
- id: review_normalize
|
||
kind: llm_chat
|
||
depends_on: [review_gate]
|
||
with:
|
||
system: "Emit a strict 6-line block. No commentary outside it."
|
||
task: |
|
||
Parse the user's free-form review of the script draft and emit
|
||
exactly this block:
|
||
|
||
DECISION: <proceed|cancel>
|
||
HAS_OVERRIDES: <yes|no>
|
||
NEW_RENDER_STYLE: <new one-line value, or "unchanged">
|
||
NEW_IDENTITY_ANCHOR: <new one-line value, or "unchanged">
|
||
NEW_N_SHOTS: <integer 1..10, or "unchanged">
|
||
NEW_NOTES: <any other adjustments to story / shots / voiceover, or "unchanged">
|
||
|
||
Rules:
|
||
- DECISION: cancel only on explicit cancel/取消/算了/停 words.
|
||
- DECISION: proceed otherwise (approvals AND adjustments).
|
||
- HAS_OVERRIDES: yes if ANY of NEW_RENDER_STYLE /
|
||
NEW_IDENTITY_ANCHOR / NEW_N_SHOTS / NEW_NOTES differs from
|
||
"unchanged".
|
||
- NEW_RENDER_STYLE / NEW_IDENTITY_ANCHOR / NEW_NOTES: use the
|
||
same language as the user's reply.
|
||
- NEW_N_SHOTS: extract integer (e.g. "改成 5 镜头" → 5).
|
||
Clamp 1..10. Else "unchanged".
|
||
|
||
Free-form user review:
|
||
{{ inputs.get('collected', {}).get('review_gate', {}) | tojson | truncate(2200) }}
|
||
|
||
Original assumptions (for delta detection):
|
||
{{ outputs.intake_extract | truncate(800) }}
|
||
|
||
# =========================================================================
|
||
# 4b. Re-read the script from disk so any hand-edits the user made to
|
||
# script.txt during the review pause are honoured by the redraft
|
||
# step. When the user didn't touch the file this is just an echo
|
||
# of the original draft.
|
||
# =========================================================================
|
||
- id: script_reread
|
||
kind: skill_exec
|
||
skill: text-file-read
|
||
depends_on: [review_gate, script_save_draft]
|
||
with:
|
||
input: "<<SLUG>>/script.txt"
|
||
|
||
# =========================================================================
|
||
# 5. Re-draft script when the user supplied adjustments. Free.
|
||
# =========================================================================
|
||
- id: script_revised
|
||
kind: agent
|
||
skill: ai-video-script
|
||
depends_on: [review_normalize, script_reread]
|
||
when: "'DECISION: proceed' in outputs.review_normalize and 'HAS_OVERRIDES: yes' in outputs.review_normalize"
|
||
with:
|
||
task: |
|
||
Re-draft the script applying the user's overrides. Keep the
|
||
same OUTPUT FORMAT as ai-video-script's SKILL.md. If NEW_N_SHOTS
|
||
is an integer, use exactly that many shot blocks (1..10).
|
||
Otherwise keep the original N_SHOTS.
|
||
|
||
Output style: plain text only. No emoji.
|
||
Language: keep the user's original request language.
|
||
|
||
Apply overrides in priority: NEW_NOTES → NEW_N_SHOTS →
|
||
NEW_RENDER_STYLE → NEW_IDENTITY_ANCHOR. "unchanged" fields
|
||
inherit from the previous script verbatim.
|
||
|
||
Previous script (re-read from disk — if the user hand-edited
|
||
script.txt during review, those edits are already baked in
|
||
here, so preserve them):
|
||
{{ outputs.script_reread | truncate(8000) }}
|
||
|
||
Parsed overrides:
|
||
{{ outputs.review_normalize | truncate(1500) }}
|
||
|
||
User original request:
|
||
{{ inputs.user_message | xml_escape | truncate(800) }}
|
||
|
||
# =========================================================================
|
||
# 6. Pick the final script everyone downstream reads.
|
||
# =========================================================================
|
||
- id: final_script
|
||
kind: llm_chat
|
||
depends_on: [review_normalize, script_reread, script_revised]
|
||
with:
|
||
system: "Echo one of two inputs verbatim. No commentary. No new content."
|
||
task: |
|
||
If a revised script block is present below, echo it verbatim.
|
||
Otherwise echo the re-read script verbatim (this preserves any
|
||
hand-edits the user made to script.txt during review).
|
||
|
||
REVISED (may be empty):
|
||
{{ outputs.get('script_revised', '') | truncate(8000) }}
|
||
|
||
RE-READ FROM DISK:
|
||
{{ outputs.script_reread | truncate(8000) }}
|
||
|
||
# =========================================================================
|
||
# 7. Save the final script to disk (overwrites the draft so the file
|
||
# on disk always reflects the post-review canonical script —
|
||
# important when the LLM produced a revision the user didn't write
|
||
# by hand).
|
||
# =========================================================================
|
||
- id: script_save
|
||
kind: tool_call
|
||
tool: write_file
|
||
tool_allowlist: [write_file]
|
||
depends_on: [final_script]
|
||
tool_args:
|
||
path: "<<SLUG>>/script.txt"
|
||
content: "{{ outputs.final_script }}"
|
||
|
||
# =========================================================================
|
||
# 8. Title / subtitle / ending text extracts (cheap llm_chat).
|
||
# =========================================================================
|
||
- id: title_extract
|
||
kind: llm_chat
|
||
depends_on: [final_script]
|
||
with:
|
||
system: "Return one line of text. No quotes, no prefix, no commentary."
|
||
task: |
|
||
From the script, output exactly the value after "TITLE:"
|
||
inside the "=== OVERVIEW ===" block. Single line.
|
||
|
||
Script:
|
||
{{ outputs.final_script | truncate(8000) }}
|
||
|
||
- id: subtitle_extract
|
||
kind: llm_chat
|
||
depends_on: [final_script]
|
||
with:
|
||
system: "Return one line of text. No quotes, no prefix, no commentary."
|
||
task: |
|
||
Compose a short subtitle for the cover card describing this
|
||
drama in 5-12 characters (or 2-4 English words). Match the
|
||
script's language. Examples:
|
||
Chinese script → "AI 短剧 · 30 秒"
|
||
English script → "AI Short Drama · 30s"
|
||
|
||
Script (read OVERVIEW.TITLE / DURATION_S / AUDIENCE):
|
||
{{ outputs.final_script | truncate(2000) }}
|
||
|
||
- id: ending_text_extract
|
||
kind: llm_chat
|
||
depends_on: [final_script]
|
||
with:
|
||
system: "Return one line of text. No quotes, no prefix, no commentary."
|
||
task: |
|
||
Output the appropriate ending-card text. Single line, no commentary.
|
||
Chinese script → 完
|
||
English script → THE END
|
||
Other languages → THE END
|
||
|
||
Script (sample to detect language):
|
||
{{ outputs.final_script | truncate(1500) }}
|
||
|
||
# =========================================================================
|
||
# 8b. Universal identity-reference image. One full-cast neutral lineup
|
||
# PNG that every shot's video step uses as the IDENTITY anchor
|
||
# (input_reference). Each shot ALSO passes its own composition
|
||
# PNG (N_shot.png) as a second reference. Two-anchor model:
|
||
# slot 1 (reference.png) → who the characters look like
|
||
# slot 2 (N_shot.png) → how the scene is laid out
|
||
# =========================================================================
|
||
- id: reference_prompt_extract
|
||
kind: llm_chat
|
||
depends_on: [final_script]
|
||
with:
|
||
system: "Return one line of text. No quotes, no prefix, no commentary."
|
||
task: |
|
||
Build a single-line image prompt for a full-cast identity
|
||
reference card. The picture must show EVERY named character
|
||
that appears in ANY shot of the script (NOT just the
|
||
OVERVIEW.IDENTITY_ANCHOR anchors — supporting cast, cameo
|
||
characters, anyone the script mentions by name in any SHOT
|
||
block also belongs here), standing together in a neutral
|
||
lineup against a neutral backdrop. The downstream video model
|
||
uses this image as the universal identity anchor for every
|
||
shot.
|
||
|
||
Procedure (do these silently in your head; only emit the final
|
||
single-line prompt):
|
||
|
||
1. Read the entire script. Enumerate every distinct named
|
||
character that appears in ANY SHOT_N block's IMAGE_PROMPT
|
||
or VIDEO_PROMPT. Include characters who appear in only one
|
||
shot. Deduplicate by name. Let N be the count.
|
||
2. For each character, write the most complete canonical
|
||
attribute string the script gives them (name, age,
|
||
ethnicity, hair, outfit, distinguishing accessory). Pull
|
||
missing fields from OVERVIEW.IDENTITY_ANCHOR if needed.
|
||
3. Compose the final prompt as a single line in this exact
|
||
order:
|
||
|
||
<char 1 description>; <char 2 description>; ...; <char N description>, ALL <N> characters standing side by side in a horizontal full-body group lineup, every character clearly visible from head to toe, evenly spaced across frame, wide-angle group photo, neutral studio lighting, neutral light grey backdrop, no props, no background scene, group portrait composition, <OVERVIEW.RENDER_STYLE verbatim>, --ar 9:16
|
||
|
||
- Use ; (semicolon) BETWEEN characters, exactly as in the
|
||
examples above.
|
||
- State the integer N explicitly inside "ALL <N> characters".
|
||
- If N = 1, still say "ALL 1 character" and drop the
|
||
"side by side / horizontal lineup" phrasing — write
|
||
"single-character full-body portrait" instead.
|
||
|
||
Output a single line. No quotes. No commentary outside the
|
||
prompt itself.
|
||
|
||
Script (READ THE FULL SCRIPT, including every SHOT_N block,
|
||
not just OVERVIEW):
|
||
{{ outputs.final_script | truncate(8000) }}
|
||
|
||
- id: reference_image
|
||
kind: skill_exec
|
||
skill: nano-banana-pro
|
||
depends_on: [reference_prompt_extract, review_normalize]
|
||
when: "'DECISION: proceed' in outputs.review_normalize"
|
||
with:
|
||
prompt: "{{ outputs.reference_prompt_extract | truncate(800) }}"
|
||
filename: "<<SLUG>>/reference.png"
|
||
aspect_ratio: "9:16"
|
||
image_size: "1K"
|
||
# Use 3-pro as primary here: this image runs ONCE per drama and
|
||
# has to render every cast member visibly, which 3-pro handles
|
||
# better than 3.1-flash on dense multi-subject prompts. Per-shot
|
||
# images keep 3.1-flash for cost.
|
||
model: "google/gemini-3-pro-image-preview"
|
||
max_retries: 1
|
||
fallback_model: "google/gemini-3.1-flash-image-preview"
|
||
placeholder_on_fail: "yes"
|
||
|
||
# =========================================================================
|
||
# 9. Cover card image + 2s video (gated on proceed).
|
||
# =========================================================================
|
||
- id: cover_image
|
||
kind: skill_exec
|
||
skill: title-card-image
|
||
depends_on: [title_extract, subtitle_extract, review_normalize]
|
||
when: "'DECISION: proceed' in outputs.review_normalize"
|
||
with:
|
||
text: "{{ outputs.title_extract | truncate(40) }}"
|
||
subtitle: "{{ outputs.subtitle_extract | truncate(40) }}"
|
||
output: "<<SLUG>>/0_cover.png"
|
||
background: "#101018"
|
||
text_color: "#ffffff"
|
||
font_size: 80
|
||
subtitle_size: 32
|
||
width: 720
|
||
height: 1280
|
||
|
||
- id: cover_video
|
||
kind: skill_exec
|
||
skill: video-still-animator
|
||
depends_on: [cover_image, review_normalize]
|
||
when: "'DECISION: proceed' in outputs.review_normalize"
|
||
with:
|
||
input_image: "<<SLUG>>/0_cover.png"
|
||
output_path: "<<SLUG>>/0_cover.mp4"
|
||
duration: 2
|
||
width: 720
|
||
height: 1280
|
||
fps: 24
|
||
zoom_rate: 0.0008
|
||
'''
|
||
|
||
# Per-shot extract block template (img_prompt, vid_prompt, duration).
|
||
EXTRACT_TMPL = '''
|
||
# ---- SHOT_{N} extracts (run even if shot doesn't exist; returns sentinel) ----
|
||
- id: shot{N}_img_prompt
|
||
kind: llm_chat
|
||
depends_on: [final_script]
|
||
with:
|
||
system: "Return one line of text. No quotes, no prefix, no commentary."
|
||
task: |
|
||
If the script contains a "=== SHOT_{N} ===" block:
|
||
output exactly the value after "IMAGE_PROMPT:" inside that block.
|
||
Single line, no quotes, no label.
|
||
If it does NOT (because N_SHOTS < {N}):
|
||
output exactly the literal sentinel: __SHOT_ABSENT__
|
||
|
||
Script:
|
||
{{{{ outputs.final_script | truncate(8000) }}}}
|
||
|
||
- id: shot{N}_vid_prompt
|
||
kind: llm_chat
|
||
depends_on: [final_script]
|
||
with:
|
||
system: "Return one line of text. No quotes, no prefix, no commentary."
|
||
task: |
|
||
If the script contains a "=== SHOT_{N} ===" block:
|
||
output exactly the value after "VIDEO_PROMPT:" inside that block.
|
||
Single line.
|
||
If it does NOT: output exactly: __SHOT_ABSENT__
|
||
|
||
Script:
|
||
{{{{ outputs.final_script | truncate(8000) }}}}
|
||
|
||
- id: shot{N}_duration
|
||
kind: llm_chat
|
||
depends_on: [final_script]
|
||
with:
|
||
system: "Return exactly one integer or the literal __SHOT_ABSENT__. No commentary."
|
||
task: |
|
||
If the script contains a "=== SHOT_{N} ===" block:
|
||
output exactly the integer after "DURATION_S:" inside that
|
||
block, clamped to [3, 15]. Digits only, no units.
|
||
If it does NOT: output exactly: __SHOT_ABSENT__
|
||
|
||
Script:
|
||
{{{{ outputs.final_script | truncate(8000) }}}}
|
||
'''
|
||
|
||
# Per-shot image + video + fallback template.
|
||
EXEC_TMPL = '''
|
||
# ---- SHOT_{N} image / video / fallback ----
|
||
- id: shot{N}_image
|
||
kind: skill_exec
|
||
skill: nano-banana-pro
|
||
depends_on: [shot{N}_img_prompt, review_normalize]
|
||
when: "'DECISION: proceed' in outputs.review_normalize and '__SHOT_ABSENT__' not in outputs.shot{N}_img_prompt"
|
||
with:
|
||
prompt: "{{{{ outputs.shot{N}_img_prompt | truncate(800) }}}}"
|
||
filename: "<<SLUG>>/{N}_shot.png"
|
||
aspect_ratio: "9:16"
|
||
image_size: "1K"
|
||
max_retries: 1
|
||
fallback_model: "google/gemini-3-pro-image-preview"
|
||
placeholder_on_fail: "yes"
|
||
|
||
- id: shot{N}_video
|
||
kind: skill_exec
|
||
skill: seedance-2-prompt
|
||
depends_on: [shot{N}_vid_prompt, shot{N}_duration, reference_image, shot{N}_image, review_normalize]
|
||
when: "'DECISION: proceed' in outputs.review_normalize and '__SHOT_ABSENT__' not in outputs.shot{N}_vid_prompt"
|
||
on_failure: shot{N}_video_fallback
|
||
with:
|
||
# Prepend Assets Mapping so seedance knows the role of each
|
||
# input_reference image. Mirrors the upstream JiMeng prompt
|
||
# convention (see references/recipes.md "Mode: All-Reference"):
|
||
# @image1 / reference[1] = identity anchor (full-cast lineup)
|
||
# @image2 / reference[2] = scene composition (this shot)
|
||
# Keeping the preamble in English even when the shot directive
|
||
# is Chinese — seedance parses English instruction prefixes
|
||
# reliably regardless of the user-content language.
|
||
prompt: "Mode: All-Reference. Assets Mapping: reference[1] is the full-cast identity anchor (USE strictly for character likeness, faces, hair, skin tone, outfits, and accessories — keep these byte-identical to the reference across cuts). reference[2] is THIS shot's scene composition reference (USE for camera angle, framing, character blocking, prop placement, and background layout). Shot directive: {{{{ outputs.shot{N}_vid_prompt | truncate(700) }}}}"
|
||
filename: "<<SLUG>>/{N}_shot.mp4"
|
||
input_image: ""
|
||
input_reference: "<<SLUG>>/reference.png"
|
||
input_reference_2: "<<SLUG>>/{N}_shot.png"
|
||
aspect_ratio: "9:16"
|
||
# `| int(5)` parses the duration extract as an integer, falling
|
||
# back to 5 if the LLM emitted anything non-numeric (sentinel
|
||
# __SHOT_ABSENT__, units like "10s", chain-of-thought text). A
|
||
# raw truncate would slice "__SHOT_ABSENT__" to "__S" and crash
|
||
# the downstream CLI's duration validator.
|
||
duration: "{{{{ outputs.shot{N}_duration | int(5) }}}}"
|
||
model: "bytedance/seedance-2.0"
|
||
max_retries: 2
|
||
|
||
- id: shot{N}_video_fallback
|
||
kind: skill_exec
|
||
skill: video-still-animator
|
||
with:
|
||
input_image: "<<SLUG>>/{N}_shot.png"
|
||
output_path: "<<SLUG>>/{N}_shot.mp4"
|
||
duration: "{{{{ outputs.shot{N}_duration | int(5) }}}}"
|
||
width: 720
|
||
height: 1280
|
||
fps: 24
|
||
'''
|
||
|
||
# Tail blocks (ending, merge, subtitles, deliver).
|
||
TAIL = '''
|
||
# =========================================================================
|
||
# Ending card image + 1.5s video.
|
||
# =========================================================================
|
||
- id: ending_image
|
||
kind: skill_exec
|
||
skill: title-card-image
|
||
depends_on: [ending_text_extract, review_normalize]
|
||
when: "'DECISION: proceed' in outputs.review_normalize"
|
||
with:
|
||
text: "{{ outputs.ending_text_extract | truncate(20) }}"
|
||
subtitle: ""
|
||
output: "<<SLUG>>/99_ending.png"
|
||
background: "#0a0a10"
|
||
text_color: "#e0e0e8"
|
||
font_size: 96
|
||
width: 720
|
||
height: 1280
|
||
|
||
- id: ending_video
|
||
kind: skill_exec
|
||
skill: video-still-animator
|
||
depends_on: [ending_image, review_normalize]
|
||
when: "'DECISION: proceed' in outputs.review_normalize"
|
||
with:
|
||
input_image: "<<SLUG>>/99_ending.png"
|
||
output_path: "<<SLUG>>/99_ending.mp4"
|
||
duration: 2
|
||
width: 720
|
||
height: 1280
|
||
fps: 24
|
||
zoom_rate: 0.0005
|
||
|
||
# =========================================================================
|
||
# Stitch cover + shots(1..10 that exist) + ending. video-merger sorts
|
||
# numeric prefix; 0_cover < 1..10_shot < 99_ending.
|
||
# =========================================================================
|
||
- id: merge
|
||
kind: skill_exec
|
||
skill: video-merger
|
||
depends_on:
|
||
- cover_video
|
||
- shot1_video
|
||
- shot2_video
|
||
- shot3_video
|
||
- shot4_video
|
||
- shot5_video
|
||
- shot6_video
|
||
- shot7_video
|
||
- shot8_video
|
||
- shot9_video
|
||
- shot10_video
|
||
- ending_video
|
||
- review_normalize
|
||
when: "'DECISION: proceed' in outputs.review_normalize"
|
||
with:
|
||
input_dir: "<<SLUG>>"
|
||
output_path: "<<SLUG>>/final.mp4"
|
||
mode: "full"
|
||
transition: 0.5
|
||
fps: 24
|
||
crf: 22
|
||
preset: "medium"
|
||
|
||
- id: subtitles_srt
|
||
kind: skill_exec
|
||
skill: srt-from-script
|
||
depends_on: [final_script, review_normalize]
|
||
when: "'DECISION: proceed' in outputs.review_normalize"
|
||
with:
|
||
script: "{{ outputs.final_script }}"
|
||
output_path: "<<SLUG>>/subs.srt"
|
||
gap_ms: 200
|
||
leading_offset_ms: 2000
|
||
|
||
- id: subtitled_final
|
||
kind: skill_exec
|
||
skill: subtitle-burner
|
||
depends_on: [merge, subtitles_srt, review_normalize]
|
||
when: "'DECISION: proceed' in outputs.review_normalize"
|
||
with:
|
||
input: "<<SLUG>>/final.mp4"
|
||
subtitles: "<<SLUG>>/subs.srt"
|
||
output: "<<SLUG>>/final_subtitled.mp4"
|
||
font_size: 42
|
||
margin_v: 80
|
||
|
||
- id: deliver
|
||
kind: llm_chat
|
||
depends_on: [final_script, review_normalize, script_save]
|
||
with:
|
||
system: "Write a concise delivery message in the user's language. No emoji. Branch on DECISION."
|
||
task: |
|
||
Compose a 4-10 line summary tailored to the user's decision.
|
||
|
||
User original request:
|
||
{{ inputs.user_message | xml_escape | truncate(400) }}
|
||
|
||
Decision marker:
|
||
{{ outputs.review_normalize | truncate(400) }}
|
||
|
||
Final script:
|
||
{{ outputs.final_script | truncate(2500) }}
|
||
|
||
Script saved at:
|
||
{{ outputs.script_save | truncate(200) }}
|
||
|
||
Merge output:
|
||
{{ outputs.get('merge', '') | truncate(800) }}
|
||
|
||
Subtitled-final output:
|
||
{{ outputs.get('subtitled_final', '') | truncate(800) }}
|
||
|
||
Branching rules:
|
||
- If "DECISION: proceed":
|
||
* Title (from final_script OVERVIEW.TITLE), shot count, total duration.
|
||
* Headline path = subtitled_final (the burned-in subtitle MP4).
|
||
* Also list: un-subtitled merge path, SRT path, script.txt path,
|
||
folder containing intermediates.
|
||
* Mention HAS_OVERRIDES if yes.
|
||
- If "DECISION: cancel":
|
||
* Acknowledge, note the script was still saved at script_save's
|
||
path so it's not lost.
|
||
* Offer to re-trigger.
|
||
Respond in the same language as the user's original request.
|
||
---
|
||
|
||
# meta-short-drama
|
||
|
||
End-to-end short-drama generator with one free-form user-review gate
|
||
before any paid step. **1-10 shots** (default 5), title card + ending
|
||
card, in-language burned subtitles, and the generated script is saved
|
||
to disk regardless of outcome.
|
||
|
||
## What it does
|
||
|
||
1. **`intake_extract`** scans the user message for RENDER_STYLE,
|
||
IDENTITY_ANCHOR, and N_SHOTS (1-10). Fills in defaults when missing.
|
||
2. **`script_draft`** calls `ai-video-script` with the inferred values
|
||
pasted verbatim into every shot prompt.
|
||
3. **`review_gate`** — single free-form pause. The user can approve,
|
||
rewrite render style / character / shot count / shot details, or
|
||
cancel in plain language.
|
||
4. **`review_normalize`** parses the free-form reply.
|
||
5. **`script_revised`** (conditional) redrafts when overrides present.
|
||
6. **`final_script`** echoes the canonical script.
|
||
7. **`script_save`** writes `script.txt` to the run folder
|
||
(always — even on cancel, so the user keeps the draft).
|
||
8. **`title_extract` / `subtitle_extract` / `ending_text_extract`**
|
||
pull cover/ending text in the script's language.
|
||
9. **`cover_image` + `cover_video`** — Pillow title card + 2s Ken-Burns
|
||
clip (`0_cover.mp4` — sorts first in merge).
|
||
10. **Per-shot extracts × 10** — for shots 1..10 the LLM emits either
|
||
the real prompts/duration OR the literal sentinel `__SHOT_ABSENT__`.
|
||
Image/video steps gate on the sentinel so unused slots stay dormant.
|
||
11. **Image generation per active shot** — `nano-banana-pro`, retry +
|
||
fallback model + placeholder PNG (image step never aborts DAG).
|
||
12. **`reference_prompt_extract` + `reference_image`** — one extra
|
||
`nano-banana-pro` call produces `reference.png`, a full-cast neutral
|
||
lineup of every named character on a neutral backdrop. Used as the
|
||
universal IDENTITY anchor for every shot's seedance call so the
|
||
character does not drift across cuts (nano-banana would otherwise
|
||
re-roll subtly different faces per shot).
|
||
13. **Video generation per active shot** — `seedance-2.0`, retry twice;
|
||
on persistent refusal the Ken-Burns substitute fires using the
|
||
shot's PNG. Each shot passes TWO reference images to seedance,
|
||
AND the per-shot prompt is wrapped with an explicit "Assets
|
||
Mapping" preamble in the upstream JiMeng convention so seedance
|
||
knows the role of each reference:
|
||
reference[1] = `reference.png` (full-cast identity anchor — used
|
||
strictly for character likeness / faces / hair /
|
||
outfits / accessories across all shots)
|
||
reference[2] = `N_shot.png` (this shot's scene composition
|
||
reference — used for camera angle, framing,
|
||
blocking, prop placement, background layout)
|
||
The Assets Mapping preamble is in English even when the per-shot
|
||
directive is Chinese — seedance parses English instruction prefixes
|
||
reliably regardless of the user-content language. Empty / missing
|
||
references are still filtered before the API call (so direct CLI
|
||
callers using a single anchor remain backwards-compatible).
|
||
13. **`ending_image` + `ending_video`** — Pillow "完" / "THE END" card
|
||
+ 1.5s Ken-Burns clip (`99_ending.mp4` — sorts last).
|
||
14. **`merge`** — `video-merger` stitches `0_cover` + active shots
|
||
+ `99_ending` via numeric-prefix sort. ffmpeg cross-fade transitions.
|
||
15. **`subtitles_srt`** — SRT cues from VOICEOVER per shot, shifted by
|
||
the 2-second cover duration so cue timing matches the merged
|
||
timeline.
|
||
16. **`subtitled_final`** — `subtitle-burner` burns the SRT into
|
||
`final_subtitled.mp4`.
|
||
17. **`deliver`** — always runs, branches on DECISION. Lists the saved
|
||
script path so the user keeps a copy regardless.
|
||
|
||
## Outputs
|
||
|
||
```
|
||
<workspace>/meta_short_drama/<slug>/
|
||
script.txt # full final script (always)
|
||
reference.png # full-cast identity reference (used by every shot_video)
|
||
0_cover.png 0_cover.mp4
|
||
1_shot.png 1_shot.mp4 ┐
|
||
2_shot.png 2_shot.mp4 ├ only for active shots (1..N_SHOTS)
|
||
... ┘
|
||
99_ending.png 99_ending.mp4
|
||
subs.srt
|
||
final.mp4 # merged, no subtitles
|
||
final_subtitled.mp4 # subtitled — the deliverable
|
||
```
|
||
|
||
## Dependencies
|
||
|
||
| Skill | Purpose | Models / Tools |
|
||
|---|---|---|
|
||
| `ai-video-script` | Structured shot list (1-10 shots) | LLM |
|
||
| `nano-banana-pro` | Per-shot first-frame PNG | OpenRouter Gemini 3.1 / 3 pro |
|
||
| `seedance-2-prompt` | Per-shot MP4 | OpenRouter Seedance 2.0 (or Volcengine ARK) |
|
||
| `video-still-animator` | Ken-Burns fallback / cover & ending clips | ffmpeg ≥ 5.0 |
|
||
| `video-merger` | Stitch cover + shots + ending | ffmpeg ≥ 5.0 |
|
||
| `srt-from-script` | VOICEOVER → SRT with cover offset | Python stdlib |
|
||
| `subtitle-burner` | Burn SRT into MP4 | ffmpeg + libass |
|
||
| `title-card-image` | Pillow cover + ending PNG cards | Pillow |
|
||
| (builtin) `write_file` | Save script.txt (no skill needed) | OpenSquilla builtin |
|
||
| `text-file-read` | Re-read script.txt after review pause | Python stdlib |
|
||
|
||
Environment:
|
||
- `OPENROUTER_API_KEY` must be set.
|
||
- `ffmpeg` and `ffprobe` on PATH.
|
||
- Pillow installed (already in opensquilla deps).
|
||
|
||
## Risk
|
||
|
||
`high` — writes files, spends real OpenRouter credits, runs ffmpeg
|
||
subprocesses. The review_gate ensures user consent before any paid step.
|
||
|
||
## Limits (v2)
|
||
|
||
- 1-10 shots; default 5. The DAG always declares 10 slots but
|
||
`__SHOT_ABSENT__` gating keeps unused slots dormant.
|
||
- Per-shot duration follows the script's DURATION_S (clamped 3-15s by
|
||
seedance API). Total drama length scales linearly.
|
||
- 9:16 portrait.
|
||
- Per-shot seedance failures fall back to Ken-Burns. Image step
|
||
has its own placeholder fallback. Prompt-extract llm_chats still
|
||
abort the run if they return malformed output.
|
||
- Concurrent runs with identical user_message collide on the same
|
||
slug-derived subdir.
|
||
|
||
## When NOT to use
|
||
|
||
- Single image / single clip / script-only / stitch-only — use the
|
||
underlying skills directly.
|
||
'''
|
||
|
||
|
||
def render() -> str:
|
||
parts: list[str] = [HEAD]
|
||
|
||
# All 10 shot extract blocks together.
|
||
for n in range(1, MAX_SHOTS + 1):
|
||
parts.append(EXTRACT_TMPL.format(N=n))
|
||
|
||
# All 10 shot exec blocks together.
|
||
for n in range(1, MAX_SHOTS + 1):
|
||
parts.append(EXEC_TMPL.format(N=n))
|
||
|
||
parts.append(TAIL)
|
||
rendered = "".join(parts)
|
||
return rendered.replace("<<SLUG>>", SLUG_TMPL)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
sys.stdout.write(render())
|