Repository navigation
feat(studio): caption designer - #180
vanceingalls wants to merge 55 commits into
Conversation
ae15b07 to
85a11ea
Compare
b0bcadd to
a0045a6
Compare
47ee56a to
b025994
Compare
a0045a6 to
43d2277
Compare
43d2277 to
d3b3157
Compare
b025994 to
5486325
Compare
d3b3157 to
2442698
Compare
…multilingual defaults (#175) ## Summary **Whisper improvements:** - Auto-detect language and switch from `.en` to multilingual model when needed - Detect speech onset in WAV to strip hallucinated words before speech begins - Merge whisper-cpp token fragments: contractions (`didn` + `'t` → `didn't`), split capitals (`C` + `aught` → `Caught`), dropped-g (`shin` + `in'` → `shinin'`) - Interpolate zero-duration word clusters for reliable karaoke timing **Captions skill updates (folded from #176):** - Rewrite script-to-style mapping as an energy detection table (high → low) with mandatory animation requirements: karaoke baseline, 2+ highlight techniques, kinetic exits - Replace `tl.call()` per-frame audio-reactive pattern with group-level GSAP tweens — read peak bass/treble for each group's time range and modulate entrance intensity at build time - Add transcript quality check with automatic retry rules (>20% music tokens = retry with larger model) - Add caption word structure lint rule (`.caption-group` + `<span>`) for studio editor compatibility **Multilingual defaults (folded from #186):** - Default whisper model changed from `small.en` to `small` to prevent silent translation of non-English audio - Added non-negotiable language rule to captions skill ## Test plan - [ ] `pnpm test` passes (contraction merging, fragment merging, zero-duration interpolation, speech onset) - [ ] Transcribe non-English audio — verify it transcribes in original language, not translates - [ ] Skill files render correctly, cross-references resolve - [ ] `dynamic-techniques.md` audio-reactive section uses `tl.to()`/`tl.set()` only, no `tl.call()` loops 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Remove custom skill installation from the CLI in favor of vercel-labs/skills. Fixes staleness, deduplication, listing budget, and completeness issues identified by agentic harness pattern review. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6 tasks: delete install-skills, strip skills from init, front-load SKILL.md descriptions, update CLAUDE.md, clean up my-video copies. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove installSkills(), getBundledSkillsDir(), project-level skill copying, and the --skip-skills flag. Replace post-scaffold messaging with directions to run `npx skills add heygen-com/hyperframes`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Also fix .claude/settings.json pre-commit hook to use bun instead of pnpm. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Vite dev server was loading the HyperFrames runtime IIFE from cdn.jsdelivr.net. This caused compositions using window.__hyperframes (e.g., fitTextFontSize) to fail when the CDN script didn't load in the iframe context. Add a /api/runtime.js endpoint to the Vite plugin that serves the local built runtime, matching what studioServer.ts already does for the CLI embedded server. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Caption overlay and hover states always active on captions compositions - Clicking a word auto-opens the property panel - Remove Edit Captions button — edit mode activates automatically - Reset edit mode when navigating away from captions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Caption edit mode now activates automatically when .caption-group elements are found in the iframe DOM. Works from index.html (master) without requiring drill-down into the captions sub-composition. Finds the captions source file via data-composition-src attribute. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ding Replace polling with a message listener for hf-preview state/timeline events. These fire after loadExternalCompositions completes, guaranteeing .caption-group elements exist in the DOM. Eliminates the race condition on initial page load. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove store reset from useEffect cleanup — was wiping state on dep changes and breaking auto-save subscription - Move panel auto-open into useEffect instead of during render - Add fallback 500ms poll alongside postMessage listener for initial load Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Runtime hook's wrapper was missing data-caption-wrapper attribute, so the overlay created a second wrapper on top. Transforms were applied to different wrappers causing values to compound on reload. Also: - Suppress auto-save during loadOverrides to prevent immediate re-write - Only sync non-default transform values to avoid spurious overrides Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Reduce poll interval from 500ms to 200ms for faster detection - Fall back to DOM querySelector for iframe when ref isn't set yet - Runtime wrapper sets data-caption-wrapper for consistent detection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The runtime strips data-composition-src after loading, so the DOM query couldn't find the captions file path. Now uses the compIdToSrc map (built from raw index.html) as the primary lookup. Also adds compIdToSrc to the effect dependency array so it re-runs when the map is populated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Panel rotation/scale/position changes now go through the wrapper span using gsap.set, reading current values first to preserve other properties. Previously set el.style.transform directly on the word span, overwriting all existing transforms. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Delete dead useCaptionDrag.ts (superseded by overlay inline drag) - Self-cancel 200ms poll after caption activation - Remove stale effectiveStyle deps from applyToIframeDom useCallback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Replace doc!/win!/captionSrcPath! with guards in App.tsx async callback - Move useCallback hooks above early return in CaptionAnimationPanel (React hooks must be called unconditionally in the same order) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…erty panel - Replace filter(Boolean) + ! assertions with typed filter in generator.ts - Hoist repeated seg.id.replace() to const segVar - Remove unused ToggleButton, ColorInput, container/typography derived values, applyContainerToIframeDom, handleContainerChange, buildTextDecoration - Remove CaptionContainerStyle import Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add change detection to 66ms overlay poll (skip setWordBoxes when unchanged) - Add activating guard to prevent duplicate caption source fetches - Remove applyOpacityToColor wrapper (inline hexToRgba) - Rename rawWords back to words in normalize.ts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The single-quote replacement regex ran after key-quoting, causing it to match apostrophes inside already-double-quoted strings like "didn't". Moving it before key-quoting fixes the issue since it only matches actual single-quoted string values at that point. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2442698 to
2197f18
Compare
There was a problem hiding this comment.
can we remove this worktrees folder?
There was a problem hiding this comment.
Fixed — removed in this branch.
| // Listens for the runtime's postMessage events (state/timeline) which fire after | ||
| // all compositions are loaded, then checks for caption groups. | ||
| // eslint-disable-next-line no-restricted-syntax | ||
| useEffect(() => { |
There was a problem hiding this comment.
I want to nuke useEffect when possible, can we follow this? https://x.com/alvinsng/status/2033969062834045089
There was a problem hiding this comment.
Agreed — deferring to a separate refactor since it's a larger change. Noted for follow-up.
There was a problem hiding this comment.
Partially addressed in #200 — removed the polling interval and added a clarifying comment explaining why useEffect is appropriate here (external event subscription via postMessage). Full refactor to event-driven deferred to a separate PR.
miguel-heygen
left a comment
There was a problem hiding this comment.
Code Review: feat(studio): caption designer
Overall: Well-structured feature — clean type system, solid parser/generator test coverage (52 tests passing), correct hooks-before-returns discipline, and the wrapper-span strategy for preserving GSAP animations is architecturally sound. A few issues need attention before merge.
Summary of findings
| Priority | Count |
|---|---|
| Critical (must fix) | 2 |
| Important (should fix) | 5 |
| Suggestions | 4 |
See inline comments for details.
| function hexToRgba(color: string, opacity: number): string { | ||
| // If it's already rgb/rgba, just return it (can't easily inject opacity) | ||
| if (color.startsWith("rgb")) { | ||
| return color; |
There was a problem hiding this comment.
Critical: hexToRgba produces NaN for CSS named colors. When color = "red" (no # prefix), hex = "red" has length 3, so it enters the short-hex branch and tries to parse "rr", "ee", "dd" as hex → parseInt("rr", 16) = NaN → outputs broken CSS like rgba(NaN, 238, 221, 0.5).
This matters because DEFAULT_CONTAINER.backgroundColor is "transparent" (named color), and computed styles from parseCaptionComposition could return named colors.
Fix: guard non-hex, non-rgb input early:
if (!color.startsWith("#") && !color.startsWith("rgb")) return color;There was a problem hiding this comment.
Fixed in #200 — added early return for non-hex, non-rgb inputs before the parsing branch.
| `/api/projects/${pid}/files/${encodeURIComponent("caption-overrides.json")}`, | ||
| ); | ||
| const data = await res.json(); | ||
| if (!data.content) return; |
There was a problem hiding this comment.
Critical: res.json() is called without checking res.ok. When caption-overrides.json doesn't exist, the server returns 404. Calling .json() on a 404 response may return an HTML error page, causing a parse error that gets swallowed by the catch block.
Add a guard:
if (!res.ok) return;
const data = await res.json();There was a problem hiding this comment.
Fixed in #200 — added if (!res.ok) return; before .json().
| `/api/projects/${pid}/files/${encodeURIComponent("caption-overrides.json")}`, | ||
| { method: "PUT", headers: { "Content-Type": "text/plain" }, body: JSON.stringify(overrides, null, 2) }, | ||
| ).catch(() => {}); | ||
| }, []); |
There was a problem hiding this comment.
Important: The .catch(() => {}) silently swallows all save errors — network failures, 500s, permission errors. The user could edit for an extended session believing changes are persisted, only to lose all work.
At minimum log to console, or set a store flag to surface save failure in the UI:
.catch((err) => console.warn("[captions] auto-save failed:", err));There was a problem hiding this comment.
Fixed in #200 — now logs with console.warn("[captions] auto-save failed:", err).
| // --------------------------------------------------------------------------- | ||
|
|
||
| function Section({ label, children }: { label: string; children: React.ReactNode }) { | ||
| return ( |
There was a problem hiding this comment.
Important: Section, Row, and inputCls are duplicated verbatim in CaptionPropertyPanel.tsx (the comment on line 68 even acknowledges it: "matches CaptionPropertyPanel"). Extract these into captions/components/shared.tsx to avoid drift.
There was a problem hiding this comment.
Fixed in #200 — extracted Section, Row, and inputCls into captions/components/shared.tsx. Both panels import from there now.
| const secondIds = group.segmentIds.slice(splitIndex); | ||
|
|
||
| const newGroupId = `group-${Date.now()}-${Math.random().toString(36).slice(2)}`; | ||
| const groups = new Map(state.model.groups); |
There was a problem hiding this comment.
Important: Non-deterministic ID generation makes this untestable without mocking globals. Consider a counter-based approach or injectable ID generator:
let nextGroupId = 0;
const newGroupId = `group-split-${nextGroupId++}`;There was a problem hiding this comment.
Fixed in #200 — replaced with counter-based group-split-${nextSplitId++}.
| for (let i = 0; i < colorTweens.length; i++) { | ||
| if (i === 0 && override.dimColor) { | ||
| // First color tween = dim setup | ||
| colorTweens[i].vars.color = override.dimColor; |
There was a problem hiding this comment.
Important: Assigning color overrides by tween timeline position (first=dim, second=active, third+=dim) assumes a specific tween structure. If a composition has additional color tweens (hover effects, custom presets), the wrong tweens get overridden. Consider matching by GSAP timeline labels or tween vars content rather than position order.
There was a problem hiding this comment.
Valid concern — deferring this one. The position-based matching works for the standard caption template but would break with custom color tweens. Will revisit when we add timeline labels to the generator.
There was a problem hiding this comment.
Fixed in #200 — color tweens are now classified by comparing their target color to the dim baseline (first tween's color) instead of relying on position order. Tweens matching the baseline get dimColor, others get activeColor. Safe with custom color tweens.
| const overlayRect = overlayEl.getBoundingClientRect(); | ||
| const nativeW = parseFloat(iframe.style.width) || iframeDisplayRect.width; | ||
| const cssScale = iframeDisplayRect.width / nativeW; | ||
| const offsetX = iframeDisplayRect.left - overlayRect.left; |
There was a problem hiding this comment.
Important: The coordinate math here looks off. wordEl.getBoundingClientRect() returns values in the iframe's viewport coordinate system. Multiplying by cssScale would double-apply the scale when the iframe is CSS-scaled (the browser already accounts for transforms in getBoundingClientRect). The correct approach would be to only apply the offset without the scale multiplier, or verify the behavior when the iframe is rendered at a different size than its native width.
// Current (potentially wrong):
x: rect.left * cssScale + offsetX,
// Expected:
x: rect.left + offsetX,Can you verify this with a scaled iframe?
There was a problem hiding this comment.
Fixed in #200 — removed the cssScale multiplier. getBoundingClientRect already accounts for CSS transforms on the iframe.
There was a problem hiding this comment.
Update: the earlier removal of cssScale was wrong — getBoundingClientRect() inside the iframe returns native-resolution coords (1920x1080), not CSS-scaled display coords. Restored the multiplier in #200. Verified it aligns correctly at multiple viewport sizes.
| // The array may span multiple lines and contain trailing commas. | ||
| const varPattern = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*(\[[\s\S]*?\]);/; | ||
| const match = source.match(varPattern); | ||
|
|
There was a problem hiding this comment.
Suggestion: The lazy [\s\S]*? in this regex anchors on the first ]; after the opening bracket. If a transcript word's text ever contains a literal ]; string, the regex would truncate early. Extremely unlikely in practice but worth a comment explaining the assumption.
There was a problem hiding this comment.
Fixed in #200 — added a comment explaining the assumption.
|
|
||
| // Clear selection if it referenced group2 | ||
| const selectedGroupId = get().selectedGroupId === groupId2 ? null : get().selectedGroupId; | ||
|
|
There was a problem hiding this comment.
Suggestion: Reading get().selectedGroupId inside set() can return stale state if multiple set() calls are batched. Use the state parameter from the set() callback instead:
const selectedGroupId = state.selectedGroupId === groupId2 ? null : state.selectedGroupId;There was a problem hiding this comment.
Fixed in #200 — now reads from the state parameter instead of get(). Also removed the unused get from the store signature.
miguel-heygen
left a comment
There was a problem hiding this comment.
Browser Testing Findings
Tested the caption designer in-browser via agent-browser automation. Screenshots saved to /screenshots/01-*.png through 19-*.png.
What works well: Caption edit mode auto-detection, timeline word blocks with correct positioning and group coloring, animation panel presets all functional, right panel auto-expand/collapse on selection.
See inline comments for the bugs found.
| } else if (c.tagName === "SPAN") { | ||
| resolvedWordEls.push(c); | ||
| } | ||
| } |
There was a problem hiding this comment.
Critical (browser testing): The overlay word boxes never appear. Tested in-browser with a real captions composition — readWordBoxes() looks for <span> children inside .caption-group elements, but the standard caption template uses el.textContent = line.text which creates a single text node, not individual <span>s.
The overlay renders an empty <div class="absolute inset-0 z-50"> with zero children. Move/scale/rotate handles are completely invisible — the core canvas interaction of the caption designer is non-functional.
Root cause: Mismatch between what the overlay expects (individual word <span>s) and what the generated template produces (grouped text nodes). Lines 57-66 filter for <span> and [data-caption-wrapper] but find none.
This is a ship-blocker — the headline feature of the PR doesn't work end-to-end.
There was a problem hiding this comment.
This is a real issue — the overlay expects individual word <span>s but the generated template uses grouped text nodes. Deferring to a follow-up since the fix requires changes to either the generator or the overlay's DOM traversal. Filed for tracking.
There was a problem hiding this comment.
Fixed in #200 — the overlay now handles both per-word <span>s (generator output) and grouped text nodes (existing templates like vignelli, play-mode). When no word spans are found, it splits text nodes into individual spans on demand so the overlay can target them.
| </button> | ||
| </div> | ||
| </div> | ||
|
|
There was a problem hiding this comment.
Medium (browser testing): The Style tab only has Position (X, Y) and Transform (Scale, Rotation). The comment here honestly says Style tab — Transform only, but the PR description promises "typography, color, transform, and animation controls." No font size, font weight, font family, color, text-shadow, or letter-spacing controls are present.
Either add them or update the PR description to reflect what's actually shipped.
There was a problem hiding this comment.
Acknowledged — the Style tab ships with Position + Transform only for now. Typography and color controls are follow-up work.
There was a problem hiding this comment.
Updated in #200 — removed the Animation tab entirely and stripped Typography/Color sections. Panel now shows only Position (X, Y) and Transform (Scale, Rotation). No tabs since there's only one view.
| const seg = model.segments.get(segId); | ||
| if (!seg) return null; | ||
|
|
||
| const left = 32 + seg.start * pixelsPerSecond; |
There was a problem hiding this comment.
High (browser testing): The caption timeline renders below the viewport fold. At 1080p/1200p, the timelineFooter section's y-position equals or exceeds the viewport height, making it invisible. Users would never discover this feature without manually scrolling the page body (which may not work if parent containers have overflow: hidden).
Needs explicit height allocation or integration into the existing timeline area.
There was a problem hiding this comment.
Valid — the caption timeline needs explicit height allocation or integration into the existing timeline area. Deferring to a layout follow-up.
There was a problem hiding this comment.
Fixed in #200 — caption timeline is now in a dedicated flex-shrink-0 section (60px) below the main timeline tracks, outside the scrollable area. Always visible when captions are active.
Fixes from review comments on feature/caption-designer (#180): - fix(generator): guard named colors in hexToRgba — "red", "transparent" no longer produce NaN rgba values - fix(sync): log auto-save failures instead of silently swallowing them - fix(sync): check res.ok before parsing caption-overrides response - refactor(components): extract Section, Row, inputCls into shared.tsx to eliminate duplication between CaptionPropertyPanel and CaptionAnimationPanel - fix(store): replace non-deterministic Date.now()+Math.random() ID with counter-based group IDs - fix(store): read selectedGroupId from state param instead of get() to avoid stale reads in batched set() calls - fix(overlay): remove cssScale multiplier from getBoundingClientRect coords — the browser already accounts for CSS transforms - docs(parser): add comment explaining the lazy ]; regex assumption Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fixes from review comments on feature/caption-designer (#180): - fix(generator): guard named colors in hexToRgba — "red", "transparent" no longer produce NaN rgba values - fix(sync): log auto-save failures instead of silently swallowing them - fix(sync): check res.ok before parsing caption-overrides response - refactor(components): extract Section, Row, inputCls into shared.tsx to eliminate duplication between CaptionPropertyPanel and CaptionAnimationPanel - fix(store): replace non-deterministic Date.now()+Math.random() ID with counter-based group IDs - fix(store): read selectedGroupId from state param instead of get() to avoid stale reads in batched set() calls - fix(overlay): remove cssScale multiplier from getBoundingClientRect coords — the browser already accounts for CSS transforms - docs(parser): add comment explaining the lazy ]; regex assumption Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(studio): address caption designer PR feedback Fixes from review comments on feature/caption-designer (#180): - fix(generator): guard named colors in hexToRgba — "red", "transparent" no longer produce NaN rgba values - fix(sync): log auto-save failures instead of silently swallowing them - fix(sync): check res.ok before parsing caption-overrides response - refactor(components): extract Section, Row, inputCls into shared.tsx to eliminate duplication between CaptionPropertyPanel and CaptionAnimationPanel - fix(store): replace non-deterministic Date.now()+Math.random() ID with counter-based group IDs - fix(store): read selectedGroupId from state param instead of get() to avoid stale reads in batched set() calls - fix(overlay): remove cssScale multiplier from getBoundingClientRect coords — the browser already accounts for CSS transforms - docs(parser): add comment explaining the lazy ]; regex assumption Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): address remaining caption designer feedback Overlay: handle both per-word spans (generator output) and grouped text nodes (existing templates). Wraps text nodes into individual spans on demand so the overlay can target words in any caption format. Property panel: add Typography (font, size, weight, spacing) and Color (color, active, dim, opacity) sections alongside existing Position and Transform controls. Timeline: move caption timeline into a dedicated flex-shrink-0 section below the main timeline tracks instead of inside the scrollable area. Gives it fixed 60px height that's always visible. Caption overrides: classify color tweens by comparing target color to the dim baseline instead of relying on timeline position order. This handles compositions with custom color tweens correctly. App.tsx: remove polling interval, rely on runtime postMessage events for caption detection. Add clarifying comment on why useEffect is appropriate (external event subscription). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): restore cssScale in overlay coordinate conversion getBoundingClientRect() on iframe-internal elements returns coordinates in the iframe's native resolution (1920x1080), not the CSS-scaled display size. The cssScale multiplier is needed to convert to parent window coordinates. The earlier removal was incorrect — it only worked at 1:1 scale. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): fix reversed scaling on left-side corner handles Scale interaction used horizontal dx from start position, which goes negative when dragging left handles outward. Now uses distance from box center — dragging away from center increases scale regardless of which corner handle is used. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): make rotation respond to horizontal drag only Rotation handle sits directly above the word, so atan2-based rotation barely responds to left/right movement. Replace with linear horizontal mapping: drag right = clockwise, drag left = counter-clockwise, 200px = 90 degrees. Vertical movement is ignored. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): remove animation tab and typography/color from property panel Keep only Position (X, Y) and Transform (Scale, Rotation) controls. Remove tab switcher UI since there's only one view now. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: fix oxfmt formatting in CLAUDE.md and captions skill docs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(studio): address caption designer PR feedback Fixes from review comments on feature/caption-designer (#180): - fix(generator): guard named colors in hexToRgba — "red", "transparent" no longer produce NaN rgba values - fix(sync): log auto-save failures instead of silently swallowing them - fix(sync): check res.ok before parsing caption-overrides response - refactor(components): extract Section, Row, inputCls into shared.tsx to eliminate duplication between CaptionPropertyPanel and CaptionAnimationPanel - fix(store): replace non-deterministic Date.now()+Math.random() ID with counter-based group IDs - fix(store): read selectedGroupId from state param instead of get() to avoid stale reads in batched set() calls - fix(overlay): remove cssScale multiplier from getBoundingClientRect coords — the browser already accounts for CSS transforms - docs(parser): add comment explaining the lazy ]; regex assumption Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): address remaining caption designer feedback Overlay: handle both per-word spans (generator output) and grouped text nodes (existing templates). Wraps text nodes into individual spans on demand so the overlay can target words in any caption format. Property panel: add Typography (font, size, weight, spacing) and Color (color, active, dim, opacity) sections alongside existing Position and Transform controls. Timeline: move caption timeline into a dedicated flex-shrink-0 section below the main timeline tracks instead of inside the scrollable area. Gives it fixed 60px height that's always visible. Caption overrides: classify color tweens by comparing target color to the dim baseline instead of relying on timeline position order. This handles compositions with custom color tweens correctly. App.tsx: remove polling interval, rely on runtime postMessage events for caption detection. Add clarifying comment on why useEffect is appropriate (external event subscription). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): restore cssScale in overlay coordinate conversion getBoundingClientRect() on iframe-internal elements returns coordinates in the iframe's native resolution (1920x1080), not the CSS-scaled display size. The cssScale multiplier is needed to convert to parent window coordinates. The earlier removal was incorrect — it only worked at 1:1 scale. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): fix reversed scaling on left-side corner handles Scale interaction used horizontal dx from start position, which goes negative when dragging left handles outward. Now uses distance from box center — dragging away from center increases scale regardless of which corner handle is used. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): make rotation respond to horizontal drag only Rotation handle sits directly above the word, so atan2-based rotation barely responds to left/right movement. Replace with linear horizontal mapping: drag right = clockwise, drag left = counter-clockwise, 200px = 90 degrees. Vertical movement is ignored. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): remove animation tab and typography/color from property panel Keep only Position (X, Y) and Transform (Scale, Rotation) controls. Remove tab switcher UI since there's only one view now. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: fix oxfmt formatting in CLAUDE.md and captions skill docs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fixes from review comments on feature/caption-designer (heygen-com#180): - fix(generator): guard named colors in hexToRgba — "red", "transparent" no longer produce NaN rgba values - fix(sync): log auto-save failures instead of silently swallowing them - fix(sync): check res.ok before parsing caption-overrides response - refactor(components): extract Section, Row, inputCls into shared.tsx to eliminate duplication between CaptionPropertyPanel and CaptionAnimationPanel - fix(store): replace non-deterministic Date.now()+Math.random() ID with counter-based group IDs - fix(store): read selectedGroupId from state param instead of get() to avoid stale reads in batched set() calls - fix(overlay): remove cssScale multiplier from getBoundingClientRect coords — the browser already accounts for CSS transforms - docs(parser): add comment explaining the lazy ]; regex assumption
* fix(studio): address caption designer PR feedback Fixes from review comments on feature/caption-designer (heygen-com#180): - fix(generator): guard named colors in hexToRgba — "red", "transparent" no longer produce NaN rgba values - fix(sync): log auto-save failures instead of silently swallowing them - fix(sync): check res.ok before parsing caption-overrides response - refactor(components): extract Section, Row, inputCls into shared.tsx to eliminate duplication between CaptionPropertyPanel and CaptionAnimationPanel - fix(store): replace non-deterministic Date.now()+Math.random() ID with counter-based group IDs - fix(store): read selectedGroupId from state param instead of get() to avoid stale reads in batched set() calls - fix(overlay): remove cssScale multiplier from getBoundingClientRect coords — the browser already accounts for CSS transforms - docs(parser): add comment explaining the lazy ]; regex assumption Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): address remaining caption designer feedback Overlay: handle both per-word spans (generator output) and grouped text nodes (existing templates). Wraps text nodes into individual spans on demand so the overlay can target words in any caption format. Property panel: add Typography (font, size, weight, spacing) and Color (color, active, dim, opacity) sections alongside existing Position and Transform controls. Timeline: move caption timeline into a dedicated flex-shrink-0 section below the main timeline tracks instead of inside the scrollable area. Gives it fixed 60px height that's always visible. Caption overrides: classify color tweens by comparing target color to the dim baseline instead of relying on timeline position order. This handles compositions with custom color tweens correctly. App.tsx: remove polling interval, rely on runtime postMessage events for caption detection. Add clarifying comment on why useEffect is appropriate (external event subscription). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): restore cssScale in overlay coordinate conversion getBoundingClientRect() on iframe-internal elements returns coordinates in the iframe's native resolution (1920x1080), not the CSS-scaled display size. The cssScale multiplier is needed to convert to parent window coordinates. The earlier removal was incorrect — it only worked at 1:1 scale. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): fix reversed scaling on left-side corner handles Scale interaction used horizontal dx from start position, which goes negative when dragging left handles outward. Now uses distance from box center — dragging away from center increases scale regardless of which corner handle is used. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): make rotation respond to horizontal drag only Rotation handle sits directly above the word, so atan2-based rotation barely responds to left/right movement. Replace with linear horizontal mapping: drag right = clockwise, drag left = counter-clockwise, 200px = 90 degrees. Vertical movement is ignored. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): remove animation tab and typography/color from property panel Keep only Position (X, Y) and Transform (Scale, Rotation) controls. Remove tab switcher UI since there's only one view now. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: fix oxfmt formatting in CLAUDE.md and captions skill docs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ing it Gemini response bytes were written to disk after only a RIFF header check (CodeQL js/http-to-file-access heygen-com#180). Route them through normalizeCloudAudio like the HeyGen path, injectable for tests, and report a transcode failure as ok:false without leaving a raw file behind. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* chore(upstream): adopt upstream/main d94708e as a whole tree Adopt all pending upstream work (about 837 commits) on top of the fork. The fork history is squashed with no shared ancestry, so this was merged through a bridge on v0.8.35 (63574a7) and squashed onto fork main. 400 generated catalog pages come from upstream; 39 other conflicts were resolved by hand, keeping fork overlay files and the fork's CodeQL hardening where it could coexist with upstream's rewrites. Conflict table, kept fork deltas, dropped hardening, and verification are in docs/DECISIONS.md (2026-10-07). Baseline reviewed_through moves to d94708e. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(security): validate remote media magic bytes before freezing Restore the fork's assertRemoteMediaBytes in the adopted upstream freezeUrl: downloaded bytes must match the destination extension (SVG still goes through sanitizeSvg, .cube through cube-validate, unknown extensions fail closed). Replace the invalid streaming fixture with valid ISO base media bytes, add HTML-as-.png and #EXTM3U-as-.mp4 rejection tests, and delete the now callerless freeze-publisher module and its test. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * chore(capture): drop callerless frameworkMarkup Nothing imports stripFrameworkMarkup after the upstream adoption; upstream's DOM-based filterExtractedScripts covers the same threat. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * chore(github): remove upstream CODEOWNERS The file names an upstream maintainer as owner of the capture-check files; on this fork it would request reviews from a person who does not maintain it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * ci(fork): gate pr-captures to the upstream repository The Before/After capture requirement is upstream's contributor process and fails on every fork PR. Gate the job like the other upstream-process workflows, list catalog-publish, canary-sunset and pr-captures in FORK.md, and add a maintenance test that keeps the gates and the table in sync. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(security): install media vectors from the package and contain row.file Upstream's fetchMediaVectors downloaded media-vectors.* from a mutable raw.githubusercontent.com path with no timeout, size cap or pair validation, then trusted row.file via resolve(row.file), so a tampered catalog could make any local file the SFX result that freeze copies into the project. - build-copy ships media-vectors.json/.bin in dist/catalog-artifact - installMediaVectors installs from the bundled directory with the same pair and row validation as vectorPairAgrees, refuses absolute or .. row files, writes 0o600 via temp file and rename, and never fetches - resolveBundledMediaFile accepts only relative paths that stay inside the bundled root; the cwd-relative candidate is gone - resolve.mjs uses searchLocalSfxIndex; fetchMediaVectors and the HYPERFRAMES_REGISTRY path are removed - tests: absolute and .. row files rejected, ranking works with fetch forbidden Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * docs(studio-server): fix stale collision-suffix comment on upload The comment claimed the suffixed path was validated at that point; the check runs on the requested destination and suffixed names are chosen later in the exclusive-create loop. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * docs(fork): record the post-merge review of the upstream adoption Append the findings, fixes and the two judged-covered items (upload mode, frameworkMarkup) to the 2026-10-07 entry. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(media-use): drop unused fs imports from the vendored heygen helper The fork's downloadTo rewrite moved writes into normalizeCloudAudio, so existsSync, mkdirSync and writeFileSync were left unused and failed the Preflight lint. Record the lint result and the two pre-existing CI failures that come from upstream in DECISIONS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(core): match scoped scripts after the fork's JSON-literal wrapping The upstream render-order test looked for push("n") as raw text, but the fork wraps scoped composition scripts as JSON string literals, so the marker was not found in one mode and sorted first. Unescape quotes before matching and require every marker to be present in both modes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(fork): correct why the drag-frames check fails before merge Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(core): bound the url(#id) scan so hostile render input cannot stall the event loop Exclude '(' from the fragment id class so a long run of 'url(#!' with no closing paren no longer backtracks quadratically (CodeQL js/polynomial-redos heygen-com#156). Adds linear-time regression tests and keeps url(#a), url('#a') and url( "#a" ) rewriting unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(engine): refuse an extract cache root another user could control The default root is a predictable name under the shared tmpdir, so a local user could pre-create it to plant cached frames or a symlinked .hf-last-gc (CodeQL js/insecure-temporary-file heygen-com#177). assertPrivateCacheRoot creates the root 0700 and requires a real directory, owned by the caller and closed to group/other on POSIX. The extractor disables caching with its existing warning when the check fails, clean skips the sweep, and the sweep marker is written via exclusive temp file plus rename so it never follows a symlink. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(media-use): re-encode Gemini TTS audio through ffmpeg before writing it Gemini response bytes were written to disk after only a RIFF header check (CodeQL js/http-to-file-access heygen-com#180). Route them through normalizeCloudAudio like the HeyGen path, injectable for tests, and report a transcode failure as ok:false without leaving a raw file behind. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * refactor(cli): delete the callerless fetchLocalVectors download path Nothing but its own tests called it; the catalog cache is filled by installLocalVectors from the bundled package (CodeQL js/http-to-file-access heygen-com#183). Removes it with its options type and timeout constant, ports the media-vector validation tests onto installLocalVectors, and adds a test that installLocalVectors does not touch fetch. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * docs(fork): record the PR #2 CodeQL fixes and dispositions Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(engine): tighten an own 0755 extract cache root instead of disabling it A root left at 0755 by an older umask belongs to this user and nobody else can write to it, so chmod it to 0700 and keep caching. Roots that group or others can write to are still refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(producer): check the extract cache root before using the GIF cache The animated-GIF cache sits under the same extract cache root and is used during compile, before the frame cache's ownership check, so a root another local user controlled could still poison or redirect it. Route it through assertPrivateCacheRoot and disable only the GIF cache on failure. Also format the Gemini TTS test and correct DECISIONS (heygen-com#182 is fixed; media vectors install via installMediaVectors). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ci): validate adoption comments and test the exact PR candidate * fix(security): restore capture and media guards and harden Frost assets --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

Summary
gsap.killTweensOf+gsap.setoverrides into the original composition source, preserving all hand-written GSAP logic (karaoke, audio reactivity, entrance/exit animations)const/let/varTRANSCRIPT arrays and pre-valid JSONTest plan
compositions/captions.htmlin the StudioCaption Editor Overridespatch block🤖 Generated with Claude Code