mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
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:
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user