mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(core): let a composition declare its caption colour states
Classification by colour equality has to guess: it takes the first colour
tween's value as the dim baseline and calls everything else active. A
composition whose two states share a colour therefore has every tween
classified dim, and the caller's activeColor is silently dropped -- a real
failure, now covered by a test that fails without this change.
A tween may declare its state as data: { captionState: "dim" | "active" }.
GSAP passes unknown vars through untouched, so declaring costs nothing at
runtime, and resolution is per tween -- a composition can declare some and
leave the rest to the fallback, which is unchanged for anything undeclared.
This is the composition telling us what it built rather than us inferring it
from what it happens to look like. The data-driven caption templates already
author their state tweens from resolved values and never guess; this closes
part of that capability gap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d9b00e57eb
commit
1e7799bfbb
@@ -34,6 +34,19 @@ function installGsapMock() {
|
||||
return { setCalls };
|
||||
}
|
||||
|
||||
/** A gsap mock whose `getTweensOf` returns the supplied colour tweens, so classification is testable. */
|
||||
function installGsapMockWithTweens(tweens: Array<Record<string, unknown>>) {
|
||||
const gsap = {
|
||||
set() {},
|
||||
killTweensOf() {},
|
||||
getTweensOf() {
|
||||
return tweens.map((vars, i) => ({ vars, startTime: () => i }));
|
||||
},
|
||||
};
|
||||
Object.defineProperty(window, "gsap", { configurable: true, value: gsap });
|
||||
return tweens;
|
||||
}
|
||||
|
||||
async function flushCaptionOverrides() {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await Promise.resolve();
|
||||
@@ -122,3 +135,52 @@ describe("applyCaptionOverrides", () => {
|
||||
expect(setCalls[0]?.vars).toEqual({ x: 16 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("caption state declaration", () => {
|
||||
it("honours a declared state when dim and active colours are IDENTICAL", async () => {
|
||||
// The failure the declaration exists for. Classification by colour equality takes the first
|
||||
// tween's colour as the dim baseline, so a composition whose two states share a colour has
|
||||
// every tween classified dim — and `activeColor` is silently dropped.
|
||||
const tweens = installGsapMockWithTweens([
|
||||
{ color: "#888", data: { captionState: "dim" } },
|
||||
{ color: "#888", data: { captionState: "active" } },
|
||||
]);
|
||||
installCaptionOverrideFetch([{ wordIndex: 0, dimColor: "#111", activeColor: "#eee" }]);
|
||||
document.body.innerHTML = `<div class="caption-group"><span id="w0">Hi</span></div>`;
|
||||
|
||||
applyCaptionOverrides();
|
||||
await flushCaptionOverrides();
|
||||
|
||||
expect(tweens[0].color).toBe("#111");
|
||||
expect(tweens[1].color).toBe("#eee");
|
||||
});
|
||||
|
||||
it("falls back to colour classification when nothing is declared", async () => {
|
||||
// Undeclared compositions must keep working exactly as before — the declaration is additive.
|
||||
const tweens = installGsapMockWithTweens([{ color: "#222" }, { color: "#fff" }]);
|
||||
installCaptionOverrideFetch([{ wordIndex: 0, dimColor: "#111", activeColor: "#eee" }]);
|
||||
document.body.innerHTML = `<div class="caption-group"><span id="w0">Hi</span></div>`;
|
||||
|
||||
applyCaptionOverrides();
|
||||
await flushCaptionOverrides();
|
||||
|
||||
expect(tweens[0].color).toBe("#111");
|
||||
expect(tweens[1].color).toBe("#eee");
|
||||
});
|
||||
|
||||
it("prefers the declaration over the colour heuristic when they disagree", async () => {
|
||||
// Declared order is deliberately the reverse of what colour-equality would infer.
|
||||
const tweens = installGsapMockWithTweens([
|
||||
{ color: "#222", data: { captionState: "active" } },
|
||||
{ color: "#fff", data: { captionState: "dim" } },
|
||||
]);
|
||||
installCaptionOverrideFetch([{ wordIndex: 0, dimColor: "#111", activeColor: "#eee" }]);
|
||||
document.body.innerHTML = `<div class="caption-group"><span id="w0">Hi</span></div>`;
|
||||
|
||||
applyCaptionOverrides();
|
||||
await flushCaptionOverrides();
|
||||
|
||||
expect(tweens[0].color).toBe("#eee");
|
||||
expect(tweens[1].color).toBe("#111");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,23 @@ interface GsapStatic {
|
||||
getTweensOf: (target: Element) => GsapTween[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The caption state a composition DECLARES for a colour tween, if any.
|
||||
*
|
||||
* Authored as `data: { captionState: "dim" | "active" }` in the tween's vars. GSAP passes unknown
|
||||
* vars through untouched, so this costs a declaring composition nothing at runtime.
|
||||
*
|
||||
* It exists because the fallback below has to GUESS. Classifying by colour equality breaks outright
|
||||
* when a composition's two states share a colour: every tween matches the dim baseline, and the
|
||||
* active override is silently dropped. A declaration is the composition telling us what it built,
|
||||
* rather than us inferring it from what it happens to look like.
|
||||
*/
|
||||
function declaredCaptionState(tween: GsapTween): "dim" | "active" | undefined {
|
||||
const data = tween.vars.data as { captionState?: unknown } | undefined;
|
||||
const state = data?.captionState;
|
||||
return state === "dim" || state === "active" ? state : undefined;
|
||||
}
|
||||
|
||||
function resolveCaptionWordElement(el: Element | null): HTMLElement | null {
|
||||
if (!(el instanceof HTMLElement)) return null;
|
||||
if (el.dataset.captionWrapper !== "true") return el;
|
||||
@@ -138,13 +155,15 @@ export function applyCaptionOverrides(): void {
|
||||
const dimBaseline = colorTweens[0] ? String(colorTweens[0].vars.color) : "";
|
||||
|
||||
for (const tw of colorTweens) {
|
||||
const tweenColor = String(tw.vars.color);
|
||||
if (tweenColor === dimBaseline) {
|
||||
// This tween targets the dim/inactive color
|
||||
// A declaration wins over the colour guess, per tween, so a composition can declare
|
||||
// some tweens and leave others to the fallback.
|
||||
const state =
|
||||
declaredCaptionState(tw) ??
|
||||
(String(tw.vars.color) === dimBaseline ? "dim" : "active");
|
||||
if (state === "dim") {
|
||||
if (override.dimColor) tw.vars.color = override.dimColor;
|
||||
} else {
|
||||
// This tween targets the active/spoken color
|
||||
if (override.activeColor) tw.vars.color = override.activeColor;
|
||||
} else if (override.activeColor) {
|
||||
tw.vars.color = override.activeColor;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user