reframe-video 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,11 @@ Verified against each asset page's license field on 2026-06-11.
6
6
  |---|---|---|---|
7
7
  | keypress-001/004/007/010/014.wav | [Keyboard Soundpack #1](https://opengameart.org/content/keyboard-soundpack-1-typing-and-single-keystrokes) (Cherry KC 1000 recordings) | unicaegames | CC0 |
8
8
  | click_002/003/004.ogg, confirmation_001.ogg | [Interface Sounds](https://opengameart.org/content/interface-sounds) | Kenney (kenney.nl) | CC0 |
9
+ | tick.wav (tick_001), pop.wav (drop_002), shimmer.wav (glass_002 + echo tail) | [Interface Sounds](https://opengameart.org/content/interface-sounds) | Kenney (kenney.nl) | CC0 |
10
+ | whoosh.wav (wind body), rise.wav (reversed slice) | [Air whoosh](https://opengameart.org/content/air-whoosh) | qubodup | CC0 |
11
+ | whoosh.wav (transient layer: swish-9) | [Swishes Sound Pack](https://opengameart.org/content/swishes-sound-pack) | artisticdude | CC0 |
12
+ | thud.wav (trimmed) | [Muffled Distant Explosion](https://opengameart.org/content/muffled-distant-explosion) | NenadSimic | CC0 |
13
+ | bgm-song21.mp3 | [Mysterious Ambience (song21)](https://opengameart.org/content/mysterious-ambience-song21) | cynicmusic (pixelsphere.org) | multi-licensed; used under its CC0 option |
9
14
 
10
15
  CC0 requires no attribution; this file records provenance anyway.
11
16
  Files placed here named after a procedural SFX (e.g. `whoosh.wav`) override
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/dist/bin.js CHANGED
@@ -283,6 +283,7 @@ var init_validate = __esm({
283
283
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
284
284
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
285
285
  text: [...COMMON_PROPS, "content", "contentDecimals", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
286
+ image: [...COMMON_PROPS, "src", "width", "height"],
286
287
  group: COMMON_PROPS
287
288
  };
288
289
  SceneValidationError = class extends Error {
@@ -600,6 +601,43 @@ var init_evaluate = __esm({
600
601
  }
601
602
  });
602
603
 
604
+ // ../core/src/assets.ts
605
+ function collectImageSrcs(ir) {
606
+ const srcs = /* @__PURE__ */ new Set();
607
+ const imageIds = /* @__PURE__ */ new Set();
608
+ const walkNodes = (nodes) => {
609
+ for (const node of nodes) {
610
+ if (node.type === "image") {
611
+ imageIds.add(node.id);
612
+ srcs.add(node.props.src);
613
+ }
614
+ if (node.type === "group") walkNodes(node.children);
615
+ }
616
+ };
617
+ walkNodes(ir.nodes);
618
+ for (const overrides of Object.values(ir.states ?? {})) {
619
+ for (const [nodeId, props] of Object.entries(overrides)) {
620
+ if (imageIds.has(nodeId) && typeof props.src === "string") srcs.add(props.src);
621
+ }
622
+ }
623
+ const walkTimeline = (step) => {
624
+ if (!step) return;
625
+ if (step.kind === "seq" || step.kind === "par" || step.kind === "stagger") {
626
+ for (const child of step.children) walkTimeline(child);
627
+ } else if (step.kind === "tween" && imageIds.has(step.target)) {
628
+ const src = step.props.src;
629
+ if (typeof src === "string") srcs.add(src);
630
+ }
631
+ };
632
+ walkTimeline(ir.timeline);
633
+ return [...srcs];
634
+ }
635
+ var init_assets = __esm({
636
+ "../core/src/assets.ts"() {
637
+ "use strict";
638
+ }
639
+ });
640
+
603
641
  // ../core/src/index.ts
604
642
  var init_src = __esm({
605
643
  "../core/src/index.ts"() {
@@ -613,6 +651,7 @@ var init_src = __esm({
613
651
  init_evaluate();
614
652
  init_interpolate();
615
653
  init_behaviors();
654
+ init_assets();
616
655
  }
617
656
  });
618
657
 
@@ -988,12 +1027,12 @@ async function encodeMp4(framesDir, fps, outFile) {
988
1027
  "+faststart",
989
1028
  outFile
990
1029
  ];
991
- await new Promise((resolve4, reject) => {
1030
+ await new Promise((resolve5, reject) => {
992
1031
  const proc = spawn2("ffmpeg", args, { stdio: ["ignore", "ignore", "pipe"] });
993
1032
  let stderr = "";
994
1033
  proc.stderr.on("data", (d) => stderr += d.toString());
995
1034
  proc.on("close", (code) => {
996
- if (code === 0) resolve4();
1035
+ if (code === 0) resolve5();
997
1036
  else reject(new Error(`ffmpeg exited with ${code}:
998
1037
  ${stderr.slice(-2e3)}`));
999
1038
  });
@@ -1036,6 +1075,45 @@ var init_fonts = __esm({
1036
1075
  }
1037
1076
  });
1038
1077
 
1078
+ // ../render-cli/src/images.ts
1079
+ import { readFile as readFile2 } from "node:fs/promises";
1080
+ import { existsSync as existsSync2 } from "node:fs";
1081
+ import { extname, isAbsolute as isAbsolute2, resolve as resolve2 } from "node:path";
1082
+ async function buildImageAssets(ir, sceneDir) {
1083
+ const assets = {};
1084
+ for (const src of collectImageSrcs(ir)) {
1085
+ const mime = MIME[extname(src).toLowerCase()];
1086
+ if (!mime) {
1087
+ throw new Error(
1088
+ `image "${src}": unsupported format "${extname(src)}" \u2014 supported: ${Object.keys(MIME).join(" ")}`
1089
+ );
1090
+ }
1091
+ const candidates = [isAbsolute2(src) ? src : null, resolve2(sceneDir, src)].filter(
1092
+ (c) => c !== null
1093
+ );
1094
+ const found = candidates.find((c) => existsSync2(c));
1095
+ if (!found) {
1096
+ throw new Error(`image "${src}" not found (tried: ${candidates.join(", ")})`);
1097
+ }
1098
+ const data = await readFile2(found);
1099
+ assets[src] = `data:${mime};base64,${data.toString("base64")}`;
1100
+ }
1101
+ return assets;
1102
+ }
1103
+ var MIME;
1104
+ var init_images = __esm({
1105
+ "../render-cli/src/images.ts"() {
1106
+ "use strict";
1107
+ init_src();
1108
+ MIME = {
1109
+ ".png": "image/png",
1110
+ ".jpg": "image/jpeg",
1111
+ ".jpeg": "image/jpeg",
1112
+ ".webp": "image/webp"
1113
+ };
1114
+ }
1115
+ });
1116
+
1039
1117
  // ../render-cli/src/vclock.ts
1040
1118
  var VCLOCK_SOURCE;
1041
1119
  var init_vclock = __esm({
@@ -1141,8 +1219,8 @@ async function withPage(size, fn) {
1141
1219
  async function browserBundle() {
1142
1220
  if (bundleCache) return bundleCache;
1143
1221
  if (true) {
1144
- const { readFile: readFile4 } = await import("node:fs/promises");
1145
- bundleCache = await readFile4(
1222
+ const { readFile: readFile5 } = await import("node:fs/promises");
1223
+ bundleCache = await readFile5(
1146
1224
  join4(dirname4(fileURLToPath3(import.meta.url)), "browserEntry.js"),
1147
1225
  "utf8"
1148
1226
  );
@@ -1161,6 +1239,7 @@ async function browserBundle() {
1161
1239
  }
1162
1240
  async function captureIr(ir, opts) {
1163
1241
  await mkdir2(opts.framesDir, { recursive: true });
1242
+ const assets = await buildImageAssets(ir, opts.sceneDir ?? process.cwd());
1164
1243
  const bundle = await browserBundle();
1165
1244
  return withPage(ir.size, async (page) => {
1166
1245
  await page.setContent(
@@ -1169,8 +1248,8 @@ async function captureIr(ir, opts) {
1169
1248
  await injectFonts(page);
1170
1249
  await page.addScriptTag({ content: bundle });
1171
1250
  const info = await page.evaluate(
1172
- (sceneIr) => window.__reframe.init(sceneIr),
1173
- ir
1251
+ ([sceneIr, imageAssets]) => window.__reframe.init(sceneIr, imageAssets),
1252
+ [ir, assets]
1174
1253
  );
1175
1254
  const fps = opts.fps ?? info.fps;
1176
1255
  const duration = opts.duration ?? info.duration;
@@ -1187,6 +1266,7 @@ var init_frameLoop = __esm({
1187
1266
  "../render-cli/src/frameLoop.ts"() {
1188
1267
  "use strict";
1189
1268
  init_fonts();
1269
+ init_images();
1190
1270
  init_vclock();
1191
1271
  init_reframeGlobal();
1192
1272
  framePath = (dir, i) => join4(dir, `${String(i).padStart(5, "0")}.png`);
@@ -1202,9 +1282,9 @@ __export(batch_exports, {
1202
1282
  parseCsv: () => parseCsv,
1203
1283
  runBatch: () => runBatch
1204
1284
  });
1205
- import { mkdir as mkdir3, mkdtemp as mkdtemp2, readFile as readFile2, rm as rm2, writeFile as writeFile4 } from "node:fs/promises";
1285
+ import { mkdir as mkdir3, mkdtemp as mkdtemp2, readFile as readFile3, rm as rm2, writeFile as writeFile4 } from "node:fs/promises";
1206
1286
  import { tmpdir as tmpdir3 } from "node:os";
1207
- import { join as join5 } from "node:path";
1287
+ import { join as join5, dirname as dirname5 } from "node:path";
1208
1288
  function overlayFromFlat(row, name) {
1209
1289
  const doc = { reframeOverlay: 1, name };
1210
1290
  for (const [key2, raw] of Object.entries(row)) {
@@ -1278,7 +1358,7 @@ function parseCsv(text) {
1278
1358
  });
1279
1359
  }
1280
1360
  async function loadRows(path) {
1281
- const text = await readFile2(path, "utf8");
1361
+ const text = await readFile3(path, "utf8");
1282
1362
  if (path.endsWith(".csv")) return parseCsv(text);
1283
1363
  const parsed = JSON.parse(text);
1284
1364
  if (!Array.isArray(parsed)) throw new Error(`${path}: expected a JSON array of row objects`);
@@ -1304,6 +1384,7 @@ async function runBatch(scene, rows, opts) {
1304
1384
  try {
1305
1385
  const captured = await captureIr(ir, {
1306
1386
  framesDir,
1387
+ ...opts.scenePath !== void 0 && { sceneDir: dirname5(opts.scenePath) },
1307
1388
  ...opts.fps !== void 0 && { fps: opts.fps }
1308
1389
  });
1309
1390
  if (plan) {
@@ -1362,12 +1443,12 @@ __export(loadScene_exports, {
1362
1443
  loadScene: () => loadScene
1363
1444
  });
1364
1445
  import { build as build2 } from "esbuild";
1365
- import { readFile as readFile3 } from "node:fs/promises";
1366
- import { dirname as dirname5, resolve as resolve2 } from "node:path";
1446
+ import { readFile as readFile4 } from "node:fs/promises";
1447
+ import { dirname as dirname6, resolve as resolve3 } from "node:path";
1367
1448
  import { fileURLToPath as fileURLToPath4 } from "node:url";
1368
1449
  async function loadScene(path) {
1369
1450
  if (path.endsWith(".json")) {
1370
- const ir = JSON.parse(await readFile3(path, "utf8"));
1451
+ const ir = JSON.parse(await readFile4(path, "utf8"));
1371
1452
  validateScene(ir);
1372
1453
  return ir;
1373
1454
  }
@@ -1401,21 +1482,21 @@ var init_loadScene = __esm({
1401
1482
  "../render-cli/src/loadScene.ts"() {
1402
1483
  "use strict";
1403
1484
  init_src();
1404
- HERE = dirname5(fileURLToPath4(import.meta.url));
1405
- CORE_ENTRY = true ? resolve2(HERE, "index.js") : resolve2(HERE, "..", "..", "core", "src", "index.ts");
1485
+ HERE = dirname6(fileURLToPath4(import.meta.url));
1486
+ CORE_ENTRY = true ? resolve3(HERE, "index.js") : resolve3(HERE, "..", "..", "core", "src", "index.ts");
1406
1487
  }
1407
1488
  });
1408
1489
 
1409
1490
  // ../render-cli/src/reframe.ts
1410
1491
  import { spawn as spawn3, spawnSync } from "node:child_process";
1411
- import { existsSync as existsSync2 } from "node:fs";
1492
+ import { existsSync as existsSync3 } from "node:fs";
1412
1493
  import { mkdir as mkdir4, writeFile as writeFile5 } from "node:fs/promises";
1413
- import { basename, isAbsolute as isAbsolute2, join as join6, resolve as resolve3 } from "node:path";
1414
- import { dirname as dirname6 } from "node:path";
1494
+ import { basename, isAbsolute as isAbsolute3, join as join6, resolve as resolve4 } from "node:path";
1495
+ import { dirname as dirname7 } from "node:path";
1415
1496
  import { fileURLToPath as fileURLToPath5 } from "node:url";
1416
1497
  var PACKAGED = true;
1417
- var HERE2 = dirname6(fileURLToPath5(import.meta.url));
1418
- var ROOT2 = PACKAGED ? resolve3(HERE2, "..") : resolve3(HERE2, "..", "..", "..");
1498
+ var HERE2 = dirname7(fileURLToPath5(import.meta.url));
1499
+ var ROOT2 = PACKAGED ? resolve4(HERE2, "..") : resolve4(HERE2, "..", "..", "..");
1419
1500
  var USER_CWD = process.env.INIT_CWD ?? process.cwd();
1420
1501
  var RENDER_CLI = PACKAGED ? join6(ROOT2, "dist", "cli.js") : join6(ROOT2, "packages", "render-cli", "src", "cli.ts");
1421
1502
  var ANALYZE = PACKAGED ? join6(ROOT2, "dist", "analyze.js") : join6(ROOT2, "benchmark", "harness", "motion", "analyze.ts");
@@ -1431,7 +1512,7 @@ usage:
1431
1512
  ${CMD} guide [--regen] print the scene-authoring guide (for you or your AI)
1432
1513
  ${CMD} demo run the edit-survival demo (3 mp4s into out/)
1433
1514
  `;
1434
- var userPath = (p) => isAbsolute2(p) ? p : resolve3(USER_CWD, p);
1515
+ var userPath = (p) => isAbsolute3(p) ? p : resolve4(USER_CWD, p);
1435
1516
  function fail(message) {
1436
1517
  console.error(`error: ${message}`);
1437
1518
  process.exit(2);
@@ -1533,7 +1614,7 @@ async function main() {
1533
1614
 
1534
1615
  ${USAGE}`);
1535
1616
  const inputPath = userPath(input);
1536
- if (!existsSync2(inputPath)) fail(`no such file: ${inputPath}`);
1617
+ if (!existsSync3(inputPath)) fail(`no such file: ${inputPath}`);
1537
1618
  const mode = /\.(ts|json)$/.test(input) ? "ir" : /\.html$/.test(input) ? "html" : null;
1538
1619
  if (!mode) {
1539
1620
  fail(`cannot infer render mode from "${input}" \u2014 expected .ts/.json (reframe scene) or .html (GSAP page)`);
@@ -1561,7 +1642,7 @@ ${USAGE}`);
1561
1642
  if (!sceneArg || !dataArg) fail(`usage: ${CMD} batch <scene.ts> <data.json|csv> [...]`);
1562
1643
  const scenePath = userPath(sceneArg);
1563
1644
  const dataPath = userPath(dataArg);
1564
- for (const p of [scenePath, dataPath]) if (!existsSync2(p)) fail(`no such file: ${p}`);
1645
+ for (const p of [scenePath, dataPath]) if (!existsSync3(p)) fail(`no such file: ${p}`);
1565
1646
  preflightFfmpeg();
1566
1647
  let outDir = PACKAGED ? join6(USER_CWD, "out", "batch") : join6(ROOT2, "out", "batch");
1567
1648
  let concurrency = 3;
@@ -1576,10 +1657,10 @@ ${USAGE}`);
1576
1657
  }
1577
1658
  const { loadRows: loadRows2, runBatch: runBatch2 } = await Promise.resolve().then(() => (init_batch(), batch_exports));
1578
1659
  const { loadScene: loadScene2 } = await Promise.resolve().then(() => (init_loadScene(), loadScene_exports));
1579
- const { readFile: readFile4 } = await import("node:fs/promises");
1660
+ const { readFile: readFile5 } = await import("node:fs/promises");
1580
1661
  const scene = await loadScene2(scenePath);
1581
1662
  const baseOverlays = await Promise.all(
1582
- baseOverlayPaths.map(async (p) => JSON.parse(await readFile4(p, "utf8")))
1663
+ baseOverlayPaths.map(async (p) => JSON.parse(await readFile5(p, "utf8")))
1583
1664
  );
1584
1665
  const rows = await loadRows2(dataPath);
1585
1666
  if (rows.length === 0) fail(`${dataPath}: no data rows`);
@@ -1610,7 +1691,7 @@ ${results.length - failed} rendered (${orphaned} with orphans), ${failed} failed
1610
1691
  if (PACKAGED) {
1611
1692
  const { createRequire } = await import("node:module");
1612
1693
  const vitePkg = createRequire(import.meta.url).resolve("vite/package.json");
1613
- const viteBin = join6(dirname6(vitePkg), "bin", "vite.js");
1694
+ const viteBin = join6(dirname7(vitePkg), "bin", "vite.js");
1614
1695
  process.exit(
1615
1696
  await run(process.execPath, [viteBin, join6(ROOT2, "preview")], {
1616
1697
  env: { REFRAME_SCENE_DIR: USER_CWD }
@@ -1633,7 +1714,7 @@ ${results.length - failed} rendered (${orphaned} with orphans), ${failed} failed
1633
1714
  const targetDir = inRepo ? join6(ROOT2, "examples", "scenes") : USER_CWD;
1634
1715
  const target = join6(targetDir, `${name}.ts`);
1635
1716
  const shown = inRepo ? `examples/scenes/${name}.ts` : `${name}.ts`;
1636
- if (existsSync2(target)) fail(`${shown} already exists`);
1717
+ if (existsSync3(target)) fail(`${shown} already exists`);
1637
1718
  const id = name.split("-")[0] ?? name;
1638
1719
  await writeFile5(target, SCENE_TEMPLATE(name, id));
1639
1720
  console.log(`created ${shown}
@@ -1652,8 +1733,8 @@ ${results.length - failed} rendered (${orphaned} with orphans), ${failed} failed
1652
1733
  }
1653
1734
  case "guide": {
1654
1735
  const file = rest.includes("--regen") ? PACKAGED ? join6(ROOT2, "guides", "regen-contract.md") : join6(ROOT2, "docs", "regen-contract.md") : PACKAGED ? join6(ROOT2, "guides", "edsl-guide.md") : join6(ROOT2, "benchmark", "guides", "edsl-guide.md");
1655
- const { readFile: readFile4 } = await import("node:fs/promises");
1656
- process.stdout.write(await readFile4(file, "utf8"));
1736
+ const { readFile: readFile5 } = await import("node:fs/promises");
1737
+ process.stdout.write(await readFile5(file, "utf8"));
1657
1738
  return;
1658
1739
  }
1659
1740
  case "demo":
@@ -137,6 +137,7 @@
137
137
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
138
138
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
139
139
  text: [...COMMON_PROPS, "content", "contentDecimals", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
140
+ image: [...COMMON_PROPS, "src", "width", "height"],
140
141
  group: COMMON_PROPS
141
142
  };
142
143
 
@@ -394,6 +395,23 @@
394
395
  });
395
396
  return;
396
397
  }
398
+ case "image": {
399
+ const width = num(id, "width", node.props.width);
400
+ const height = num(id, "height", node.props.height);
401
+ const [ax, ay] = ANCHOR_FACTORS[node.props.anchor ?? "top-left"];
402
+ ops.push({
403
+ type: "image",
404
+ id,
405
+ transform: matrix,
406
+ opacity,
407
+ src: str(id, "src", node.props.src),
408
+ width,
409
+ height,
410
+ offsetX: -width * ax,
411
+ offsetY: -height * ay
412
+ });
413
+ return;
414
+ }
397
415
  case "text": {
398
416
  const [ax, ay] = ANCHOR_FACTORS[node.props.anchor ?? "top-left"];
399
417
  const raw = valueAt(id, "content", node.props.content);
@@ -424,7 +442,7 @@
424
442
  }
425
443
 
426
444
  // ../renderer-canvas/src/index.ts
427
- function renderFrame(ctx2, compiled2, t) {
445
+ function renderFrame(ctx2, compiled2, t, images2) {
428
446
  const { size, background } = compiled2.ir;
429
447
  ctx2.setTransform(1, 0, 0, 1, 0, 0);
430
448
  ctx2.clearRect(0, 0, size.width, size.height);
@@ -432,9 +450,9 @@
432
450
  ctx2.fillStyle = background;
433
451
  ctx2.fillRect(0, 0, size.width, size.height);
434
452
  }
435
- drawDisplayList(ctx2, evaluate(compiled2, t));
453
+ drawDisplayList(ctx2, evaluate(compiled2, t), images2);
436
454
  }
437
- function drawDisplayList(ctx2, ops) {
455
+ function drawDisplayList(ctx2, ops, images2) {
438
456
  for (const op of ops) {
439
457
  ctx2.save();
440
458
  ctx2.setTransform(...op.transform);
@@ -490,6 +508,25 @@
490
508
  ctx2.stroke();
491
509
  break;
492
510
  }
511
+ case "image": {
512
+ const img = images2?.get(op.src);
513
+ if (img) {
514
+ ctx2.drawImage(img, op.offsetX, op.offsetY, op.width, op.height);
515
+ } else {
516
+ ctx2.fillStyle = "#2A2A30";
517
+ ctx2.fillRect(op.offsetX, op.offsetY, op.width, op.height);
518
+ ctx2.strokeStyle = "#FF00FF";
519
+ ctx2.lineWidth = 2;
520
+ ctx2.strokeRect(op.offsetX, op.offsetY, op.width, op.height);
521
+ ctx2.beginPath();
522
+ ctx2.moveTo(op.offsetX, op.offsetY);
523
+ ctx2.lineTo(op.offsetX + op.width, op.offsetY + op.height);
524
+ ctx2.moveTo(op.offsetX + op.width, op.offsetY);
525
+ ctx2.lineTo(op.offsetX, op.offsetY + op.height);
526
+ ctx2.stroke();
527
+ }
528
+ break;
529
+ }
493
530
  case "text": {
494
531
  ctx2.font = `${op.fontWeight} ${op.fontSize}px ${quoteFamily(op.fontFamily)}`;
495
532
  if (op.letterSpacing) ctx2.letterSpacing = `${op.letterSpacing}px`;
@@ -512,8 +549,10 @@
512
549
  var compiled = null;
513
550
  var ctx = null;
514
551
  var canvas = null;
552
+ var images = /* @__PURE__ */ new Map();
515
553
  window.__reframe = {
516
- init(ir) {
554
+ // fully decode every image before the first frame — renderFrame is sync
555
+ async init(ir, assets = {}) {
517
556
  compiled = compileScene(ir);
518
557
  canvas = document.createElement("canvas");
519
558
  canvas.width = ir.size.width;
@@ -521,11 +560,19 @@
521
560
  document.body.appendChild(canvas);
522
561
  ctx = canvas.getContext("2d", { willReadFrequently: true });
523
562
  if (!ctx) throw new Error("could not create 2d context");
563
+ await Promise.all(
564
+ Object.entries(assets).map(async ([src, dataUrl]) => {
565
+ const img = new Image();
566
+ img.src = dataUrl;
567
+ await img.decode();
568
+ images.set(src, img);
569
+ })
570
+ );
524
571
  return { duration: compiled.duration, fps: ir.fps ?? 30 };
525
572
  },
526
573
  renderFrame(t) {
527
574
  if (!compiled || !ctx || !canvas) throw new Error("init() not called");
528
- renderFrame(ctx, compiled, t);
575
+ renderFrame(ctx, compiled, t, images);
529
576
  return canvas.toDataURL("image/png");
530
577
  }
531
578
  };
package/dist/cli.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env tsx
2
2
 
3
3
  // ../render-cli/src/cli.ts
4
- import { mkdtemp as mkdtemp2, readFile as readFile3, rm as rm2 } from "node:fs/promises";
4
+ import { mkdtemp as mkdtemp2, readFile as readFile4, rm as rm2 } from "node:fs/promises";
5
5
  import { tmpdir as tmpdir3 } from "node:os";
6
- import { basename, join as join5, resolve as resolve3 } from "node:path";
6
+ import { basename, dirname as dirname6, join as join5, resolve as resolve4 } from "node:path";
7
7
 
8
8
  // ../core/src/ir.ts
9
9
  var DEFAULT_TO_DURATION = 0.5;
@@ -142,6 +142,7 @@ var PROPS_BY_TYPE = {
142
142
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
143
143
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
144
144
  text: [...COMMON_PROPS, "content", "contentDecimals", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
145
+ image: [...COMMON_PROPS, "src", "width", "height"],
145
146
  group: COMMON_PROPS
146
147
  };
147
148
  var SceneValidationError = class extends Error {
@@ -542,6 +543,38 @@ var EASE_TABLE = {
542
543
  };
543
544
  var EASE_NAMES = Object.keys(EASE_TABLE);
544
545
 
546
+ // ../core/src/assets.ts
547
+ function collectImageSrcs(ir) {
548
+ const srcs = /* @__PURE__ */ new Set();
549
+ const imageIds = /* @__PURE__ */ new Set();
550
+ const walkNodes = (nodes) => {
551
+ for (const node of nodes) {
552
+ if (node.type === "image") {
553
+ imageIds.add(node.id);
554
+ srcs.add(node.props.src);
555
+ }
556
+ if (node.type === "group") walkNodes(node.children);
557
+ }
558
+ };
559
+ walkNodes(ir.nodes);
560
+ for (const overrides of Object.values(ir.states ?? {})) {
561
+ for (const [nodeId, props] of Object.entries(overrides)) {
562
+ if (imageIds.has(nodeId) && typeof props.src === "string") srcs.add(props.src);
563
+ }
564
+ }
565
+ const walkTimeline = (step) => {
566
+ if (!step) return;
567
+ if (step.kind === "seq" || step.kind === "par" || step.kind === "stagger") {
568
+ for (const child of step.children) walkTimeline(child);
569
+ } else if (step.kind === "tween" && imageIds.has(step.target)) {
570
+ const src = step.props.src;
571
+ if (typeof src === "string") srcs.add(src);
572
+ }
573
+ };
574
+ walkTimeline(ir.timeline);
575
+ return [...srcs];
576
+ }
577
+
545
578
  // ../render-cli/src/audio/index.ts
546
579
  import { dirname as dirname2 } from "node:path";
547
580
 
@@ -881,12 +914,12 @@ async function encodeMp4(framesDir, fps, outFile) {
881
914
  "+faststart",
882
915
  outFile
883
916
  ];
884
- await new Promise((resolve4, reject) => {
917
+ await new Promise((resolve5, reject) => {
885
918
  const proc = spawn2("ffmpeg", args, { stdio: ["ignore", "ignore", "pipe"] });
886
919
  let stderr = "";
887
920
  proc.stderr.on("data", (d) => stderr += d.toString());
888
921
  proc.on("close", (code) => {
889
- if (code === 0) resolve4();
922
+ if (code === 0) resolve5();
890
923
  else reject(new Error(`ffmpeg exited with ${code}:
891
924
  ${stderr.slice(-2e3)}`));
892
925
  });
@@ -925,6 +958,38 @@ async function fontFaceCss() {
925
958
  return cssCache;
926
959
  }
927
960
 
961
+ // ../render-cli/src/images.ts
962
+ import { readFile as readFile2 } from "node:fs/promises";
963
+ import { existsSync as existsSync2 } from "node:fs";
964
+ import { extname, isAbsolute as isAbsolute2, resolve as resolve2 } from "node:path";
965
+ var MIME = {
966
+ ".png": "image/png",
967
+ ".jpg": "image/jpeg",
968
+ ".jpeg": "image/jpeg",
969
+ ".webp": "image/webp"
970
+ };
971
+ async function buildImageAssets(ir, sceneDir) {
972
+ const assets = {};
973
+ for (const src of collectImageSrcs(ir)) {
974
+ const mime = MIME[extname(src).toLowerCase()];
975
+ if (!mime) {
976
+ throw new Error(
977
+ `image "${src}": unsupported format "${extname(src)}" \u2014 supported: ${Object.keys(MIME).join(" ")}`
978
+ );
979
+ }
980
+ const candidates = [isAbsolute2(src) ? src : null, resolve2(sceneDir, src)].filter(
981
+ (c) => c !== null
982
+ );
983
+ const found = candidates.find((c) => existsSync2(c));
984
+ if (!found) {
985
+ throw new Error(`image "${src}" not found (tried: ${candidates.join(", ")})`);
986
+ }
987
+ const data = await readFile2(found);
988
+ assets[src] = `data:${mime};base64,${data.toString("base64")}`;
989
+ }
990
+ return assets;
991
+ }
992
+
928
993
  // ../render-cli/src/vclock.ts
929
994
  var VCLOCK_SOURCE = String.raw`
930
995
  (() => {
@@ -1014,8 +1079,8 @@ var bundleCache = null;
1014
1079
  async function browserBundle() {
1015
1080
  if (bundleCache) return bundleCache;
1016
1081
  if (true) {
1017
- const { readFile: readFile4 } = await import("node:fs/promises");
1018
- bundleCache = await readFile4(
1082
+ const { readFile: readFile5 } = await import("node:fs/promises");
1083
+ bundleCache = await readFile5(
1019
1084
  join4(dirname4(fileURLToPath3(import.meta.url)), "browserEntry.js"),
1020
1085
  "utf8"
1021
1086
  );
@@ -1034,6 +1099,7 @@ async function browserBundle() {
1034
1099
  }
1035
1100
  async function captureIr(ir, opts) {
1036
1101
  await mkdir2(opts.framesDir, { recursive: true });
1102
+ const assets = await buildImageAssets(ir, opts.sceneDir ?? process.cwd());
1037
1103
  const bundle = await browserBundle();
1038
1104
  return withPage(ir.size, async (page) => {
1039
1105
  await page.setContent(
@@ -1042,8 +1108,8 @@ async function captureIr(ir, opts) {
1042
1108
  await injectFonts(page);
1043
1109
  await page.addScriptTag({ content: bundle });
1044
1110
  const info = await page.evaluate(
1045
- (sceneIr) => window.__reframe.init(sceneIr),
1046
- ir
1111
+ ([sceneIr, imageAssets]) => window.__reframe.init(sceneIr, imageAssets),
1112
+ [ir, assets]
1047
1113
  );
1048
1114
  const fps = opts.fps ?? info.fps;
1049
1115
  const duration = opts.duration ?? info.duration;
@@ -1080,14 +1146,14 @@ async function captureHtml(htmlPath, opts) {
1080
1146
 
1081
1147
  // ../render-cli/src/loadScene.ts
1082
1148
  import { build as build2 } from "esbuild";
1083
- import { readFile as readFile2 } from "node:fs/promises";
1084
- import { dirname as dirname5, resolve as resolve2 } from "node:path";
1149
+ import { readFile as readFile3 } from "node:fs/promises";
1150
+ import { dirname as dirname5, resolve as resolve3 } from "node:path";
1085
1151
  import { fileURLToPath as fileURLToPath4 } from "node:url";
1086
1152
  var HERE = dirname5(fileURLToPath4(import.meta.url));
1087
- var CORE_ENTRY = true ? resolve2(HERE, "index.js") : resolve2(HERE, "..", "..", "core", "src", "index.ts");
1153
+ var CORE_ENTRY = true ? resolve3(HERE, "index.js") : resolve3(HERE, "..", "..", "core", "src", "index.ts");
1088
1154
  async function loadScene(path) {
1089
1155
  if (path.endsWith(".json")) {
1090
- const ir = JSON.parse(await readFile2(path, "utf8"));
1156
+ const ir = JSON.parse(await readFile3(path, "utf8"));
1091
1157
  validateScene(ir);
1092
1158
  return ir;
1093
1159
  }
@@ -1128,7 +1194,7 @@ function parseArgs(argv) {
1128
1194
  }
1129
1195
  const args = {
1130
1196
  mode,
1131
- input: resolve3(input),
1197
+ input: resolve4(input),
1132
1198
  out: `${basename(input).replace(/\.[^.]+$/, "")}.mp4`,
1133
1199
  keepFrames: false,
1134
1200
  overlays: [],
@@ -1140,8 +1206,8 @@ function parseArgs(argv) {
1140
1206
  else if (a === "--fps") args.fps = Number(rest[++i]);
1141
1207
  else if (a === "--duration") args.duration = Number(rest[++i]);
1142
1208
  else if (a === "--keep-frames") args.keepFrames = true;
1143
- else if (a === "--frames-dir") args.framesDir = resolve3(rest[++i]);
1144
- else if (a === "--overlay") args.overlays.push(resolve3(rest[++i]));
1209
+ else if (a === "--frames-dir") args.framesDir = resolve4(rest[++i]);
1210
+ else if (a === "--overlay") args.overlays.push(resolve4(rest[++i]));
1145
1211
  else if (a === "--no-audio") args.noAudio = true;
1146
1212
  else {
1147
1213
  console.error(`unknown flag ${a}`);
@@ -1159,7 +1225,7 @@ async function main() {
1159
1225
  let ir = await loadScene(args.input);
1160
1226
  if (args.overlays.length > 0) {
1161
1227
  const docs = await Promise.all(
1162
- args.overlays.map(async (p) => JSON.parse(await readFile3(p, "utf8")))
1228
+ args.overlays.map(async (p) => JSON.parse(await readFile4(p, "utf8")))
1163
1229
  );
1164
1230
  const composed = composeScene(ir, ...docs);
1165
1231
  console.error(formatComposeReport(composed.report));
@@ -1174,6 +1240,7 @@ async function main() {
1174
1240
  }
1175
1241
  result = await captureIr(ir, {
1176
1242
  framesDir,
1243
+ sceneDir: dirname6(args.input),
1177
1244
  ...args.fps !== void 0 && { fps: args.fps },
1178
1245
  ...args.duration !== void 0 && { duration: args.duration }
1179
1246
  });
package/dist/index.js CHANGED
@@ -136,6 +136,7 @@ var PROPS_BY_TYPE = {
136
136
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
137
137
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
138
138
  text: [...COMMON_PROPS, "content", "contentDecimals", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
139
+ image: [...COMMON_PROPS, "src", "width", "height"],
139
140
  group: COMMON_PROPS
140
141
  };
141
142
  var SceneValidationError = class extends Error {
@@ -293,6 +294,10 @@ function text(props) {
293
294
  const { id, ...rest } = props;
294
295
  return { type: "text", id, props: rest };
295
296
  }
297
+ function image(props) {
298
+ const { id, ...rest } = props;
299
+ return { type: "image", id, props: rest };
300
+ }
296
301
  function group(props, children) {
297
302
  const { id, ...rest } = props;
298
303
  return { type: "group", id, props: rest, children };
@@ -826,6 +831,23 @@ function evaluate(compiled, t) {
826
831
  });
827
832
  return;
828
833
  }
834
+ case "image": {
835
+ const width = num(id, "width", node.props.width);
836
+ const height = num(id, "height", node.props.height);
837
+ const [ax, ay] = ANCHOR_FACTORS[node.props.anchor ?? "top-left"];
838
+ ops.push({
839
+ type: "image",
840
+ id,
841
+ transform: matrix,
842
+ opacity,
843
+ src: str(id, "src", node.props.src),
844
+ width,
845
+ height,
846
+ offsetX: -width * ax,
847
+ offsetY: -height * ay
848
+ });
849
+ return;
850
+ }
829
851
  case "text": {
830
852
  const [ax, ay] = ANCHOR_FACTORS[node.props.anchor ?? "top-left"];
831
853
  const raw = valueAt(id, "content", node.props.content);
@@ -854,6 +876,38 @@ function evaluate(compiled, t) {
854
876
  for (const node of compiled.ir.nodes) walk(node, IDENTITY, 1);
855
877
  return ops;
856
878
  }
879
+
880
+ // ../core/src/assets.ts
881
+ function collectImageSrcs(ir) {
882
+ const srcs = /* @__PURE__ */ new Set();
883
+ const imageIds = /* @__PURE__ */ new Set();
884
+ const walkNodes = (nodes) => {
885
+ for (const node of nodes) {
886
+ if (node.type === "image") {
887
+ imageIds.add(node.id);
888
+ srcs.add(node.props.src);
889
+ }
890
+ if (node.type === "group") walkNodes(node.children);
891
+ }
892
+ };
893
+ walkNodes(ir.nodes);
894
+ for (const overrides of Object.values(ir.states ?? {})) {
895
+ for (const [nodeId, props] of Object.entries(overrides)) {
896
+ if (imageIds.has(nodeId) && typeof props.src === "string") srcs.add(props.src);
897
+ }
898
+ }
899
+ const walkTimeline = (step) => {
900
+ if (!step) return;
901
+ if (step.kind === "seq" || step.kind === "par" || step.kind === "stagger") {
902
+ for (const child of step.children) walkTimeline(child);
903
+ } else if (step.kind === "tween" && imageIds.has(step.target)) {
904
+ const src = step.props.src;
905
+ if (typeof src === "string") srcs.add(src);
906
+ }
907
+ };
908
+ walkTimeline(ir.timeline);
909
+ return [...srcs];
910
+ }
857
911
  export {
858
912
  DEFAULT_FPS,
859
913
  DEFAULT_TO_DURATION,
@@ -862,12 +916,14 @@ export {
862
916
  PROPS_BY_TYPE,
863
917
  SFX_DURATION,
864
918
  SceneValidationError,
919
+ collectImageSrcs,
865
920
  compileScene,
866
921
  composeScene,
867
922
  ellipse,
868
923
  evaluate,
869
924
  formatComposeReport,
870
925
  group,
926
+ image,
871
927
  isColor,
872
928
  lerpValue,
873
929
  line,
@@ -1,6 +1,6 @@
1
1
  // ../renderer-canvas/src/index.ts
2
2
  import { evaluate } from "@reframe/core";
3
- function renderFrame(ctx, compiled, t) {
3
+ function renderFrame(ctx, compiled, t, images) {
4
4
  const { size, background } = compiled.ir;
5
5
  ctx.setTransform(1, 0, 0, 1, 0, 0);
6
6
  ctx.clearRect(0, 0, size.width, size.height);
@@ -8,9 +8,9 @@ function renderFrame(ctx, compiled, t) {
8
8
  ctx.fillStyle = background;
9
9
  ctx.fillRect(0, 0, size.width, size.height);
10
10
  }
11
- drawDisplayList(ctx, evaluate(compiled, t));
11
+ drawDisplayList(ctx, evaluate(compiled, t), images);
12
12
  }
13
- function drawDisplayList(ctx, ops) {
13
+ function drawDisplayList(ctx, ops, images) {
14
14
  for (const op of ops) {
15
15
  ctx.save();
16
16
  ctx.setTransform(...op.transform);
@@ -66,6 +66,25 @@ function drawDisplayList(ctx, ops) {
66
66
  ctx.stroke();
67
67
  break;
68
68
  }
69
+ case "image": {
70
+ const img = images?.get(op.src);
71
+ if (img) {
72
+ ctx.drawImage(img, op.offsetX, op.offsetY, op.width, op.height);
73
+ } else {
74
+ ctx.fillStyle = "#2A2A30";
75
+ ctx.fillRect(op.offsetX, op.offsetY, op.width, op.height);
76
+ ctx.strokeStyle = "#FF00FF";
77
+ ctx.lineWidth = 2;
78
+ ctx.strokeRect(op.offsetX, op.offsetY, op.width, op.height);
79
+ ctx.beginPath();
80
+ ctx.moveTo(op.offsetX, op.offsetY);
81
+ ctx.lineTo(op.offsetX + op.width, op.offsetY + op.height);
82
+ ctx.moveTo(op.offsetX + op.width, op.offsetY);
83
+ ctx.lineTo(op.offsetX, op.offsetY + op.height);
84
+ ctx.stroke();
85
+ }
86
+ break;
87
+ }
69
88
  case "text": {
70
89
  ctx.font = `${op.fontWeight} ${op.fontSize}px ${quoteFamily(op.fontFamily)}`;
71
90
  if (op.letterSpacing) ctx.letterSpacing = `${op.letterSpacing}px`;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Asset discovery shared by every consumer that must preload images before
3
+ * rendering (the capture page and the preview). One walker means the two
4
+ * sides can never disagree about which srcs a scene uses — including srcs
5
+ * introduced only mid-scene by a state override or a tween.
6
+ */
7
+ import type { SceneIR } from "./ir.js";
8
+ /** All image srcs a scene can ever display, deduped, in discovery order. */
9
+ export declare function collectImageSrcs(ir: SceneIR): string[];
@@ -2,7 +2,7 @@
2
2
  * The eDSL surface: thin factories that build plain IR objects.
3
3
  * `scene()` validates and returns the IR — the return value is the document.
4
4
  */
5
- import type { AudioIR, BehaviorIR, Ease, EllipseProps, GroupProps, LineProps, NodeIR, PropValue, RectProps, SceneIR, Size, StateOverride, TextProps, TimelineIR } from "./ir.js";
5
+ import type { AudioIR, BehaviorIR, Ease, EllipseProps, GroupProps, ImageProps, LineProps, NodeIR, PropValue, RectProps, SceneIR, Size, StateOverride, TextProps, TimelineIR } from "./ir.js";
6
6
  export interface SceneInput {
7
7
  id: string;
8
8
  size: Size;
@@ -30,6 +30,9 @@ export declare function line(props: {
30
30
  export declare function text(props: {
31
31
  id: string;
32
32
  } & TextProps): NodeIR;
33
+ export declare function image(props: {
34
+ id: string;
35
+ } & ImageProps): NodeIR;
33
36
  export declare function group(props: {
34
37
  id: string;
35
38
  } & GroupProps, children: NodeIR[]): NodeIR;
@@ -53,6 +53,14 @@ export type DisplayOp = (OpBase & {
53
53
  letterSpacing: number;
54
54
  align: TextAlign;
55
55
  baseline: TextBaseline;
56
+ }) | (OpBase & {
57
+ type: "image";
58
+ /** Raw src string as authored in the IR — consumers resolve it. */
59
+ src: string;
60
+ width: number;
61
+ height: number;
62
+ offsetX: number;
63
+ offsetY: number;
56
64
  });
57
65
  export type DisplayList = DisplayOp[];
58
66
  export declare function evaluate(compiled: CompiledScene, t: number): DisplayList;
@@ -7,3 +7,4 @@ export { resolveAudioPlan, SFX_DURATION, type AudioPlan, type ResolvedCue, } fro
7
7
  export { evaluate, type DisplayList, type DisplayOp, type Mat2D, type TextAlign, type TextBaseline, } from "./evaluate.js";
8
8
  export { resolveEase, lerpValue, isColor, EASE_NAMES } from "./interpolate.js";
9
9
  export { sampleBehavior } from "./behaviors.js";
10
+ export { collectImageSrcs } from "./assets.js";
@@ -65,6 +65,17 @@ export interface TextProps extends BaseProps {
65
65
  }
66
66
  export interface GroupProps extends BaseProps {
67
67
  }
68
+ export interface ImageProps extends BaseProps {
69
+ /**
70
+ * Image file path: absolute, or relative to the scene file. Drawn
71
+ * stretched to width×height. As a string prop it switches discretely at
72
+ * segment start (no crossfade) — for hard-cut sequences stack image
73
+ * nodes and step their opacity instead.
74
+ */
75
+ src: string;
76
+ width: number;
77
+ height: number;
78
+ }
68
79
  export type NodeIR = {
69
80
  type: "rect";
70
81
  id: string;
@@ -81,6 +92,10 @@ export type NodeIR = {
81
92
  type: "text";
82
93
  id: string;
83
94
  props: TextProps;
95
+ } | {
96
+ type: "image";
97
+ id: string;
98
+ props: ImageProps;
84
99
  } | {
85
100
  type: "group";
86
101
  id: string;
@@ -34,6 +34,11 @@ Factories return plain data. Every node needs a unique `id`.
34
34
  `content` may be a number; numeric content interpolates (count-up) and renders
35
35
  via `toFixed(contentDecimals ?? 0)`. For a "8.2"-style label, set
36
36
  `contentDecimals: 1`.
37
+ - `image({ id, src, x, y, width, height, opacity?, rotation?, scale?, anchor? })` —
38
+ `src` is a file path, absolute or relative to the scene file; drawn stretched
39
+ to `width`×`height` (png/jpg/webp). `src` switches discretely (no crossfade) —
40
+ for hard-cut frame sequences stack image nodes and step their `opacity`; for
41
+ a dissolve, crossfade two nodes' opacity.
37
42
  - `group({ id, x, y, opacity?, rotation?, scale?, anchor? }, children)` — children's
38
43
  coordinates are relative to the group; group opacity/transform multiply down.
39
44
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reframe-video",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Declarative motion graphics that AI can write and humans can tweak — human edits survive AI regeneration. Deterministic mp4 renders from a plain-data scene format.",
5
5
  "keywords": [
6
6
  "motion-graphics",
@@ -5,7 +5,7 @@
5
5
  * path never uses wall-clock time.
6
6
  */
7
7
 
8
- import { evaluate, type DisplayOp, type SceneIR } from "@reframe/core";
8
+ import { collectImageSrcs, evaluate, type DisplayOp, type SceneIR } from "@reframe/core";
9
9
  import { renderFrame } from "@reframe/renderer-canvas";
10
10
  import { userScenes } from "virtual:reframe-user-scenes";
11
11
  import { buildPanel } from "./panel.js";
@@ -13,17 +13,23 @@ import { EditorStore } from "./store.js";
13
13
 
14
14
  interface SceneEntry {
15
15
  label: string;
16
+ /** Absolute directory of the scene file — relative image srcs resolve here. */
17
+ dir: string;
16
18
  load: () => Promise<{ default: SceneIR }>;
17
19
  }
18
20
 
19
21
  const exampleModules = ({} as Record<string, () => Promise<{ default: SceneIR }>>);
20
22
  const modules: Record<string, SceneEntry> = {};
21
23
  for (const path of Object.keys(exampleModules).sort()) {
22
- modules[path] = { label: path.split("/").pop()!.replace(".ts", ""), load: exampleModules[path]! };
24
+ modules[path] = {
25
+ label: path.split("/").pop()!.replace(".ts", ""),
26
+ dir: __REFRAME_EXAMPLES_DIR__,
27
+ load: exampleModules[path]!,
28
+ };
23
29
  }
24
30
  // scenes from the directory `reframe preview` was invoked in
25
- for (const { name, load } of userScenes) {
26
- modules[`user:${name}`] ??= { label: `${name} (cwd)`, load };
31
+ for (const { name, dir, load } of userScenes) {
32
+ modules[`user:${name}`] ??= { label: `${name} (cwd)`, dir, load };
27
33
  }
28
34
 
29
35
  const canvas = document.getElementById("canvas") as HTMLCanvasElement;
@@ -47,9 +53,43 @@ for (const [key, entry] of Object.entries(modules)) {
47
53
  select.appendChild(option);
48
54
  }
49
55
 
56
+ // decoded images keyed by raw src; missing entries render as a placeholder
57
+ const images = new Map<string, CanvasImageSource>();
58
+ const imageLoads = new Map<string, Promise<void>>();
59
+ let sceneDir = "";
60
+
61
+ /** Load any not-yet-loaded srcs of the current scene via /@fs. */
62
+ function ensureImages(): Promise<void> {
63
+ if (!store) return Promise.resolve();
64
+ const pending: Promise<void>[] = [];
65
+ for (const src of collectImageSrcs(store.compiled.ir)) {
66
+ if (images.has(src) || imageLoads.has(src)) continue;
67
+ const url = `/@fs${src.startsWith("/") ? src : `${sceneDir}/${src}`}`;
68
+ const load = new Promise<void>((done) => {
69
+ const img = new Image();
70
+ img.onload = () => {
71
+ images.set(src, img);
72
+ done();
73
+ draw();
74
+ };
75
+ img.onerror = () => {
76
+ console.warn(`image "${src}" failed to load (${url}) — rendering placeholder`);
77
+ done();
78
+ };
79
+ img.src = url;
80
+ });
81
+ imageLoads.set(src, load);
82
+ pending.push(load);
83
+ }
84
+ return Promise.all(pending).then(() => undefined);
85
+ }
86
+
50
87
  async function loadScene(path: string) {
51
88
  const mod = await modules[path]!.load();
52
89
  store = new EditorStore(mod.default);
90
+ sceneDir = modules[path]!.dir;
91
+ images.clear();
92
+ imageLoads.clear();
53
93
  (window as unknown as { __store: EditorStore }).__store = store; // debug/testing hook
54
94
  panel = buildPanel(store, panelRoot);
55
95
  canvas.width = store.compiled.ir.size.width;
@@ -58,9 +98,11 @@ async function loadScene(path: string) {
58
98
  t = Math.min(t, store!.compiled.duration);
59
99
  if (kind === "structure") panel!.rebuild();
60
100
  else panel!.refreshReport();
101
+ void ensureImages(); // an edited src loads lazily, then redraws
61
102
  draw();
62
103
  });
63
104
  await document.fonts.ready;
105
+ await ensureImages();
64
106
  t = 0;
65
107
  panel.rebuild();
66
108
  draw();
@@ -73,7 +115,8 @@ function applyMat(m: number[], x: number, y: number): [number, number] {
73
115
  function opCorners(op: DisplayOp): [number, number][] {
74
116
  switch (op.type) {
75
117
  case "rect":
76
- case "ellipse": {
118
+ case "ellipse":
119
+ case "image": {
77
120
  const { offsetX: x, offsetY: y, width: w, height: h } = op;
78
121
  return [[x, y], [x + w, y], [x + w, y + h], [x, y + h]].map(([px, py]) =>
79
122
  applyMat(op.transform, px!, py!),
@@ -96,7 +139,7 @@ function opCorners(op: DisplayOp): [number, number][] {
96
139
 
97
140
  function draw() {
98
141
  if (!store) return;
99
- renderFrame(ctx, store.compiled, t);
142
+ renderFrame(ctx, store.compiled, t, images);
100
143
 
101
144
  if (store.selectedId) {
102
145
  const ops = evaluate(store.compiled, t).filter((op) => op.id === store!.selectedId);
@@ -1,4 +1,12 @@
1
1
  declare module "virtual:reframe-user-scenes" {
2
2
  import type { SceneIR } from "@reframe/core";
3
- export const userScenes: { name: string; load: () => Promise<{ default: SceneIR }> }[];
3
+ export const userScenes: {
4
+ name: string;
5
+ /** Absolute directory containing the scene file. */
6
+ dir: string;
7
+ load: () => Promise<{ default: SceneIR }>;
8
+ }[];
4
9
  }
10
+
11
+ /** Absolute path of examples/scenes (injected by vite define; "" when packaged). */
12
+ declare const __REFRAME_EXAMPLES_DIR__: string;
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync, readdirSync } from "node:fs";
2
- import { basename, join, resolve } from "node:path";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
3
  import { defineConfig, type Plugin } from "vite";
4
4
 
5
5
  const PKG_ROOT = resolve(__dirname, "..");
@@ -31,7 +31,7 @@ const userScenesPlugin: Plugin = {
31
31
  if (id !== "\0reframe-user-scenes") return undefined;
32
32
  const entries = userScenes().map(
33
33
  (s) =>
34
- ` { name: ${JSON.stringify(s.name)}, load: () => import(${JSON.stringify(`/@fs${s.path}`)}) },`,
34
+ ` { name: ${JSON.stringify(s.name)}, dir: ${JSON.stringify(dirname(s.path))}, load: () => import(${JSON.stringify(`/@fs${s.path}`)}) },`,
35
35
  );
36
36
  return `export const userScenes = [\n${entries.join("\n")}\n];\n`;
37
37
  },
@@ -39,6 +39,7 @@ const userScenesPlugin: Plugin = {
39
39
 
40
40
  export default defineConfig({
41
41
  plugins: [userScenesPlugin],
42
+ define: { __REFRAME_EXAMPLES_DIR__: '""' }, // packaged preview ships no examples
42
43
  resolve: {
43
44
  alias: {
44
45
  "@reframe/core": resolve(PKG_ROOT, "dist", "index.js"),