fix(skills): stage SVGs that capture wrote into capture/assets/svgs/ (#3336)

`hyperframes capture` extracts inline SVGs into capture/assets/svgs/, and the
capture manifest advertises them to the agent as `assets/svgs/<name>.svg`, so a
frame names one in `asset_candidates` exactly the way it names a screenshot.
stageAssets searched only capture/{assets,assets/videos,screenshots}, so every
captured SVG resolved to nothing: logged as a non-fatal anomaly, and the frame
404'd the brand mark it had been told to use.

Add the directory to the search list, and cover it with a test that fails
without the fix.

lib/assets.mjs is byte-identical across product-launch-video,
faceless-explainer and pr-to-video, so the fix lands in all three. Folding it
into hyperframes-core/scripts/lib/, where frame-packets-core.mjs already lives,
is a separate change.

Co-authored-by: anikam13 <22992075+anikam13@users.noreply.github.com>
This commit is contained in:
Miguel Ángel
2026-08-20 16:22:20 -04:00
committed by GitHub
co-authored by anikam13
parent 9140c0eaa1
commit c66c9a4c76
5 changed files with 65 additions and 10 deletions
+4 -4
View File
@@ -6,7 +6,7 @@
"files": 138 "files": 138
}, },
"faceless-explainer": { "faceless-explainer": {
"hash": "f37cb1dba2890b79", "hash": "7d587ff36c975d9a",
"files": 24 "files": 24
}, },
"figma": { "figma": {
@@ -62,12 +62,12 @@
"files": 132 "files": 132
}, },
"pr-to-video": { "pr-to-video": {
"hash": "8ea0227e18ab5fc6", "hash": "14250b018114d26d",
"files": 30 "files": 30
}, },
"product-launch-video": { "product-launch-video": {
"hash": "e89a8d11d99b4edf", "hash": "12c0895d4963aa90",
"files": 28 "files": 29
}, },
"remotion-to-hyperframes": { "remotion-to-hyperframes": {
"hash": "bf184a65059b95e8", "hash": "bf184a65059b95e8",
@@ -17,8 +17,8 @@ export function basenamesFromCandidates(value) {
} }
// Copy each frame's asset_candidates from capture/{assets,assets/videos, // Copy each frame's asset_candidates from capture/{assets,assets/videos,
// screenshots} into assets/. Already-staged files are left as is (first-wins), // assets/svgs, screenshots} into assets/. Already-staged files are left as is
// so calling this twice is safe. Returns { staged, wanted, anomalies }. // (first-wins), so calling this twice is safe. Returns { staged, wanted, anomalies }.
export function stageAssets({ hyperframesDir, frames }) { export function stageAssets({ hyperframesDir, frames }) {
const wanted = new Set(); const wanted = new Set();
for (const f of frames) { for (const f of frames) {
@@ -27,6 +27,7 @@ export function stageAssets({ hyperframesDir, frames }) {
const captureDirs = [ const captureDirs = [
join(hyperframesDir, "capture/assets"), join(hyperframesDir, "capture/assets"),
join(hyperframesDir, "capture/assets/videos"), // videos download into a subdir join(hyperframesDir, "capture/assets/videos"), // videos download into a subdir
join(hyperframesDir, "capture/assets/svgs"), // inline SVGs extract into a subdir
join(hyperframesDir, "capture/screenshots"), join(hyperframesDir, "capture/screenshots"),
]; ];
const assetsDir = join(hyperframesDir, "assets"); const assetsDir = join(hyperframesDir, "assets");
+3 -2
View File
@@ -17,8 +17,8 @@ export function basenamesFromCandidates(value) {
} }
// Copy each frame's asset_candidates from capture/{assets,assets/videos, // Copy each frame's asset_candidates from capture/{assets,assets/videos,
// screenshots} into assets/. Already-staged files are left as is (first-wins), // assets/svgs, screenshots} into assets/. Already-staged files are left as is
// so calling this twice is safe. Returns { staged, wanted, anomalies }. // (first-wins), so calling this twice is safe. Returns { staged, wanted, anomalies }.
export function stageAssets({ hyperframesDir, frames }) { export function stageAssets({ hyperframesDir, frames }) {
const wanted = new Set(); const wanted = new Set();
for (const f of frames) { for (const f of frames) {
@@ -27,6 +27,7 @@ export function stageAssets({ hyperframesDir, frames }) {
const captureDirs = [ const captureDirs = [
join(hyperframesDir, "capture/assets"), join(hyperframesDir, "capture/assets"),
join(hyperframesDir, "capture/assets/videos"), // videos download into a subdir join(hyperframesDir, "capture/assets/videos"), // videos download into a subdir
join(hyperframesDir, "capture/assets/svgs"), // inline SVGs extract into a subdir
join(hyperframesDir, "capture/screenshots"), join(hyperframesDir, "capture/screenshots"),
]; ];
const assetsDir = join(hyperframesDir, "assets"); const assetsDir = join(hyperframesDir, "assets");
@@ -17,8 +17,8 @@ export function basenamesFromCandidates(value) {
} }
// Copy each frame's asset_candidates from capture/{assets,assets/videos, // Copy each frame's asset_candidates from capture/{assets,assets/videos,
// screenshots} into assets/. Already-staged files are left as is (first-wins), // assets/svgs, screenshots} into assets/. Already-staged files are left as is
// so calling this twice is safe. Returns { staged, wanted, anomalies }. // (first-wins), so calling this twice is safe. Returns { staged, wanted, anomalies }.
export function stageAssets({ hyperframesDir, frames }) { export function stageAssets({ hyperframesDir, frames }) {
const wanted = new Set(); const wanted = new Set();
for (const f of frames) { for (const f of frames) {
@@ -27,6 +27,7 @@ export function stageAssets({ hyperframesDir, frames }) {
const captureDirs = [ const captureDirs = [
join(hyperframesDir, "capture/assets"), join(hyperframesDir, "capture/assets"),
join(hyperframesDir, "capture/assets/videos"), // videos download into a subdir join(hyperframesDir, "capture/assets/videos"), // videos download into a subdir
join(hyperframesDir, "capture/assets/svgs"), // inline SVGs extract into a subdir
join(hyperframesDir, "capture/screenshots"), join(hyperframesDir, "capture/screenshots"),
]; ];
const assetsDir = join(hyperframesDir, "assets"); const assetsDir = join(hyperframesDir, "assets");
@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { stageAssets } from "./lib/assets.mjs";
// ── captured SVGs are stageable ──────────────────────────────────────────────
// Regression: `hyperframes capture` extracts inline SVGs into capture/assets/svgs/
// (assetDownloader.ts), and the capture manifest advertises them to the agent as
// `assets/svgs/<name>.svg`, so a frame names one in `asset_candidates` exactly as
// it names a screenshot. stageAssets only searched capture/{assets,assets/videos,
// screenshots}, so every captured SVG resolved to nothing — reported as a
// non-fatal anomaly, and the frame 404'd the logo it had been told to use.
function projectWithCapturedSvg() {
const dir = mkdtempSync(join(tmpdir(), "product-launch-stage-assets-"));
mkdirSync(join(dir, "capture/assets/svgs"), { recursive: true });
mkdirSync(join(dir, "capture/screenshots"), { recursive: true });
writeFileSync(join(dir, "capture/assets/svgs/brand-mark.svg"), "<svg/>");
writeFileSync(join(dir, "capture/screenshots/hero.png"), "png");
return dir;
}
const frames = [
{ extra: { asset_candidates: "assets/svgs/brand-mark.svg — the mark; assets/hero.png — hero" } },
];
test("stages an SVG that capture wrote into capture/assets/svgs/", () => {
const dir = projectWithCapturedSvg();
const { staged, wanted, anomalies } = stageAssets({ hyperframesDir: dir, frames });
assert.equal(wanted.size, 2);
assert.equal(staged, 2, `expected both assets staged, got anomalies: ${anomalies.join("; ")}`);
assert.deepEqual(anomalies, []);
assert.ok(existsSync(join(dir, "assets/brand-mark.svg")));
assert.ok(existsSync(join(dir, "assets/hero.png")));
});
test("still reports an asset that exists nowhere under capture/", () => {
const dir = projectWithCapturedSvg();
const { staged, anomalies } = stageAssets({
hyperframesDir: dir,
frames: [{ extra: { asset_candidates: "assets/svgs/absent.svg — never captured" } }],
});
assert.equal(staged, 0);
assert.equal(anomalies.length, 1);
assert.match(anomalies[0], /absent\.svg/);
});