-
-
Notifications
You must be signed in to change notification settings - Fork 61
feat: add word-timestamp auto-caption example #53
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| # Auto-captions example | ||
|
|
||
| This example converts word-timestamped speech-to-text output into FableCut `kind:"text"` clips with the built-in `karaoke` animation. It does not change the editor or require a specific speech-to-text engine. | ||
|
|
||
| ## 1. Produce word timestamps | ||
|
|
||
| Provide a JSON file containing either a top-level `words` array or a bare array: | ||
|
|
||
| ```json | ||
| { | ||
| "words": [ | ||
| {"word": "Welcome", "start": 0.00, "end": 0.42}, | ||
| {"word": "to", "start": 0.43, "end": 0.58}, | ||
| {"word": "FableCut", "start": 0.60, "end": 1.20} | ||
| ] | ||
| } | ||
| ``` | ||
|
|
||
| The format can be produced by faster-whisper, whisper.cpp adapters, or another STT engine. The script accepts `start` and `end` in seconds. | ||
|
|
||
| ## 2. Generate caption clips | ||
|
|
||
| ```bash | ||
| python examples/auto-captions/auto_captions.py \ | ||
| --transcript transcript.json \ | ||
| --output captions.json | ||
| ``` | ||
|
|
||
| The output contains a `clips` array. Add those clips to an existing `project.json` with the MCP `fablecut_patch_project` tool, or merge them into the project document before using `PUT /api/project`. | ||
|
|
||
| ```json | ||
| {"ops":[{"op":"addClip","clip":{"kind":"text","track":"V3","start":0,"duration":1.2,"props":{"text":"Welcome to FableCut","textAnim":"karaoke","wordRate":0.2}}}]} | ||
| ``` | ||
|
|
||
| The generated clips default to `V3`, use four words per line, cap a line at about 1.8 seconds, and derive `wordRate` from the observed word durations. Tune the grouping with `--max-words` and `--max-seconds`. | ||
|
Comment on lines
+31
to
+35
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Use a timing-consistent The sample transcript spans 1.2 seconds across three words, but 🤖 Prompt for AI Agents |
||
|
|
||
| ## Optional local transcription | ||
|
|
||
| Install faster-whisper separately, then let the script transcribe an audio or video file: | ||
|
|
||
| ```bash | ||
| pip install faster-whisper | ||
| python examples/auto-captions/auto_captions.py \ | ||
| --audio media/talk.mp4 \ | ||
| --model base \ | ||
| --output captions.json | ||
| ``` | ||
|
|
||
| The generated JSON is deliberately separate from `project.json`, so a user can review or transform the captions before applying them to a live editor. | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,106 @@ | ||||||||||||||||||||||
| #!/usr/bin/env python3 | ||||||||||||||||||||||
| """Create FableCut karaoke text clips from word-timestamped transcription JSON. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| The input format is intentionally engine-agnostic: either a JSON file containing | ||||||||||||||||||||||
| {"words": [{"word": "hello", "start": 0.0, "end": 0.4}, ...]} or a list of | ||||||||||||||||||||||
| those word objects. Use --transcript to provide an existing transcript, or | ||||||||||||||||||||||
| --audio with faster-whisper installed to transcribe locally. | ||||||||||||||||||||||
| """ | ||||||||||||||||||||||
| from __future__ import annotations | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| import argparse | ||||||||||||||||||||||
| import json | ||||||||||||||||||||||
| import subprocess | ||||||||||||||||||||||
| from pathlib import Path | ||||||||||||||||||||||
| from typing import Any | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| def load_words(path: Path) -> list[dict[str, Any]]: | ||||||||||||||||||||||
| data = json.loads(path.read_text(encoding="utf-8")) | ||||||||||||||||||||||
| words = data.get("words", data) if isinstance(data, dict) else data | ||||||||||||||||||||||
| if not isinstance(words, list): | ||||||||||||||||||||||
| raise ValueError("transcript must be a list or an object containing a 'words' list") | ||||||||||||||||||||||
| clean = [] | ||||||||||||||||||||||
| for item in words: | ||||||||||||||||||||||
| if not isinstance(item, dict) or not item.get("word"): | ||||||||||||||||||||||
| continue | ||||||||||||||||||||||
| start, end = float(item["start"]), float(item["end"]) | ||||||||||||||||||||||
| if end <= start: | ||||||||||||||||||||||
| raise ValueError(f"word has invalid interval: {item!r}") | ||||||||||||||||||||||
| clean.append({"word": str(item["word"]).strip(), "start": start, "end": end}) | ||||||||||||||||||||||
| return sorted(clean, key=lambda item: item["start"]) | ||||||||||||||||||||||
|
Comment on lines
+27
to
+31
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Reject non-finite timestamps.
Proposed fix+import math
+
...
for item in words:
if not isinstance(item, dict) or not item.get("word"):
continue
start, end = float(item["start"]), float(item["end"])
- if end <= start:
+ if not math.isfinite(start) or not math.isfinite(end) or end <= start:
raise ValueError(f"word has invalid interval: {item!r}")📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| def transcribe(audio: Path, model_name: str) -> list[dict[str, Any]]: | ||||||||||||||||||||||
| try: | ||||||||||||||||||||||
| from faster_whisper import WhisperModel | ||||||||||||||||||||||
| except ImportError as exc: | ||||||||||||||||||||||
| raise SystemExit("--audio requires faster-whisper; install it with: pip install faster-whisper") from exc | ||||||||||||||||||||||
| model = WhisperModel(model_name) | ||||||||||||||||||||||
| segments, _ = model.transcribe(str(audio), word_timestamps=True) | ||||||||||||||||||||||
| return [ | ||||||||||||||||||||||
| {"word": word.word.strip(), "start": word.start, "end": word.end} | ||||||||||||||||||||||
| for segment in segments | ||||||||||||||||||||||
| for word in (segment.words or []) | ||||||||||||||||||||||
| if word.word.strip() | ||||||||||||||||||||||
| ] | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| def group_words(words: list[dict[str, Any]], max_words: int, max_seconds: float) -> list[dict[str, Any]]: | ||||||||||||||||||||||
| lines = [] | ||||||||||||||||||||||
| current: list[dict[str, Any]] = [] | ||||||||||||||||||||||
| for word in words: | ||||||||||||||||||||||
| too_many = len(current) >= max_words | ||||||||||||||||||||||
| too_long = current and word["end"] - current[0]["start"] > max_seconds | ||||||||||||||||||||||
| if current and (too_many or too_long): | ||||||||||||||||||||||
| lines.append(current) | ||||||||||||||||||||||
| current = [] | ||||||||||||||||||||||
| current.append(word) | ||||||||||||||||||||||
| if current: | ||||||||||||||||||||||
| lines.append(current) | ||||||||||||||||||||||
| return [ | ||||||||||||||||||||||
| { | ||||||||||||||||||||||
| "text": " ".join(word["word"] for word in line), | ||||||||||||||||||||||
| "start": line[0]["start"], | ||||||||||||||||||||||
| "duration": max(line[-1]["end"] - line[0]["start"], 0.01), | ||||||||||||||||||||||
| "wordRate": max((word["end"] - word["start"] for word in line), default=0.15), | ||||||||||||||||||||||
|
Comment on lines
+63
to
+66
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the karaoke renderer and tests to confirm the `wordRate` unit.
rg -n -C 5 \
-g '*.ts' -g '*.tsx' -g '*.js' -g '*.jsx' -g '*.json' \
'\bwordRate\b|["'\'']karaoke["'\'']' .Repository: ronak-create/FableCut Length of output: 7120 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- auto_captions.py structure ---'
ast-grep outline examples/auto-captions/auto_captions.py || true
printf '%s\n' '--- caption construction ---'
cat -n examples/auto-captions/auto_captions.py | sed -n '1,120p'
printf '%s\n' '--- renderer timing logic ---'
cat -n app.js | sed -n '4750,4770p;4890,4905p'
rg -n -C 8 'wordRate|textAnim === "karaoke"|textAnim == "karaoke"|karaoke' app.js mcp-server.jsRepository: ronak-create/FableCut Length of output: 18181 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- auto_captions.py structure ---'
ast-grep outline examples/auto-captions/auto_captions.py || true
printf '%s\n' '--- caption construction ---'
cat -n examples/auto-captions/auto_captions.py | sed -n '1,120p'
printf '%s\n' '--- renderer timing logic ---'
cat -n app.js | sed -n '4750,4770p;4890,4905p'
rg -n -C 8 'wordRate|textAnim === "karaoke"|textAnim == "karaoke"|karaoke' app.js mcp-server.jsRepository: ronak-create/FableCut Length of output: 18181 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- per-word animation calculation ---'
cat -n app.js | sed -n '5040,5130p'
printf '%s\n' '--- deterministic grouping probe ---'
python3 - <<'PY'
import ast
from pathlib import Path
source = Path("examples/auto-captions/auto_captions.py").read_text()
tree = ast.parse(source)
ns = {}
exec(compile(ast.Module(body=[n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.Import, ast.ImportFrom))], type_ignores=[]), "<probe>", "exec"), ns)
cases = {
"overlap": [
{"word": "one", "start": 0.0, "end": 0.4},
{"word": "two", "start": 0.2, "end": 1.4},
{"word": "three", "start": 0.3, "end": 0.8},
],
"gaps": [
{"word": "one", "start": 0.0, "end": 0.1},
{"word": "two", "start": 0.9, "end": 1.0},
{"word": "three", "start": 1.1, "end": 1.2},
],
}
for name, words in cases.items():
result = ns["group_words"](words, max_words=10, max_seconds=10.0)
print(name, result)
line = words
span_end = max(w["end"] for w in line)
span = span_end - line[0]["start"]
print("expected_span_end", span_end, "expected_span", span,
"expected_rate_span_per_word", span / len(line))
PYRepository: ronak-create/FableCut Length of output: 4889 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- per-word animation calculation ---'
cat -n app.js | sed -n '5040,5130p'
printf '%s\n' '--- deterministic grouping probe ---'
python3 - <<'PY'
import ast
from pathlib import Path
source = Path("examples/auto-captions/auto_captions.py").read_text()
tree = ast.parse(source)
ns = {}
exec(compile(ast.Module(
body=[n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.Import, ast.ImportFrom))],
type_ignores=[],
), "<probe>", "exec"), ns)
cases = {
"overlap": [
{"word": "one", "start": 0.0, "end": 0.4},
{"word": "two", "start": 0.2, "end": 1.4},
{"word": "three", "start": 0.3, "end": 0.8},
],
"gaps": [
{"word": "one", "start": 0.0, "end": 0.1},
{"word": "two", "start": 0.9, "end": 1.0},
{"word": "three", "start": 1.1, "end": 1.2},
],
}
for name, words in cases.items():
result = ns["group_words"](words, max_words=10, max_seconds=10.0)
print(name, result)
span_end = max(w["end"] for w in words)
span = span_end - words[0]["start"]
print(
"expected_span_end", span_end,
"expected_span", span,
"expected_rate_span_per_word", span / len(words),
)
PYRepository: ronak-create/FableCut Length of output: 4889 Derive timing from the complete caption span. Use the latest word end for 🤖 Prompt for AI Agents |
||||||||||||||||||||||
| } | ||||||||||||||||||||||
| for line in lines | ||||||||||||||||||||||
| ] | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| def main() -> None: | ||||||||||||||||||||||
| parser = argparse.ArgumentParser(description=__doc__) | ||||||||||||||||||||||
| source = parser.add_mutually_exclusive_group(required=True) | ||||||||||||||||||||||
| source.add_argument("--transcript", type=Path, help="word-timestamp JSON file") | ||||||||||||||||||||||
| source.add_argument("--audio", type=Path, help="audio/video file; requires faster-whisper") | ||||||||||||||||||||||
| parser.add_argument("--model", default="base", help="faster-whisper model for --audio") | ||||||||||||||||||||||
| parser.add_argument("--output", type=Path, default=Path("project.captions.json")) | ||||||||||||||||||||||
| parser.add_argument("--max-words", type=int, default=4) | ||||||||||||||||||||||
| parser.add_argument("--max-seconds", type=float, default=1.8) | ||||||||||||||||||||||
| args = parser.parse_args() | ||||||||||||||||||||||
|
Comment on lines
+79
to
+81
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Reject invalid grouping limits.
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| words = load_words(args.transcript) if args.transcript else transcribe(args.audio, args.model) | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Apply the same normalization to The 🤖 Prompt for AI Agents |
||||||||||||||||||||||
| clips = [] | ||||||||||||||||||||||
| for index, line in enumerate(group_words(words, args.max_words, args.max_seconds), 1): | ||||||||||||||||||||||
| clips.append({ | ||||||||||||||||||||||
| "id": f"caption_{index:04d}", | ||||||||||||||||||||||
| "kind": "text", | ||||||||||||||||||||||
| "track": "V3", | ||||||||||||||||||||||
| "start": line["start"], | ||||||||||||||||||||||
| "duration": line["duration"], | ||||||||||||||||||||||
| "props": { | ||||||||||||||||||||||
| "text": line["text"], | ||||||||||||||||||||||
| "textAnim": "karaoke", | ||||||||||||||||||||||
| "wordRate": line["wordRate"], | ||||||||||||||||||||||
| "fontSize": 72, | ||||||||||||||||||||||
| "bold": True, | ||||||||||||||||||||||
| "color": "#ffffff", | ||||||||||||||||||||||
| "textShadow": 12, | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| }) | ||||||||||||||||||||||
| args.output.write_text(json.dumps({"clips": clips}, indent=2) + "\n", encoding="utf-8") | ||||||||||||||||||||||
| print(f"Wrote {len(clips)} caption clips to {args.output}") | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if __name__ == "__main__": | ||||||||||||||||||||||
| main() | ||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Document the
clipstoopsconversion for MCP.The generated artifact contains a top-level
clipsarray, butmcp-server.jsapplies oneaddClipoperation at a time throughops[].clip. The example shows only one hand-written operation and does not explain how to apply all generated clips. Add a short transformation example or state that each generated clip must become oneaddClipoperation.🤖 Prompt for AI Agents