Skip to content

feat: add word-timestamp auto-caption example - #53

Merged
ronak-create merged 1 commit into
ronak-create:mainfrom
madebysaira:feat/auto-captions-example
Aug 15, 2026
Merged

feat: add word-timestamp auto-caption example#53
ronak-create merged 1 commit into
ronak-create:mainfrom
madebysaira:feat/auto-captions-example

Conversation

@madebysaira

@madebysaira madebysaira commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Closes #4

What changed

This PR adds the requested examples/auto-captions example without changing the editor itself. It converts engine-agnostic word timestamps into FableCut kind:"text" clips using the built-in karaoke text animation.

Implementation

  1. auto_captions.py accepts either a JSON transcript containing a words array or a bare word list.
  2. It validates and sorts word intervals, groups captions by configurable word and duration limits, and derives wordRate from observed speech timing.
  3. It optionally supports local transcription through faster-whisper, while keeping the default path independent of any STT engine.
  4. README.md documents the transcript format, CLI usage, MCP patch workflow, REST workflow, and optional local transcription.

Verification

  • Ran the script against a fixture containing eight word timestamps.
  • Confirmed it emitted two caption clips with valid timing, textAnim: "karaoke", and the expected grouped text.
  • Ran git diff --check successfully.

Scope

The example deliberately emits a reviewable captions.json artifact instead of silently overwriting project.json. This keeps it safe for live editor sessions and lets users inspect or transform captions before applying them.

Known limitations

The sample does not bundle an STT model or audio dependency. Users may provide timestamps from any engine, or install faster-whisper for the optional local path.

Summary by CodeRabbit

  • New Features

    • Added a command-line tool for generating karaoke-style caption clips from word-timestamped transcripts.
    • Supports optional local audio/video transcription with faster-whisper.
    • Groups captions by word count and display duration, validates transcript data, and exports clip metadata as JSON.
  • Documentation

    • Added setup and usage guidance, including transcript formatting, caption defaults, project integration, and local transcription options.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Added an auto-captions CLI example. It loads word-timestamped JSON or transcribes media with faster-whisper, groups words into timed captions, creates karaoke text clips on track V3, and writes JSON output. The README documents setup and usage.

Changes

Auto-captions workflow

Layer / File(s) Summary
Timestamp input and transcription
examples/auto-captions/auto_captions.py
The CLI validates and sorts transcript words. It can also obtain word timestamps through faster-whisper.
Caption grouping and JSON generation
examples/auto-captions/auto_captions.py
The CLI groups words by count and duration, creates karaoke caption clips on track V3, and writes JSON output.
Example usage documentation
examples/auto-captions/README.md
The README documents transcript input, caption generation, project integration, grouping options, and optional faster-whisper transcription.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Operator
  participant AutoCaptions as auto_captions.py
  participant Transcript as Transcript JSON or faster-whisper
  participant Output as Caption JSON
  Operator->>AutoCaptions: provide transcript or media and options
  AutoCaptions->>Transcript: load or transcribe word timestamps
  Transcript-->>AutoCaptions: return normalized words
  AutoCaptions->>AutoCaptions: group words and create karaoke clips
  AutoCaptions->>Output: write timed clips on track V3
  Output-->>Operator: report generated caption JSON
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR implements timestamp parsing, grouping, V3 karaoke clips, speech-rate timing, and workflow docs, but it does not write captions into project.json as required by issue #4. Add an option to update project.json or the open editor through REST/MCP, while retaining captions.json as an optional review artifact.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the new word-timestamp auto-caption example, which is the primary change.
Out of Scope Changes check ✅ Passed The README and CLI changes are directly related to the linked issue and PR objectives; no unrelated code changes are described.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Prompt for all review comments with 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.

Inline comments:
In `@examples/auto-captions/auto_captions.py`:
- Around line 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.
- 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.
- Around line 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.
- Around line 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.

In `@examples/auto-captions/README.md`:
- Around line 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.
- Around line 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.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1774224f-601f-4b8d-aa5a-db8db8768337

📥 Commits

Reviewing files that changed from the base of the PR and between 5ecbf00 and 70a9c60.

📒 Files selected for processing (2)
  • examples/auto-captions/README.md
  • examples/auto-captions/auto_captions.py

Comment on lines +27 to +31
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"])

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.

Comment on lines +63 to +66
"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),

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.

Comment on lines +79 to +81
parser.add_argument("--max-words", type=int, default=4)
parser.add_argument("--max-seconds", type=float, default=1.8)
args = parser.parse_args()

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.

parser.add_argument("--max-words", type=int, default=4)
parser.add_argument("--max-seconds", type=float, default=1.8)
args = parser.parse_args()
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.

Comment on lines +29 to +33
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}}}]}
```

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.

Comment on lines +31 to +35
```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`.

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.

@ronak-create
ronak-create merged commit 84829bd into ronak-create:main Aug 15, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

examples/auto-captions: STT word timestamps -> karaoke text clips

2 participants