fix(registry): encapsulate caption template runtimes in IIFEs

caption-highlight was already IIFE-wrapped; the other four leaked their
runtime (hfAttach/hfBuild/...) as script-scope globals. Harmless when boot
was synchronous, but the data-driven retrofit defers attach behind
fonts.ready + the sibling fetch — with two caption components pasted into
one composition document, every script finishes before any deferred boot
runs, the last script's definitions win the shared scope, and the first
component never registers its timeline (a renderer waiting on it hangs to
timeout). Wrapping each template's script keeps its internals private so
each boot attaches its own component. window.__HF_CAPTION_ATTACH__ remains
intentionally window-scoped (last-defined-wins).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
vanceingalls
2026-07-24 05:25:41 +00:00
co-authored by Claude Fable 5
parent 4f69947191
commit e2846eb7cc
4 changed files with 2129 additions and 2071 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -131,478 +131,492 @@
</div> </div>
<script> <script>
var HF_COMPOSITION_ID = "caption-pill-karaoke"; // IIFE: keep every template's runtime private to itself so two caption
var HF_ROOT_ID = "pill-karaoke"; // components pasted into one composition document can't clobber each
var HF_GROUP_SELECTOR = ".caption-group"; // 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-pill-karaoke";
var HF_ROOT_ID = "pill-karaoke";
var HF_GROUP_SELECTOR = ".caption-group";
var HF_DEMO_DATA = { var HF_DEMO_DATA = {
version: 1, version: 1,
segments: [ segments: [
{ {
start: 0, 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.", 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: [ words: [
{ text: "Every", start: 0.0, end: 0.3 }, { text: "Every", start: 0.0, end: 0.3 },
{ text: "great", start: 0.3, end: 0.55 }, { text: "great", start: 0.3, end: 0.55 },
{ text: "video", start: 0.55, end: 0.85 }, { text: "video", start: 0.55, end: 0.85 },
{ text: "starts", start: 0.85, end: 1.1 }, { text: "starts", start: 0.85, end: 1.1 },
{ text: "with", start: 1.1, end: 1.25 }, { text: "with", start: 1.1, end: 1.25 },
{ text: "a", start: 1.25, end: 1.35 }, { text: "a", start: 1.25, end: 1.35 },
{ text: "single", start: 1.35, end: 1.65 }, { text: "single", start: 1.35, end: 1.65 },
{ text: "frame.", start: 1.65, end: 2.0 }, { text: "frame.", start: 1.65, end: 2.0 },
{ text: "HyperFrames", start: 2.1, end: 2.65 }, { text: "HyperFrames", start: 2.1, end: 2.65 },
{ text: "lets", start: 2.65, end: 2.85 }, { text: "lets", start: 2.65, end: 2.85 },
{ text: "you", start: 2.85, end: 2.95 }, { text: "you", start: 2.85, end: 2.95 },
{ text: "write", start: 2.95, end: 3.15 }, { text: "write", start: 2.95, end: 3.15 },
{ text: "HTML", start: 3.15, end: 3.55 }, { text: "HTML", start: 3.15, end: 3.55 },
{ text: "and", start: 3.55, end: 3.65 }, { text: "and", start: 3.55, end: 3.65 },
{ text: "render", start: 3.65, end: 3.95 }, { text: "render", start: 3.65, end: 3.95 },
{ text: "professional", start: 3.95, end: 4.45 }, { text: "professional", start: 3.95, end: 4.45 },
{ text: "video.", start: 4.45, end: 4.8 }, { text: "video.", start: 4.45, end: 4.8 },
{ text: "No", start: 4.9, end: 5.05 }, { text: "No", start: 4.9, end: 5.05 },
{ text: "timeline.", start: 5.05, end: 5.45 }, { text: "timeline.", start: 5.05, end: 5.45 },
{ text: "No", start: 5.5, end: 5.65 }, { text: "No", start: 5.5, end: 5.65 },
{ text: "drag", start: 5.65, end: 5.85 }, { text: "drag", start: 5.65, end: 5.85 },
{ text: "and", start: 5.85, end: 5.95 }, { text: "and", start: 5.85, end: 5.95 },
{ text: "drop.", start: 5.95, end: 6.25 }, { text: "drop.", start: 5.95, end: 6.25 },
{ text: "Just", start: 6.35, end: 6.55 }, { text: "Just", start: 6.35, end: 6.55 },
{ text: "code", start: 6.55, end: 6.8 }, { text: "code", start: 6.55, end: 6.8 },
{ text: "that", start: 6.8, end: 6.95 }, { text: "that", start: 6.8, end: 6.95 },
{ text: "becomes", start: 6.95, end: 7.3 }, { text: "becomes", start: 6.95, end: 7.3 },
{ text: "cinema.", start: 7.3, end: 7.7 }, { text: "cinema.", start: 7.3, end: 7.7 },
], ],
}, },
], ],
}; };
/* ── HyperFrames caption-data runtime (shared across caption templates) ── */ /* ── HyperFrames caption-data runtime (shared across caption templates) ── */
var HF_CONTRACT_VERSION = 1; var HF_CONTRACT_VERSION = 1;
var HF_DEFAULT_RESOLUTION = { width: 1920, height: 1080 }; var HF_DEFAULT_RESOLUTION = { width: 1920, height: 1080 };
function hfFlattenSegments(segments) { function hfFlattenSegments(segments) {
var out = []; var out = [];
(segments || []).forEach(function (seg) { (segments || []).forEach(function (seg) {
((seg && seg.words) || []).forEach(function (w) { ((seg && seg.words) || []).forEach(function (w) {
var start = Math.max(0, Number(w.start) || 0); var start = Math.max(0, Number(w.start) || 0);
out.push({ out.push({
text: String(w.word || w.text || "").trim(), text: String(w.word || w.text || "").trim(),
start: start, start: start,
end: Math.max(start, Number(w.end) || 0), end: Math.max(start, Number(w.end) || 0),
});
}); });
}); });
}); out.sort(function (a, b) {
out.sort(function (a, b) { return a.start - b.start;
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 && Math.floor(Number(data.version)) > 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;
var W = Math.max(1, Math.round(Number(res.width) || HF_DEFAULT_RESOLUTION.width));
var H = Math.max(1, Math.round(Number(res.height) || HF_DEFAULT_RESOLUTION.height));
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");
}
function hfRunGate(words, durationS, failReason) {
if (failReason) {
hfPublishStatus({
ok: false,
present: false,
contained: false,
reason: failReason,
version: HF_CONTRACT_VERSION,
durationS: durationS || 0,
}); });
return; return out.filter(function (w) {
}
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 DURATION, SAFE_ZONE_WIDTH, PILL_MAX_WIDTH, BASE_FONT_SIZE, MIN_FONT_SIZE, WORD_SPACING;
var PILL_MIN_PADDING_X = 30;
var PILL_MAX_PADDING_X = 60;
var FONT_WIDTH_SAFETY = 1.18;
var FONT_FAMILY = "Poppins";
var FONT_WEIGHT = "700";
var MAX_WORDS_PER_GROUP = 4;
var PAUSE_THRESHOLD = 0.1;
var GROUP_END_BUFFER = 0.3;
var COLOR_INACTIVE = "#A6A6A6";
var COLOR_ACTIVE = "#1C1E1D";
var hfColorActive = COLOR_ACTIVE;
var COLOR_FADE_DURATION = 0.1;
var WORD_FADE_LEAD = 0.05;
function normalizeWords(rawWords) {
return rawWords
.map(function (item) {
var text = String(item.word || item.text || "").trim();
return {
text: text,
start: Math.max(0, Number(item.start) || 0),
end: Math.min(DURATION, Math.max(Number(item.start) || 0, Number(item.end) || 0)),
};
})
.filter(function (w) {
return w.text.length > 0; return w.text.length > 0;
}); });
}
var measureCanvas = document.createElement("canvas");
var measureContext = measureCanvas.getContext("2d");
function measureWordWidth(text, fontSize) {
measureContext.font = FONT_WEIGHT + " " + fontSize + "px " + FONT_FAMILY;
return measureContext.measureText(text).width * FONT_WIDTH_SAFETY;
}
function measureLineWidth(words, fontSize) {
return words.reduce(function (total, word, index) {
return (
total + measureWordWidth(word.text.toLowerCase(), fontSize) + (index ? WORD_SPACING : 0)
);
}, 0);
}
function splitLinesForGroup(words, fontSize) {
var maxLineWidth = PILL_MAX_WIDTH - PILL_MIN_PADDING_X * 2;
if (
words.some(function (w) {
return measureWordWidth(w.text.toLowerCase(), fontSize) > maxLineWidth;
})
) {
return null;
} }
if (measureLineWidth(words, fontSize) <= maxLineWidth) return [words];
var bestSplit = null; function hfDeriveDuration(words) {
var bestImbalance = Infinity; var end = 0;
for (var split = 1; split < words.length; split++) { words.forEach(function (w) {
var firstLine = words.slice(0, split); if (w.end > end) end = w.end;
var secondLine = words.slice(split); });
var firstWidth = measureLineWidth(firstLine, fontSize); return end;
var secondWidth = measureLineWidth(secondLine, fontSize); }
if (firstWidth <= maxLineWidth && secondWidth <= maxLineWidth) {
var imbalance = Math.abs(firstWidth - secondWidth); function hfValidate(data) {
if (imbalance < bestImbalance) { if (!data || typeof data !== "object") return { ok: false, reason: "not-an-object" };
bestSplit = [firstLine, secondLine]; if (data.version != null && Math.floor(Number(data.version)) > HF_CONTRACT_VERSION) {
bestImbalance = imbalance; 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;
var W = Math.max(1, Math.round(Number(res.width) || HF_DEFAULT_RESOLUTION.width));
var H = Math.max(1, Math.round(Number(res.height) || HF_DEFAULT_RESOLUTION.height));
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");
}
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 DURATION, SAFE_ZONE_WIDTH, PILL_MAX_WIDTH, BASE_FONT_SIZE, MIN_FONT_SIZE, WORD_SPACING;
var PILL_MIN_PADDING_X = 30;
var PILL_MAX_PADDING_X = 60;
var FONT_WIDTH_SAFETY = 1.18;
var FONT_FAMILY = "Poppins";
var FONT_WEIGHT = "700";
var MAX_WORDS_PER_GROUP = 4;
var PAUSE_THRESHOLD = 0.1;
var GROUP_END_BUFFER = 0.3;
var COLOR_INACTIVE = "#A6A6A6";
var COLOR_ACTIVE = "#1C1E1D";
var hfColorActive = COLOR_ACTIVE;
var COLOR_FADE_DURATION = 0.1;
var WORD_FADE_LEAD = 0.05;
function normalizeWords(rawWords) {
return rawWords
.map(function (item) {
var text = String(item.word || item.text || "").trim();
return {
text: text,
start: Math.max(0, Number(item.start) || 0),
end: Math.min(DURATION, Math.max(Number(item.start) || 0, Number(item.end) || 0)),
};
})
.filter(function (w) {
return w.text.length > 0;
});
}
var measureCanvas = document.createElement("canvas");
var measureContext = measureCanvas.getContext("2d");
function measureWordWidth(text, fontSize) {
measureContext.font = FONT_WEIGHT + " " + fontSize + "px " + FONT_FAMILY;
return measureContext.measureText(text).width * FONT_WIDTH_SAFETY;
}
function measureLineWidth(words, fontSize) {
return words.reduce(function (total, word, index) {
return (
total +
measureWordWidth(word.text.toLowerCase(), fontSize) +
(index ? WORD_SPACING : 0)
);
}, 0);
}
function splitLinesForGroup(words, fontSize) {
var maxLineWidth = PILL_MAX_WIDTH - PILL_MIN_PADDING_X * 2;
if (
words.some(function (w) {
return measureWordWidth(w.text.toLowerCase(), fontSize) > maxLineWidth;
})
) {
return null;
}
if (measureLineWidth(words, fontSize) <= maxLineWidth) return [words];
var bestSplit = null;
var bestImbalance = Infinity;
for (var split = 1; split < words.length; split++) {
var firstLine = words.slice(0, split);
var secondLine = words.slice(split);
var firstWidth = measureLineWidth(firstLine, fontSize);
var secondWidth = measureLineWidth(secondLine, fontSize);
if (firstWidth <= maxLineWidth && secondWidth <= maxLineWidth) {
var imbalance = Math.abs(firstWidth - secondWidth);
if (imbalance < bestImbalance) {
bestSplit = [firstLine, secondLine];
bestImbalance = imbalance;
}
} }
} }
return bestSplit;
} }
return bestSplit;
}
function fitsInTwoLinesAtSize(words, fontSize) { function fitsInTwoLinesAtSize(words, fontSize) {
return splitLinesForGroup(words, fontSize) !== null; return splitLinesForGroup(words, fontSize) !== null;
} }
function planPillLayout(words, fontSize) { function planPillLayout(words, fontSize) {
var lines = splitLinesForGroup(words, fontSize) || [words]; var lines = splitLinesForGroup(words, fontSize) || [words];
var widestLine = lines.reduce(function (max, lineWords) { var widestLine = lines.reduce(function (max, lineWords) {
return Math.max(max, measureLineWidth(lineWords, fontSize)); return Math.max(max, measureLineWidth(lineWords, fontSize));
}, 0); }, 0);
var availablePadding = Math.floor((PILL_MAX_WIDTH - widestLine) / 2); var availablePadding = Math.floor((PILL_MAX_WIDTH - widestLine) / 2);
var padX = Math.max(PILL_MIN_PADDING_X, Math.min(PILL_MAX_PADDING_X, availablePadding)); var padX = Math.max(PILL_MIN_PADDING_X, Math.min(PILL_MAX_PADDING_X, availablePadding));
var pillWidth = Math.min(PILL_MAX_WIDTH, Math.ceil(widestLine + padX * 2)); var pillWidth = Math.min(PILL_MAX_WIDTH, Math.ceil(widestLine + padX * 2));
return { lines: lines, padX: padX, pillWidth: pillWidth }; return { lines: lines, padX: padX, pillWidth: pillWidth };
} }
function makeGroups(words) { function makeGroups(words) {
var groups = []; var groups = [];
var current = []; var current = [];
words.forEach(function (word, index) { words.forEach(function (word, index) {
var testGroup = current.concat([word]); var testGroup = current.concat([word]);
var nextWord = words[index + 1]; var nextWord = words[index + 1];
var wouldExceedMaxWords = testGroup.length > MAX_WORDS_PER_GROUP; var wouldExceedMaxWords = testGroup.length > MAX_WORDS_PER_GROUP;
var wouldExceedTwoLines = !fitsInTwoLinesAtSize(testGroup, BASE_FONT_SIZE); var wouldExceedTwoLines = !fitsInTwoLinesAtSize(testGroup, BASE_FONT_SIZE);
if (wouldExceedMaxWords || wouldExceedTwoLines) { if (wouldExceedMaxWords || wouldExceedTwoLines) {
if (current.length) { if (current.length) {
groups.push({
words: current.slice(),
start: current[0].start,
end: current[current.length - 1].end,
});
}
current = [word];
return;
}
current.push(word);
var hasPunctuation = /[,.:!?]$/.test(word.text);
var pauseDuration = nextWord ? nextWord.start - word.end : 0;
var hasNaturalPause = pauseDuration >= PAUSE_THRESHOLD;
var maxWordsReached = current.length >= MAX_WORDS_PER_GROUP;
if (maxWordsReached || !nextWord || hasPunctuation || hasNaturalPause) {
groups.push({ groups.push({
words: current.slice(), words: current.slice(),
start: current[0].start, start: current[0].start,
end: current[current.length - 1].end, end: current[current.length - 1].end,
}); });
current = [];
} }
current = [word]; });
return; if (current.length)
}
current.push(word);
var hasPunctuation = /[,.:!?]$/.test(word.text);
var pauseDuration = nextWord ? nextWord.start - word.end : 0;
var hasNaturalPause = pauseDuration >= PAUSE_THRESHOLD;
var maxWordsReached = current.length >= MAX_WORDS_PER_GROUP;
if (maxWordsReached || !nextWord || hasPunctuation || hasNaturalPause) {
groups.push({ groups.push({
words: current.slice(), words: current.slice(),
start: current[0].start, start: current[0].start,
end: current[current.length - 1].end, end: current[current.length - 1].end,
}); });
current = []; return groups;
}
function fontSizeForGroup(words) {
var size = BASE_FONT_SIZE;
while (size > MIN_FONT_SIZE && !fitsInTwoLinesAtSize(words, size)) {
size -= 2;
} }
}); return size;
if (current.length)
groups.push({
words: current.slice(),
start: current[0].start,
end: current[current.length - 1].end,
});
return groups;
}
function fontSizeForGroup(words) {
var size = BASE_FONT_SIZE;
while (size > MIN_FONT_SIZE && !fitsInTwoLinesAtSize(words, size)) {
size -= 2;
} }
return size;
}
function buildCaptions(groups) { function buildCaptions(groups) {
var stage = document.getElementById("caption-stage"); var stage = document.getElementById("caption-stage");
groups.forEach(function (group, groupIndex) { groups.forEach(function (group, groupIndex) {
var groupEl = document.createElement("div"); var groupEl = document.createElement("div");
var pillEl = document.createElement("div"); var pillEl = document.createElement("div");
var copyEl = document.createElement("div"); var copyEl = document.createElement("div");
var fontSize = fontSizeForGroup(group.words); var fontSize = fontSizeForGroup(group.words);
groupEl.id = "caption-group-" + groupIndex; groupEl.id = "caption-group-" + groupIndex;
groupEl.className = "caption-group"; groupEl.className = "caption-group";
pillEl.className = "caption-pill"; pillEl.className = "caption-pill";
copyEl.className = "caption-copy"; copyEl.className = "caption-copy";
copyEl.style.fontSize = fontSize + "px"; copyEl.style.fontSize = fontSize + "px";
var indexedWords = group.words.map(function (word, wordIndex) { var indexedWords = group.words.map(function (word, wordIndex) {
return { text: word.text, start: word.start, end: word.end, wordIndex: wordIndex }; return { text: word.text, start: word.start, end: word.end, wordIndex: wordIndex };
});
var layout = planPillLayout(indexedWords, fontSize);
pillEl.style.width = layout.pillWidth + "px";
pillEl.style.maxWidth = PILL_MAX_WIDTH + "px";
pillEl.style.paddingLeft = layout.padX + "px";
pillEl.style.paddingRight = layout.padX + "px";
layout.lines.slice(0, 2).forEach(function (lineWords) {
var lineEl = document.createElement("div");
lineEl.className = "caption-line";
lineWords.forEach(function (word) {
var wordEl = document.createElement("span");
wordEl.id = "caption-word-" + groupIndex + "-" + word.wordIndex;
wordEl.className = "caption-word";
wordEl.textContent = word.text.toLowerCase();
lineEl.appendChild(wordEl);
}); });
copyEl.appendChild(lineEl); var layout = planPillLayout(indexedWords, fontSize);
}); pillEl.style.width = layout.pillWidth + "px";
pillEl.appendChild(copyEl); pillEl.style.maxWidth = PILL_MAX_WIDTH + "px";
groupEl.appendChild(pillEl); pillEl.style.paddingLeft = layout.padX + "px";
stage.appendChild(groupEl); pillEl.style.paddingRight = layout.padX + "px";
}); layout.lines.slice(0, 2).forEach(function (lineWords) {
} var lineEl = document.createElement("div");
lineEl.className = "caption-line";
function hfTeardown() { lineWords.forEach(function (word) {
document.getElementById("caption-stage").innerHTML = ""; var wordEl = document.createElement("span");
} wordEl.id = "caption-word-" + groupIndex + "-" + word.wordIndex;
wordEl.className = "caption-word";
function hfBuild(words, layout, durationS) { wordEl.textContent = word.text.toLowerCase();
DURATION = durationS; lineEl.appendChild(wordEl);
BASE_FONT_SIZE = Math.round(72 * layout.fontScale); });
MIN_FONT_SIZE = Math.round(52 * layout.fontScale); copyEl.appendChild(lineEl);
SAFE_ZONE_WIDTH = Math.round(1600 * layout.scaleX); });
PILL_MAX_WIDTH = SAFE_ZONE_WIDTH; pillEl.appendChild(copyEl);
WORD_SPACING = Math.round(16 * layout.fontScale); groupEl.appendChild(pillEl);
stage.appendChild(groupEl);
var stage = document.getElementById("caption-stage");
var stageHeight = Math.round(220 * layout.fontScale);
stage.style.width = SAFE_ZONE_WIDTH + "px";
stage.style.height = stageHeight + "px";
stage.style.bottom = Math.round(90 * layout.scaleY) + "px";
var rootEl = document.getElementById(HF_ROOT_ID);
var accent = getComputedStyle(rootEl).getPropertyValue("--hf-caption-accent").trim();
hfColorActive = accent || COLOR_ACTIVE;
var WORDS = normalizeWords(words);
var GROUPS = makeGroups(WORDS);
buildCaptions(GROUPS);
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 });
GROUPS.forEach(function (group, groupIndex) {
var groupEl = document.getElementById("caption-group-" + groupIndex);
var nextGroup = GROUPS[groupIndex + 1];
var isLastGroup = groupIndex === GROUPS.length - 1;
var effectiveEnd = isLastGroup
? DURATION
: Math.min(nextGroup.start, group.end + GROUP_END_BUFFER);
var visibleStart = Math.max(0, group.start);
var visibleEnd = Math.max(visibleStart + 0.01, effectiveEnd);
tl.set(groupEl, { opacity: 1 }, visibleStart);
tl.set(groupEl, { opacity: 0 }, visibleEnd);
group.words.forEach(function (word, wordIndex) {
var wordEl = document.getElementById("caption-word-" + groupIndex + "-" + wordIndex);
var isFirstWord = wordIndex === 0;
var wordStart = Math.max(visibleStart, word.start - WORD_FADE_LEAD);
tl.set(wordEl, { color: isFirstWord ? hfColorActive : COLOR_INACTIVE }, visibleStart);
if (isFirstWord) return;
tl.to(
wordEl,
{ color: hfColorActive, duration: COLOR_FADE_DURATION, ease: "none" },
wordStart,
);
});
});
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 () {
hfAttach(data);
}); });
} }
if (window.__HF_CAPTION__) {
start(window.__HF_CAPTION__); function hfTeardown() {
return; document.getElementById("caption-stage").innerHTML = "";
} }
fetch("./caption-data.json")
.then(function (r) { function hfBuild(words, layout, durationS) {
return r.ok ? r.json() : null; DURATION = durationS;
}) BASE_FONT_SIZE = Math.round(72 * layout.fontScale);
.catch(function () { MIN_FONT_SIZE = Math.round(52 * layout.fontScale);
return null; SAFE_ZONE_WIDTH = Math.round(1600 * layout.scaleX);
}) PILL_MAX_WIDTH = SAFE_ZONE_WIDTH;
.then(function (json) { WORD_SPACING = Math.round(16 * layout.fontScale);
start(json || HF_DEMO_DATA);
var stage = document.getElementById("caption-stage");
var stageHeight = Math.round(220 * layout.fontScale);
stage.style.width = SAFE_ZONE_WIDTH + "px";
stage.style.height = stageHeight + "px";
stage.style.bottom = Math.round(90 * layout.scaleY) + "px";
var rootEl = document.getElementById(HF_ROOT_ID);
var accent = getComputedStyle(rootEl).getPropertyValue("--hf-caption-accent").trim();
hfColorActive = accent || COLOR_ACTIVE;
var WORDS = normalizeWords(words);
var GROUPS = makeGroups(WORDS);
buildCaptions(GROUPS);
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 });
GROUPS.forEach(function (group, groupIndex) {
var groupEl = document.getElementById("caption-group-" + groupIndex);
var nextGroup = GROUPS[groupIndex + 1];
var isLastGroup = groupIndex === GROUPS.length - 1;
var effectiveEnd = isLastGroup
? DURATION
: Math.min(nextGroup.start, group.end + GROUP_END_BUFFER);
var visibleStart = Math.max(0, group.start);
var visibleEnd = Math.max(visibleStart + 0.01, effectiveEnd);
tl.set(groupEl, { opacity: 1 }, visibleStart);
tl.set(groupEl, { opacity: 0 }, visibleEnd);
group.words.forEach(function (word, wordIndex) {
var wordEl = document.getElementById("caption-word-" + groupIndex + "-" + wordIndex);
var isFirstWord = wordIndex === 0;
var wordStart = Math.max(visibleStart, word.start - WORD_FADE_LEAD);
tl.set(wordEl, { color: isFirstWord ? hfColorActive : COLOR_INACTIVE }, visibleStart);
if (isFirstWord) return;
tl.to(
wordEl,
{ color: hfColorActive, duration: COLOR_FADE_DURATION, ease: "none" },
wordStart,
);
});
});
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 () {
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);
});
})();
})(); })();
</script> </script>
</body> </body>
@@ -117,511 +117,523 @@
</div> </div>
<script> <script>
var HF_COMPOSITION_ID = "caption-weight-shift"; // IIFE: keep every template's runtime private to itself so two caption
var HF_ROOT_ID = "weight-shift"; // components pasted into one composition document can't clobber each
var HF_GROUP_SELECTOR = ".caption-group"; // 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 HF_DEMO_DATA = { var HF_DEMO_DATA = {
version: 1, version: 1,
segments: [ segments: [
{ {
start: 0, 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.", 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: [ words: [
{ text: "Every", start: 0.0, end: 0.3 }, { text: "Every", start: 0.0, end: 0.3 },
{ text: "great", start: 0.3, end: 0.55 }, { text: "great", start: 0.3, end: 0.55 },
{ text: "video", start: 0.55, end: 0.85 }, { text: "video", start: 0.55, end: 0.85 },
{ text: "starts", start: 0.85, end: 1.1 }, { text: "starts", start: 0.85, end: 1.1 },
{ text: "with", start: 1.1, end: 1.25 }, { text: "with", start: 1.1, end: 1.25 },
{ text: "a", start: 1.25, end: 1.35 }, { text: "a", start: 1.25, end: 1.35 },
{ text: "single", start: 1.35, end: 1.65 }, { text: "single", start: 1.35, end: 1.65 },
{ text: "frame.", start: 1.65, end: 2.0 }, { text: "frame.", start: 1.65, end: 2.0 },
{ text: "HyperFrames", start: 2.1, end: 2.65 }, { text: "HyperFrames", start: 2.1, end: 2.65 },
{ text: "lets", start: 2.65, end: 2.85 }, { text: "lets", start: 2.65, end: 2.85 },
{ text: "you", start: 2.85, end: 2.95 }, { text: "you", start: 2.85, end: 2.95 },
{ text: "write", start: 2.95, end: 3.15 }, { text: "write", start: 2.95, end: 3.15 },
{ text: "HTML", start: 3.15, end: 3.55 }, { text: "HTML", start: 3.15, end: 3.55 },
{ text: "and", start: 3.55, end: 3.65 }, { text: "and", start: 3.55, end: 3.65 },
{ text: "render", start: 3.65, end: 3.95 }, { text: "render", start: 3.65, end: 3.95 },
{ text: "professional", start: 3.95, end: 4.45 }, { text: "professional", start: 3.95, end: 4.45 },
{ text: "video.", start: 4.45, end: 4.8 }, { text: "video.", start: 4.45, end: 4.8 },
{ text: "No", start: 4.9, end: 5.05 }, { text: "No", start: 4.9, end: 5.05 },
{ text: "timeline.", start: 5.05, end: 5.45 }, { text: "timeline.", start: 5.05, end: 5.45 },
{ text: "No", start: 5.5, end: 5.65 }, { text: "No", start: 5.5, end: 5.65 },
{ text: "drag", start: 5.65, end: 5.85 }, { text: "drag", start: 5.65, end: 5.85 },
{ text: "and", start: 5.85, end: 5.95 }, { text: "and", start: 5.85, end: 5.95 },
{ text: "drop.", start: 5.95, end: 6.25 }, { text: "drop.", start: 5.95, end: 6.25 },
{ text: "Just", start: 6.35, end: 6.55 }, { text: "Just", start: 6.35, end: 6.55 },
{ text: "code", start: 6.55, end: 6.8 }, { text: "code", start: 6.55, end: 6.8 },
{ text: "that", start: 6.8, end: 6.95 }, { text: "that", start: 6.8, end: 6.95 },
{ text: "becomes", start: 6.95, end: 7.3 }, { text: "becomes", start: 6.95, end: 7.3 },
{ text: "cinema.", start: 7.3, end: 7.7 }, { text: "cinema.", start: 7.3, end: 7.7 },
], ],
}, },
], ],
}; };
/* ── HyperFrames caption-data runtime (shared across caption templates) ── */ /* ── HyperFrames caption-data runtime (shared across caption templates) ── */
var HF_CONTRACT_VERSION = 1; var HF_CONTRACT_VERSION = 1;
var HF_DEFAULT_RESOLUTION = { width: 1920, height: 1080 }; var HF_DEFAULT_RESOLUTION = { width: 1920, height: 1080 };
function hfFlattenSegments(segments) { function hfFlattenSegments(segments) {
var out = []; var out = [];
(segments || []).forEach(function (seg) { (segments || []).forEach(function (seg) {
((seg && seg.words) || []).forEach(function (w) { ((seg && seg.words) || []).forEach(function (w) {
var start = Math.max(0, Number(w.start) || 0); var start = Math.max(0, Number(w.start) || 0);
out.push({ out.push({
text: String(w.word || w.text || "").trim(), text: String(w.word || w.text || "").trim(),
start: start, start: start,
end: Math.max(start, Number(w.end) || 0), end: Math.max(start, Number(w.end) || 0),
});
}); });
}); });
}); out.sort(function (a, b) {
out.sort(function (a, b) { return a.start - b.start;
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 && Math.floor(Number(data.version)) > 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;
var W = Math.max(1, Math.round(Number(res.width) || HF_DEFAULT_RESOLUTION.width));
var H = Math.max(1, Math.round(Number(res.height) || HF_DEFAULT_RESOLUTION.height));
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");
}
function hfRunGate(words, durationS, failReason) {
if (failReason) {
hfPublishStatus({
ok: false,
present: false,
contained: false,
reason: failReason,
version: HF_CONTRACT_VERSION,
durationS: durationS || 0,
}); });
return; return out.filter(function (w) {
} return w.text.length > 0;
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);
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"); function hfDeriveDuration(words) {
var measureContext = measureCanvas.getContext("2d"); var end = 0;
words.forEach(function (w) {
function measureWord(word) { if (w.end > end) end = w.end;
return measureContext.measureText(word.text.toLowerCase()).width * FONT_WIDTH_SAFETY; });
} return end;
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; function hfValidate(data) {
for (var s = 1; s < words.length; s++) { if (!data || typeof data !== "object") return { ok: false, reason: "not-an-object" };
var w1 = measureLineWidth(words.slice(0, s)); if (data.version != null && Math.floor(Number(data.version)) > HF_CONTRACT_VERSION) {
var w2 = measureLineWidth(words.slice(s)); return { ok: false, reason: "unsupported-version" };
if (w1 <= MAX_LINE_WIDTH && w2 <= MAX_LINE_WIDTH) {
var diff = Math.abs(w1 - w2);
if (diff < bestDiff) {
bestDiff = diff;
bestSplit = s;
}
} }
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) };
} }
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) { function hfApplyStageConfig(data, durationS) {
var out = []; var res = (data && data.resolution) || HF_DEFAULT_RESOLUTION;
groups.forEach(function (group) { var W = Math.max(1, Math.round(Number(res.width) || HF_DEFAULT_RESOLUTION.width));
if ( var H = Math.max(1, Math.round(Number(res.height) || HF_DEFAULT_RESOLUTION.height));
group.words.length === 1 && var root = document.getElementById(HF_ROOT_ID);
out.length > 0 && [document.documentElement, document.body, root].forEach(function (el) {
out[out.length - 1].words.length < MAX_WORDS_PER_GROUP el.style.width = W + "px";
) { el.style.height = H + "px";
out[out.length - 1] = makeGroup(out[out.length - 1].words.concat(group.words)); });
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 { } else {
out.push(group); root.style.removeProperty("--hf-caption-primary");
} }
}); if (typeof brand.accentColor === "string" && brand.accentColor) {
root.style.setProperty("--hf-caption-accent", brand.accentColor);
for (var i = 0; i < out.length; i++) { } else {
if (out[i].words.length !== 1) continue; root.style.removeProperty("--hf-caption-accent");
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 {
W: W,
H: H,
scaleX: W / 1920,
scaleY: H / 1080,
fontScale: Math.min(W, H) / 1080,
};
} }
return out; function hfPublishStatus(status) {
} window.__HF_CAPTION_STATUS = status;
document
.getElementById(HF_ROOT_ID)
.setAttribute("data-caption-status", status.ok ? "ok" : "failed");
}
function makeGroups(words) { function hfRunGate(words, durationS, failReason) {
var groups = []; if (failReason) {
var current = []; hfPublishStatus({
words.forEach(function (word, index) { ok: false,
var nextWord = words[index + 1]; present: false,
var testGroup = current.concat([word]); contained: false,
if (testGroup.length > MAX_WORDS_PER_GROUP || !fitsInTwoLines(testGroup)) { reason: failReason,
if (current.length) groups.push(makeGroup(current)); version: HF_CONTRACT_VERSION,
current = [word]; durationS: durationS || 0,
});
return; return;
} }
current.push(word); var root = document.getElementById(HF_ROOT_ID);
var punctuation = /[,.:!?]$/.test(word.text); var rootRect = root.getBoundingClientRect();
var pause = nextWord ? nextWord.start - word.end : 0; var groups = root.querySelectorAll(HF_GROUP_SELECTOR);
if ( var present = false;
!nextWord || var contained = true;
punctuation || var tokens = [];
pause >= PAUSE_THRESHOLD || words.forEach(function (w) {
current.length >= MAX_WORDS_PER_GROUP var t = w.text.toLowerCase().replace(/[^a-z0-9]/g, "");
) { if (t) tokens.push(t);
groups.push(makeGroup(current)); });
current = []; groups.forEach(function (el) {
} var prevOpacity = el.style.opacity;
}); var prevVisibility = el.style.visibility;
if (current.length) groups.push(makeGroup(current)); el.style.opacity = "1";
return avoidSingleWordGroups(groups); 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,
});
}
function makeGroup(words) { var SAFE_ZONE_WIDTH, MAX_LINE_WIDTH, CAPTION_FONT_SIZE, DURATION;
return { var MAX_WORDS_PER_GROUP = 4;
words: words.slice(), var WORD_SPACING_DESIGN = 12;
lines: splitLines(words), var WORD_SPACING;
start: words[0].start, var PAUSE_THRESHOLD = 0.1;
end: words[words.length - 1].end, 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 buildCaptions(groups) { function normalizeWords(rawWords) {
var stage = document.getElementById("caption-stage"); return rawWords
groups.forEach(function (group, groupIndex) { .map(function (item) {
var groupText = group.words return {
.map(function (w) { text: String(item.word || item.text || "").trim(),
return w.text.toLowerCase(); start: Math.max(0, Number(item.start) || 0),
end: Math.min(DURATION, Math.max(Number(item.start) || 0, Number(item.end) || 0)),
};
}) })
.join(" "); .filter(function (word) {
var computedSize = fitFontSize( return word.text.length > 0;
groupText,
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); }
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");
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);
}
}); });
groupEl.appendChild(copyEl);
stage.appendChild(groupEl);
});
}
function hfTeardown() { for (var i = 0; i < out.length; i++) {
document.getElementById("caption-stage").innerHTML = ""; 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));
function hfBuild(words, layout, durationS) { out.splice(i + 1, 1);
DURATION = durationS; } else if (i > 0 && out[i - 1].words.length < MAX_WORDS_PER_GROUP) {
CAPTION_FONT_SIZE = Math.round(72 * layout.fontScale); out[i - 1] = makeGroup(out[i - 1].words.concat(out[i].words));
SAFE_ZONE_WIDTH = Math.round(1400 * layout.scaleX); out.splice(i, 1);
MAX_LINE_WIDTH = SAFE_ZONE_WIDTH - Math.round(40 * layout.scaleX); i--;
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 out;
});
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 makeGroups(words) {
function start(data) { var groups = [];
var ready = var current = [];
document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve(); words.forEach(function (word, index) {
ready.then(function () { var nextWord = words[index + 1];
hfAttach(data); 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) {
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 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);
}); });
} }
if (window.__HF_CAPTION__) {
start(window.__HF_CAPTION__); function hfTeardown() {
return; document.getElementById("caption-stage").innerHTML = "";
} }
fetch("./caption-data.json")
.then(function (r) { function hfBuild(words, layout, durationS) {
return r.ok ? r.json() : null; DURATION = durationS;
}) CAPTION_FONT_SIZE = Math.round(72 * layout.fontScale);
.catch(function () { SAFE_ZONE_WIDTH = Math.round(1400 * layout.scaleX);
return null; MAX_LINE_WIDTH = SAFE_ZONE_WIDTH - Math.round(40 * layout.scaleX);
}) WORD_SPACING = Math.round(WORD_SPACING_DESIGN * layout.fontScale);
.then(function (json) {
start(json || HF_DEMO_DATA); 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 () {
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);
});
})();
})(); })();
</script> </script>
</body> </body>