Skip to content

feat(run_episodes): batch runner for episode directories (depends on #41)#42

Open
xiaogang-sudo wants to merge 8 commits into
browser-use:mainfrom
xiaogang-sudo:feat/run-episodes
Open

feat(run_episodes): batch runner for episode directories (depends on #41)#42
xiaogang-sudo wants to merge 8 commits into
browser-use:mainfrom
xiaogang-sudo:feat/run-episodes

Conversation

@xiaogang-sudo
Copy link
Copy Markdown

@xiaogang-sudo xiaogang-sudo commented May 19, 2026

Summary

Adds a convention-driven episode-batch runner that complements the existing jobs.json / jobs.csv manifest path in srt_driven_edit. Given a root directory whose immediate subdirectories are episodes, it discovers eps that have the required file set and runs the edit pipeline on each.

batch/
├─ ep01/
│   ├─ source.mp4
│   ├─ script.srt
│   ├─ edit_plan.json
│   └─ voice.wav        # optional — becomes ep's global voice
├─ ep02/
│   └─ ...
└─ ep03/
    └─ ...

One command:

python helpers/run_episodes.py batch/
python helpers/run_episodes.py batch/ --bg-volume 0.1 --style cjk-natural --continue-on-error

Each ep gets its own final.mp4 + edit/ artifacts; an aggregate run_episodes_summary.json lands at the root.

Behavior

  • Discovery is forgiving. Subdirs missing a required file are skipped with a printed reason instead of aborting the batch. Hard-fails only when no usable ep is found.
  • Failure mode mirrors batch-manifest semantics. Without --continue-on-error, the first failing ep aborts the run. With it, the run completes and individual failures appear in the summary. Process exits non-zero whenever any ep failed.
  • No new edit primitives_make_job(...) just composes a Job dataclass and hands it to srt_driven_edit.run_job. So global voice mixing, sync tails, cache invalidation, etc. all behave identically to single-job runs.

Reviewer notes

⚠ Depends on PR #41 (feat: SRT-driven edit pipeline + edit-plan recommender). run_episodes.py imports Job, run_job, preflight, and safe_ascii_name from srt_driven_edit.py, which only exists on that branch. The diff in this PR shows 4 commits because feat/srt-driven-edit has not yet merged to main — only the last commit (b7dbd6e) is new in this PR; the other three are exactly PR #41. Once #41 merges, I'll rebase this branch onto main.

  • Tests need ffmpeg + ffprobe on PATH; conftest.py skips otherwise.

Test plan

  • pip install -e ".[dev]"
  • python -m pytest tests/test_run_episodes.py -v (7 tests, ~12s)
  • python -m pytest tests/ -v (35 tests including the full suite, ~55s)

7 pytest cases included:

  • discover skips incomplete dirs without erroring
  • discover picks up voice.wav when present
  • empty root raises
  • full e2e with 3 synthetic eps each producing final.mp4
  • --continue-on-error skips ep with out-of-bounds range, finishes others
  • hard abort without --continue-on-error
  • per-ep voice.wav reflected in QC audio.mode

🤖 Generated with Claude Code


Summary by cubic

Adds a convention-based batch runner that discovers episode folders and runs the SRT-driven edit pipeline for each, plus a project-root main.py wrapper for single runs. Updates srt_video_editor.py to cut per-cue clips with ffmpeg instead of only validating.

  • New Features

    • Discovers episodes at //; requires source.mp4, script.srt, edit_plan.json; picks up optional voice.wav. Skips incomplete dirs; errors only if none are valid.
    • Failure control: aborts on first error by default; --continue-on-error finishes all and exits non-zero if any episode failed.
    • Outputs <ep>/final.mp4 and per-episode edit/ artifacts; writes <root>/run_episodes_summary.json with structured failure records (index, paths, stderr tail).
    • Reuses srt_driven_edit.Job and run_job for parity with manifest mode.
    • Adds main.py wrapper: auto-fills --srt input/script.srt, --plan input/edit_plan.json, and -o output/final.mp4; injects --source input/source.mp4 and --voice input/voice.wav only if files exist; --batch skips these defaults.
    • Updates srt_video_editor.py: Form A only; validates ids, prints SRT-to-source mapping, and now extracts per-cue clips to temp/clip_<id:03d>.mp4 via ffmpeg (-ss before -i, H.264 video, AAC audio). Defaults --srt input/script.srt, --plan input/edit_plan.json, --source input/source.mp4, --temp-dir temp/; clear errors on bad ranges or missing ffmpeg.
  • Dependencies

    • Added dev extra with pytest>=7.

Written for commit 09783f5. Summary will update on new commits. Review in cubic

xiaogang-sudo and others added 8 commits May 19, 2026 21:00
Independent helper that assembles a final cut by aligning source ranges
to an SRT timeline, bypassing the existing transcript-based EDL flow.
Use when you have a finished script (script.srt = final captions
timeline) and a list of source ranges keyed by SRT id.

Pipeline: parse SRT + plan -> strict validate -> align -> extract
segments (per-source ffprobe, HDR tone-map, sync tails, cache) -> gap
clips for non-contiguous SRT cues -> lossless concat -> final pass with
optional global voice mix + subtitle burn LAST (Hard Rule 1).

Key correctness properties:
- All intermediates land in a safe-ASCII temp work_dir; CJK / quoted
  user paths never reach libavfilter or the concat demuxer.
- SRT input decoded with utf-8-sig / utf-8 / gb18030 / cp936 / cp1252
  fallback; cue settings (position:90% etc.) tolerated.
- Per-segment cache keyed by ffmpeg version + encoding params +
  effective bg_volume so encoder tweaks invalidate stale clips.
- Source streams probed once; no-audio source auto-degrades bg_volume
  to 0 for its segments; out-of-bounds ranges fail fast.
- Global --voice spans the whole timeline (apad/atrim to total_duration
  in the final compose), not per-segment — a 5s VO does not restart at
  every cut.
- 30ms audio fades + fps=24,setpts and aresample sync tails on every
  segment prevent A/V drift through many short concats.
- burn_subtitles is self-defending: unsafe subs paths are copied to a
  temp ASCII SRT before being fed to libavfilter.
- Batch (jobs.json / .csv) auto-isolates outputs by manifest index;
  --continue-on-error skips failing rows; --no-overwrite refuses to
  clobber existing outputs.

Includes examples (Form A array, Form B object with multi-source +
voices, batch manifest, CJK SRT) and pytest coverage (14 e2e + batch
tests using lavfi-synthesized media; passes against ffmpeg 8.x on
Windows).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…cript

Bridges the gap between Scribe word-level transcripts and the
srt_driven_edit pipeline. Given a final-cut script.srt and a source
recording's Scribe JSON, produces an edit_plan.json (Form A or B) plus
a sidecar review markdown for human-in-the-loop QA.

Matching strategy is intentionally local (no LLM, no API):
  1. Filter the transcript to timestamped 'word' tokens (audio_event /
     spacing skipped; --keep-audio-events keeps markers as context).
  2. Group consecutive words into non-overlapping candidates, breaking
     on sentence-end punctuation, silences >= gap_threshold, or speaker
     change. Long candidates split at phrase punctuation, then by hard
     word-level windows. All edges land on word boundaries.
  3. Score each (cue, candidate) pair as
       0.7 * (0.6 * SequenceMatcher + 0.4 * Jaccard)
       + 0.3 * 1/(1+|dur_delta|/cue_dur)
     where Jaccard auto-switches between Latin word-token and CJK
     character-bigram representations.
  4. Greedy assignment; --allow-reuse drops the no-reuse constraint.
  5. Emit Form A (default, drop-in for srt_driven_edit --plan) or Form
     B; review markdown lists matched text, score, duration delta, and
     warnings (low score / duration mismatch / candidate-shorter-than-
     cue).

Hard failure modes (exit 1): any cue with no assignable candidate;
malformed transcript JSON; transcript with no word tokens.
Soft failures (warnings only): low score, candidate too short for cue.

The matcher cannot understand storyline — if SRT narration words do
not appear in the source transcript, scores will be low. The sidecar
review.md is the manual QA surface; it is intentionally not pulled
into the plan (parse_plan in srt_driven_edit stays strict).

--packed (takes_packed.md) and --context-window flags are reserved
placeholders only; both raise no error but do not yet alter behavior.

Includes 11 pytest tests including a full end-to-end:
recommend -> sde.run_job -> final.mp4 against lavfi-synthesized media.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CLAUDE.md is auto-loaded by Claude Code when working in this directory,
giving sessions a consistent picture of the project's scope, tech
constraints, and out-of-bounds behaviors before the user has to say it.

AGENTS.md does the same for Codex review sessions, classifying review
output into must-fix / should-improve / later so suggestions are
actionable rather than open-ended rewrites.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Convention-driven multi-episode runner. Given a root containing one
subdirectory per episode, discovers eps that have the required file set
and runs srt_driven_edit on each. Complements the existing jobs.json /
jobs.csv manifest path with a flatter, zero-config workflow.

Per-episode layout (all under <root>/<ep>/):
  source.mp4       required
  script.srt       required
  edit_plan.json   required (Form A or B)
  voice.wav        optional — wired in as the ep's global voice

Outputs land at <root>/<ep>/final.mp4 with edit/ artifacts (EDL, QC,
cache) inside each ep dir; an aggregate summary lands at
<root>/run_episodes_summary.json.

Dirs missing required files are SKIPPED with a printed reason rather
than aborting, so a partial batch is still actionable. Hard-fails only
when no usable ep is found. --continue-on-error makes per-ep ffmpeg
failures non-fatal too; without it, the first failure aborts the run.
Process exits non-zero if any episode failed, even in continue mode.

Includes 7 pytest cases:
  - discover skips incomplete dirs without erroring
  - discover picks up voice.wav when present
  - empty root raises
  - full e2e with 3 synthetic eps each producing final.mp4
  - continue-on-error skips ep with out-of-bounds range, finishes others
  - hard abort without continue-on-error
  - per-ep voice.wav reflected in QC audio.mode

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Until now a failed batch row only recorded {job, ok: False, error}. To
diagnose an ffmpeg crash you had to scroll the terminal back; for a
malformed manifest row you had no idea which row index errored. This
commit adds a structured diagnostic payload to every failure entry in
both the srt_driven_edit batch path and the run_episodes path.

New shape per failed entry:
  {job, ok: False, index, error,
   srt, plan, source, output,
   stderr_tail}

  - `index` is the 0-based position in the manifest / discovered ep list,
    so the summary trivially round-trips back to the bad row.
  - `srt` / `plan` / `source` / `output` come from the resolved Job when
    available; for rows that crash inside job_from_dict (no Job yet),
    they fall back to the raw manifest_row dict so context is never lost.
  - `stderr_tail` is the last 30 lines / 2 KB of ffmpeg's stderr,
    populated only when the failure originated in run_ff. Pre-flight /
    validation errors leave it empty by design.

To carry the stderr tail without breaking the existing
`except SystemExit:` pattern, add a `PipelineError(SystemExit)` subclass
with a `.stderr_tail` attribute, raised by `run_ff` on non-zero exit.
Existing handlers continue to work via `getattr(e, "stderr_tail", "")`.

The new helper `make_failure_record(...)` is exported from
srt_driven_edit and reused by both the CLI's batch loop and
run_episodes.run_episodes so the two paths stay in sync.

Tests added (4):
  - test_run_ff_raises_pipeline_error_with_stderr — direct unit test
    of PipelineError carrying real ffmpeg stderr
  - test_batch_failure_record_includes_paths — out-of-bounds range fails
    pre-extract; record carries index/srt/plan/source/output, empty
    stderr_tail
  - test_batch_malformed_row_failure_record — row missing 'plan' still
    yields a usable record sourced from the raw manifest row
  - test_run_episodes_failure_record_includes_paths — same for the
    directory-based runner

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Thin wrapper over helpers/srt_driven_edit.py that fills in the layout
described in CLAUDE.md:

  input/source.mp4 + input/script.srt + input/edit_plan.json
    --(python main.py)-->
  output/final.mp4

Behavior:
  - `--srt`, `--plan`, and `-o` defaults are injected when the user did
    not supply them; output/ is auto-created.
  - `--source` and `--voice` defaults are injected only when the
    corresponding file actually exists under input/, so Form B users
    without input/source.mp4 do not get a misleading "missing on disk"
    error from a defaulted flag they never wanted.
  - Both bare (`--srt foo`) and equals (`--srt=foo`) forms count as
    user-supplied; no double-injection.
  - `--batch <manifest>` short-circuits all single-job defaults so the
    manifest fully owns its paths.

The wrapper performs argv rewriting then forwards to
srt_driven_edit.main(), so every existing flag (style, bg-volume,
no-overwrite, continue-on-error, etc.) keeps working unchanged.

7 unit tests cover: bare defaults, source/voice file-gated injection,
user-flag precedence, equals-form recognition, batch short-circuit,
and the short -o alias.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A standalone, self-contained scaffold for the SRT-driven editor that
deliberately does not touch video. Reads script.srt + edit_plan.json
(Form A only), validates id matching with clear error messages, and
prints each cue's planned source-time range alongside the cue's output
range and a text preview.

Why a separate, smaller file when helpers/srt_driven_edit.py already
exists: this version is meant to be read top-to-bottom in one sitting.
It has zero imports from helpers/, no dependency on ffmpeg, and ~150
lines including comments and blank space. It is the natural starting
point for someone learning the pipeline before the production code.

Scope strictly per spec:
  - parse SRT (utf-8-sig, CRLF, cue settings tolerated)
  - parse plan (Form A only — Form B is explicitly rejected with a
    pointer to the full pipeline)
  - validate id sets match, with duplicate detection on both sides
  - print the cue/source-range table

Deliberately NOT implemented:
  - ffmpeg invocation
  - EDL or QC artifact emission
  - Form B sources / voices maps
  - global voice mixing
  - subtitle burn
  - cache, batch, run_episodes integration

Defaults to input/script.srt and input/edit_plan.json so the canonical
project layout from CLAUDE.md works with no flags.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extends the minimal entry point with the first ffmpeg pass. The
existing SRT parsing, plan parsing, and id validation are unchanged;
print_report still runs before extraction so you see the planned
mapping before the cutter touches the disk.

Adds two functions:

  cut_clip(source, start, end, out)
    -ss before -i + libx264 re-encode (frame-accurate). Keeps the
    original audio via -c:a aac. Raises SystemExit with the full
    command and the complete ffmpeg stderr on non-zero exit; raises
    a friendly "ffmpeg not on PATH" message instead of FileNotFoundError
    when the binary is missing.

  extract_clips(cues, plan, source, temp_dir)
    Iterates cues in id order, computes source_end - source_start,
    hard-fails with the offending id on duration <= 0, prints
    `id / start / end / out_path` per clip before invoking the cutter,
    and writes to `<temp_dir>/clip_<id:03d>.mp4`. Filenames are keyed
    by cue id (not enumerate) so a clip is traceable to its cue at
    a glance even with sparse ids.

Two new CLI flags:
  --source <path>     defaults to input/source.mp4
  --temp-dir <path>   defaults to temp/ (auto-created)

Deliberately NOT done (still out of scope for the minimal scaffold):
  concatenation, audio fades, sync tails, HDR tone-map, subtitle
  burn, EDL artifact, QC report. Those live in helpers/srt_driven_edit.py.

Smoke-verified end-to-end:
  - 3-cue happy path: 3 clip_NNN.mp4 files emitted
  - bad plan (source_end < source_start on id=2): clip 1 cuts, run
    aborts with `plan id=2: source_end ... <= source_start ...`

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant