完成任务16:编写 04_长期发展/环境保护.md

This commit is contained in:
root
2026-05-23 16:51:48 +00:00
parent 33ea0114ea
commit c8cd92ba7c
734 changed files with 188060 additions and 604 deletions

View File

@@ -0,0 +1,375 @@
"""Apply a color grade to a video via ffmpeg filter chain.
Two modes:
1. Preset mode — pick a named preset (e.g. `warm_cinematic`, `neutral_punch`).
Simple fixed filter chain applied uniformly.
2. Auto mode (DEFAULT) — analyze the clip mathematically and emit a subtle
per-clip correction. Samples N frames via ffmpeg, computes mean brightness,
RMS contrast, saturation. Emits a bounded filter string that corrects
under-exposure, flatness, and mild desaturation without applying any
creative color shift. All adjustments capped at ±8% on any axis.
The goal is "make it look clean without looking graded". Never applies
creative LUTs, teal/orange splits, or filmic curves. For creative looks,
use `--preset warm_cinematic` explicitly.
Usage:
python helpers/grade.py <input> -o <output> # auto mode
python helpers/grade.py <input> -o <output> --preset warm_cinematic
python helpers/grade.py <input> -o <output> --filter 'eq=contrast=1.1'
python helpers/grade.py --print-preset warm_cinematic # print filter only
python helpers/grade.py --analyze <input> # print auto-grade analysis
Can also be imported by render.py: `get_preset(name)` and `auto_grade_for_clip(path, edl_range)`.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import tempfile
from pathlib import Path
PRESETS: dict[str, str] = {
# Subtle baseline — barely perceptible cleanup. No color shift.
# Use when auto-analysis isn't available or when you want a safe floor.
"subtle": "eq=contrast=1.03:saturation=0.98",
# Minimal corrective grade: light contrast + subtle S-curve, no color shifts.
"neutral_punch": (
"eq=contrast=1.06:brightness=0.0:saturation=1.0,"
"curves=master='0/0 0.25/0.23 0.75/0.77 1/1'"
),
# OPT-IN creative preset for retro/cinematic looks ONLY. Not a default.
# +12% contrast, crushed blacks, -12% sat, warm shadows + cool highs, filmic curve.
# Originally from HEURISTICS §6 — too aggressive for standard launch content.
"warm_cinematic": (
"eq=contrast=1.12:brightness=-0.02:saturation=0.88,"
"colorbalance="
"rs=0.02:gs=0.0:bs=-0.03:"
"rm=0.04:gm=0.01:bm=-0.02:"
"rh=0.08:gh=0.02:bh=-0.05,"
"curves=master='0/0 0.25/0.22 0.75/0.78 1/1'"
),
# Flat — no grade. Useful as a sentinel for "skip grading this source".
"none": "",
}
def get_preset(name: str) -> str:
"""Return the ffmpeg filter string for a preset name. Empty string for 'none'."""
if name not in PRESETS:
raise KeyError(
f"unknown preset '{name}'. Available: {', '.join(sorted(PRESETS))}"
)
return PRESETS[name]
# -------- Auto grade (data-driven, per-clip) --------------------------------
def _sample_frame_stats(
video: Path,
start: float,
duration: float,
n_samples: int = 10,
) -> dict[str, float]:
"""Sample N frames from a range and compute brightness/contrast/saturation stats.
Uses ffmpeg's `signalstats` filter which gives us YMIN, YMAX, YAVG, SATAVG
etc. in the metadata. We average across the sample range.
Returns:
{
"y_mean": mean Y (luma) in 0..1,
"y_std": approximate stddev of Y across samples (0..1),
"sat_mean": mean saturation in 0..1,
}
"""
# Use signalstats + metadata=print to get per-frame stats
# Sample fps = n_samples / duration, clamped so we don't over-sample short clips
fps = max(0.5, min(n_samples / max(duration, 0.1), 10.0))
with tempfile.NamedTemporaryFile(mode="w+", suffix=".txt", delete=False) as f:
metadata_path = f.name
try:
cmd = [
"ffmpeg", "-y", "-hide_banner", "-nostats",
"-ss", f"{start:.3f}",
"-i", str(video),
"-t", f"{duration:.3f}",
"-vf", f"fps={fps:.2f},signalstats,metadata=print:file={metadata_path}",
"-f", "null", "-",
]
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
# Parse signalstats metadata. Signalstats reports values in the NATIVE
# bit depth of the decoded frame (8-bit → 0-255, 10-bit → 0-1023). We
# read YBITDEPTH and normalize by (2^depth - 1) so downstream math is
# in 0..1 regardless of source bit depth.
y_avgs: list[float] = []
y_mins: list[float] = []
y_maxs: list[float] = []
sat_avgs: list[float] = []
bit_depth: int = 8
def _parse_value(line: str) -> float | None:
try:
return float(line.rsplit("=", 1)[1])
except (ValueError, IndexError):
return None
with open(metadata_path) as f:
for line in f:
line = line.strip()
if "lavfi.signalstats.YBITDEPTH" in line:
v = _parse_value(line)
if v is not None:
bit_depth = int(v)
elif "lavfi.signalstats.YAVG" in line:
v = _parse_value(line)
if v is not None:
y_avgs.append(v)
elif "lavfi.signalstats.YMIN" in line:
v = _parse_value(line)
if v is not None:
y_mins.append(v)
elif "lavfi.signalstats.YMAX" in line:
v = _parse_value(line)
if v is not None:
y_maxs.append(v)
elif "lavfi.signalstats.SATAVG" in line:
v = _parse_value(line)
if v is not None:
sat_avgs.append(v)
if not y_avgs:
# Analysis failed — return neutral defaults (no correction)
return {"y_mean": 0.5, "y_std": 0.18, "sat_mean": 0.25}
# Normalize by native bit-depth max value
max_val = (2 ** bit_depth) - 1
y_mean = (sum(y_avgs) / len(y_avgs)) / max_val
y_range = (
((sum(y_maxs) / len(y_maxs)) - (sum(y_mins) / len(y_mins))) / max_val
if y_maxs and y_mins
else 0.7
)
sat_mean = ((sum(sat_avgs) / len(sat_avgs)) / max_val) if sat_avgs else 0.25
return {
"y_mean": y_mean,
"y_std": y_range / 4.0, # range ÷ 4 ≈ stddev for normal-ish distributions
"sat_mean": sat_mean,
}
finally:
Path(metadata_path).unlink(missing_ok=True)
def auto_grade_for_clip(
video: Path,
start: float = 0.0,
duration: float | None = None,
verbose: bool = False,
) -> tuple[str, dict[str, float]]:
"""Analyze a clip range and emit a subtle per-clip correction filter.
Returns (filter_string, stats_dict). The filter is bounded to ±8% on any axis
and applies NO color shift. It only addresses:
- Underexposure (lift gamma slightly if too dark)
- Flatness (tiny contrast boost if range is narrow)
- Desaturation (tiny sat boost if extremely flat)
If the clip is already well-balanced, returns the baseline `subtle` preset.
"""
if duration is None:
# Probe duration
probe_cmd = [
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
str(video),
]
try:
duration = float(subprocess.check_output(probe_cmd).decode().strip())
except Exception:
duration = 10.0
stats = _sample_frame_stats(video, start, duration)
y_mean = stats["y_mean"]
y_range = stats["y_std"] * 4.0 # back to range
sat_mean = stats["sat_mean"]
# ------ Decision rules ---------------------------------------------------
# All caps bounded to ±8%. Target "clean, not graded".
# Contrast: target y_range ≈ 0.72. Boost gently if flat, never reduce.
contrast_adj = 1.0
if y_range < 0.65:
# Map [0.50, 0.65] → [1.08, 1.03]
t = max(0.0, min(1.0, (y_range - 0.50) / 0.15))
contrast_adj = 1.08 - 0.05 * t
else:
contrast_adj = 1.03 # subtle baseline
# Gamma: target y_mean ≈ 0.48. Lift gently if too dark.
gamma_adj = 1.0
if y_mean < 0.42:
# Map [0.30, 0.42] → [1.10, 1.02]
t = max(0.0, min(1.0, (y_mean - 0.30) / 0.12))
gamma_adj = 1.10 - 0.08 * t
elif y_mean > 0.60:
# Slightly overexposed — tiny pullback
gamma_adj = 0.97
# Saturation: target sat_mean ≈ 0.25. Never desaturate aggressively;
# modest boost if very flat. Default to 0.98 (tiny pullback — most digital
# video is slightly over-saturated on consumer displays).
sat_adj = 0.98
if sat_mean < 0.18:
# Very flat — tiny boost
sat_adj = 1.04
elif sat_mean > 0.38:
# Already punchy — hold
sat_adj = 0.96
# Clamp all adjustments hard
contrast_adj = max(0.94, min(1.08, contrast_adj))
gamma_adj = max(0.94, min(1.10, gamma_adj))
sat_adj = max(0.94, min(1.06, sat_adj))
# Build filter string
eq_parts = []
if abs(contrast_adj - 1.0) > 0.005:
eq_parts.append(f"contrast={contrast_adj:.3f}")
if abs(gamma_adj - 1.0) > 0.005:
eq_parts.append(f"gamma={gamma_adj:.3f}")
if abs(sat_adj - 1.0) > 0.005:
eq_parts.append(f"saturation={sat_adj:.3f}")
if not eq_parts:
filter_string = ""
else:
filter_string = "eq=" + ":".join(eq_parts)
if verbose:
print(f" auto-grade stats:")
print(f" y_mean={y_mean:.3f} y_range={y_range:.3f} sat_mean={sat_mean:.3f}")
print(f" → contrast={contrast_adj:.3f} gamma={gamma_adj:.3f} sat={sat_adj:.3f}")
print(f" → filter: {filter_string or '(empty)'}")
return filter_string, stats
def apply_grade(input_path: Path, output_path: Path, filter_string: str) -> None:
output_path.parent.mkdir(parents=True, exist_ok=True)
if not filter_string:
cmd = [
"ffmpeg", "-y", "-i", str(input_path),
"-c", "copy", str(output_path),
]
else:
cmd = [
"ffmpeg", "-y", "-i", str(input_path),
"-vf", filter_string,
"-c:v", "libx264", "-preset", "fast", "-crf", "18",
"-pix_fmt", "yuv420p",
"-c:a", "copy",
"-movflags", "+faststart",
str(output_path),
]
subprocess.run(cmd, check=True)
def main() -> None:
ap = argparse.ArgumentParser(description="Apply a color grade via ffmpeg filter chain")
ap.add_argument("input", type=Path, nargs="?", help="Input video")
ap.add_argument("-o", "--output", type=Path, help="Output video")
ap.add_argument(
"--preset",
type=str,
default=None,
choices=list(PRESETS.keys()),
help="Grade preset. Omit for auto mode (default).",
)
ap.add_argument(
"--filter",
type=str,
default=None,
help="Raw ffmpeg filter string. Overrides --preset.",
)
ap.add_argument(
"--analyze",
type=Path,
default=None,
help="Analyze a clip and print the auto-grade filter it would produce. No output written.",
)
ap.add_argument(
"--print-preset",
type=str,
default=None,
help="Print the filter string for a preset and exit. No input/output needed.",
)
ap.add_argument(
"--list-presets",
action="store_true",
help="List available presets and exit.",
)
args = ap.parse_args()
if args.list_presets:
for name, f in PRESETS.items():
print(f"{name}:")
print(f" {f}" if f else " (no filter)")
print()
return
if args.print_preset is not None:
print(get_preset(args.print_preset))
return
if args.analyze is not None:
if not args.analyze.exists():
sys.exit(f"input not found: {args.analyze}")
filter_string, stats = auto_grade_for_clip(args.analyze, verbose=True)
print(f"\nfilter: {filter_string or '(none)'}")
print(f"stats: {json.dumps(stats, indent=2)}")
return
if not args.input or not args.output:
ap.error("input and -o/--output are required unless using --analyze/--print-preset/--list-presets")
if not args.input.exists():
sys.exit(f"input not found: {args.input}")
# Decide filter string
if args.filter is not None:
filter_string = args.filter
elif args.preset is not None:
filter_string = get_preset(args.preset)
else:
# Auto mode (default)
filter_string, _ = auto_grade_for_clip(args.input, verbose=True)
print(f"grading {args.input.name}{args.output.name}")
if filter_string:
print(f" filter: {filter_string[:120]}{'...' if len(filter_string) > 120 else ''}")
else:
print(" filter: (none — copy)")
apply_grade(args.input, args.output, filter_string)
print(f"done: {args.output}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,206 @@
"""Pack all Scribe transcripts in <edit>/transcripts/ into one readable markdown.
Groups word-level entries into phrase-level lines, breaking on any silence
>= 0.5s OR speaker change. Each phrase gets a [start-end] prefix. This is
the PRIMARY artifact the editor sub-agent reads to pick cuts — it fits one
hour of takes in a tenth the tokens of raw Scribe JSON and gives
word-boundary precision from text alone.
Output: <edit>/takes_packed.md
Usage:
python helpers/pack_transcripts.py --edit-dir <edit_dir>
python helpers/pack_transcripts.py --edit-dir <edit_dir> --silence-threshold 0.5
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
def format_time(seconds: float) -> str:
"""Format a time in seconds as "NNN.NN" with fixed 6-char width for alignment."""
return f"{seconds:06.2f}"
def format_duration(seconds: float) -> str:
"""Format a duration as "Ms" or "Mm SSs"."""
if seconds < 60:
return f"{seconds:.1f}s"
m = int(seconds // 60)
s = seconds - m * 60
return f"{m}m {s:04.1f}s"
def group_into_phrases(
words: list[dict],
silence_threshold: float = 0.5,
) -> list[dict]:
"""Walk a Scribe word list, break into phrases on silence >= threshold
OR speaker change. Returns list of {start, end, text, speaker_id}.
Scribe `words` entries have types 'word', 'spacing', or 'audio_event'.
We keep 'word' and 'audio_event' content in phrase text. 'spacing'
entries carry the silence information via their start/end times.
"""
phrases: list[dict] = []
current_words: list[dict] = []
current_start: float | None = None
current_speaker: str | None = None
def flush() -> None:
nonlocal current_words, current_start, current_speaker
if not current_words:
return
text_parts: list[str] = []
for w in current_words:
t = w.get("type", "word")
raw = (w.get("text") or "").strip()
if not raw:
continue
if t == "audio_event":
if not raw.startswith("("):
raw = f"({raw})"
text_parts.append(raw)
if not text_parts:
current_words = []
current_start = None
current_speaker = None
return
text = " ".join(text_parts)
text = text.replace(" ,", ",").replace(" .", ".").replace(" ?", "?").replace(" !", "!")
end_time = current_words[-1].get("end", current_words[-1].get("start", current_start or 0.0))
phrases.append({
"start": current_start,
"end": end_time,
"text": text,
"speaker_id": current_speaker,
})
current_words = []
current_start = None
current_speaker = None
prev_end: float | None = None
for w in words:
t = w.get("type", "word")
if t == "spacing":
# spacing entries mark the gaps between words; if the gap is long,
# flush the current phrase.
start = w.get("start")
end = w.get("end")
if start is not None and end is not None:
gap = end - start
if gap >= silence_threshold:
flush()
continue
# 'word' or 'audio_event'
start = w.get("start")
if start is None:
continue
speaker = w.get("speaker_id")
# Flush on speaker change
if current_speaker is not None and speaker is not None and speaker != current_speaker:
flush()
# Flush on a long gap from the previous kept token
if prev_end is not None and start - prev_end >= silence_threshold:
flush()
if current_start is None:
current_start = start
current_speaker = speaker
current_words.append(w)
prev_end = w.get("end", start)
flush()
return phrases
def pack_one_file(json_path: Path, silence_threshold: float) -> tuple[str, float, list[dict]]:
"""Return (header_name, duration, phrases) for one transcript file."""
data = json.loads(json_path.read_text())
words = data.get("words", [])
phrases = group_into_phrases(words, silence_threshold)
if phrases:
duration = phrases[-1]["end"] - phrases[0]["start"]
else:
duration = 0.0
return json_path.stem, duration, phrases
def render_markdown(entries: list[tuple[str, float, list[dict]]], silence_threshold: float) -> str:
lines: list[str] = []
lines.append("# Packed transcripts")
lines.append("")
lines.append(f"Phrase-level, grouped on silences ≥ {silence_threshold:.1f}s or speaker change.")
lines.append("Use `[start-end]` ranges to address cuts in the EDL.")
lines.append("")
for name, duration, phrases in entries:
lines.append(f"## {name} (duration: {format_duration(duration)}, {len(phrases)} phrases)")
if not phrases:
lines.append(" _no speech detected_")
lines.append("")
continue
for p in phrases:
spk = p.get("speaker_id")
if spk is not None:
# Scribe returns IDs like "speaker_0" — strip the prefix for readability
spk_str = str(spk)
if spk_str.startswith("speaker_"):
spk_str = spk_str[len("speaker_"):]
spk_tag = f" S{spk_str}"
else:
spk_tag = ""
lines.append(f" [{format_time(p['start'])}-{format_time(p['end'])}]{spk_tag} {p['text']}")
lines.append("")
return "\n".join(lines)
def main() -> None:
ap = argparse.ArgumentParser(description="Pack Scribe transcripts into takes_packed.md")
ap.add_argument("--edit-dir", type=Path, required=True, help="Edit directory containing transcripts/")
ap.add_argument(
"--silence-threshold",
type=float,
default=0.5,
help="Break phrases on silences >= this (seconds). Default 0.5.",
)
ap.add_argument(
"-o", "--output",
type=Path,
default=None,
help="Output path (default: <edit-dir>/takes_packed.md)",
)
args = ap.parse_args()
edit_dir = args.edit_dir.resolve()
transcripts_dir = edit_dir / "transcripts"
if not transcripts_dir.is_dir():
sys.exit(f"no transcripts directory at {transcripts_dir}")
json_files = sorted(transcripts_dir.glob("*.json"))
if not json_files:
sys.exit(f"no .json files in {transcripts_dir}")
entries = [pack_one_file(p, args.silence_threshold) for p in json_files]
markdown = render_markdown(entries, args.silence_threshold)
out_path = args.output or (edit_dir / "takes_packed.md")
out_path.write_text(markdown)
total_phrases = sum(len(e[2]) for e in entries)
total_duration = sum(e[1] for e in entries)
kb = out_path.stat().st_size / 1024
print(f"packed {len(entries)} transcripts → {out_path}")
print(f" {total_phrases} phrases, {format_duration(total_duration)} total runtime")
print(f" {kb:.1f} KB")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,642 @@
"""Render a video from an EDL.
Implements the HEURISTICS render pipeline in the correct order:
1. Per-segment extract with color grade + 30ms audio fades baked in
2. Lossless -c copy concat into base.mp4
3. If overlays or subtitles: single filter graph that overlays animations
(with PTS shift so frame 0 lands at the overlay window start)
and applies `subtitles` filter LAST → final.mp4
Optionally builds a master SRT from the per-source transcripts + EDL
output-timeline offsets, applies the proven force_style (2-word
UPPERCASE chunks, Helvetica 18 Bold, MarginV=35).
Usage:
python helpers/render.py <edl.json> -o final.mp4
python helpers/render.py <edl.json> -o preview.mp4 --preview
python helpers/render.py <edl.json> -o final.mp4 --build-subtitles
python helpers/render.py <edl.json> -o final.mp4 --no-subtitles
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
try:
from grade import get_preset, auto_grade_for_clip # same directory
except Exception:
def get_preset(name: str) -> str:
return ""
def auto_grade_for_clip(video, start=0.0, duration=None, verbose=False): # type: ignore
return "eq=contrast=1.03:saturation=0.98", {}
# -------- Subtitle style (bold-overlay, proven at 1920×1080 and 1080×1920) --
#
# MarginV is NOT taste — it is a platform safe-zone rule.
# TikTok / IG Reels / Shorts UI (caption, username, music, right-rail actions)
# covers roughly the bottom ~2530% of a 1080×1920 frame. Captions placed near
# the bottom edge get clipped or obscured by the UI. libass auto-scales the
# render canvas relative to PlayResY=288, so MarginV=90 lands the caption
# baseline roughly 30% up from the bottom on any aspect — clear of the UI on
# every major vertical-video platform. Do not drop this below ~75 without a
# specific reason.
SUB_FORCE_STYLE = (
"FontName=Helvetica,FontSize=18,Bold=1,"
"PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,BackColour=&H00000000,"
"BorderStyle=1,Outline=2,Shadow=0,"
"Alignment=2,MarginV=90"
)
# -------- Helpers ------------------------------------------------------------
def run(cmd: list[str], quiet: bool = False) -> None:
if not quiet:
print(f" $ {' '.join(str(c) for c in cmd[:6])}{'' if len(cmd) > 6 else ''}")
subprocess.run(cmd, check=True)
def resolve_grade_filter(grade_field: str | None) -> str:
"""The EDL's 'grade' field can be a preset name, a raw ffmpeg filter, or 'auto'.
Returns the filter string to embed into the per-segment -vf chain.
For 'auto', returns the sentinel "__AUTO__" which is resolved per-segment.
"""
if not grade_field:
return ""
if grade_field == "auto":
return "__AUTO__"
# Preset names are short identifiers, filter strings contain '=' or ','.
if re.fullmatch(r"[a-zA-Z0-9_\-]+", grade_field):
try:
return get_preset(grade_field)
except KeyError:
print(f"warning: unknown preset '{grade_field}', using as raw filter")
return grade_field
return grade_field
def resolve_path(maybe_path: str, base: Path) -> Path:
"""Resolve a path that may be absolute or relative to `base`."""
p = Path(maybe_path)
if p.is_absolute():
return p
return (base / p).resolve()
# -------- HDR → SDR tone mapping (HLG / PQ sources) --------------------------
#
# iPhone defaults to HLG HDR in Rec.2020 (and many mirrorless cameras ship PQ).
# If the source is HDR and we only downconvert bit depth (yuv420p10le → yuv420p)
# without tone-mapping, the output is 8-bit but still carries HLG/PQ transfer
# metadata. Players that honor the metadata (screen recorders, most social
# upload re-encodes) interpret 8-bit values in an HDR container and the result
# looks oversaturated / blown out. QuickTime on macOS can hide this locally —
# screen recording and uploaded renders cannot.
#
# Fix: detect HDR via color_transfer and prepend a zscale+tonemap chain to the
# vf graph so the output is clean Rec.709 SDR.
HDR_TRANSFERS = {"smpte2084", "arib-std-b67"} # PQ (HDR10) and HLG
TONEMAP_CHAIN = (
"zscale=t=linear:npl=100,"
"format=gbrpf32le,"
"zscale=p=bt709,"
"tonemap=tonemap=hable:desat=0,"
"zscale=t=bt709:m=bt709:r=tv,"
"format=yuv420p"
)
def is_hdr_source(video: Path) -> bool:
"""Return True if the source uses a PQ or HLG transfer function."""
try:
out = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=color_transfer",
"-of", "default=noprint_wrappers=1:nokey=1", str(video)],
capture_output=True, text=True, check=True,
)
return out.stdout.strip() in HDR_TRANSFERS
except subprocess.CalledProcessError:
return False
# -------- Per-segment extraction (Rule 2 + Rule 3) --------------------------
def extract_segment(
source: Path,
seg_start: float,
duration: float,
grade_filter: str,
out_path: Path,
preview: bool = False,
draft: bool = False,
) -> None:
"""Extract a cut range as its own MP4 with grade + 30ms audio fades baked in.
`-ss` before `-i` for fast accurate seeking. Scale to 1080p from 4K.
Quality ladder:
- final (default): 1080p libx264 fast CRF 20
- preview: 1080p libx264 medium CRF 22 (evaluable for QC)
- draft: 720p libx264 ultrafast CRF 28 (cut-point check only)
"""
out_path.parent.mkdir(parents=True, exist_ok=True)
if draft:
scale = "scale=1280:-2"
else:
scale = "scale=1920:-2"
vf_parts: list[str] = []
if is_hdr_source(source):
vf_parts.append(TONEMAP_CHAIN)
vf_parts.append(scale)
if grade_filter:
vf_parts.append(grade_filter)
vf = ",".join(vf_parts)
# 30ms audio fades at both edges (Rule 3) — prevent pops
fade_out_start = max(0.0, duration - 0.03)
af = f"afade=t=in:st=0:d=0.03,afade=t=out:st={fade_out_start:.3f}:d=0.03"
if draft:
preset, crf = "ultrafast", "28"
elif preview:
preset, crf = "medium", "22"
else:
preset, crf = "fast", "20"
cmd = [
"ffmpeg", "-y",
"-ss", f"{seg_start:.3f}",
"-i", str(source),
"-t", f"{duration:.3f}",
"-vf", vf,
"-af", af,
"-c:v", "libx264", "-preset", preset, "-crf", crf,
"-pix_fmt", "yuv420p", "-r", "24",
"-c:a", "aac", "-b:a", "192k", "-ar", "48000",
"-movflags", "+faststart",
str(out_path),
]
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
def extract_all_segments(
edl: dict,
edit_dir: Path,
preview: bool,
draft: bool = False,
) -> list[Path]:
"""Extract every EDL range into edit_dir/clips_graded/seg_NN.mp4.
Returns the ordered list of segment paths.
If the EDL `grade` is "auto", analyze each segment range with
`auto_grade_for_clip` and apply a per-segment subtle correction.
Otherwise, apply the same preset/raw filter to every segment.
"""
resolved = resolve_grade_filter(edl.get("grade"))
is_auto = resolved == "__AUTO__"
clips_dir = edit_dir / (
"clips_draft" if draft else ("clips_preview" if preview else "clips_graded")
)
clips_dir.mkdir(parents=True, exist_ok=True)
ranges = edl["ranges"]
sources = edl["sources"]
seg_paths: list[Path] = []
print(f"extracting {len(ranges)} segment(s) → {clips_dir.name}/")
if is_auto:
print(" (auto-grade per segment: analyzing each range)")
for i, r in enumerate(ranges):
src_name = r["source"]
src_path = resolve_path(sources[src_name], edit_dir)
start = float(r["start"])
end = float(r["end"])
duration = end - start
out_path = clips_dir / f"seg_{i:02d}_{src_name}.mp4"
if is_auto:
seg_filter, _stats = auto_grade_for_clip(src_path, start=start, duration=duration, verbose=False)
else:
seg_filter = resolved
note = r.get("beat") or r.get("note") or ""
print(f" [{i:02d}] {src_name} {start:7.2f}-{end:7.2f} ({duration:5.2f}s) {note}")
if is_auto:
print(f" grade: {seg_filter or '(none)'}")
extract_segment(src_path, start, duration, seg_filter, out_path, preview=preview, draft=draft)
seg_paths.append(out_path)
return seg_paths
# -------- Lossless concat ----------------------------------------------------
def concat_segments(segment_paths: list[Path], out_path: Path, edit_dir: Path) -> None:
"""Lossless concat via the concat demuxer. No re-encode."""
out_path.parent.mkdir(parents=True, exist_ok=True)
concat_list = edit_dir / "_concat.txt"
concat_list.write_text("".join(f"file '{p.resolve()}'\n" for p in segment_paths))
cmd = [
"ffmpeg", "-y",
"-f", "concat", "-safe", "0",
"-i", str(concat_list),
"-c", "copy",
"-movflags", "+faststart",
str(out_path),
]
print(f"concat → {out_path.name}")
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
concat_list.unlink(missing_ok=True)
# -------- Master SRT (Rule 5) ------------------------------------------------
PUNCT_BREAK = set(".,!?;:")
def _srt_timestamp(seconds: float) -> str:
total_ms = int(round(seconds * 1000))
h, rem = divmod(total_ms, 3600_000)
m, rem = divmod(rem, 60_000)
s, ms = divmod(rem, 1000)
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
def _words_in_range(transcript: dict, t_start: float, t_end: float) -> list[dict]:
out: list[dict] = []
for w in transcript.get("words", []):
if w.get("type") != "word":
continue
ws = w.get("start")
we = w.get("end")
if ws is None or we is None:
continue
if we <= t_start or ws >= t_end:
continue
out.append(w)
return out
def build_master_srt(edl: dict, edit_dir: Path, out_path: Path) -> None:
"""Build an output-timeline SRT from per-source transcripts.
- 2-word chunks (break on any punctuation in between)
- UPPERCASE text
- Output times computed as word.start - segment_start + segment_offset
"""
transcripts_dir = edit_dir / "transcripts"
sources = edl["sources"]
entries: list[tuple[float, float, str]] = []
seg_offset = 0.0
for r in edl["ranges"]:
src_name = r["source"]
seg_start = float(r["start"])
seg_end = float(r["end"])
seg_duration = seg_end - seg_start
tr_path = transcripts_dir / f"{src_name}.json"
if not tr_path.exists():
print(f" no transcript for {src_name}, skipping captions for this segment")
seg_offset += seg_duration
continue
transcript = json.loads(tr_path.read_text())
words_in_seg = _words_in_range(transcript, seg_start, seg_end)
# Group into 2-word chunks, break on punctuation
chunks: list[list[dict]] = []
current: list[dict] = []
for w in words_in_seg:
text = (w.get("text") or "").strip()
if not text:
continue
current.append(w)
# Break if the current text ends in punctuation or we hit 2 words
ends_in_punct = bool(text) and text[-1] in PUNCT_BREAK
if len(current) >= 2 or ends_in_punct:
chunks.append(current)
current = []
if current:
chunks.append(current)
for chunk in chunks:
local_start = max(seg_start, chunk[0].get("start", seg_start))
local_end = min(seg_end, chunk[-1].get("end", seg_end))
out_start = max(0.0, local_start - seg_start) + seg_offset
out_end = max(0.0, local_end - seg_start) + seg_offset
if out_end <= out_start:
out_end = out_start + 0.4
text = " ".join((w.get("text") or "").strip() for w in chunk)
text = re.sub(r"\s+", " ", text).strip()
# Strip trailing punctuation for cleaner uppercase look
text = text.rstrip(",;:")
text = text.upper()
entries.append((out_start, out_end, text))
seg_offset += seg_duration
# Sort and write as SRT
entries.sort(key=lambda e: e[0])
lines: list[str] = []
for i, (a, b, t) in enumerate(entries, start=1):
lines.append(str(i))
lines.append(f"{_srt_timestamp(a)} --> {_srt_timestamp(b)}")
lines.append(t)
lines.append("")
out_path.write_text("\n".join(lines))
print(f"master SRT → {out_path.name} ({len(entries)} cues)")
# -------- Loudness normalization (social-ready audio) -----------------------
# Social-media standard: -14 LUFS integrated, -1 dBTP peak, LRA 11 LU.
# Matches YouTube / Instagram / TikTok / X / LinkedIn normalization targets.
LOUDNORM_I = -14.0
LOUDNORM_TP = -1.0
LOUDNORM_LRA = 11.0
def measure_loudness(video_path: Path) -> dict[str, str] | None:
"""Run ffmpeg loudnorm first pass and parse the JSON measurement.
Returns a dict with measured_i, measured_tp, measured_lra, measured_thresh,
target_offset, or None if measurement failed.
"""
filter_str = (
f"loudnorm=I={LOUDNORM_I}:TP={LOUDNORM_TP}:LRA={LOUDNORM_LRA}:print_format=json"
)
cmd = [
"ffmpeg", "-y", "-hide_banner", "-nostats",
"-i", str(video_path),
"-af", filter_str,
"-vn", "-f", "null", "-",
]
proc = subprocess.run(cmd, capture_output=True, text=True)
# loudnorm prints the JSON to stderr at the end of the run
stderr = proc.stderr
# Find the JSON block — loudnorm output contains a `{ ... }` block
start = stderr.rfind("{")
end = stderr.rfind("}")
if start == -1 or end == -1 or end <= start:
return None
try:
data = json.loads(stderr[start : end + 1])
except json.JSONDecodeError:
return None
needed = {"input_i", "input_tp", "input_lra", "input_thresh", "target_offset"}
if not needed.issubset(data.keys()):
return None
return data
def apply_loudnorm_two_pass(
input_path: Path,
output_path: Path,
preview: bool = False,
) -> bool:
"""Run two-pass loudnorm on input_path, write normalized copy to output_path.
Returns True on success, False if measurement failed (caller should fall
back to copying the input unchanged).
In preview mode, skips the measurement pass and uses a one-pass approximation
for speed. Final mode always does the proper two-pass.
"""
if preview:
# One-pass approximation — faster, slightly less accurate.
filter_str = f"loudnorm=I={LOUDNORM_I}:TP={LOUDNORM_TP}:LRA={LOUDNORM_LRA}"
cmd = [
"ffmpeg", "-y", "-hide_banner", "-nostats",
"-i", str(input_path),
"-c:v", "copy",
"-af", filter_str,
"-c:a", "aac", "-b:a", "192k", "-ar", "48000",
"-movflags", "+faststart",
str(output_path),
]
print(f" loudnorm (1-pass preview) → {output_path.name}")
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
return True
# Full two-pass
print(f" loudnorm pass 1: measuring {input_path.name}")
measurement = measure_loudness(input_path)
if measurement is None:
print(" loudnorm measurement failed — falling back to 1-pass")
return apply_loudnorm_two_pass(input_path, output_path, preview=True)
print(f" measured: I={measurement['input_i']} LUFS "
f"TP={measurement['input_tp']} LRA={measurement['input_lra']}")
filter_str = (
f"loudnorm=I={LOUDNORM_I}:TP={LOUDNORM_TP}:LRA={LOUDNORM_LRA}"
f":measured_I={measurement['input_i']}"
f":measured_TP={measurement['input_tp']}"
f":measured_LRA={measurement['input_lra']}"
f":measured_thresh={measurement['input_thresh']}"
f":offset={measurement['target_offset']}"
f":linear=true"
)
cmd = [
"ffmpeg", "-y", "-hide_banner", "-nostats",
"-i", str(input_path),
"-c:v", "copy",
"-af", filter_str,
"-c:a", "aac", "-b:a", "192k", "-ar", "48000",
"-movflags", "+faststart",
str(output_path),
]
print(f" loudnorm pass 2: normalizing → {output_path.name}")
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
return True
# -------- Final compositing (Rule 1 + Rule 4) -------------------------------
def build_final_composite(
base_path: Path,
overlays: list[dict],
subtitles_path: Path | None,
out_path: Path,
edit_dir: Path,
) -> None:
"""Final pass: base → overlays (PTS-shifted) → subtitles LAST → out.
If there are no overlays and no subtitles, just copy base to out.
"""
has_overlays = bool(overlays)
has_subs = subtitles_path is not None and subtitles_path.exists()
if not has_overlays and not has_subs:
# Nothing to do — just rename/copy base to final name
run(["ffmpeg", "-y", "-i", str(base_path), "-c", "copy", str(out_path)], quiet=True)
return
inputs: list[str] = ["-i", str(base_path)]
for ov in overlays:
ov_path = resolve_path(ov["file"], edit_dir)
inputs += ["-i", str(ov_path)]
filter_parts: list[str] = []
# PTS-shift every overlay so its frame 0 lands at start_in_output
for idx, ov in enumerate(overlays, start=1):
t = float(ov["start_in_output"])
filter_parts.append(f"[{idx}:v]setpts=PTS-STARTPTS+{t}/TB[a{idx}]")
# Chain overlays on top of base
current = "[0:v]"
for idx, ov in enumerate(overlays, start=1):
t = float(ov["start_in_output"])
dur = float(ov["duration"])
end = t + dur
next_label = f"[v{idx}]"
filter_parts.append(
f"{current}[a{idx}]overlay=enable='between(t,{t:.3f},{end:.3f})'{next_label}"
)
current = next_label
# Subtitles LAST — Rule 1
if has_subs:
subs_abs = str(subtitles_path.resolve()).replace(":", r"\:").replace("'", r"\'")
filter_parts.append(
f"{current}subtitles='{subs_abs}':force_style='{SUB_FORCE_STYLE}'[outv]"
)
out_label = "[outv]"
else:
# Rename the last overlay output to [outv] for consistency
if has_overlays:
filter_parts.append(f"{current}null[outv]")
out_label = "[outv]"
else:
out_label = "[0:v]"
filter_complex = ";".join(filter_parts)
cmd = [
"ffmpeg", "-y",
*inputs,
"-filter_complex", filter_complex,
"-map", out_label,
"-map", "0:a",
"-c:v", "libx264", "-preset", "fast", "-crf", "18",
"-pix_fmt", "yuv420p",
"-c:a", "copy",
"-movflags", "+faststart",
str(out_path),
]
print(f"compositing → {out_path.name}")
print(f" overlays: {len(overlays)}, subtitles: {'yes' if has_subs else 'no'}")
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
# -------- Main ---------------------------------------------------------------
def main() -> None:
ap = argparse.ArgumentParser(description="Render a video from an EDL")
ap.add_argument("edl", type=Path, help="Path to edl.json")
ap.add_argument("-o", "--output", type=Path, required=True, help="Output video path")
ap.add_argument(
"--preview",
action="store_true",
help="Preview mode: 1080p, medium, CRF 22 — evaluable for QC, faster than final.",
)
ap.add_argument(
"--draft",
action="store_true",
help="Draft mode: 720p, ultrafast, CRF 28 — cut-point verification only.",
)
ap.add_argument(
"--build-subtitles",
action="store_true",
help="Build master.srt from transcripts + EDL offsets before compositing",
)
ap.add_argument(
"--no-subtitles",
action="store_true",
help="Skip subtitles even if the EDL references one",
)
ap.add_argument(
"--no-loudnorm",
action="store_true",
help="Skip audio loudness normalization. Default is on (-14 LUFS, -1 dBTP, LRA 11).",
)
args = ap.parse_args()
edl_path = args.edl.resolve()
if not edl_path.exists():
sys.exit(f"edl not found: {edl_path}")
edl = json.loads(edl_path.read_text())
edit_dir = edl_path.parent
out_path = args.output.resolve()
# 1. Extract per-segment (auto-grade per range if EDL grade is "auto")
segment_paths = extract_all_segments(
edl, edit_dir, preview=args.preview, draft=args.draft
)
# 2. Concat → base
if args.draft:
base_name = "base_draft.mp4"
elif args.preview:
base_name = "base_preview.mp4"
else:
base_name = "base.mp4"
base_path = edit_dir / base_name
concat_segments(segment_paths, base_path, edit_dir)
# 3. Subtitles: build if requested, resolve final path
subs_path: Path | None = None
if not args.no_subtitles:
if args.build_subtitles:
subs_path = edit_dir / "master.srt"
build_master_srt(edl, edit_dir, subs_path)
elif edl.get("subtitles"):
subs_path = resolve_path(edl["subtitles"], edit_dir)
if not subs_path.exists():
print(f"warning: subtitles path in EDL does not exist: {subs_path}")
subs_path = None
# 4. Composite (overlays + subtitles LAST) → intermediate (pre-loudnorm) path
overlays = edl.get("overlays") or []
if args.no_loudnorm:
# Composite directly to final output
build_final_composite(base_path, overlays, subs_path, out_path, edit_dir)
else:
# Composite to a temp file, then run loudnorm → final output
tmp_composite = out_path.with_suffix(".prenorm.mp4")
build_final_composite(base_path, overlays, subs_path, tmp_composite, edit_dir)
print("loudness normalization → social-ready (-14 LUFS / -1 dBTP / LRA 11)")
apply_loudnorm_two_pass(tmp_composite, out_path, preview=args.draft)
tmp_composite.unlink(missing_ok=True)
size_mb = out_path.stat().st_size / (1024 * 1024)
print(f"\ndone: {out_path} ({size_mb:.1f} MB)")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,392 @@
"""Filmstrip + waveform composite PNG for a time range of a video.
The only visual drill-down tool. Given a video and a [start, end] range,
extracts N evenly spaced frames via ffmpeg, composites them into a
horizontal filmstrip, and renders a waveform ribbon below with word
labels overlaid from the transcript (if available) and silence gaps
shaded.
Use this at decision points — ambiguous pauses, retake disambiguation,
cut-point sanity checks. Do NOT call it in a scan loop over every
utterance; it's an on-demand drill-down, not a background index.
Usage:
python helpers/timeline_view.py <video> <start> <end>
python helpers/timeline_view.py <video> <start> <end> -o out.png
python helpers/timeline_view.py <video> <start> <end> --n-frames 12
python helpers/timeline_view.py <video> <start> <end> --transcript <path>
python helpers/timeline_view.py --edl <edl.json> (full-project view — not yet)
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import tempfile
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFont
# -------- Frame extraction ---------------------------------------------------
def extract_frames(video: Path, start: float, end: float, n: int, dest_dir: Path) -> list[Path]:
"""Extract N frames evenly spaced across [start, end]. Returns paths in order."""
dest_dir.mkdir(parents=True, exist_ok=True)
if n < 1:
n = 1
if n == 1:
times = [(start + end) / 2.0]
else:
step = (end - start) / (n - 1)
times = [start + i * step for i in range(n)]
paths: list[Path] = []
for i, t in enumerate(times):
out = dest_dir / f"f_{i:03d}.jpg"
cmd = [
"ffmpeg", "-y",
"-ss", f"{t:.3f}",
"-i", str(video),
"-frames:v", "1",
"-q:v", "4",
"-vf", "scale=320:-2",
str(out),
]
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
paths.append(out)
return paths
# -------- Audio envelope (librosa if available, ffmpeg fallback) ------------
def compute_envelope(video: Path, start: float, end: float, samples: int = 2000) -> np.ndarray:
"""Extract the audio segment and return an RMS envelope of length `samples`.
Uses ffmpeg to dump mono 16kHz PCM to a temp wav, then computes a
windowed RMS. Falls back gracefully if the source has no audio.
"""
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
wav = Path(f.name)
try:
cmd = [
"ffmpeg", "-y",
"-ss", f"{start:.3f}",
"-i", str(video),
"-t", f"{(end - start):.3f}",
"-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le",
str(wav),
]
r = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if r.returncode != 0 or not wav.exists() or wav.stat().st_size == 0:
return np.zeros(samples)
# Read the WAV manually — avoid librosa as a hard dep
import wave
with wave.open(str(wav), "rb") as w:
frames = w.readframes(w.getnframes())
pcm = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
if pcm.size == 0:
return np.zeros(samples)
# Windowed RMS → envelope of length `samples`
n = pcm.size
window = max(1, n // samples)
usable = (n // window) * window
reshaped = pcm[:usable].reshape(-1, window)
env = np.sqrt(np.mean(reshaped ** 2, axis=1))
if env.size < samples:
env = np.pad(env, (0, samples - env.size))
elif env.size > samples:
env = env[:samples]
# Normalize to [0, 1]
if env.max() > 0:
env = env / env.max()
return env
finally:
wav.unlink(missing_ok=True)
# -------- Transcript word overlays ------------------------------------------
def words_in_range(transcript_path: Path, start: float, end: float) -> list[dict]:
if not transcript_path.exists():
return []
data = json.loads(transcript_path.read_text())
out: list[dict] = []
for w in data.get("words", []):
t = w.get("type", "word")
ws = w.get("start")
we = w.get("end")
if ws is None or we is None:
continue
if we <= start or ws >= end:
continue
out.append(w)
return out
def find_silences(words: list[dict], start: float, end: float, threshold: float = 0.4) -> list[tuple[float, float]]:
"""Find gaps >= threshold seconds inside [start, end] between kept tokens."""
gaps: list[tuple[float, float]] = []
prev_end = start
for w in words:
if w.get("type") == "spacing":
continue
ws = max(start, w.get("start", start))
if ws - prev_end >= threshold:
gaps.append((prev_end, ws))
prev_end = max(prev_end, w.get("end", ws))
if end - prev_end >= threshold:
gaps.append((prev_end, end))
return gaps
# -------- Font loading -------------------------------------------------------
FONT_CANDIDATES = [
"/System/Library/Fonts/Menlo.ttc",
"/System/Library/Fonts/Helvetica.ttc",
"/System/Library/Fonts/SFNSMono.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
]
def load_font(size: int) -> ImageFont.ImageFont:
for fp in FONT_CANDIDATES:
if Path(fp).exists():
try:
return ImageFont.truetype(fp, size)
except Exception:
continue
return ImageFont.load_default()
# -------- Composite ----------------------------------------------------------
BG = (18, 18, 22)
FG = (235, 235, 235)
DIM = (110, 110, 120)
ACCENT = (255, 140, 60)
SILENCE = (50, 80, 120, 120) # muted blue, semi-transparent
WAVE = (140, 180, 255)
def render_timeline(
video: Path,
start: float,
end: float,
out_path: Path,
n_frames: int,
transcript: Path | None,
) -> None:
# Frame extraction
with tempfile.TemporaryDirectory() as tmp:
tmp_dir = Path(tmp)
print(f"extracting {n_frames} frames from {start:.2f}s to {end:.2f}s")
frame_paths = extract_frames(video, start, end, n_frames, tmp_dir)
# Layout metrics
canvas_width = 1920
frame_h = 180
filmstrip_y = 50
filmstrip_h = frame_h
wave_y = filmstrip_y + filmstrip_h + 20
wave_h = 220
label_y = wave_y + wave_h + 10
canvas_height = label_y + 60
# Load + resize frames to uniform height and compute total width
imgs: list[Image.Image] = []
for fp in frame_paths:
img = Image.open(fp).convert("RGB")
aspect = img.width / img.height
new_w = int(frame_h * aspect)
imgs.append(img.resize((new_w, frame_h), Image.LANCZOS))
total_frame_w = sum(img.width for img in imgs) + (len(imgs) - 1) * 4
content_w = max(1400, total_frame_w)
canvas_width = max(canvas_width, content_w + 100)
canvas = Image.new("RGB", (canvas_width, canvas_height), BG)
draw = ImageDraw.Draw(canvas, "RGBA")
header_font = load_font(22)
label_font = load_font(14)
small_font = load_font(12)
# Header — time range
draw.text(
(50, 12),
f"{video.name} {start:.2f}s → {end:.2f}s ({(end - start):.2f}s, {n_frames} frames)",
fill=FG,
font=header_font,
)
# Filmstrip
x = 50
strip_width = canvas_width - 100
if total_frame_w <= strip_width:
cursor = 50
for img in imgs:
canvas.paste(img, (cursor, filmstrip_y))
cursor += img.width + 4
draw_width = cursor - 50
else:
scale = strip_width / total_frame_w
new_h = int(frame_h * scale)
cursor = 50
for img in imgs:
new_w = int(img.width * scale)
scaled = img.resize((new_w, new_h), Image.LANCZOS)
canvas.paste(scaled, (cursor, filmstrip_y + (filmstrip_h - new_h) // 2))
cursor += new_w + max(2, int(4 * scale))
draw_width = cursor - 50
strip_x0 = 50
strip_x1 = 50 + draw_width
strip_span = strip_x1 - strip_x0
def time_to_x(t: float) -> int:
frac = (t - start) / max(1e-6, (end - start))
return int(strip_x0 + frac * strip_span)
# Waveform background
draw.rectangle((strip_x0, wave_y, strip_x1, wave_y + wave_h), fill=(28, 28, 34))
# Silence shading (under the waveform)
words = words_in_range(transcript, start, end) if transcript else []
silences = find_silences(words, start, end, threshold=0.4) if words else []
for a, b in silences:
xa = time_to_x(a)
xb = time_to_x(b)
draw.rectangle((xa, wave_y, xb, wave_y + wave_h), fill=SILENCE)
# Waveform envelope
env = compute_envelope(video, start, end, samples=max(strip_span, 200))
mid_y = wave_y + wave_h // 2
max_amp = wave_h // 2 - 8
points_top: list[tuple[int, int]] = []
points_bot: list[tuple[int, int]] = []
for i, v in enumerate(env):
xi = strip_x0 + int(i * strip_span / max(1, len(env) - 1))
a = int(v * max_amp)
points_top.append((xi, mid_y - a))
points_bot.append((xi, mid_y + a))
if points_top:
draw.line(points_top, fill=WAVE, width=1, joint="curve")
draw.line(points_bot, fill=WAVE, width=1, joint="curve")
# Fill between
poly = points_top + list(reversed(points_bot))
draw.polygon(poly, fill=(*WAVE, 60))
# Word labels above the waveform (only words lasting ≥ 120ms to avoid clutter)
last_label_x = -9999
for w in words:
if w.get("type") != "word":
continue
ws = w.get("start")
we = w.get("end")
text = (w.get("text") or "").strip()
if not text or ws is None or we is None:
continue
if (we - ws) < 0.05:
continue
cx = (time_to_x(ws) + time_to_x(we)) // 2
if cx - last_label_x < 28:
continue
# Tiny tick on the waveform
draw.line((cx, wave_y - 4, cx, wave_y), fill=DIM, width=1)
# Text above the waveform
draw.text((cx + 2, wave_y - 18), text, fill=FG, font=small_font)
last_label_x = cx
# Time ruler below waveform
ruler_y = wave_y + wave_h + 2
n_ticks = 6
for i in range(n_ticks + 1):
frac = i / n_ticks
t = start + frac * (end - start)
xi = strip_x0 + int(frac * strip_span)
draw.line((xi, ruler_y, xi, ruler_y + 6), fill=DIM, width=1)
draw.text((xi - 20, ruler_y + 8), f"{t:.2f}s", fill=DIM, font=label_font)
# Silences legend if any
if silences:
txt = f"shaded bands = silences ≥ 400ms ({len(silences)} gap(s))"
draw.text((strip_x0, label_y + 30), txt, fill=DIM, font=label_font)
out_path.parent.mkdir(parents=True, exist_ok=True)
canvas.save(out_path, "PNG", optimize=True)
print(f"saved: {out_path} ({out_path.stat().st_size // 1024} KB)")
def main() -> None:
ap = argparse.ArgumentParser(description="Filmstrip + waveform composite for a video range")
ap.add_argument("video", type=Path, nargs="?", help="Source video")
ap.add_argument("start", type=float, nargs="?", help="Start time in seconds")
ap.add_argument("end", type=float, nargs="?", help="End time in seconds")
ap.add_argument("-o", "--output", type=Path, default=None, help="Output PNG path")
ap.add_argument("--n-frames", type=int, default=10, help="Number of frames in the filmstrip (default 10)")
ap.add_argument(
"--transcript",
type=Path,
default=None,
help="Path to transcript.json for word labels + silence shading. "
"If omitted, will auto-resolve to <video_parent>/edit/transcripts/<video_stem>.json",
)
ap.add_argument(
"--edl",
type=Path,
default=None,
help="(Not yet implemented) Render a full-project timeline from an EDL",
)
args = ap.parse_args()
if args.edl:
sys.exit("--edl mode is not implemented yet; use range mode")
if not args.video or args.start is None or args.end is None:
ap.error("video, start, and end are required")
video = args.video.resolve()
if not video.exists():
sys.exit(f"video not found: {video}")
if args.end <= args.start:
sys.exit("end must be > start")
# Auto-resolve transcript if not given
transcript = args.transcript
if transcript is None:
auto = video.parent / "edit" / "transcripts" / f"{video.stem}.json"
if auto.exists():
transcript = auto
out_path = args.output
if out_path is None:
out_dir = video.parent / "edit" / "verify"
out_dir.mkdir(parents=True, exist_ok=True)
out_path = out_dir / f"{video.stem}_{args.start:.2f}-{args.end:.2f}.png"
render_timeline(
video=video,
start=args.start,
end=args.end,
out_path=out_path,
n_frames=args.n_frames,
transcript=transcript,
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,175 @@
"""Transcribe a video with ElevenLabs Scribe.
Extracts mono 16kHz audio via ffmpeg, uploads to Scribe with verbatim +
diarize + audio events + word-level timestamps, writes the full response
to <edit_dir>/transcripts/<video_stem>.json.
Cached: if the output file already exists, the upload is skipped.
Usage:
python helpers/transcribe.py <video_path>
python helpers/transcribe.py <video_path> --edit-dir /custom/edit
python helpers/transcribe.py <video_path> --language en
python helpers/transcribe.py <video_path> --num-speakers 2
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import tempfile
import time
from pathlib import Path
import requests
SCRIBE_URL = "https://api.elevenlabs.io/v1/speech-to-text"
def load_api_key() -> str:
for candidate in [Path(__file__).resolve().parent.parent / ".env", Path(".env")]:
if candidate.exists():
for line in candidate.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
if k.strip() == "ELEVENLABS_API_KEY":
return v.strip().strip('"').strip("'")
v = os.environ.get("ELEVENLABS_API_KEY", "")
if not v:
sys.exit("ELEVENLABS_API_KEY not found in .env or environment")
return v
def extract_audio(video_path: Path, dest: Path) -> None:
cmd = [
"ffmpeg", "-y", "-i", str(video_path),
"-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le",
str(dest),
]
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def call_scribe(
audio_path: Path,
api_key: str,
language: str | None = None,
num_speakers: int | None = None,
) -> dict:
data: dict[str, str] = {
"model_id": "scribe_v1",
"diarize": "true",
"tag_audio_events": "true",
"timestamps_granularity": "word",
}
if language:
data["language_code"] = language
if num_speakers:
data["num_speakers"] = str(num_speakers)
with open(audio_path, "rb") as f:
resp = requests.post(
SCRIBE_URL,
headers={"xi-api-key": api_key},
files={"file": (audio_path.name, f, "audio/wav")},
data=data,
timeout=1800,
)
if resp.status_code != 200:
raise RuntimeError(f"Scribe returned {resp.status_code}: {resp.text[:500]}")
return resp.json()
def transcribe_one(
video: Path,
edit_dir: Path,
api_key: str,
language: str | None = None,
num_speakers: int | None = None,
verbose: bool = True,
) -> Path:
"""Transcribe a single video. Returns path to transcript JSON.
Cached: returns existing path immediately if the transcript already exists.
"""
transcripts_dir = edit_dir / "transcripts"
transcripts_dir.mkdir(parents=True, exist_ok=True)
out_path = transcripts_dir / f"{video.stem}.json"
if out_path.exists():
if verbose:
print(f"cached: {out_path.name}")
return out_path
if verbose:
print(f" extracting audio from {video.name}", flush=True)
t0 = time.time()
with tempfile.TemporaryDirectory() as tmp:
audio = Path(tmp) / f"{video.stem}.wav"
extract_audio(video, audio)
size_mb = audio.stat().st_size / (1024 * 1024)
if verbose:
print(f" uploading {video.stem}.wav ({size_mb:.1f} MB)", flush=True)
payload = call_scribe(audio, api_key, language, num_speakers)
out_path.write_text(json.dumps(payload, indent=2))
dt = time.time() - t0
if verbose:
kb = out_path.stat().st_size / 1024
print(f" saved: {out_path.name} ({kb:.1f} KB) in {dt:.1f}s")
if isinstance(payload, dict) and "words" in payload:
print(f" words: {len(payload['words'])}")
return out_path
def main() -> None:
ap = argparse.ArgumentParser(description="Transcribe a video with ElevenLabs Scribe")
ap.add_argument("video", type=Path, help="Path to video file")
ap.add_argument(
"--edit-dir",
type=Path,
default=None,
help="Edit output directory (default: <video_parent>/edit)",
)
ap.add_argument(
"--language",
type=str,
default=None,
help="Optional ISO language code (e.g., 'en'). Omit to auto-detect.",
)
ap.add_argument(
"--num-speakers",
type=int,
default=None,
help="Optional number of speakers when known. Improves diarization accuracy.",
)
args = ap.parse_args()
video = args.video.resolve()
if not video.exists():
sys.exit(f"video not found: {video}")
edit_dir = (args.edit_dir or (video.parent / "edit")).resolve()
api_key = load_api_key()
transcribe_one(
video=video,
edit_dir=edit_dir,
api_key=api_key,
language=args.language,
num_speakers=args.num_speakers,
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,118 @@
"""Batch-transcribe every video in a directory with 4 parallel workers.
Walks <videos_dir> for common video extensions, runs ElevenLabs Scribe on
each, writes transcripts to <videos_dir>/edit/transcripts/<name>.json.
Cached per-file: any source that already has a transcript is skipped.
Usage:
python helpers/transcribe_batch.py <videos_dir>
python helpers/transcribe_batch.py <videos_dir> --workers 4
python helpers/transcribe_batch.py <videos_dir> --num-speakers 2
python helpers/transcribe_batch.py <videos_dir> --edit-dir /custom/edit
"""
from __future__ import annotations
import argparse
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from transcribe import load_api_key, transcribe_one
VIDEO_EXTS = {".mp4", ".MP4", ".mov", ".MOV", ".mkv", ".MKV", ".avi", ".AVI", ".m4v"}
def find_videos(videos_dir: Path) -> list[Path]:
videos = sorted(
p for p in videos_dir.iterdir()
if p.is_file() and p.suffix in VIDEO_EXTS
)
return videos
def main() -> None:
ap = argparse.ArgumentParser(description="Parallel batch transcription of a videos directory")
ap.add_argument("videos_dir", type=Path, help="Directory containing source videos")
ap.add_argument(
"--edit-dir",
type=Path,
default=None,
help="Edit output directory (default: <videos_dir>/edit)",
)
ap.add_argument("--workers", type=int, default=4, help="Parallel workers (default: 4)")
ap.add_argument(
"--language",
type=str,
default=None,
help="Optional ISO language code. Omit to auto-detect per file.",
)
ap.add_argument(
"--num-speakers",
type=int,
default=None,
help="Optional number of speakers. Improves diarization when known.",
)
args = ap.parse_args()
videos_dir = args.videos_dir.resolve()
if not videos_dir.is_dir():
sys.exit(f"not a directory: {videos_dir}")
edit_dir = (args.edit_dir or (videos_dir / "edit")).resolve()
(edit_dir / "transcripts").mkdir(parents=True, exist_ok=True)
videos = find_videos(videos_dir)
if not videos:
sys.exit(f"no videos found in {videos_dir}")
already_cached = [v for v in videos if (edit_dir / "transcripts" / f"{v.stem}.json").exists()]
pending = [v for v in videos if v not in already_cached]
print(f"found {len(videos)} videos ({len(already_cached)} cached, {len(pending)} to transcribe)")
if not pending:
print("nothing to do")
return
api_key = load_api_key()
print(f"transcribing {len(pending)} files with {args.workers} parallel workers")
t0 = time.time()
errors: list[tuple[Path, str]] = []
with ThreadPoolExecutor(max_workers=args.workers) as pool:
futures = {
pool.submit(
transcribe_one,
video=v,
edit_dir=edit_dir,
api_key=api_key,
language=args.language,
num_speakers=args.num_speakers,
verbose=False,
): v
for v in pending
}
for fut in as_completed(futures):
v = futures[fut]
try:
out = fut.result()
print(f" + {v.stem}{out.name}")
except Exception as e:
errors.append((v, str(e)))
print(f" x {v.stem} FAILED: {e}")
dt = time.time() - t0
print(f"\ndone in {dt:.1f}s")
if errors:
print(f"{len(errors)} failures:")
for v, msg in errors:
print(f" {v.name}: {msg}")
sys.exit(1)
if __name__ == "__main__":
main()