mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
## Summary This stacked PR makes caption overrides refresh-safe. Caption edits are still saved to `caption-overrides.json`, but override targets are now stable across preview refreshes and regenerated caption HTML. ## Architecture - **Stable word identity**: generated caption HTML preserves optional transcript word IDs in the `TRANSCRIPT` array and emits those IDs on word spans. - **Parser continuity**: the caption parser preserves existing transcript word `id` fields instead of regenerating index-only identity. - **Override loading**: Studio loads saved overrides by `wordId` first, with the existing `wordIndex` fallback kept for older overrides. - **Idempotent runtime wrapping**: transform overrides reuse an existing `data-caption-wrapper="true"` wrapper instead of nesting wrappers on every refresh. - **Animation compatibility**: overrides still wrap the word so inner word-level GSAP animation can continue to target the original span. ## User Impact Users can edit caption word position, scale, rotation, color, opacity, font size, font weight, and font family, then refresh without overrides drifting to the wrong word or accumulating nested wrappers. ## Main Files - `packages/core/src/runtime/captionOverrides.ts` - `packages/studio/src/captions/generator.ts` - `packages/studio/src/captions/parser.ts` - `packages/studio/src/captions/hooks/useCaptionSync.ts` ## Test Plan ```bash volta run --node 22.20.0 bun run --filter @hyperframes/core test -- src/runtime/captionOverrides.test.ts volta run --node 22.20.0 packages/studio/node_modules/.bin/vitest run --root packages/studio --config /dev/null src/captions/parser.test.ts src/captions/generator.test.ts volta run --node 22.20.0 bun run --filter @hyperframes/core typecheck volta run --node 22.20.0 bun run --filter @hyperframes/studio typecheck volta run --node 22.20.0 bunx oxlint <changed files> volta run --node 22.20.0 bunx oxfmt --check <changed files> git diff --check ```
392 lines
13 KiB
TypeScript
392 lines
13 KiB
TypeScript
// @vitest-environment node
|
|
import { describe, it, expect } from "vitest";
|
|
import { extractTranscript, buildCaptionModel, TranscriptWord } from "./parser";
|
|
import { DEFAULT_STYLE, DEFAULT_CONTAINER, DEFAULT_ANIMATION_SET } from "./types";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixtures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const STANDARD_CAPTION_SOURCE = `
|
|
(function () {
|
|
const TRANSCRIPT = [
|
|
{ text: "We", start: 0.119, end: 0.259 },
|
|
{ text: "asked", start: 0.319, end: 0.479 },
|
|
{ text: "what", start: 0.519, end: 0.659 },
|
|
{ text: "you", start: 0.699, end: 0.819 },
|
|
{ text: "needed.", start: 0.859, end: 1.819 },
|
|
];
|
|
// rest of composition code ...
|
|
})();
|
|
`;
|
|
|
|
const SCRIPT_VARIABLE_SOURCE = `
|
|
(function () {
|
|
const script = [
|
|
{ text: "We", start: 0.119, end: 0.259 },
|
|
{ text: "asked", start: 0.319, end: 0.479 },
|
|
{ text: "what", start: 0.519, end: 0.659 },
|
|
];
|
|
// rest of composition code ...
|
|
})();
|
|
`;
|
|
|
|
const LET_TRANSCRIPT_SOURCE = `
|
|
(function () {
|
|
let TRANSCRIPT = [
|
|
{ text: "Hello", start: 0.0, end: 0.5 },
|
|
{ text: "world", start: 0.6, end: 1.0 },
|
|
];
|
|
})();
|
|
`;
|
|
|
|
const VAR_TRANSCRIPT_SOURCE = `
|
|
(function () {
|
|
var TRANSCRIPT = [
|
|
{ text: "Hello", start: 0.0, end: 0.5 },
|
|
];
|
|
})();
|
|
`;
|
|
|
|
const SINGLE_QUOTED_SOURCE = `
|
|
(function () {
|
|
const TRANSCRIPT = [
|
|
{ text: 'We', start: 0.119, end: 0.259 },
|
|
{ text: 'asked', start: 0.319, end: 0.479 },
|
|
];
|
|
})();
|
|
`;
|
|
|
|
const TRAILING_COMMA_SOURCE = `
|
|
(function () {
|
|
const TRANSCRIPT = [
|
|
{ text: "We", start: 0.119, end: 0.259, },
|
|
{ text: "asked", start: 0.319, end: 0.479, },
|
|
];
|
|
})();
|
|
`;
|
|
|
|
const NON_CAPTION_SOURCE = `
|
|
(function () {
|
|
const config = { fps: 30, duration: 10 };
|
|
const elements = ["title", "subtitle"];
|
|
gsap.to(".clip", { opacity: 1 });
|
|
})();
|
|
`;
|
|
|
|
const EMPTY_SOURCE = ``;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("extractTranscript", () => {
|
|
describe("TRANSCRIPT variable", () => {
|
|
it("extracts words from a standard TRANSCRIPT array", () => {
|
|
const words = extractTranscript(STANDARD_CAPTION_SOURCE);
|
|
expect(words).toHaveLength(5);
|
|
expect(words[0]).toEqual({ text: "We", start: 0.119, end: 0.259 });
|
|
expect(words[1]).toEqual({ text: "asked", start: 0.319, end: 0.479 });
|
|
expect(words[4]).toEqual({ text: "needed.", start: 0.859, end: 1.819 });
|
|
});
|
|
|
|
it("handles let TRANSCRIPT declaration", () => {
|
|
const words = extractTranscript(LET_TRANSCRIPT_SOURCE);
|
|
expect(words).toHaveLength(2);
|
|
expect(words[0]).toEqual({ text: "Hello", start: 0.0, end: 0.5 });
|
|
expect(words[1]).toEqual({ text: "world", start: 0.6, end: 1.0 });
|
|
});
|
|
|
|
it("handles var TRANSCRIPT declaration", () => {
|
|
const words = extractTranscript(VAR_TRANSCRIPT_SOURCE);
|
|
expect(words).toHaveLength(1);
|
|
expect(words[0]).toEqual({ text: "Hello", start: 0.0, end: 0.5 });
|
|
});
|
|
|
|
it("preserves stable word ids when present", () => {
|
|
const words = extractTranscript(`
|
|
const TRANSCRIPT = [
|
|
{ id: "word-a", text: "Hello", start: 0, end: 0.4 },
|
|
{ id: "word-b", text: "world", start: 0.5, end: 1 },
|
|
];
|
|
`);
|
|
|
|
expect(words).toEqual([
|
|
{ id: "word-a", text: "Hello", start: 0, end: 0.4 },
|
|
{ id: "word-b", text: "world", start: 0.5, end: 1 },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("script variable name", () => {
|
|
it("extracts words from a const script array (warm-grain template variant)", () => {
|
|
const words = extractTranscript(SCRIPT_VARIABLE_SOURCE);
|
|
expect(words).toHaveLength(3);
|
|
expect(words[0]).toEqual({ text: "We", start: 0.119, end: 0.259 });
|
|
expect(words[2]).toEqual({ text: "what", start: 0.519, end: 0.659 });
|
|
});
|
|
});
|
|
|
|
describe("non-caption source", () => {
|
|
it("returns empty array when no TRANSCRIPT or script variable is found", () => {
|
|
const words = extractTranscript(NON_CAPTION_SOURCE);
|
|
expect(words).toEqual([]);
|
|
});
|
|
|
|
it("returns empty array for an empty string", () => {
|
|
const words = extractTranscript(EMPTY_SOURCE);
|
|
expect(words).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("single-quoted values", () => {
|
|
it("parses arrays with single-quoted text values", () => {
|
|
const words = extractTranscript(SINGLE_QUOTED_SOURCE);
|
|
expect(words).toHaveLength(2);
|
|
expect(words[0]).toEqual({ text: "We", start: 0.119, end: 0.259 });
|
|
expect(words[1]).toEqual({ text: "asked", start: 0.319, end: 0.479 });
|
|
});
|
|
});
|
|
|
|
describe("trailing commas", () => {
|
|
it("handles trailing commas inside objects", () => {
|
|
const words = extractTranscript(TRAILING_COMMA_SOURCE);
|
|
expect(words).toHaveLength(2);
|
|
expect(words[0]).toEqual({ text: "We", start: 0.119, end: 0.259 });
|
|
});
|
|
});
|
|
|
|
describe("real-world source samples", () => {
|
|
it("handles a realistic production-style TRANSCRIPT block with many words", () => {
|
|
const source = `
|
|
(function() {
|
|
const TRANSCRIPT = [
|
|
{ text: "We", start: 0.119, end: 0.259 },
|
|
{ text: "asked", start: 0.319, end: 0.479 },
|
|
{ text: "what", start: 0.519, end: 0.659 },
|
|
{ text: "you", start: 0.699, end: 0.819 },
|
|
{ text: "needed.", start: 0.859, end: 1.819 },
|
|
{ text: "Forty-seven", start: 1.86, end: 2.299 },
|
|
{ text: "percent", start: 2.399, end: 2.679 },
|
|
{ text: "of", start: 2.7, end: 2.799 },
|
|
];
|
|
})();
|
|
`;
|
|
const words = extractTranscript(source);
|
|
expect(words).toHaveLength(8);
|
|
expect(words[5]).toEqual({ text: "Forty-seven", start: 1.86, end: 2.299 });
|
|
});
|
|
|
|
it("handles words with punctuation in text values", () => {
|
|
const source = `
|
|
const TRANSCRIPT = [
|
|
{ text: "graphics,", start: 3.579, end: 4.599 },
|
|
{ text: "you", start: 4.679, end: 5.179 },
|
|
{ text: "attention.", start: 5.299, end: 5.759 },
|
|
];
|
|
`;
|
|
const words = extractTranscript(source);
|
|
expect(words).toHaveLength(3);
|
|
expect(words[0].text).toBe("graphics,");
|
|
expect(words[2].text).toBe("attention.");
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// buildCaptionModel tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const SEVEN_WORDS: TranscriptWord[] = [
|
|
{ text: "We", start: 0.1, end: 0.3 },
|
|
{ text: "asked", start: 0.4, end: 0.6 },
|
|
{ text: "what", start: 0.7, end: 0.9 },
|
|
{ text: "you", start: 1.0, end: 1.2 },
|
|
{ text: "needed.", start: 1.3, end: 1.8 },
|
|
{ text: "Forty-seven", start: 1.9, end: 2.3 },
|
|
{ text: "percent", start: 2.4, end: 2.7 },
|
|
];
|
|
|
|
describe("buildCaptionModel", () => {
|
|
describe("grouping", () => {
|
|
it("produces 2 groups for 7 words with wordsPerGroup=5 (5 + 2)", () => {
|
|
const model = buildCaptionModel(SEVEN_WORDS, {
|
|
width: 1920,
|
|
height: 1080,
|
|
duration: 10,
|
|
wordsPerGroup: 5,
|
|
});
|
|
expect(model.groupOrder).toHaveLength(2);
|
|
expect(model.groups.size).toBe(2);
|
|
});
|
|
|
|
it("first group has 5 segments and second group has 2 segments", () => {
|
|
const model = buildCaptionModel(SEVEN_WORDS, {
|
|
width: 1920,
|
|
height: 1080,
|
|
duration: 10,
|
|
wordsPerGroup: 5,
|
|
});
|
|
const firstGroupId = model.groupOrder[0];
|
|
const secondGroupId = model.groupOrder[1];
|
|
expect(model.groups.get(firstGroupId)?.segmentIds).toHaveLength(5);
|
|
expect(model.groups.get(secondGroupId)?.segmentIds).toHaveLength(2);
|
|
});
|
|
|
|
it("uses default wordsPerGroup of 5 when not specified", () => {
|
|
const model = buildCaptionModel(SEVEN_WORDS, {
|
|
width: 1280,
|
|
height: 720,
|
|
duration: 5,
|
|
});
|
|
expect(model.groupOrder).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe("segments", () => {
|
|
it("segments have correct text matching the transcript words", () => {
|
|
const model = buildCaptionModel(SEVEN_WORDS, {
|
|
width: 1920,
|
|
height: 1080,
|
|
duration: 10,
|
|
wordsPerGroup: 5,
|
|
});
|
|
expect(model.segments.size).toBe(7);
|
|
|
|
const firstGroupId = model.groupOrder[0];
|
|
const firstGroup = model.groups.get(firstGroupId);
|
|
const firstSegmentId = firstGroup?.segmentIds[0];
|
|
const firstSegment = firstSegmentId ? model.segments.get(firstSegmentId) : undefined;
|
|
expect(firstSegment?.text).toBe("We");
|
|
|
|
const secondGroupId = model.groupOrder[1];
|
|
const secondGroup = model.groups.get(secondGroupId);
|
|
const sixthSegmentId = secondGroup?.segmentIds[0];
|
|
const sixthSegment = sixthSegmentId ? model.segments.get(sixthSegmentId) : undefined;
|
|
expect(sixthSegment?.text).toBe("Forty-seven");
|
|
});
|
|
|
|
it("segments have correct start and end timing from the transcript", () => {
|
|
const model = buildCaptionModel(SEVEN_WORDS, {
|
|
width: 1920,
|
|
height: 1080,
|
|
duration: 10,
|
|
wordsPerGroup: 5,
|
|
});
|
|
const firstGroupId = model.groupOrder[0];
|
|
const firstGroup = model.groups.get(firstGroupId);
|
|
const segId = firstGroup?.segmentIds[4];
|
|
const fifthSegment = segId ? model.segments.get(segId) : undefined;
|
|
expect(fifthSegment?.start).toBe(1.3);
|
|
expect(fifthSegment?.end).toBe(1.8);
|
|
expect(fifthSegment?.text).toBe("needed.");
|
|
});
|
|
|
|
it("segments have correct groupIndex reflecting position within their group", () => {
|
|
const model = buildCaptionModel(SEVEN_WORDS, {
|
|
width: 1920,
|
|
height: 1080,
|
|
duration: 10,
|
|
wordsPerGroup: 5,
|
|
});
|
|
const secondGroupId = model.groupOrder[1];
|
|
const secondGroup = model.groups.get(secondGroupId);
|
|
const segId = secondGroup?.segmentIds[1];
|
|
const segment = segId ? model.segments.get(segId) : undefined;
|
|
expect(segment?.groupIndex).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("model dimensions", () => {
|
|
it("stores correct width, height, and duration on the model", () => {
|
|
const model = buildCaptionModel(SEVEN_WORDS, {
|
|
width: 1920,
|
|
height: 1080,
|
|
duration: 30.5,
|
|
wordsPerGroup: 5,
|
|
});
|
|
expect(model.width).toBe(1920);
|
|
expect(model.height).toBe(1080);
|
|
expect(model.duration).toBe(30.5);
|
|
});
|
|
});
|
|
|
|
describe("default styles", () => {
|
|
it("groups have DEFAULT_STYLE applied", () => {
|
|
const model = buildCaptionModel(SEVEN_WORDS, {
|
|
width: 1920,
|
|
height: 1080,
|
|
duration: 10,
|
|
wordsPerGroup: 5,
|
|
});
|
|
const firstGroupId = model.groupOrder[0];
|
|
const group = model.groups.get(firstGroupId);
|
|
expect(group?.style).toEqual(DEFAULT_STYLE);
|
|
});
|
|
|
|
it("groups have DEFAULT_CONTAINER applied", () => {
|
|
const model = buildCaptionModel(SEVEN_WORDS, {
|
|
width: 1920,
|
|
height: 1080,
|
|
duration: 10,
|
|
wordsPerGroup: 5,
|
|
});
|
|
const firstGroupId = model.groupOrder[0];
|
|
const group = model.groups.get(firstGroupId);
|
|
expect(group?.containerStyle).toEqual(DEFAULT_CONTAINER);
|
|
});
|
|
|
|
it("groups have DEFAULT_ANIMATION_SET applied", () => {
|
|
const model = buildCaptionModel(SEVEN_WORDS, {
|
|
width: 1920,
|
|
height: 1080,
|
|
duration: 10,
|
|
wordsPerGroup: 5,
|
|
});
|
|
const firstGroupId = model.groupOrder[0];
|
|
const group = model.groups.get(firstGroupId);
|
|
expect(group?.animation.entrance).toEqual(DEFAULT_ANIMATION_SET.entrance);
|
|
expect(group?.animation.highlight).toBe(DEFAULT_ANIMATION_SET.highlight);
|
|
expect(group?.animation.exit).toEqual(DEFAULT_ANIMATION_SET.exit);
|
|
});
|
|
|
|
it("model defaultAnimation matches DEFAULT_ANIMATION_SET", () => {
|
|
const model = buildCaptionModel(SEVEN_WORDS, {
|
|
width: 1920,
|
|
height: 1080,
|
|
duration: 10,
|
|
wordsPerGroup: 5,
|
|
});
|
|
expect(model.defaultAnimation.entrance).toEqual(DEFAULT_ANIMATION_SET.entrance);
|
|
expect(model.defaultAnimation.highlight).toBe(DEFAULT_ANIMATION_SET.highlight);
|
|
expect(model.defaultAnimation.exit).toEqual(DEFAULT_ANIMATION_SET.exit);
|
|
});
|
|
});
|
|
|
|
describe("edge cases", () => {
|
|
it("handles an empty transcript returning a model with no segments or groups", () => {
|
|
const model = buildCaptionModel([], {
|
|
width: 1920,
|
|
height: 1080,
|
|
duration: 10,
|
|
wordsPerGroup: 5,
|
|
});
|
|
expect(model.segments.size).toBe(0);
|
|
expect(model.groups.size).toBe(0);
|
|
expect(model.groupOrder).toHaveLength(0);
|
|
});
|
|
|
|
it("handles transcript with exactly wordsPerGroup words producing 1 group", () => {
|
|
const fiveWords = SEVEN_WORDS.slice(0, 5);
|
|
const model = buildCaptionModel(fiveWords, {
|
|
width: 1920,
|
|
height: 1080,
|
|
duration: 10,
|
|
wordsPerGroup: 5,
|
|
});
|
|
expect(model.groupOrder).toHaveLength(1);
|
|
expect(model.segments.size).toBe(5);
|
|
});
|
|
});
|
|
});
|