完成任务16:编写 04_长期发展/环境保护.md
This commit is contained in:
375
skills/video-use/helpers/grade.py
Normal file
375
skills/video-use/helpers/grade.py
Normal 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()
|
||||
206
skills/video-use/helpers/pack_transcripts.py
Normal file
206
skills/video-use/helpers/pack_transcripts.py
Normal 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()
|
||||
642
skills/video-use/helpers/render.py
Normal file
642
skills/video-use/helpers/render.py
Normal 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 ~25–30% 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()
|
||||
392
skills/video-use/helpers/timeline_view.py
Normal file
392
skills/video-use/helpers/timeline_view.py
Normal 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()
|
||||
175
skills/video-use/helpers/transcribe.py
Normal file
175
skills/video-use/helpers/transcribe.py
Normal 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()
|
||||
118
skills/video-use/helpers/transcribe_batch.py
Normal file
118
skills/video-use/helpers/transcribe_batch.py
Normal 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()
|
||||
Reference in New Issue
Block a user