Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions examples/auto-captions/README.md
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}}}]}
```
Comment on lines +29 to +33

Copy link
Copy Markdown

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 clips to ops conversion for MCP.

The generated artifact contains a top-level clips array, but mcp-server.js applies one addClip operation at a time through ops[].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 one addClip operation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/auto-captions/README.md` around lines 29 - 33, The README example
should explain how the top-level clips array maps to MCP operations: each
generated clip must become its own addClip operation in ops[].clip when using
fablecut_patch_project. Update the example or surrounding text to show or
clearly state this one-to-one transformation while preserving the existing
project update guidance.


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use a timing-consistent wordRate example.

The sample transcript spans 1.2 seconds across three words, but wordRate: 0.2 does not match the documented seconds-per-word timing. Use the generated value, or set this illustrative example to approximately 0.4 seconds per word.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/auto-captions/README.md` around lines 31 - 35, Update the example
addClip payload in the README so its wordRate matches the transcript timing: use
the generated value or approximately 0.4 seconds per word for three words
spanning 1.2 seconds. Leave the surrounding caption-grouping documentation
unchanged.


## 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.
106 changes: 106 additions & 0 deletions examples/auto-captions/auto_captions.py
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject non-finite timestamps.

float("nan") and float("inf") pass the current interval check. The generated artifact can then contain NaN or Infinity, which project JSON consumers can reject. Validate that both timestamps are finite before adding the word.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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"])
start, end = float(item["start"]), float(item["end"])
if not math.isfinite(start) or not math.isfinite(end) or 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"])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/auto-captions/auto_captions.py` around lines 27 - 31, Update the
timestamp validation in the word-cleaning function before appending to clean:
reject any start or end value that is not finite, in addition to the existing
end <= start check. Use the standard finite-number validation and preserve the
current ValueError behavior for invalid intervals.



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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.js

Repository: 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.js

Repository: 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))
PY

Repository: 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),
    )
PY

Repository: ronak-create/FableCut

Length of output: 4889


Derive timing from the complete caption span.

Use the latest word end for duration, not line[-1]["end"]. The renderer treats wordRate as the seconds between word entrances. Derive it from the full caption span and word count, such as span / len(line), so overlaps and gaps affect karaoke timing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/auto-captions/auto_captions.py` around lines 63 - 66, Update the
caption timing fields in the line-building logic: compute duration from the
latest word end across the complete line rather than relying on line[-1]["end"],
and derive wordRate from the full caption span divided by len(line), preserving
the existing minimum duration/rate safeguards while allowing gaps and overlaps
to influence karaoke timing.

}
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject invalid grouping limits.

--max-words 0 or a negative value still creates one-word captions. Non-positive, non-finite --max-seconds values also produce misleading grouping behavior. Validate both options after parsing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/auto-captions/auto_captions.py` around lines 79 - 81, Validate the
parsed max_words and max_seconds options immediately after args.parse_args() in
the argument-handling flow: require max_words to be a positive integer and
max_seconds to be finite and greater than zero, rejecting invalid values before
caption grouping begins.

words = load_words(args.transcript) if args.transcript else transcribe(args.audio, args.model)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Apply the same normalization to --audio input.

The --audio path bypasses the sorting and interval validation in load_words(). Extract a shared word-normalization helper and apply it to both transcript and faster-whisper output before grouping captions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/auto-captions/auto_captions.py` at line 82, Extract the sorting and
interval-validation logic from load_words into a shared word-normalization
helper, then apply that helper to both load_words transcript results and
transcribe(args.audio, args.model) output before caption grouping. Preserve the
existing normalized behavior for transcript input and ensure audio-derived words
follow the same ordering and validation.

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()
Loading