feat(registry): bring back the video-primitive moves (#3169)

Restores the 208 catalog items reverted after their previews 404'd in
production, this time on the payload mechanism rather than the .html files
that caused the outage.

The generator no longer writes a preview document to docs/public. That writer,
and the machinery under it, existed only to produce files the docs host
discards, so it is gone rather than bypassed. Items now embed the composition
itself via a payload, which is what the previous change already does for the
items that were already in the catalog.

The variables explorer is parked, not restored: it drove its preview through
the same unpublished .html path, so it would have shown an empty frame. Items
that declare variables get the live player plus the static variables table, and
reconnecting the explorer to payloads is a follow-up.
This commit is contained in:
Miguel Ángel
2026-08-10 18:46:03 -04:00
committed by GitHub
parent 0f76305191
commit 9734578e60
1383 changed files with 255573 additions and 5760 deletions
@@ -90,7 +90,7 @@
max-width: 1400px;
line-height: 1.1;
white-space: nowrap;
color: var(--hf-caption-primary, #ffffff);
color: #ffffff;
font-family: "Montserrat", Arial, sans-serif;
font-weight: 300;
font-size: 72px;
@@ -117,568 +117,281 @@
</div>
<script>
// IIFE: keep every template's runtime private to itself so two caption
// components pasted into one composition document can't clobber each
// other's hfAttach/hfBuild via shared script-scope names (boot is async,
// so collisions surface AFTER all scripts have executed).
(function () {
var HF_COMPOSITION_ID = "caption-weight-shift";
var HF_ROOT_ID = "weight-shift";
var HF_GROUP_SELECTOR = ".caption-group";
var DURATION = 8;
var SAFE_ZONE_WIDTH = 1400;
var MAX_LINE_WIDTH = SAFE_ZONE_WIDTH - 40;
var CAPTION_FONT_SIZE = 72;
var MAX_WORDS_PER_GROUP = 4;
var WORD_SPACING = 12;
var PAUSE_THRESHOLD = 0.1;
var ENTRY_DURATION = 100 / 1000;
var WEIGHT_TRANSITION = 100 / 1000;
var FONT_WIDTH_SAFETY = 1.14;
var FONT_WEIGHT_LIGHT = 300;
var FONT_WEIGHT_BOLD = 700;
var HF_DEMO_DATA = {
version: 1,
segments: [
{
start: 0,
text: "Every great video starts with a single frame. HyperFrames lets you write HTML and render professional video. No timeline. No drag and drop. Just code that becomes cinema.",
words: [
{ text: "Every", start: 0.0, end: 0.3 },
{ text: "great", start: 0.3, end: 0.55 },
{ text: "video", start: 0.55, end: 0.85 },
{ text: "starts", start: 0.85, end: 1.1 },
{ text: "with", start: 1.1, end: 1.25 },
{ text: "a", start: 1.25, end: 1.35 },
{ text: "single", start: 1.35, end: 1.65 },
{ text: "frame.", start: 1.65, end: 2.0 },
{ text: "HyperFrames", start: 2.1, end: 2.65 },
{ text: "lets", start: 2.65, end: 2.85 },
{ text: "you", start: 2.85, end: 2.95 },
{ text: "write", start: 2.95, end: 3.15 },
{ text: "HTML", start: 3.15, end: 3.55 },
{ text: "and", start: 3.55, end: 3.65 },
{ text: "render", start: 3.65, end: 3.95 },
{ text: "professional", start: 3.95, end: 4.45 },
{ text: "video.", start: 4.45, end: 4.8 },
{ text: "No", start: 4.9, end: 5.05 },
{ text: "timeline.", start: 5.05, end: 5.45 },
{ text: "No", start: 5.5, end: 5.65 },
{ text: "drag", start: 5.65, end: 5.85 },
{ text: "and", start: 5.85, end: 5.95 },
{ text: "drop.", start: 5.95, end: 6.25 },
{ text: "Just", start: 6.35, end: 6.55 },
{ text: "code", start: 6.55, end: 6.8 },
{ text: "that", start: 6.8, end: 6.95 },
{ text: "becomes", start: 6.95, end: 7.3 },
{ text: "cinema.", start: 7.3, end: 7.7 },
],
},
],
var TRANSCRIPT = [
{ text: "Every", start: 0.0, end: 0.3 },
{ text: "great", start: 0.3, end: 0.55 },
{ text: "video", start: 0.55, end: 0.85 },
{ text: "starts", start: 0.85, end: 1.1 },
{ text: "with", start: 1.1, end: 1.25 },
{ text: "a", start: 1.25, end: 1.35 },
{ text: "single", start: 1.35, end: 1.65 },
{ text: "frame.", start: 1.65, end: 2.0 },
{ text: "HyperFrames", start: 2.1, end: 2.65 },
{ text: "lets", start: 2.65, end: 2.85 },
{ text: "you", start: 2.85, end: 2.95 },
{ text: "write", start: 2.95, end: 3.15 },
{ text: "HTML", start: 3.15, end: 3.55 },
{ text: "and", start: 3.55, end: 3.65 },
{ text: "render", start: 3.65, end: 3.95 },
{ text: "professional", start: 3.95, end: 4.45 },
{ text: "video.", start: 4.45, end: 4.8 },
{ text: "No", start: 4.9, end: 5.05 },
{ text: "timeline.", start: 5.05, end: 5.45 },
{ text: "No", start: 5.5, end: 5.65 },
{ text: "drag", start: 5.65, end: 5.85 },
{ text: "and", start: 5.85, end: 5.95 },
{ text: "drop.", start: 5.95, end: 6.25 },
{ text: "Just", start: 6.35, end: 6.55 },
{ text: "code", start: 6.55, end: 6.8 },
{ text: "that", start: 6.8, end: 6.95 },
{ text: "becomes", start: 6.95, end: 7.3 },
{ text: "cinema.", start: 7.3, end: 7.7 },
];
function normalizeWords(rawWords) {
return rawWords
.map(function (item) {
return {
text: String(item.word || item.text || "").trim(),
start: Math.max(0, Number(item.start) || 0),
end: Math.min(DURATION, Math.max(Number(item.start) || 0, Number(item.end) || 0)),
};
})
.filter(function (word) {
return word.text.length > 0;
});
}
var _fitCanvas = document.createElement("canvas");
var _fitCtx = _fitCanvas.getContext("2d");
function fitFontSize(text, baseFontSize, fontWeight, fontFamily, maxWidth) {
var size = baseFontSize;
var minSize = Math.floor(baseFontSize * 0.45);
while (size > minSize) {
_fitCtx.font = fontWeight + " " + size + "px " + fontFamily;
if (_fitCtx.measureText(text).width <= maxWidth) return size;
size -= 2;
}
return minSize;
}
var measureCanvas = document.createElement("canvas");
var measureContext = measureCanvas.getContext("2d");
measureContext.font = "700 " + CAPTION_FONT_SIZE + "px Montserrat";
function measureWord(word) {
return measureContext.measureText(word.text.toLowerCase()).width * FONT_WIDTH_SAFETY;
}
function measureLineWidth(words) {
return words.reduce(function (total, word, index) {
return total + measureWord(word) + (index ? WORD_SPACING : 0);
}, 0);
}
function fitsInTwoLines(words) {
return measureLineWidth(words) <= MAX_LINE_WIDTH * 1.8;
}
function splitLines(words) {
if (words.length < 2) {
return [{ words: words, startIndex: 0 }];
}
var bestSplit = Math.ceil(words.length / 2);
var bestDiff = Infinity;
for (var s = 1; s < words.length; s++) {
var w1 = measureLineWidth(words.slice(0, s));
var w2 = measureLineWidth(words.slice(s));
if (w1 <= MAX_LINE_WIDTH && w2 <= MAX_LINE_WIDTH) {
var diff = Math.abs(w1 - w2);
if (diff < bestDiff) {
bestDiff = diff;
bestSplit = s;
}
}
}
var result = [];
var line1 = words.slice(0, bestSplit);
var line2 = words.slice(bestSplit);
if (line1.length) result.push({ words: line1, startIndex: 0 });
if (line2.length) result.push({ words: line2, startIndex: line1.length });
return result;
}
function avoidSingleWordGroups(groups) {
var out = [];
groups.forEach(function (group) {
if (
group.words.length === 1 &&
out.length > 0 &&
out[out.length - 1].words.length < MAX_WORDS_PER_GROUP
) {
out[out.length - 1] = makeGroup(out[out.length - 1].words.concat(group.words));
} else {
out.push(group);
}
});
for (var i = 0; i < out.length; i++) {
if (out[i].words.length !== 1) continue;
if (i + 1 < out.length && out[i + 1].words.length < MAX_WORDS_PER_GROUP) {
out[i] = makeGroup(out[i].words.concat(out[i + 1].words));
out.splice(i + 1, 1);
} else if (i > 0 && out[i - 1].words.length < MAX_WORDS_PER_GROUP) {
out[i - 1] = makeGroup(out[i - 1].words.concat(out[i].words));
out.splice(i, 1);
i--;
}
}
return out;
}
function makeGroups(words) {
var groups = [];
var current = [];
words.forEach(function (word, index) {
var nextWord = words[index + 1];
var testGroup = current.concat([word]);
if (testGroup.length > MAX_WORDS_PER_GROUP || !fitsInTwoLines(testGroup)) {
if (current.length) groups.push(makeGroup(current));
current = [word];
return;
}
current.push(word);
var punctuation = /[,.:!?]$/.test(word.text);
var pause = nextWord ? nextWord.start - word.end : 0;
if (
!nextWord ||
punctuation ||
pause >= PAUSE_THRESHOLD ||
current.length >= MAX_WORDS_PER_GROUP
) {
groups.push(makeGroup(current));
current = [];
}
});
if (current.length) groups.push(makeGroup(current));
return avoidSingleWordGroups(groups);
}
function makeGroup(words) {
return {
words: words.slice(),
lines: splitLines(words),
start: words[0].start,
end: words[words.length - 1].end,
};
}
/* ── HyperFrames caption-data runtime (shared across caption templates) ── */
var HF_CONTRACT_VERSION = 1;
var HF_DEFAULT_RESOLUTION = { width: 1920, height: 1080 };
function hfFlattenSegments(segments) {
var out = [];
(segments || []).forEach(function (seg) {
((seg && seg.words) || []).forEach(function (w) {
var start = Math.max(0, Number(w.start) || 0);
out.push({
text: String(w.word || w.text || "").trim(),
start: start,
end: Math.max(start, Number(w.end) || 0),
});
});
});
out.sort(function (a, b) {
return a.start - b.start;
});
return out.filter(function (w) {
return w.text.length > 0;
});
}
function hfDeriveDuration(words) {
var end = 0;
words.forEach(function (w) {
if (w.end > end) end = w.end;
});
return end;
}
function hfValidate(data) {
if (!data || typeof data !== "object") return { ok: false, reason: "not-an-object" };
if (data.version != null) {
// Number("v2") is NaN and NaN > x is always false — an explicit
// finite check keeps malformed versions from slipping past the gate.
var hfVersion = Number(data.version);
if (!Number.isFinite(hfVersion) || Math.floor(hfVersion) > HF_CONTRACT_VERSION) {
return { ok: false, reason: "unsupported-version" };
}
}
if (!Array.isArray(data.segments) || data.segments.length === 0) {
return { ok: false, reason: "no-segments" };
}
if (data.displayMode != null && data.displayMode !== "full") {
return { ok: false, reason: "unsupported-displayMode" };
}
var words = hfFlattenSegments(data.segments);
if (words.length === 0) return { ok: false, reason: "no-words" };
return { ok: true, words: words, durationS: hfDeriveDuration(words) };
}
function hfApplyStageConfig(data, durationS) {
var res = (data && data.resolution) || HF_DEFAULT_RESOLUTION;
// Mirror the published validator's <=8192 bound defensively — the
// validator is an optional pre-flight, and an unbounded stage size
// would OOM render workers.
var W = Math.min(
8192,
Math.max(1, Math.round(Number(res.width) || HF_DEFAULT_RESOLUTION.width)),
function buildCaptions(groups) {
var stage = document.getElementById("caption-stage");
groups.forEach(function (group, groupIndex) {
var groupText = group.words
.map(function (w) {
return w.text.toLowerCase();
})
.join(" ");
var computedSize = fitFontSize(
groupText,
CAPTION_FONT_SIZE,
"700",
"Montserrat",
MAX_LINE_WIDTH,
);
var H = Math.min(
8192,
Math.max(1, Math.round(Number(res.height) || HF_DEFAULT_RESOLUTION.height)),
var groupEl = document.createElement("div");
var copyEl = document.createElement("div");
groupEl.id = "caption-group-" + groupIndex;
groupEl.className = "caption-group";
copyEl.className = "caption-copy";
group.lines.forEach(function (line, lineIndex) {
var lineEl = document.createElement("div");
lineEl.className = "caption-line";
lineEl.id = "caption-group-" + groupIndex + "-line-" + lineIndex;
lineEl.style.fontWeight = lineIndex === 0 ? FONT_WEIGHT_BOLD : FONT_WEIGHT_LIGHT;
lineEl.style.fontSize = computedSize + "px";
line.words.forEach(function (word, wordIndex) {
var wordEl = document.createElement("span");
wordEl.textContent = word.text.toLowerCase();
wordEl.style.display = "inline-block";
lineEl.appendChild(wordEl);
});
copyEl.appendChild(lineEl);
});
groupEl.appendChild(copyEl);
stage.appendChild(groupEl);
});
}
var WORDS = normalizeWords(TRANSCRIPT);
var GROUPS = makeGroups(WORDS);
buildCaptions(GROUPS);
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
var visibleStarts = GROUPS.map(function (group, groupIndex) {
var prev = GROUPS[groupIndex - 1];
var availableGap = prev ? Math.max(0, group.start - prev.end) : ENTRY_DURATION;
var entryLead = Math.min(ENTRY_DURATION, availableGap);
return Math.max(0, group.start - entryLead);
});
var allGroupEls = GROUPS.map(function (_, i) {
return document.getElementById("caption-group-" + i);
});
GROUPS.forEach(function (group, groupIndex) {
var groupEl = allGroupEls[groupIndex];
var visibleStart = visibleStarts[groupIndex];
var visibleEnd = groupIndex < GROUPS.length - 1 ? visibleStarts[groupIndex + 1] : DURATION;
allGroupEls.forEach(function (otherEl, otherIndex) {
if (otherIndex !== groupIndex) tl.set(otherEl, { opacity: 0 }, visibleStart);
});
tl.set(groupEl, { opacity: 0, scale: 0.85 }, visibleStart);
tl.to(
groupEl,
{ opacity: 1, scale: 1, duration: ENTRY_DURATION, ease: "power3.out", force3D: true },
visibleStart,
);
if (group.lines.length === 2) {
var line1El = document.getElementById("caption-group-" + groupIndex + "-line-0");
var line2El = document.getElementById("caption-group-" + groupIndex + "-line-1");
var line2FirstWord = group.lines[1].words[0];
var switchTime = line2FirstWord.start;
tl.to(
line1El,
{ fontWeight: FONT_WEIGHT_LIGHT, duration: WEIGHT_TRANSITION, ease: "power2.out" },
switchTime,
);
tl.to(
line2El,
{ fontWeight: FONT_WEIGHT_BOLD, duration: WEIGHT_TRANSITION, ease: "power2.out" },
switchTime,
);
var root = document.getElementById(HF_ROOT_ID);
[document.documentElement, document.body, root].forEach(function (el) {
el.style.width = W + "px";
el.style.height = H + "px";
});
root.setAttribute("data-width", String(W));
root.setAttribute("data-height", String(H));
root.setAttribute("data-duration", String(durationS));
var brand = (data && data.brand) || {};
if (typeof brand.primaryColor === "string" && brand.primaryColor) {
root.style.setProperty("--hf-caption-primary", brand.primaryColor);
} else {
root.style.removeProperty("--hf-caption-primary");
}
if (typeof brand.accentColor === "string" && brand.accentColor) {
root.style.setProperty("--hf-caption-accent", brand.accentColor);
} else {
root.style.removeProperty("--hf-caption-accent");
}
return {
W: W,
H: H,
scaleX: W / 1920,
scaleY: H / 1080,
fontScale: Math.min(W, H) / 1080,
};
}
function hfPublishStatus(status) {
window.__HF_CAPTION_STATUS = status;
document
.getElementById(HF_ROOT_ID)
.setAttribute("data-caption-status", status.ok ? "ok" : "failed");
}
tl.set(groupEl, { opacity: 0 }, visibleEnd);
});
function hfRunGate(words, durationS, failReason) {
if (failReason) {
hfPublishStatus({
ok: false,
present: false,
contained: false,
reason: failReason,
version: HF_CONTRACT_VERSION,
durationS: durationS || 0,
});
return;
}
var root = document.getElementById(HF_ROOT_ID);
var rootRect = root.getBoundingClientRect();
var groups = root.querySelectorAll(HF_GROUP_SELECTOR);
var present = false;
var contained = true;
var tokens = [];
words.forEach(function (w) {
var t = w.text.toLowerCase().replace(/[^a-z0-9]/g, "");
if (t) tokens.push(t);
});
groups.forEach(function (el) {
var prevOpacity = el.style.opacity;
var prevVisibility = el.style.visibility;
el.style.opacity = "1";
el.style.visibility = "visible";
var r = el.getBoundingClientRect();
if (r.width > 0 && r.height > 0) {
var text = (el.textContent || "").toLowerCase();
for (var i = 0; i < tokens.length; i++) {
if (text.indexOf(tokens[i]) !== -1) {
present = true;
break;
}
}
if (
r.left < rootRect.left - 1 ||
r.right > rootRect.right + 1 ||
r.top < rootRect.top - 1 ||
r.bottom > rootRect.bottom + 1
) {
contained = false;
}
}
el.style.opacity = prevOpacity;
el.style.visibility = prevVisibility;
});
hfPublishStatus({
ok: present && contained,
present: present,
contained: contained,
version: HF_CONTRACT_VERSION,
durationS: durationS,
});
}
var SAFE_ZONE_WIDTH, MAX_LINE_WIDTH, CAPTION_FONT_SIZE, DURATION;
var MAX_WORDS_PER_GROUP = 4;
var WORD_SPACING_DESIGN = 12;
var WORD_SPACING;
var PAUSE_THRESHOLD = 0.1;
var ENTRY_DURATION = 100 / 1000;
var WEIGHT_TRANSITION = 100 / 1000;
var FONT_WIDTH_SAFETY = 1.14;
var FONT_WEIGHT_LIGHT = 300;
var FONT_WEIGHT_BOLD = 700;
function normalizeWords(rawWords) {
return rawWords
.map(function (item) {
return {
text: String(item.word || item.text || "").trim(),
start: Math.max(0, Number(item.start) || 0),
end: Math.min(DURATION, Math.max(Number(item.start) || 0, Number(item.end) || 0)),
};
})
.filter(function (word) {
return word.text.length > 0;
});
}
var _fitCanvas = document.createElement("canvas");
var _fitCtx = _fitCanvas.getContext("2d");
function fitFontSize(text, baseFontSize, fontWeight, fontFamily, maxWidth) {
var size = baseFontSize;
var minSize = Math.floor(baseFontSize * 0.45);
// minSize is a hard floor returned unverified below — self-heal
// accepts residual overflow at the floor (clipped by the stage)
// rather than shrinking below legibility.
while (size > minSize) {
_fitCtx.font = fontWeight + " " + size + "px " + fontFamily;
if (_fitCtx.measureText(text).width <= maxWidth) return size;
size -= 2;
}
return minSize;
}
var measureCanvas = document.createElement("canvas");
var measureContext = measureCanvas.getContext("2d");
function measureWord(word) {
return measureContext.measureText(word.text.toLowerCase()).width * FONT_WIDTH_SAFETY;
}
function measureLineWidth(words) {
return words.reduce(function (total, word, index) {
return total + measureWord(word) + (index ? WORD_SPACING : 0);
}, 0);
}
function fitsInTwoLines(words) {
return measureLineWidth(words) <= MAX_LINE_WIDTH * 1.8;
}
function splitLines(words) {
if (words.length < 2) {
return [{ words: words, startIndex: 0 }];
}
var bestSplit = Math.ceil(words.length / 2);
var bestDiff = Infinity;
for (var s = 1; s < words.length; s++) {
var w1 = measureLineWidth(words.slice(0, s));
var w2 = measureLineWidth(words.slice(s));
if (w1 <= MAX_LINE_WIDTH && w2 <= MAX_LINE_WIDTH) {
var diff = Math.abs(w1 - w2);
if (diff < bestDiff) {
bestDiff = diff;
bestSplit = s;
}
}
}
var result = [];
var line1 = words.slice(0, bestSplit);
var line2 = words.slice(bestSplit);
if (line1.length) result.push({ words: line1, startIndex: 0 });
if (line2.length) result.push({ words: line2, startIndex: line1.length });
return result;
}
function avoidSingleWordGroups(groups) {
var out = [];
groups.forEach(function (group) {
if (
group.words.length === 1 &&
out.length > 0 &&
out[out.length - 1].words.length < MAX_WORDS_PER_GROUP &&
// makeGroups guards every addition with fitsInTwoLines; this
// second-pass merge must too, or it can produce a group that
// splitLines cannot fit within MAX_LINE_WIDTH.
fitsInTwoLines(out[out.length - 1].words.concat(group.words))
) {
out[out.length - 1] = makeGroup(out[out.length - 1].words.concat(group.words));
} else {
out.push(group);
}
});
for (var i = 0; i < out.length; i++) {
if (out[i].words.length !== 1) continue;
if (
i + 1 < out.length &&
out[i + 1].words.length < MAX_WORDS_PER_GROUP &&
fitsInTwoLines(out[i].words.concat(out[i + 1].words))
) {
out[i] = makeGroup(out[i].words.concat(out[i + 1].words));
out.splice(i + 1, 1);
} else if (
i > 0 &&
out[i - 1].words.length < MAX_WORDS_PER_GROUP &&
fitsInTwoLines(out[i - 1].words.concat(out[i].words))
) {
out[i - 1] = makeGroup(out[i - 1].words.concat(out[i].words));
out.splice(i, 1);
i--;
}
}
return out;
}
function makeGroups(words) {
var groups = [];
var current = [];
words.forEach(function (word, index) {
var nextWord = words[index + 1];
var testGroup = current.concat([word]);
if (testGroup.length > MAX_WORDS_PER_GROUP || !fitsInTwoLines(testGroup)) {
if (current.length) groups.push(makeGroup(current));
current = [word];
return;
}
current.push(word);
var punctuation = /[,.:!?]$/.test(word.text);
var pause = nextWord ? nextWord.start - word.end : 0;
if (
!nextWord ||
punctuation ||
pause >= PAUSE_THRESHOLD ||
current.length >= MAX_WORDS_PER_GROUP
) {
groups.push(makeGroup(current));
current = [];
}
});
if (current.length) groups.push(makeGroup(current));
return avoidSingleWordGroups(groups);
}
function makeGroup(words) {
return {
words: words.slice(),
lines: splitLines(words),
start: words[0].start,
end: words[words.length - 1].end,
};
}
function buildCaptions(groups) {
var stage = document.getElementById("caption-stage");
groups.forEach(function (group, groupIndex) {
// Fit the WIDEST split line, not the joined group text: the group
// renders across up to two lines, so sizing against the joined
// string shrank two-line groups that already fit at full size.
var widestLineText = "";
var widestLineWidth = -1;
group.lines.forEach(function (line) {
var lineWidth = measureLineWidth(line.words);
if (lineWidth > widestLineWidth) {
widestLineWidth = lineWidth;
widestLineText = line.words
.map(function (w) {
return w.text.toLowerCase();
})
.join(" ");
}
});
var computedSize = fitFontSize(
widestLineText,
CAPTION_FONT_SIZE,
"700",
"Montserrat",
MAX_LINE_WIDTH,
);
var groupEl = document.createElement("div");
var copyEl = document.createElement("div");
groupEl.id = "caption-group-" + groupIndex;
groupEl.className = "caption-group";
copyEl.className = "caption-copy";
group.lines.forEach(function (line, lineIndex) {
var lineEl = document.createElement("div");
lineEl.className = "caption-line";
lineEl.id = "caption-group-" + groupIndex + "-line-" + lineIndex;
lineEl.style.fontWeight = lineIndex === 0 ? FONT_WEIGHT_BOLD : FONT_WEIGHT_LIGHT;
lineEl.style.fontSize = computedSize + "px";
lineEl.style.maxWidth = SAFE_ZONE_WIDTH + "px";
line.words.forEach(function (word, wordIndex) {
var wordEl = document.createElement("span");
wordEl.textContent = word.text.toLowerCase();
wordEl.style.display = "inline-block";
lineEl.appendChild(wordEl);
});
copyEl.appendChild(lineEl);
});
groupEl.appendChild(copyEl);
stage.appendChild(groupEl);
});
}
function hfTeardown() {
document.getElementById("caption-stage").innerHTML = "";
}
function hfBuild(words, layout, durationS) {
DURATION = durationS;
CAPTION_FONT_SIZE = Math.round(72 * layout.fontScale);
SAFE_ZONE_WIDTH = Math.round(1400 * layout.scaleX);
MAX_LINE_WIDTH = SAFE_ZONE_WIDTH - Math.round(40 * layout.scaleX);
WORD_SPACING = Math.round(WORD_SPACING_DESIGN * layout.fontScale);
var stage = document.getElementById("caption-stage");
var stageHeight = Math.round(278 * layout.fontScale);
stage.style.width = SAFE_ZONE_WIDTH + "px";
stage.style.height = stageHeight + "px";
stage.style.bottom = Math.round(100 * layout.scaleY) + "px";
measureContext.font = "700 " + CAPTION_FONT_SIZE + "px Montserrat";
var WORDS = normalizeWords(words);
var GROUPS = makeGroups(WORDS);
buildCaptions(GROUPS);
// set per-group sizing to match the runtime stage
GROUPS.forEach(function (_, i) {
var el = document.getElementById("caption-group-" + i);
el.style.width = SAFE_ZONE_WIDTH + "px";
el.style.height = stageHeight + "px";
});
var tl = gsap.timeline({ paused: true });
var visibleStarts = GROUPS.map(function (group, groupIndex) {
var prev = GROUPS[groupIndex - 1];
var availableGap = prev ? Math.max(0, group.start - prev.end) : ENTRY_DURATION;
var entryLead = Math.min(ENTRY_DURATION, availableGap);
return Math.max(0, group.start - entryLead);
});
var allGroupEls = GROUPS.map(function (_, i) {
return document.getElementById("caption-group-" + i);
});
GROUPS.forEach(function (group, groupIndex) {
var groupEl = allGroupEls[groupIndex];
var visibleStart = visibleStarts[groupIndex];
var visibleEnd =
groupIndex < GROUPS.length - 1 ? visibleStarts[groupIndex + 1] : DURATION;
// Each group already owns its full opacity lifecycle (set 0 at its
// own visibleStart, animated to 1, then set back to 0 at its own
// visibleEnd below), and groups occupy non-overlapping windows, so
// explicitly hiding every OTHER group here was redundant and made
// timeline construction O(groups^2) — this collapsed under dense
// stress transcripts (many short groups). Removed; behavior is
// unchanged (verified against templates.test.ts).
tl.set(groupEl, { opacity: 0, scale: 0.85 }, visibleStart);
tl.to(
groupEl,
{ opacity: 1, scale: 1, duration: ENTRY_DURATION, ease: "power3.out", force3D: true },
visibleStart,
);
if (group.lines.length === 2) {
var line1El = document.getElementById("caption-group-" + groupIndex + "-line-0");
var line2El = document.getElementById("caption-group-" + groupIndex + "-line-1");
var line2FirstWord = group.lines[1].words[0];
var switchTime = line2FirstWord.start;
tl.to(
line1El,
{ fontWeight: FONT_WEIGHT_LIGHT, duration: WEIGHT_TRANSITION, ease: "power2.out" },
switchTime,
);
tl.to(
line2El,
{ fontWeight: FONT_WEIGHT_BOLD, duration: WEIGHT_TRANSITION, ease: "power2.out" },
switchTime,
);
}
tl.set(groupEl, { opacity: 0 }, visibleEnd);
});
return tl;
}
var hfCurrentTimeline = null;
function hfAttach(data) {
var v = hfValidate(data);
if (!v.ok) {
hfRunGate([], 0, v.reason);
return;
}
if (hfCurrentTimeline) {
hfCurrentTimeline.kill();
hfCurrentTimeline = null;
}
hfTeardown();
var layout = hfApplyStageConfig(data, v.durationS);
var tl = hfBuild(v.words, layout, v.durationS);
tl.set({}, {}, v.durationS); // pin timeline length to the data-derived duration
// GSAP quirk (confirmed 3.14.2): a fresh timeline already reports position 0,
// so seek(0) is treated as a no-op and skips rendering .set() calls scheduled
// at t=0 — the first frame would show pre-timeline CSS defaults (usually
// hidden). render(time, suppressEvents, force) with force=true bypasses that.
tl.render(0, true, true);
hfCurrentTimeline = tl;
window.__timelines = window.__timelines || {};
window.__timelines[HF_COMPOSITION_ID] = tl;
hfRunGate(v.words, v.durationS, null);
}
window.__HF_CAPTION_ATTACH__ = hfAttach;
(function hfBoot() {
function start(data) {
var ready =
document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve();
ready.then(function () {
// A manual window.__HF_CAPTION_ATTACH__ call that lands while the
// sibling fetch / fonts.ready are still pending must win — never
// clobber it with the late-arriving boot payload.
if (hfCurrentTimeline) return;
hfAttach(data);
});
}
if (window.__HF_CAPTION__) {
start(window.__HF_CAPTION__);
return;
}
fetch("./caption-data.json")
.then(function (r) {
return r.ok ? r.json() : null;
})
.catch(function () {
return null;
})
.then(function (json) {
start(json || HF_DEMO_DATA);
});
})();
})();
window.__timelines["caption-weight-shift"] = tl;
</script>
</body>
</html>