mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(cli,core): standalone transcribe command, transcript normalization, caption lint rules (#151)
* feat(cli,core): add standalone transcribe command, transcript normalization, and caption lint rules
- Add `hyperframes transcribe` command for transcribing audio/video and importing
existing transcripts (SRT, VTT, OpenAI Whisper API JSON, whisper.cpp JSON)
- Add transcript format normalizer (normalize.ts) with auto-detection and
conversion to standard [{text, start, end}] word arrays
- Upgrade default whisper model from base.en to small.en for better accuracy
- Add --model and --language flags to both `transcribe` and `init` commands
- Extract shared patchCaptionHtml() to eliminate duplication between init.ts
and transcribe.ts (init.ts reduced by ~55 lines)
- Add 3 caption lint rules: caption_exit_missing_hard_kill,
caption_text_overflow_risk, caption_container_relative_position
- Update captions skill with model guide, format docs, music guidance,
text overflow prevention, caption exit guarantee pattern
- Expand captions skill trigger to cover lyrics, karaoke, lyric videos
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(cli): add transcribe command and --model/--language flags to CLI docs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): fix blank template lint issues
- blank/index.html: remove data-start from video (was nested in timed parent),
add class="clip" for initial hidden state
- blank/captions.html: add max-width + overflow:hidden to prevent text clipping,
add tl.set hard kill after exit tween to prevent stuck captions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: add lint-after-edit rule to repo and project CLAUDE.md
Agents must run `npx hyperframes lint` after editing compositions.
Also expand captions skill description in project template.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* style: format _shared/CLAUDE.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
7b18c0352e
commit
2f99e33bbe
@@ -603,4 +603,126 @@ describe("template_literal_selector rule", () => {
|
||||
const finding = result.findings.find((f) => f.code === "template_literal_selector");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Caption lint rules ────────────────────────────────────────────────
|
||||
|
||||
it("warns when caption exit has no hard kill tl.set", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="captions" data-width="1920" data-height="1080">
|
||||
<div id="caption-container"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
GROUPS.forEach(function(group, gi) {
|
||||
var groupEl = document.createElement("div");
|
||||
groupEl.id = "cg-" + gi;
|
||||
tl.set(groupEl, { opacity: 1 }, group.start);
|
||||
tl.to(groupEl, { opacity: 0, duration: 0.12 }, group.end - 0.12);
|
||||
});
|
||||
window.__timelines["captions"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
});
|
||||
|
||||
it("does not warn when caption exit has hard kill tl.set", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="captions" data-width="1920" data-height="1080">
|
||||
<div id="caption-container"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
GROUPS.forEach(function(group, gi) {
|
||||
var groupEl = document.createElement("div");
|
||||
groupEl.id = "cg-" + gi;
|
||||
tl.set(groupEl, { opacity: 1 }, group.start);
|
||||
tl.to(groupEl, { opacity: 0, duration: 0.12 }, group.end - 0.12);
|
||||
tl.set(groupEl, { opacity: 0, visibility: "hidden" }, group.end);
|
||||
});
|
||||
window.__timelines["captions"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("warns when caption group has nowrap without max-width", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="captions" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
.caption-group {
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["captions"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "caption_text_overflow_risk");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
});
|
||||
|
||||
it("does not warn when caption group has nowrap with max-width", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="captions" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
.caption-group {
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
max-width: 1600px;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["captions"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "caption_text_overflow_risk" && f.severity === "warning",
|
||||
);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("warns when caption container uses position: relative", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="captions" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
.caption-group {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["captions"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "caption_container_relative_position");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -710,6 +710,79 @@ export function lintHyperframeHtml(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Caption lint rules ──────────────────────────────────────────────────
|
||||
|
||||
// Rule: caption_exit_missing_hard_kill
|
||||
// Exit tweens (tl.to with opacity: 0) can fail when karaoke word-level tweens
|
||||
// conflict, leaving captions stuck on screen. A hard tl.set kill is needed.
|
||||
for (const script of scripts) {
|
||||
const content = script.content;
|
||||
const hasExitTween = /\.to\s*\([^,]+,\s*\{[^}]*opacity\s*:\s*0/.test(content);
|
||||
const hasHardKill =
|
||||
/\.set\s*\([^,]+,\s*\{[^}]*(?:visibility\s*:\s*["']hidden["']|opacity\s*:\s*0)/.test(content);
|
||||
const hasCaptionLoop =
|
||||
/forEach|\.forEach\s*\(/.test(content) && /createElement|caption|group|cg-/.test(content);
|
||||
|
||||
if (hasCaptionLoop && hasExitTween && !hasHardKill) {
|
||||
pushFinding({
|
||||
code: "caption_exit_missing_hard_kill",
|
||||
severity: "warning",
|
||||
message:
|
||||
"Caption exit animations (tl.to with opacity: 0) detected without a hard tl.set kill. " +
|
||||
"Exit tweens can fail when karaoke word-level tweens conflict, leaving captions stuck on screen.",
|
||||
fixHint:
|
||||
'Add `tl.set(groupEl, { opacity: 0, visibility: "hidden" }, group.end)` after every ' +
|
||||
"exit tl.to animation as a deterministic kill.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Rule: caption_text_overflow_risk
|
||||
// Captions with nowrap text and no max-width will clip off-screen.
|
||||
for (const style of styles) {
|
||||
const content = style.content;
|
||||
const captionBlocks = content.matchAll(
|
||||
/(\.caption[-_]?(?:group|container|text|line|word)|#caption[-_]?container)\s*\{([^}]+)\}/gi,
|
||||
);
|
||||
for (const [, selector, body] of captionBlocks) {
|
||||
if (!body) continue;
|
||||
const hasNowrap = /white-space\s*:\s*nowrap/i.test(body);
|
||||
const hasMaxWidth = /max-width/i.test(body);
|
||||
|
||||
if (hasNowrap && !hasMaxWidth) {
|
||||
pushFinding({
|
||||
code: "caption_text_overflow_risk",
|
||||
severity: "warning",
|
||||
selector: (selector ?? "").trim(),
|
||||
message: `Caption selector "${(selector ?? "").trim()}" has white-space: nowrap but no max-width. Long phrases will clip off-screen.`,
|
||||
fixHint:
|
||||
"Add max-width: 1600px (landscape) or max-width: 900px (portrait) and overflow: hidden.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rule: caption_container_relative_position
|
||||
// position: relative on caption containers causes overflow and stacking issues.
|
||||
for (const style of styles) {
|
||||
const content = style.content;
|
||||
const captionBlocks = content.matchAll(
|
||||
/(\.caption[-_]?(?:group|container|text|line)|#caption[-_]?container)\s*\{([^}]+)\}/gi,
|
||||
);
|
||||
for (const [, selector, body] of captionBlocks) {
|
||||
if (!body) continue;
|
||||
if (/position\s*:\s*relative/i.test(body)) {
|
||||
pushFinding({
|
||||
code: "caption_container_relative_position",
|
||||
severity: "warning",
|
||||
selector: (selector ?? "").trim(),
|
||||
message: `Caption selector "${(selector ?? "").trim()}" uses position: relative which causes overflow and breaks caption stacking.`,
|
||||
fixHint: "Use position: absolute for all caption elements.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── External CDN script dependency check ────────────────────────────────
|
||||
// Compositions that load CDN libraries via <script src="https://..."> work
|
||||
// correctly in bundled mode (bundleToSingleHtml auto-hoists them to the parent
|
||||
|
||||
Reference in New Issue
Block a user