diff --git a/.claude/skills/captions/SKILL.md b/.claude/skills/captions/SKILL.md new file mode 100644 index 0000000000..bec4f61bd1 --- /dev/null +++ b/.claude/skills/captions/SKILL.md @@ -0,0 +1,232 @@ +--- +name: captions +description: Build timed caption compositions from whisper.cpp transcripts in HyperFrames. Use when adding captions, subtitles, or timed text to a composition that has a transcript.json file. +--- + +# Captions + +Build HyperFrames caption compositions from whisper.cpp transcript data. + +## When to Use + +- Project has a `transcript.json` (output from whisper.cpp `--output-json`) +- User asks to "add captions", "add subtitles", or "show the words" +- Editing an existing captions composition + +## Whisper.cpp Transcript Format + +The transcript file is whisper.cpp's native JSON output. Word-level timestamps are in the `tokens` array within each segment: + +```json +{ + "transcription": [ + { + "timestamps": { "from": "00:00:00,000", "to": "00:00:05,000" }, + "offsets": { "from": 0, "to": 5000 }, + "text": " Hello world.", + "tokens": [ + { + "text": " Hello", + "offsets": { "from": 0, "to": 1000 }, + "p": 0.98 + }, + { + "text": " world", + "offsets": { "from": 1000, "to": 2000 }, + "p": 0.95 + } + ] + } + ] +} +``` + +Key fields per token: + +- `text` — word text (often has leading space for word-initial tokens) +- `offsets.from` / `offsets.to` — milliseconds +- `p` — confidence (0-1) + +### Normalizing to a Word Array + +Flatten the transcript into a simple word array for grouping: + +```js +const words = []; +for (const segment of transcript.transcription) { + for (const token of segment.tokens || []) { + const text = token.text.trim(); + if (!text) continue; + words.push({ + text: text, + start: token.offsets.from / 1000, + end: token.offsets.to / 1000, + }); + } +} +``` + +## Word Grouping + +Group words into caption lines. Never show more than 5 words at once. Break on: + +1. **Sentence boundaries** — `.` `?` `!` +2. **Pauses** — gap > 150ms between consecutive words +3. **Max word count** — 3-5 words per group (3 for portrait, 5 for landscape) + +```js +function groupTranscript(words, maxWords) { + const groups = []; + let current = []; + + words.forEach((word, i) => { + current.push(word); + + const next = words[i + 1]; + const isPause = next && next.start - word.end > 0.15; + const isSentenceEnd = /[.?!]/.test(word.text); + + if (current.length >= maxWords || isPause || isSentenceEnd || !next) { + groups.push({ + text: current.map((w) => w.text).join(" "), + start: current[0].start, + end: current[current.length - 1].end, + }); + current = []; + } + }); + return groups; +} +``` + +## Composition Structure + +Caption compositions follow the standard HyperFrames composition format: + +```html + +``` + +## Positioning + +- **Landscape (1920x1080):** Bottom third — `bottom: 80-120px`, centered horizontally +- **Portrait (1080x1920):** Lower middle — `bottom: 600-700px`, centered horizontally +- Use `position: absolute` on caption groups, stacked in the same container +- Set `pointer-events: none` on the composition so captions don't block clicks + +## Animation Patterns + +### Fade + slide (clean, modern) + +```js +tl.fromTo(el, { opacity: 0, y: 20 }, { opacity: 1, y: 0, duration: 0.4, ease: "expo.out" }, start); +tl.to(el, { opacity: 0, y: -10, duration: 0.3, ease: "expo.out" }, end - 0.3); +``` + +### Pop-in bounce (playful) + +```js +tl.to(el, { autoAlpha: 1, scale: 1.1, duration: 0.2, ease: "power2.out" }, start); +tl.to(el, { scale: 1, duration: 0.1, ease: "power2.inOut" }, start + 0.2); +tl.to(el, { autoAlpha: 0, scale: 0, duration: 0.15, ease: "power2.in" }, end); +``` + +### Hard cut (no animation) + +```js +tl.set(el, { display: "flex" }, start); +tl.set(el, { display: "none" }, end); +``` + +## Timing Rules + +- **Entrance animation must complete before exit starts.** If a group is shorter than entrance + exit duration, reduce animation durations. +- **Exit before next group enters.** Use `Math.min(group.end, nextGroup.start)` as the hide time. +- **Minimum display time:** 0.3s — skip groups shorter than this. +- **No overlapping caption groups.** Only one group visible at a time. + +## Constraints + +- **Deterministic.** No `Math.random()`, no `Date.now()`. +- **Register timeline.** `window.__timelines["captions"] = tl;` +- **IIFE scope.** Wrap script in `(function() { ... })()` to avoid globals. +- **Scoped CSS.** Prefix all selectors with `[data-composition-id="captions"]`. +- **Set `data-duration`.** Must match or exceed the last caption's end time. +- **Do not animate `visibility` or `display` on timed clips.** Use `opacity` or `autoAlpha`. +- **Do not use `tl.set()` for property changes that need to survive timeline seeking.** Use `tl.to()` with a short duration instead. + +## Loading in the Root Composition + +```html +
+``` diff --git a/.claude/skills/gsap b/.claude/skills/gsap new file mode 160000 index 0000000000..03d9f0c3db --- /dev/null +++ b/.claude/skills/gsap @@ -0,0 +1 @@ +Subproject commit 03d9f0c3dbf91e0b60582b64ed040c8911ca0174 diff --git a/.claude/skills/media/SKILL.md b/.claude/skills/media/SKILL.md new file mode 100644 index 0000000000..d25641fb36 --- /dev/null +++ b/.claude/skills/media/SKILL.md @@ -0,0 +1,261 @@ +--- +name: media +description: Video, audio, and image handling constraints for HyperFrames. Use when adding media elements to a composition or troubleshooting media playback. +--- + +# Media + +Constraints for working with video, audio, and images in HyperFrames compositions. + +## When to Use + +- Adding video, audio, or image clips to a composition +- Troubleshooting media that won't play or render +- Working with transparent video, trimming, or volume control + +## Video + +### Required Attributes + +Every `