mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 02:36:10 +00:00
feat(cli): capture-video on-demand fetcher + capture pipeline robustness
For the hyperframes.dev website-to-video flow. Real-AI-test runs against
heygen.com, huly.io, and heygen-showcase surfaced two gaps: (1) capture's
logo / asset-captioning signals missed modern React/Tailwind builds; and
(2) there was no CLI surface to pull the videos the manifest references.
New command:
• `hyperframes capture-video <project>` — on-demand downloader for
entries in capture/extracted/video-manifest.json. Capture writes the
manifest + preview PNGs but skips the mp4s; this pulls one entry by
`--index N` (matched against the entry's `index` field, NOT array
offset — gaps are possible when a preview screenshot fails). SSRF-safe
via safeFetch, 250 MB cap, content-type whitelist, race-free
exclusive-create write. Layout-aware (handles both standalone capture
and W2H project layouts).
Capture pipeline fixes:
• Structural logo signals (assetCataloger + tokenExtractor): inBanner /
inHomeLink / matchesTitleBrand. Class-substring alone caught 0/32 SVGs
on heygen.com — modern builds don't put 'logo' / 'brand' in any
className.
• Content-hash SVG slugs (assetDownloader): `svg-<8char-sha1>.svg` —
label-derived slugs mis-attributed partner-logo carousels
(heygen-logo.svg actually contained Google, hubspot-logo.svg contained
Trivago, etc.). Content-hash names are invariant by construction.
• SVG → PNG rasterization before Gemini Vision (contentExtractor): the
raw-SVG-as-text path was hallucinating wordmarks (VIVIENNE for HubSpot,
'wrestling' for Workday). Adds polarity detection so a white-glyph SVG
flattened to a blank PNG gets inverted before captioning. LOGO tag in
asset-descriptions.md when structural signals fire (independent of
Gemini key presence).
• Double-escape \/ inside the page.evaluate template literal in
assetCataloger + tokenExtractor: the original `/^https?:\/\/.../`
collapsed to `/` mid-template and threw `Unexpected token ^`. Capture
was 100% blocked on this until the escape was fixed.
• `asset-descriptions.md` header branches on Gemini-key presence with
an explicit 'Vision OFF — catalog-derived descriptions' warning.
New lint rule:
• `lintMissingLocalAsset` (cli/utils/lintProject): scans <video> / <img>
/ <source> src for local files that don't exist in the project.
Empirically the most common sub-agent mistake across multi-URL runs
(~5+ per run). Uses `resolveExistingLocalAsset` so the existence check
matches the bundler's notion of 'resolves'. Masks comment / style /
script ranges before scanning so a literal `<img src=missing.png>`
inside a tutorial comment isn't reported.
Tests: 17 new for capture-video (safeFilename decoding/sanitization,
VIDEO_CONTENT_TYPE_RE accept/reject, pickManifestEntry index-field lookup
with gaps, URL-mismatch + bad-index rejection, --index over --url
priority); 70 cases under lintProject.test.ts covering the new rule and
existing rules.
Sibling PRs in this stack:
• #PR_A1 — fix(producer): __dirname ESM banner shim
• #PR_A2 — fix(core/lint): findRootTag masks comment/style/script
This commit is contained in:
@@ -475,6 +475,215 @@ describe("audio_src_not_found", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("missing_local_asset", () => {
|
||||
it("errors when <img> src references a file that does not exist", async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<img src="capture/assets/hero.png" />
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const project = makeProject(html);
|
||||
|
||||
const { totalErrors, results } = await lintProject(project);
|
||||
|
||||
expect(totalErrors).toBeGreaterThan(0);
|
||||
const finding = results[0]?.result.findings.find((f) => f.code === "missing_local_asset");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("hero.png");
|
||||
expect(finding?.message).toContain("<img>");
|
||||
});
|
||||
|
||||
it("errors when <video> src references a missing file", async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<video id="hero" src="capture/assets/videos/clip.mp4" muted playsinline></video>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const project = makeProject(html);
|
||||
|
||||
const { totalErrors, results } = await lintProject(project);
|
||||
|
||||
expect(totalErrors).toBeGreaterThan(0);
|
||||
const finding = results[0]?.result.findings.find((f) => f.code === "missing_local_asset");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toContain("clip.mp4");
|
||||
expect(finding?.message).toContain("<video>");
|
||||
});
|
||||
|
||||
it("errors when <source> src inside <video> references a missing file", async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<video muted playsinline><source src="capture/assets/videos/clip.webm" /></video>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const project = makeProject(html);
|
||||
|
||||
const { totalErrors, results } = await lintProject(project);
|
||||
|
||||
expect(totalErrors).toBeGreaterThan(0);
|
||||
const finding = results[0]?.result.findings.find((f) => f.code === "missing_local_asset");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toContain("clip.webm");
|
||||
expect(finding?.message).toContain("<source>");
|
||||
});
|
||||
|
||||
it("does NOT report <audio> srcs (handled by audio_src_not_found)", async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<audio id="vo" src="missing.mp3" data-start="0" data-duration="3" data-track-index="0" data-volume="1"></audio>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const project = makeProject(html);
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
|
||||
const localAsset = results[0]?.result.findings.find((f) => f.code === "missing_local_asset");
|
||||
const audio = results[0]?.result.findings.find((f) => f.code === "audio_src_not_found");
|
||||
expect(localAsset).toBeUndefined();
|
||||
expect(audio).toBeDefined();
|
||||
});
|
||||
|
||||
it("does NOT report remote URLs (https:, data:, blob:)", async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<img src="https://example.com/x.png" />
|
||||
<img src="data:image/png;base64,iVBOR" />
|
||||
<img src="blob:foo" />
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const project = makeProject(html);
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
|
||||
const finding = results[0]?.result.findings.find((f) => f.code === "missing_local_asset");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT report template placeholders (__VIDEO_SRC__)", async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<video src="__VIDEO_SRC__"></video>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const project = makeProject(html);
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
|
||||
const finding = results[0]?.result.findings.find((f) => f.code === "missing_local_asset");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not error when referenced files exist on disk", async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<img src="hero.png" />
|
||||
<video src="clip.mp4"></video>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const project = makeProject(html);
|
||||
writeFileSync(join(project.dir, "hero.png"), "fake");
|
||||
writeFileSync(join(project.dir, "clip.mp4"), "fake");
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
|
||||
const finding = results[0]?.result.findings.find((f) => f.code === "missing_local_asset");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves sub-composition relative paths (../assets/foo.png)", async () => {
|
||||
const subComp = `<html><body>
|
||||
<div data-composition-id="scene" data-width="1920" data-height="1080">
|
||||
<img src="../assets/foo.png" />
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["scene"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const project = makeProject(validHtml(), { "scene.html": subComp });
|
||||
mkdirSync(join(project.dir, "assets"), { recursive: true });
|
||||
writeFileSync(join(project.dir, "assets", "foo.png"), "fake");
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
|
||||
const finding = results[0]?.result.findings.find((f) => f.code === "missing_local_asset");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("deduplicates the same missing src across multiple compositions", async () => {
|
||||
const project = makeProject(
|
||||
`<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<img src="capture/assets/x.png" />
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`,
|
||||
{
|
||||
"scene-a.html": `<html><body>
|
||||
<div data-composition-id="a" data-width="1920" data-height="1080">
|
||||
<img src="../capture/assets/x.png" />
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["a"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`,
|
||||
"scene-b.html": `<html><body>
|
||||
<div data-composition-id="b" data-width="1920" data-height="1080">
|
||||
<img src="../capture/assets/x.png" />
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["b"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`,
|
||||
},
|
||||
);
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
|
||||
const finding = results[0]?.result.findings.find((f) => f.code === "missing_local_asset");
|
||||
expect(finding).toBeDefined();
|
||||
// x.png mentioned only once despite three references
|
||||
const occurrences = (finding?.message.match(/x\.png/g) ?? []).length;
|
||||
expect(occurrences).toBe(1);
|
||||
});
|
||||
|
||||
it("emits separate findings per tag type (img + video) for clear messaging", async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<img src="missing.png" />
|
||||
<video src="missing.mp4"></video>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const project = makeProject(html);
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
|
||||
const findings = results[0]?.result.findings.filter((f) => f.code === "missing_local_asset");
|
||||
expect(findings).toHaveLength(2);
|
||||
expect(findings?.some((f) => f.message.includes("<img>"))).toBe(true);
|
||||
expect(findings?.some((f) => f.message.includes("<video>"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag <img>/<video> tokens inside <!-- -->, <style>, or <script>", async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<!-- example: <img src="commented.png"> -->
|
||||
<style>/* card uses <video src="styled.mp4"> as the surface */ .card { background: black; }</style>
|
||||
<script>const example = '<source src="scripted.webm">';</script>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const project = makeProject(html);
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
|
||||
const findings = results[0]?.result.findings.filter((f) => f.code === "missing_local_asset");
|
||||
expect(findings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("texture_mask_asset_not_found", () => {
|
||||
it("errors when CSS mask-image references a missing local texture", async () => {
|
||||
const html = `<html><body>
|
||||
|
||||
Reference in New Issue
Block a user