Merge pull request #3105 from heygen-com/fix/caption-declared-color-states

fix(core): let a composition declare its caption colour states
This commit is contained in:
Vance Ingalls
2026-08-07 20:25:35 -07:00
committed by GitHub
2 changed files with 152 additions and 15 deletions
@@ -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,103 @@ 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");
});
it("does not let a declared tween poison the baseline for undeclared ones", async () => {
// The baseline exists to guess about tweens the declaration has NOT spoken to. Deriving it from
// a tween declared "active" makes every undeclared same-state tween compare against a colour
// that has explicitly said it is not the dim reference — so the one genuinely dim tween here
// gets classified active and receives the wrong override.
const tweens = installGsapMockWithTweens([
{ color: "#eee", data: { captionState: "active" } },
{ color: "#111" },
]);
installCaptionOverrideFetch([{ wordIndex: 0, dimColor: "#dim", activeColor: "#active" }]);
document.body.innerHTML = `<div class="caption-group"><span id="w0">Hi</span></div>`;
applyCaptionOverrides();
await flushCaptionOverrides();
expect(tweens[0].color).toBe("#active");
expect(tweens[1].color).toBe("#dim");
});
it("prefers a declared dim tween as the baseline over an undeclared one", async () => {
const tweens = installGsapMockWithTweens([
{ color: "#aaa" },
{ color: "#bbb", data: { captionState: "dim" } },
]);
installCaptionOverrideFetch([{ wordIndex: 0, dimColor: "#dim", activeColor: "#active" }]);
document.body.innerHTML = `<div class="caption-group"><span id="w0">Hi</span></div>`;
applyCaptionOverrides();
await flushCaptionOverrides();
// #bbb is the declared dim reference, so the undeclared #aaa is not dim.
expect(tweens[0].color).toBe("#active");
expect(tweens[1].color).toBe("#dim");
});
it("falls through to the heuristic for a malformed declaration", async () => {
// A typo, a primitive, or a null must never ship a broken composition — an unrecognised value
// is not a state, so classification continues as if nothing was declared.
for (const data of [{ captionState: "typo" }, { captionState: 42 }, null, "notAnObject"]) {
const tweens = installGsapMockWithTweens([{ color: "#222", data }, { color: "#fff" }]);
installCaptionOverrideFetch([{ wordIndex: 0, dimColor: "#dim", activeColor: "#active" }]);
document.body.innerHTML = `<div class="caption-group"><span id="w0">Hi</span></div>`;
applyCaptionOverrides();
await flushCaptionOverrides();
expect(tweens[0].color).toBe("#dim");
expect(tweens[1].color).toBe("#active");
}
});
});
+39 -15
View File
@@ -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;
@@ -121,30 +138,37 @@ export function applyCaptionOverrides(): void {
if (override.fontWeight !== undefined) styleProps.fontWeight = override.fontWeight;
if (override.fontFamily !== undefined) styleProps.fontFamily = override.fontFamily;
// Replace color values in existing GSAP tweens.
// Instead of relying on timeline position order (fragile if custom
// color tweens exist), we classify each tween by comparing its
// target color to the current computed color of the element.
// Tweens that match the current color are "dim" tweens; tweens
// with a different color are "active" tweens.
// Replace color values in existing GSAP tweens, classified in two layers.
//
// A tween that DECLARES its state is taken at its word. Anything undeclared falls back to
// colour equality against a dim reference — a guess, and the reason the declaration exists:
// two states sharing a colour make every tween look dim.
//
// The reference is drawn only from tweens the guess still applies to (a declared "dim" one
// if present, else the first undeclared one). Deriving it from a tween declared "active"
// would compare undeclared siblings against a colour that has explicitly said it is not the
// dim reference.
if (override.activeColor || override.dimColor) {
const allTweens = gsap.getTweensOf(el);
const colorTweens = allTweens
.filter((tw) => tw.vars.color !== undefined)
.sort((a, b) => a.startTime() - b.startTime());
// Use the first tween's color as the dim baseline — if no tweens,
// fall back to computed style.
const dimBaseline = colorTweens[0] ? String(colorTweens[0].vars.color) : "";
const dimReference =
colorTweens.find((tw) => declaredCaptionState(tw) === "dim") ??
colorTweens.find((tw) => declaredCaptionState(tw) === undefined);
const dimBaseline = dimReference ? String(dimReference.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;
}
}