Architecture¶
Five diagrams covering the pipeline end-to-end, the critique loop, the enhance stage, the render stage, and the extensibility model.
1 · Pipeline overview¶
Each stage transforms the shared PipelineState and writes its output to disk before pausing for a human confirmation prompt (skippable with --yes).
flowchart TD
INPUT(["📂 Input folder\nMP4 / MOV clips + brief.txt"])
S1["**Stage 1 · Ingest**\nffmpeg · PySceneDetect\n──────────────────────────\nNormalise every clip to 480 × 854 H.264\nDetect scene-cut timestamps"]
S2["**Stage 2 · Analyse**\nGemini Files API\n──────────────────────────\nUpload normalised clips, request structured\nper-segment metadata (shot type, score, crop safety …)\nResponses cached on disk by file SHA-256"]
S3["**Stage 3 · Direct**\nGemini generate()\n──────────────────────────\nSend all segment data + brief in a single call\nReceive an ordered CutList as structured JSON"]
S4["**Stage 4 · Critique Loop**\nGemini generate()\n──────────────────────────\nCritic LLM reviews CutList against 10 criteria\nRevises and re-submits up to N times until approved"]
S5["**Stage 5 · Enhance**\nGemini generate()\n──────────────────────────\nAssign per-cut transitions, caption colours,\nborders, and entrance / exit animations\nTimeline overlay styling also resolved here"]
S6["**Stage 6 · Render**\npyCapCut\n──────────────────────────\nAssemble 9 : 16 draft from original HD clips\nMerge ReelStyle styling into TextSegments\nAuto-copy to CapCut Desktop if installed"]
OUTPUT(["🎬 CapCut Desktop\n(draft auto-imported)"])
DISK[("output_dir/\nnormalised/ inventories/\ncut_list.json critique/\nreel_style.json draft/")]
INPUT --> S1
S1 -->|"list[NormalisedClip] 👤"| S2
S2 -->|"list[ClipInventory] 👤"| S3
S3 -->|"CutList 👤"| S4
S4 -->|"CutList 👤"| S5
S5 -->|"ReelStyle 👤"| S6
S6 --> OUTPUT
S1 & S2 & S3 & S4 & S5 & S6 -. writes .-> DISK
👤 = human confirmation prompt between stages. All prompts are skipped when
--yesis passed.
2 · Stage 4 — Critique loop¶
The critic LLM checks ten criteria (duration, cut count, hook-first, shot variety, crop safety, aesthetics, story arc, brief alignment, caption timing bounds, overlay timing bounds). If any fail, it returns a corrected cut_list_v2 and the loop repeats. The loop ships the last cut list once the retry cap is hit.
flowchart TD
START(["CutList from Stage 3"])
ZERO{"max_retries = 0?"}
SKIP(["Return CutList unchanged\n(critique disabled)"])
CRITIQUE["Send CutList + inventories + brief\nto critic LLM"]
WRITE["Write critique/iteration_N.json"]
VERDICT{"verdict?"}
UPDATE["CutList ← cut_list_v2\nAppend issues to previous_issues log"]
EXHAUSTED{"retries\nexhausted?"}
APPROVED(["✅ Return approved CutList"])
CAP(["⚠️ Return last CutList\n(cap reached — ships as-is)"])
START --> ZERO
ZERO -->|yes| SKIP
ZERO -->|no| CRITIQUE
CRITIQUE --> WRITE --> VERDICT
VERDICT -->|approved| APPROVED
VERDICT -->|revise| UPDATE --> EXHAUSTED
EXHAUSTED -->|no| CRITIQUE
EXHAUSTED -->|yes| CAP
3 · Stage 5 — Enhance¶
A single LLM call focused purely on visual styling — no footage re-analysis. The approved CutList (including any overlays) and the brief are sent; the model returns a ReelStyle that decorates every cut and overlay with transition choices, text colour, border, size, and entrance/exit animations. The CutList itself is never modified.
flowchart TD
START(["Approved CutList\n+ brief.txt"])
PROMPT["Build enhance prompt\n(cut list JSON + brief text)"]
LLM["Gemini generate(ReelStyle)\ntemperature 0.5"]
CUTS["cut_styles — one CutStyle per cut\n· transition_out: cut | dissolve\n· caption_effect (null if no caption):\n color, bold, italic, size,\n border_color / border_width,\n animation_in, animation_out"]
OVERLAYS{"overlays in\nCutList?"}
OV_STYLES["overlay_styles — one OverlayStyle per overlay\n· overlay_index (0-based)\n· caption_effect (same fields)\n fade_in / fade_out recommended\n to blend across cut boundaries"]
WRITE["Write reel_style.json"]
OUT(["ReelStyle → PipelineState.reel_style"])
START --> PROMPT --> LLM --> CUTS
CUTS --> OVERLAYS
OVERLAYS -->|yes| OV_STYLES --> WRITE
OVERLAYS -->|no| WRITE
WRITE --> OUT
4 · Stage 6 — Render & CapCut export¶
run() iterates over every Cut in order, assembling a ScriptFile timeline. For each cut it resolves transition and caption styling from ReelStyle (if present), falling back to the CutList values. After all cuts are placed, timeline overlays are rendered using absolute millisecond positions. After saving, it detects CapCut on the local machine and, if found, copies source clips into the draft folder and rewrites the absolute paths in draft_info.json.
flowchart TD
START(["Approved CutList\n+ optional ReelStyle"])
INIT["Create pyCapCut DraftFolder\nfolder + display name:\nLasagnaStack - {title} {YYYYMMDD_HHMMSS}"]
MATERIAL["VideoMaterial\nprobe source clip with pymediainfo\n(original HD — not normalised)"]
CROP["Compute ClipSettings\n9:16 portrait crop from landscape source\ncenter / left_third / right_third + offset_x nudge"]
CLAMP["Clamp out-point to clip duration\n(guards against LLM over-shoot)"]
SEGMENT["VideoSegment\n(source_timerange, speed, ClipSettings)"]
TRANS{"ReelStyle transition_out\nor cut.transition_out =\nfade / dissolve?"}
ADD_TRANS["Add 叠化 cross-dissolve\nto preceding segment"]
CAP{"Cut.caption set?"}
STYLE["Resolve CaptionEffect from ReelStyle\n(color → RGB, bold, italic, size,\nborder, animation_in / animation_out)"]
ADD_CAP["TextSegment on captions track\ntransform_y positions top / center / bottom\nTextBorder and add_animation() applied"]
MORE{"more cuts?"}
OVERLAYS{"CutList.overlays\nnon-empty?"}
ADD_OV["For each overlay:\nTextSegment at absolute timeline position\n(start_ms / end_ms → microseconds)\nend clamped to total reel duration\nOverlayStyle applied if present in ReelStyle"]
SAVE["script.save() → draft_info.json\n_patch_platform() fixes OS fields"]
DETECT{"CapCut found at\n~/Movies/CapCut/User Data/?"}
EXPORT["Copy all input .mp4/.mov into draft folder\nRewrite 'path' fields in draft_info.json\nUpdate draft_meta_info.json (materials list)\nRegister draft in root_meta_info.json"]
CC(["🎬 CapCut Desktop\n(draft + media self-contained)"])
LOCAL(["📁 output_dir/draft/\n(manual import needed)"])
START --> INIT --> MATERIAL --> CROP --> CLAMP --> SEGMENT --> TRANS
TRANS -->|yes| ADD_TRANS --> CAP
TRANS -->|no| CAP
CAP -->|yes| STYLE --> ADD_CAP --> MORE
CAP -->|no| MORE
MORE -->|yes| MATERIAL
MORE -->|no| OVERLAYS
OVERLAYS -->|yes| ADD_OV --> SAVE
OVERLAYS -->|no| SAVE
SAVE --> DETECT
DETECT -->|yes| EXPORT --> CC
DETECT -->|no| LOCAL
5 · Extensibility model¶
Stage and Pipeline are abstract base classes. PipelineState is an immutable dataclass — each stage receives it and returns a new copy with its field populated. LLMClient is a provider-agnostic interface; swap it by subclassing and injecting an instance.
classDiagram
class Pipeline {
<<abstract>>
+stages() list[Stage]
+run(state, auto_confirm) PipelineState
+_run_stage(stage, state) PipelineState
+_mlflow_run_name(state) str
+_mlflow_tags(state) dict
+_log_mlflow_session_metrics(state) None
}
class Stage {
<<abstract>>
+run(state) PipelineState
+completion_message(state) str
}
class PipelineState {
+input_dir Path
+output_dir Path
+brief_path Path
+critique_max_retries int
+normalised_clips list[NormalisedClip]
+inventories list[ClipInventory]
+cut_list CutList
+reel_style ReelStyle
+draft_path Path
}
class LLMClient {
<<abstract>>
+generate(prompt, schema) BaseModel
+generate_with_video(path, prompt, schema) BaseModel
}
class ReelPipeline {
+stages() list[Stage]
+_mlflow_run_name(state) str
+_mlflow_tags(state) dict
+_log_mlflow_session_metrics(state) None
}
class GeminiClient {
+generate()
+generate_with_video()
}
class IngestStage
class AnalyseStage
class DirectStage
class CritiqueStage
class EnhanceStage
class RenderStage
Pipeline <|-- ReelPipeline
Stage <|-- IngestStage
Stage <|-- AnalyseStage
Stage <|-- DirectStage
Stage <|-- CritiqueStage
Stage <|-- EnhanceStage
Stage <|-- RenderStage
LLMClient <|-- GeminiClient
ReelPipeline "1" o-- "6" Stage : stages
Pipeline --> PipelineState : transforms
AnalyseStage --> LLMClient : uses
DirectStage --> LLMClient : uses
CritiqueStage --> LLMClient : uses
EnhanceStage --> LLMClient : uses
To add a stage: subclass
Stage, implementrun()andcompletion_message(), then insert an instance intoReelPipeline.stages. To swap the LLM provider: subclassLLMClientand pass an instance toReelPipeline(client=…). To create a new pipeline: subclassPipeline, declarestages, and optionally override_mlflow_run_name,_mlflow_tags, and_log_mlflow_session_metrics— MLflow tracking (run, per-stage CHAIN spans, and post-run metrics) is inherited automatically.