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.
- package/assets/sfx/LICENSE.md +5 -0
- package/assets/sfx/bgm-song21.mp3 +0 -0
- package/assets/sfx/pop.wav +0 -0
- package/assets/sfx/rise.wav +0 -0
- package/assets/sfx/shimmer.wav +0 -0
- package/assets/sfx/thud.wav +0 -0
- package/assets/sfx/tick.wav +0 -0
- package/assets/sfx/whoosh.wav +0 -0
- package/dist/bin.js +109 -28
- package/dist/browserEntry.js +52 -5
- package/dist/cli.js +83 -16
- package/dist/index.js +56 -0
- package/dist/renderer-canvas.js +22 -3
- package/dist/types/assets.d.ts +9 -0
- package/dist/types/dsl.d.ts +4 -1
- package/dist/types/evaluate.d.ts +8 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/ir.d.ts +15 -0
- package/guides/edsl-guide.md +5 -0
- package/package.json +1 -1
- package/preview/src/main.ts +49 -6
- package/preview/src/virtual.d.ts +9 -1
- package/preview/vite.config.ts +3 -2
package/assets/sfx/LICENSE.md
CHANGED
|
@@ -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((
|
|
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)
|
|
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:
|
|
1145
|
-
bundleCache = await
|
|
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
|
|
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
|
|
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
|
|
1366
|
-
import { dirname as
|
|
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
|
|
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 =
|
|
1405
|
-
CORE_ENTRY = true ?
|
|
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
|
|
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
|
|
1414
|
-
import { dirname as
|
|
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 =
|
|
1418
|
-
var ROOT2 = PACKAGED ?
|
|
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) =>
|
|
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 (!
|
|
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 (!
|
|
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:
|
|
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
|
|
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(
|
|
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 (
|
|
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:
|
|
1656
|
-
process.stdout.write(await
|
|
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":
|
package/dist/browserEntry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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((
|
|
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)
|
|
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:
|
|
1018
|
-
bundleCache = await
|
|
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
|
|
1084
|
-
import { dirname as dirname5, resolve as
|
|
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 ?
|
|
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
|
|
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:
|
|
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 =
|
|
1144
|
-
else if (a === "--overlay") args.overlays.push(
|
|
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
|
|
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,
|
package/dist/renderer-canvas.js
CHANGED
|
@@ -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[];
|
package/dist/types/dsl.d.ts
CHANGED
|
@@ -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;
|
package/dist/types/evaluate.d.ts
CHANGED
|
@@ -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;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/types/ir.d.ts
CHANGED
|
@@ -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;
|
package/guides/edsl-guide.md
CHANGED
|
@@ -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.
|
|
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",
|
package/preview/src/main.ts
CHANGED
|
@@ -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] = {
|
|
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);
|
package/preview/src/virtual.d.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
declare module "virtual:reframe-user-scenes" {
|
|
2
2
|
import type { SceneIR } from "@reframe/core";
|
|
3
|
-
export const userScenes: {
|
|
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;
|
package/preview/vite.config.ts
CHANGED
|
@@ -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"),
|