reframe-video 0.4.0 → 0.6.0
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/dist/bin.js +81 -10
- package/dist/browserEntry.js +53 -5
- package/dist/cli.js +43 -9
- package/dist/index.js +176 -17
- package/dist/labels.js +43 -9
- package/dist/player.js +72 -0
- package/dist/trace-cli.js +2 -2
- package/dist/types/camera.d.ts +32 -0
- package/dist/types/compile.d.ts +2 -0
- package/dist/types/cursor.d.ts +57 -0
- package/dist/types/devicePreset.d.ts +4 -0
- package/dist/types/dsl.d.ts +2 -1
- package/dist/types/index.d.ts +3 -1
- package/dist/types/ir.d.ts +24 -0
- package/guides/edsl-guide.md +56 -0
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -153,6 +153,14 @@ function compileScene(ir) {
|
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
155
|
}
|
|
156
|
+
const cameraIsNode = nodeById.has("camera");
|
|
157
|
+
if (!cameraIsNode) {
|
|
158
|
+
const cam = ir.camera ?? {};
|
|
159
|
+
initialValues.set(key("camera", "x"), cam.x ?? ir.size.width / 2);
|
|
160
|
+
initialValues.set(key("camera", "y"), cam.y ?? ir.size.height / 2);
|
|
161
|
+
initialValues.set(key("camera", "zoom"), cam.zoom ?? 1);
|
|
162
|
+
initialValues.set(key("camera", "rotation"), cam.rotation ?? 0);
|
|
163
|
+
}
|
|
156
164
|
const segments = /* @__PURE__ */ new Map();
|
|
157
165
|
const motionPaths = /* @__PURE__ */ new Map();
|
|
158
166
|
const current = new Map(initialValues);
|
|
@@ -313,6 +321,7 @@ function compileScene(ir) {
|
|
|
313
321
|
const inferredEnd = ir.timeline ? walk(ir.timeline, 0) : 0;
|
|
314
322
|
for (const list of segments.values()) list.sort((a, b) => a.t0 - b.t0);
|
|
315
323
|
for (const list of motionPaths.values()) list.sort((a, b) => a.t0 - b.t0);
|
|
324
|
+
const hasCamera = !cameraIsNode && (ir.camera !== void 0 || motionPaths.has("camera") || [...segments.keys()].some((k) => k.startsWith("camera.")));
|
|
316
325
|
return {
|
|
317
326
|
ir,
|
|
318
327
|
duration: ir.duration ?? inferredEnd,
|
|
@@ -322,7 +331,8 @@ function compileScene(ir) {
|
|
|
322
331
|
nodeById,
|
|
323
332
|
nodeOrder,
|
|
324
333
|
labelTimes,
|
|
325
|
-
beatTimes
|
|
334
|
+
beatTimes,
|
|
335
|
+
hasCamera
|
|
326
336
|
};
|
|
327
337
|
}
|
|
328
338
|
var key;
|
|
@@ -361,6 +371,14 @@ function validateScene(ir) {
|
|
|
361
371
|
};
|
|
362
372
|
collect(ir.nodes);
|
|
363
373
|
const checkProps = (where, nodeId, props) => {
|
|
374
|
+
if (nodeId === "camera" && !nodeById.has("camera")) {
|
|
375
|
+
for (const key2 of Object.keys(props)) {
|
|
376
|
+
if (!CAMERA_PROPS.includes(key2)) {
|
|
377
|
+
problems.push(`${where}: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
364
382
|
const node = nodeById.get(nodeId);
|
|
365
383
|
if (!node) {
|
|
366
384
|
problems.push(
|
|
@@ -428,12 +446,15 @@ function validateScene(ir) {
|
|
|
428
446
|
break;
|
|
429
447
|
case "motionPath": {
|
|
430
448
|
const node = nodeById.get(tl.target);
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
449
|
+
const isSceneCamera = tl.target === "camera" && !node;
|
|
450
|
+
if (!isSceneCamera) {
|
|
451
|
+
if (!node) {
|
|
452
|
+
problems.push(
|
|
453
|
+
`${path2}: motionPath targets unknown node "${tl.target}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
|
|
454
|
+
);
|
|
455
|
+
} else if (node.type === "line") {
|
|
456
|
+
problems.push(`${path2}: motionPath cannot target a line (no x/y) \u2014 "${tl.target}"`);
|
|
457
|
+
}
|
|
437
458
|
}
|
|
438
459
|
if (tl.points.length < 1) problems.push(`${path2}: motionPath "${tl.target}" needs at least 1 point`);
|
|
439
460
|
if (tl.duration !== void 0 && tl.duration <= 0) {
|
|
@@ -478,6 +499,18 @@ function validateScene(ir) {
|
|
|
478
499
|
if (ir.duration !== void 0 && ir.duration <= 0) {
|
|
479
500
|
problems.push("scene duration must be > 0");
|
|
480
501
|
}
|
|
502
|
+
if (ir.camera) {
|
|
503
|
+
if (nodeById.has("camera")) {
|
|
504
|
+
problems.push(`camera: a node is already named "camera" \u2014 rename that node or drop the scene camera (the id "camera" can't be both)`);
|
|
505
|
+
}
|
|
506
|
+
for (const [key2, value] of Object.entries(ir.camera)) {
|
|
507
|
+
if (!CAMERA_PROPS.includes(key2)) {
|
|
508
|
+
problems.push(`camera: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
|
|
509
|
+
} else if (typeof value !== "number") {
|
|
510
|
+
problems.push(`camera.${key2} must be a number`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
481
514
|
const SFX_NAMES = ["whoosh", "pop", "tick", "rise", "shimmer", "thud"];
|
|
482
515
|
for (const [i, cue] of (ir.audio?.cues ?? []).entries()) {
|
|
483
516
|
if (typeof cue.at === "string" && !labels.has(cue.at)) {
|
|
@@ -536,16 +569,17 @@ function validateComposition(comp) {
|
|
|
536
569
|
}
|
|
537
570
|
if (problems.length > 0) throw new SceneValidationError(problems);
|
|
538
571
|
}
|
|
539
|
-
var COMMON_PROPS, PROPS_BY_TYPE, SceneValidationError, TRANSITIONS;
|
|
572
|
+
var COMMON_PROPS, CAMERA_PROPS, PROPS_BY_TYPE, SceneValidationError, TRANSITIONS;
|
|
540
573
|
var init_validate = __esm({
|
|
541
574
|
"../core/src/validate.ts"() {
|
|
542
575
|
"use strict";
|
|
543
|
-
COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor"];
|
|
576
|
+
COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed"];
|
|
577
|
+
CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
|
|
544
578
|
PROPS_BY_TYPE = {
|
|
545
579
|
rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
|
|
546
580
|
ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
|
|
547
581
|
line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
|
|
548
|
-
text: [...COMMON_PROPS, "content", "contentDecimals", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
|
|
582
|
+
text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
|
|
549
583
|
image: [...COMMON_PROPS, "src", "width", "height"],
|
|
550
584
|
path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
|
|
551
585
|
group: COMMON_PROPS
|
|
@@ -857,6 +891,14 @@ var init_compose = __esm({
|
|
|
857
891
|
}
|
|
858
892
|
});
|
|
859
893
|
|
|
894
|
+
// ../core/src/camera.ts
|
|
895
|
+
var init_camera = __esm({
|
|
896
|
+
"../core/src/camera.ts"() {
|
|
897
|
+
"use strict";
|
|
898
|
+
init_dsl();
|
|
899
|
+
}
|
|
900
|
+
});
|
|
901
|
+
|
|
860
902
|
// ../core/src/presets.ts
|
|
861
903
|
function makeRng(seed) {
|
|
862
904
|
let a = seed >>> 0 || 2654435769;
|
|
@@ -1044,6 +1086,14 @@ var init_devicePreset = __esm({
|
|
|
1044
1086
|
}
|
|
1045
1087
|
});
|
|
1046
1088
|
|
|
1089
|
+
// ../core/src/cursor.ts
|
|
1090
|
+
var init_cursor = __esm({
|
|
1091
|
+
"../core/src/cursor.ts"() {
|
|
1092
|
+
"use strict";
|
|
1093
|
+
init_dsl();
|
|
1094
|
+
}
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1047
1097
|
// ../core/src/rig.ts
|
|
1048
1098
|
var init_rig = __esm({
|
|
1049
1099
|
"../core/src/rig.ts"() {
|
|
@@ -1241,6 +1291,7 @@ var init_evaluate = __esm({
|
|
|
1241
1291
|
"../core/src/evaluate.ts"() {
|
|
1242
1292
|
"use strict";
|
|
1243
1293
|
init_behaviors();
|
|
1294
|
+
init_camera();
|
|
1244
1295
|
init_interpolate();
|
|
1245
1296
|
init_path();
|
|
1246
1297
|
}
|
|
@@ -1302,8 +1353,10 @@ var init_src = __esm({
|
|
|
1302
1353
|
init_compose();
|
|
1303
1354
|
init_compile();
|
|
1304
1355
|
init_path();
|
|
1356
|
+
init_camera();
|
|
1305
1357
|
init_presets();
|
|
1306
1358
|
init_devicePreset();
|
|
1359
|
+
init_cursor();
|
|
1307
1360
|
init_rig();
|
|
1308
1361
|
init_characterPreset();
|
|
1309
1362
|
init_figure();
|
|
@@ -2325,6 +2378,7 @@ var ROOT2 = PACKAGED ? resolve4(HERE2, "..") : resolve4(HERE2, "..", "..", "..")
|
|
|
2325
2378
|
var USER_CWD = process.env.INIT_CWD ?? process.cwd();
|
|
2326
2379
|
var RENDER_CLI = PACKAGED ? join6(ROOT2, "dist", "cli.js") : join6(ROOT2, "packages", "render-cli", "src", "cli.ts");
|
|
2327
2380
|
var LABELS = PACKAGED ? join6(ROOT2, "dist", "labels.js") : join6(ROOT2, "packages", "render-cli", "src", "labels.ts");
|
|
2381
|
+
var PLAYER = PACKAGED ? join6(ROOT2, "dist", "player.js") : join6(ROOT2, "packages", "render-cli", "src", "player.ts");
|
|
2328
2382
|
var ANALYZE = PACKAGED ? join6(ROOT2, "dist", "analyze.js") : join6(ROOT2, "benchmark", "harness", "motion", "analyze.ts");
|
|
2329
2383
|
var TRACE = PACKAGED ? join6(ROOT2, "dist", "trace-cli.js") : join6(ROOT2, "benchmark", "harness", "motion", "trace-cli.ts");
|
|
2330
2384
|
var CMD = PACKAGED ? "reframe" : "pnpm reframe";
|
|
@@ -2336,6 +2390,8 @@ usage:
|
|
|
2336
2390
|
${CMD} logo <logo.svg|brand-slug> ["Name"] [--motion <preset>] [--energy 0..1] [--seed N] [-o out.mp4]
|
|
2337
2391
|
animate a logo into a sting (presets: draw-bloom, punch-in,
|
|
2338
2392
|
rise-settle, slide-bank, reveal-orbit, spin-forge)
|
|
2393
|
+
${CMD} player <scene.ts|.json> [-o out.html] bundle a scene into one self-contained HTML
|
|
2394
|
+
player (plays live in any browser or a Claude.ai artifact; visual only)
|
|
2339
2395
|
${CMD} preview open the scrub/edit UI (lists scenes in your directory)
|
|
2340
2396
|
${CMD} new <scene-name> scaffold <scene-name>.ts in your directory
|
|
2341
2397
|
${CMD} labels <scene.ts|.json> print the event clock (label \u2192 exact seconds; for sound design / timing)
|
|
@@ -2480,6 +2536,21 @@ ${USAGE}`);
|
|
|
2480
2536
|
await (PACKAGED ? run(process.execPath, [LABELS, inputPath]) : run("npx", ["tsx", LABELS, inputPath]))
|
|
2481
2537
|
);
|
|
2482
2538
|
}
|
|
2539
|
+
case "player": {
|
|
2540
|
+
const input = rest[0];
|
|
2541
|
+
if (!input || input.startsWith("-")) fail(`player needs a scene file
|
|
2542
|
+
|
|
2543
|
+
${USAGE}`);
|
|
2544
|
+
const inputPath = userPath(input);
|
|
2545
|
+
if (!existsSync4(inputPath)) fail(`no such file: ${inputPath}`);
|
|
2546
|
+
const oIdx = rest.indexOf("-o");
|
|
2547
|
+
const outBase = PACKAGED ? join6(USER_CWD, "out") : join6(ROOT2, "out");
|
|
2548
|
+
const outPath = oIdx >= 0 && rest[oIdx + 1] ? userPath(rest[oIdx + 1]) : join6(outBase, `${basename(input).replace(/\.[^.]+$/, "")}.html`);
|
|
2549
|
+
await mkdir4(dirname7(outPath), { recursive: true });
|
|
2550
|
+
process.exit(
|
|
2551
|
+
await (PACKAGED ? run(process.execPath, [PLAYER, inputPath, outPath]) : run("npx", ["tsx", PLAYER, inputPath, outPath]))
|
|
2552
|
+
);
|
|
2553
|
+
}
|
|
2483
2554
|
case "logo": {
|
|
2484
2555
|
const positional = [];
|
|
2485
2556
|
const flags = {};
|
package/dist/browserEntry.js
CHANGED
|
@@ -134,6 +134,14 @@
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
|
+
const cameraIsNode = nodeById.has("camera");
|
|
138
|
+
if (!cameraIsNode) {
|
|
139
|
+
const cam = ir.camera ?? {};
|
|
140
|
+
initialValues.set(key("camera", "x"), cam.x ?? ir.size.width / 2);
|
|
141
|
+
initialValues.set(key("camera", "y"), cam.y ?? ir.size.height / 2);
|
|
142
|
+
initialValues.set(key("camera", "zoom"), cam.zoom ?? 1);
|
|
143
|
+
initialValues.set(key("camera", "rotation"), cam.rotation ?? 0);
|
|
144
|
+
}
|
|
137
145
|
const segments = /* @__PURE__ */ new Map();
|
|
138
146
|
const motionPaths = /* @__PURE__ */ new Map();
|
|
139
147
|
const current = new Map(initialValues);
|
|
@@ -294,6 +302,7 @@
|
|
|
294
302
|
const inferredEnd = ir.timeline ? walk(ir.timeline, 0) : 0;
|
|
295
303
|
for (const list of segments.values()) list.sort((a, b) => a.t0 - b.t0);
|
|
296
304
|
for (const list of motionPaths.values()) list.sort((a, b) => a.t0 - b.t0);
|
|
305
|
+
const hasCamera = !cameraIsNode && (ir.camera !== void 0 || motionPaths.has("camera") || [...segments.keys()].some((k) => k.startsWith("camera.")));
|
|
297
306
|
return {
|
|
298
307
|
ir,
|
|
299
308
|
duration: ir.duration ?? inferredEnd,
|
|
@@ -303,22 +312,38 @@
|
|
|
303
312
|
nodeById,
|
|
304
313
|
nodeOrder,
|
|
305
314
|
labelTimes,
|
|
306
|
-
beatTimes
|
|
315
|
+
beatTimes,
|
|
316
|
+
hasCamera
|
|
307
317
|
};
|
|
308
318
|
}
|
|
309
319
|
|
|
310
320
|
// ../core/src/validate.ts
|
|
311
|
-
var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor"];
|
|
321
|
+
var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed"];
|
|
312
322
|
var PROPS_BY_TYPE = {
|
|
313
323
|
rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
|
|
314
324
|
ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
|
|
315
325
|
line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
|
|
316
|
-
text: [...COMMON_PROPS, "content", "contentDecimals", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
|
|
326
|
+
text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
|
|
317
327
|
image: [...COMMON_PROPS, "src", "width", "height"],
|
|
318
328
|
path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
|
|
319
329
|
group: COMMON_PROPS
|
|
320
330
|
};
|
|
321
331
|
|
|
332
|
+
// ../core/src/camera.ts
|
|
333
|
+
function cameraMatrix(cam, size) {
|
|
334
|
+
const W = size.width;
|
|
335
|
+
const H = size.height;
|
|
336
|
+
const x = cam.x ?? W / 2;
|
|
337
|
+
const y = cam.y ?? H / 2;
|
|
338
|
+
const zoom = cam.zoom ?? 1;
|
|
339
|
+
const r = (cam.rotation ?? 0) * Math.PI / 180;
|
|
340
|
+
const a = Math.cos(r) * zoom;
|
|
341
|
+
const b = Math.sin(r) * zoom;
|
|
342
|
+
const c = b === 0 ? 0 : -b;
|
|
343
|
+
const d = Math.cos(r) * zoom;
|
|
344
|
+
return [a, b, c, d, W / 2 - a * x - c * y, H / 2 - b * x - d * y];
|
|
345
|
+
}
|
|
346
|
+
|
|
322
347
|
// ../core/src/presets.ts
|
|
323
348
|
var SET = 1 / 120;
|
|
324
349
|
|
|
@@ -552,6 +577,17 @@
|
|
|
552
577
|
};
|
|
553
578
|
var TEXT_ALIGN = { 0: "left", 0.5: "center", 1: "right" };
|
|
554
579
|
var TEXT_BASELINE = { 0: "top", 0.5: "middle", 1: "bottom" };
|
|
580
|
+
function formatNumber(value, decimals, thousands) {
|
|
581
|
+
const fixed = value.toFixed(decimals);
|
|
582
|
+
if (!thousands) return fixed;
|
|
583
|
+
const neg = fixed.startsWith("-");
|
|
584
|
+
const body = neg ? fixed.slice(1) : fixed;
|
|
585
|
+
const dot = body.indexOf(".");
|
|
586
|
+
const intPart = dot === -1 ? body : body.slice(0, dot);
|
|
587
|
+
const frac = dot === -1 ? "" : body.slice(dot);
|
|
588
|
+
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
589
|
+
return (neg ? "-" : "") + grouped + frac;
|
|
590
|
+
}
|
|
555
591
|
function behaviorEnvelope(b, t) {
|
|
556
592
|
const from = b.from ?? Number.NEGATIVE_INFINITY;
|
|
557
593
|
const until = b.until ?? Number.POSITIVE_INFINITY;
|
|
@@ -739,7 +775,7 @@
|
|
|
739
775
|
id,
|
|
740
776
|
transform: matrix,
|
|
741
777
|
opacity,
|
|
742
|
-
content: typeof raw === "number" ? raw.
|
|
778
|
+
content: typeof raw === "number" ? formatNumber(raw, decimals, node.props.contentThousands === true) : raw,
|
|
743
779
|
fontFamily: str(id, "fontFamily", node.props.fontFamily),
|
|
744
780
|
fontSize: num(id, "fontSize", node.props.fontSize),
|
|
745
781
|
fontWeight: num(id, "fontWeight", node.props.fontWeight ?? 400),
|
|
@@ -753,7 +789,19 @@
|
|
|
753
789
|
}
|
|
754
790
|
}
|
|
755
791
|
};
|
|
756
|
-
|
|
792
|
+
const cameraRoot = compiled2.hasCamera ? cameraMatrix(
|
|
793
|
+
{
|
|
794
|
+
x: num("camera", "x", compiled2.ir.size.width / 2),
|
|
795
|
+
y: num("camera", "y", compiled2.ir.size.height / 2),
|
|
796
|
+
zoom: num("camera", "zoom", 1),
|
|
797
|
+
rotation: num("camera", "rotation", 0)
|
|
798
|
+
},
|
|
799
|
+
compiled2.ir.size
|
|
800
|
+
) : IDENTITY;
|
|
801
|
+
for (const node of compiled2.ir.nodes) {
|
|
802
|
+
const root = compiled2.hasCamera && node.props.fixed ? IDENTITY : cameraRoot;
|
|
803
|
+
walk(node, root, 1, []);
|
|
804
|
+
}
|
|
757
805
|
return ops;
|
|
758
806
|
}
|
|
759
807
|
|
package/dist/cli.js
CHANGED
|
@@ -140,6 +140,14 @@ function compileScene(ir) {
|
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
|
+
const cameraIsNode = nodeById.has("camera");
|
|
144
|
+
if (!cameraIsNode) {
|
|
145
|
+
const cam = ir.camera ?? {};
|
|
146
|
+
initialValues.set(key("camera", "x"), cam.x ?? ir.size.width / 2);
|
|
147
|
+
initialValues.set(key("camera", "y"), cam.y ?? ir.size.height / 2);
|
|
148
|
+
initialValues.set(key("camera", "zoom"), cam.zoom ?? 1);
|
|
149
|
+
initialValues.set(key("camera", "rotation"), cam.rotation ?? 0);
|
|
150
|
+
}
|
|
143
151
|
const segments = /* @__PURE__ */ new Map();
|
|
144
152
|
const motionPaths = /* @__PURE__ */ new Map();
|
|
145
153
|
const current = new Map(initialValues);
|
|
@@ -300,6 +308,7 @@ function compileScene(ir) {
|
|
|
300
308
|
const inferredEnd = ir.timeline ? walk(ir.timeline, 0) : 0;
|
|
301
309
|
for (const list of segments.values()) list.sort((a, b) => a.t0 - b.t0);
|
|
302
310
|
for (const list of motionPaths.values()) list.sort((a, b) => a.t0 - b.t0);
|
|
311
|
+
const hasCamera = !cameraIsNode && (ir.camera !== void 0 || motionPaths.has("camera") || [...segments.keys()].some((k) => k.startsWith("camera.")));
|
|
303
312
|
return {
|
|
304
313
|
ir,
|
|
305
314
|
duration: ir.duration ?? inferredEnd,
|
|
@@ -309,17 +318,19 @@ function compileScene(ir) {
|
|
|
309
318
|
nodeById,
|
|
310
319
|
nodeOrder,
|
|
311
320
|
labelTimes,
|
|
312
|
-
beatTimes
|
|
321
|
+
beatTimes,
|
|
322
|
+
hasCamera
|
|
313
323
|
};
|
|
314
324
|
}
|
|
315
325
|
|
|
316
326
|
// ../core/src/validate.ts
|
|
317
|
-
var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor"];
|
|
327
|
+
var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed"];
|
|
328
|
+
var CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
|
|
318
329
|
var PROPS_BY_TYPE = {
|
|
319
330
|
rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
|
|
320
331
|
ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
|
|
321
332
|
line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
|
|
322
|
-
text: [...COMMON_PROPS, "content", "contentDecimals", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
|
|
333
|
+
text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
|
|
323
334
|
image: [...COMMON_PROPS, "src", "width", "height"],
|
|
324
335
|
path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
|
|
325
336
|
group: COMMON_PROPS
|
|
@@ -358,6 +369,14 @@ function validateScene(ir) {
|
|
|
358
369
|
};
|
|
359
370
|
collect(ir.nodes);
|
|
360
371
|
const checkProps = (where, nodeId, props) => {
|
|
372
|
+
if (nodeId === "camera" && !nodeById.has("camera")) {
|
|
373
|
+
for (const key2 of Object.keys(props)) {
|
|
374
|
+
if (!CAMERA_PROPS.includes(key2)) {
|
|
375
|
+
problems.push(`${where}: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
361
380
|
const node = nodeById.get(nodeId);
|
|
362
381
|
if (!node) {
|
|
363
382
|
problems.push(
|
|
@@ -425,12 +444,15 @@ function validateScene(ir) {
|
|
|
425
444
|
break;
|
|
426
445
|
case "motionPath": {
|
|
427
446
|
const node = nodeById.get(tl.target);
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
447
|
+
const isSceneCamera = tl.target === "camera" && !node;
|
|
448
|
+
if (!isSceneCamera) {
|
|
449
|
+
if (!node) {
|
|
450
|
+
problems.push(
|
|
451
|
+
`${path2}: motionPath targets unknown node "${tl.target}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
|
|
452
|
+
);
|
|
453
|
+
} else if (node.type === "line") {
|
|
454
|
+
problems.push(`${path2}: motionPath cannot target a line (no x/y) \u2014 "${tl.target}"`);
|
|
455
|
+
}
|
|
434
456
|
}
|
|
435
457
|
if (tl.points.length < 1) problems.push(`${path2}: motionPath "${tl.target}" needs at least 1 point`);
|
|
436
458
|
if (tl.duration !== void 0 && tl.duration <= 0) {
|
|
@@ -475,6 +497,18 @@ function validateScene(ir) {
|
|
|
475
497
|
if (ir.duration !== void 0 && ir.duration <= 0) {
|
|
476
498
|
problems.push("scene duration must be > 0");
|
|
477
499
|
}
|
|
500
|
+
if (ir.camera) {
|
|
501
|
+
if (nodeById.has("camera")) {
|
|
502
|
+
problems.push(`camera: a node is already named "camera" \u2014 rename that node or drop the scene camera (the id "camera" can't be both)`);
|
|
503
|
+
}
|
|
504
|
+
for (const [key2, value] of Object.entries(ir.camera)) {
|
|
505
|
+
if (!CAMERA_PROPS.includes(key2)) {
|
|
506
|
+
problems.push(`camera: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
|
|
507
|
+
} else if (typeof value !== "number") {
|
|
508
|
+
problems.push(`camera.${key2} must be a number`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
478
512
|
const SFX_NAMES = ["whoosh", "pop", "tick", "rise", "shimmer", "thud"];
|
|
479
513
|
for (const [i, cue] of (ir.audio?.cues ?? []).entries()) {
|
|
480
514
|
if (typeof cue.at === "string" && !labels.has(cue.at)) {
|
package/dist/index.js
CHANGED
|
@@ -134,6 +134,14 @@ function compileScene(ir) {
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
|
+
const cameraIsNode = nodeById.has("camera");
|
|
138
|
+
if (!cameraIsNode) {
|
|
139
|
+
const cam = ir.camera ?? {};
|
|
140
|
+
initialValues.set(key("camera", "x"), cam.x ?? ir.size.width / 2);
|
|
141
|
+
initialValues.set(key("camera", "y"), cam.y ?? ir.size.height / 2);
|
|
142
|
+
initialValues.set(key("camera", "zoom"), cam.zoom ?? 1);
|
|
143
|
+
initialValues.set(key("camera", "rotation"), cam.rotation ?? 0);
|
|
144
|
+
}
|
|
137
145
|
const segments = /* @__PURE__ */ new Map();
|
|
138
146
|
const motionPaths = /* @__PURE__ */ new Map();
|
|
139
147
|
const current = new Map(initialValues);
|
|
@@ -294,6 +302,7 @@ function compileScene(ir) {
|
|
|
294
302
|
const inferredEnd = ir.timeline ? walk(ir.timeline, 0) : 0;
|
|
295
303
|
for (const list of segments.values()) list.sort((a, b) => a.t0 - b.t0);
|
|
296
304
|
for (const list of motionPaths.values()) list.sort((a, b) => a.t0 - b.t0);
|
|
305
|
+
const hasCamera = !cameraIsNode && (ir.camera !== void 0 || motionPaths.has("camera") || [...segments.keys()].some((k) => k.startsWith("camera.")));
|
|
297
306
|
return {
|
|
298
307
|
ir,
|
|
299
308
|
duration: ir.duration ?? inferredEnd,
|
|
@@ -303,17 +312,19 @@ function compileScene(ir) {
|
|
|
303
312
|
nodeById,
|
|
304
313
|
nodeOrder,
|
|
305
314
|
labelTimes,
|
|
306
|
-
beatTimes
|
|
315
|
+
beatTimes,
|
|
316
|
+
hasCamera
|
|
307
317
|
};
|
|
308
318
|
}
|
|
309
319
|
|
|
310
320
|
// ../core/src/validate.ts
|
|
311
|
-
var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor"];
|
|
321
|
+
var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed"];
|
|
322
|
+
var CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
|
|
312
323
|
var PROPS_BY_TYPE = {
|
|
313
324
|
rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
|
|
314
325
|
ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
|
|
315
326
|
line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
|
|
316
|
-
text: [...COMMON_PROPS, "content", "contentDecimals", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
|
|
327
|
+
text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
|
|
317
328
|
image: [...COMMON_PROPS, "src", "width", "height"],
|
|
318
329
|
path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
|
|
319
330
|
group: COMMON_PROPS
|
|
@@ -352,6 +363,14 @@ function validateScene(ir) {
|
|
|
352
363
|
};
|
|
353
364
|
collect(ir.nodes);
|
|
354
365
|
const checkProps = (where, nodeId, props) => {
|
|
366
|
+
if (nodeId === "camera" && !nodeById.has("camera")) {
|
|
367
|
+
for (const key2 of Object.keys(props)) {
|
|
368
|
+
if (!CAMERA_PROPS.includes(key2)) {
|
|
369
|
+
problems.push(`${where}: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
355
374
|
const node = nodeById.get(nodeId);
|
|
356
375
|
if (!node) {
|
|
357
376
|
problems.push(
|
|
@@ -419,12 +438,15 @@ function validateScene(ir) {
|
|
|
419
438
|
break;
|
|
420
439
|
case "motionPath": {
|
|
421
440
|
const node = nodeById.get(tl.target);
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
441
|
+
const isSceneCamera = tl.target === "camera" && !node;
|
|
442
|
+
if (!isSceneCamera) {
|
|
443
|
+
if (!node) {
|
|
444
|
+
problems.push(
|
|
445
|
+
`${path2}: motionPath targets unknown node "${tl.target}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
|
|
446
|
+
);
|
|
447
|
+
} else if (node.type === "line") {
|
|
448
|
+
problems.push(`${path2}: motionPath cannot target a line (no x/y) \u2014 "${tl.target}"`);
|
|
449
|
+
}
|
|
428
450
|
}
|
|
429
451
|
if (tl.points.length < 1) problems.push(`${path2}: motionPath "${tl.target}" needs at least 1 point`);
|
|
430
452
|
if (tl.duration !== void 0 && tl.duration <= 0) {
|
|
@@ -469,6 +491,18 @@ function validateScene(ir) {
|
|
|
469
491
|
if (ir.duration !== void 0 && ir.duration <= 0) {
|
|
470
492
|
problems.push("scene duration must be > 0");
|
|
471
493
|
}
|
|
494
|
+
if (ir.camera) {
|
|
495
|
+
if (nodeById.has("camera")) {
|
|
496
|
+
problems.push(`camera: a node is already named "camera" \u2014 rename that node or drop the scene camera (the id "camera" can't be both)`);
|
|
497
|
+
}
|
|
498
|
+
for (const [key2, value] of Object.entries(ir.camera)) {
|
|
499
|
+
if (!CAMERA_PROPS.includes(key2)) {
|
|
500
|
+
problems.push(`camera: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
|
|
501
|
+
} else if (typeof value !== "number") {
|
|
502
|
+
problems.push(`camera.${key2} must be a number`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
472
506
|
const SFX_NAMES = ["whoosh", "pop", "tick", "rise", "shimmer", "thud"];
|
|
473
507
|
for (const [i, cue] of (ir.audio?.cues ?? []).entries()) {
|
|
474
508
|
if (typeof cue.at === "string" && !labels.has(cue.at)) {
|
|
@@ -863,6 +897,26 @@ function formatComposeReport(report) {
|
|
|
863
897
|
return lines.join("\n");
|
|
864
898
|
}
|
|
865
899
|
|
|
900
|
+
// ../core/src/camera.ts
|
|
901
|
+
var CAMERA_ID = "camera";
|
|
902
|
+
var CAMERA_PROPS2 = ["x", "y", "zoom", "rotation"];
|
|
903
|
+
function cameraMatrix(cam, size) {
|
|
904
|
+
const W = size.width;
|
|
905
|
+
const H = size.height;
|
|
906
|
+
const x = cam.x ?? W / 2;
|
|
907
|
+
const y = cam.y ?? H / 2;
|
|
908
|
+
const zoom = cam.zoom ?? 1;
|
|
909
|
+
const r = (cam.rotation ?? 0) * Math.PI / 180;
|
|
910
|
+
const a = Math.cos(r) * zoom;
|
|
911
|
+
const b = Math.sin(r) * zoom;
|
|
912
|
+
const c = b === 0 ? 0 : -b;
|
|
913
|
+
const d = Math.cos(r) * zoom;
|
|
914
|
+
return [a, b, c, d, W / 2 - a * x - c * y, H / 2 - b * x - d * y];
|
|
915
|
+
}
|
|
916
|
+
function cameraTo(props, opts = {}) {
|
|
917
|
+
return tween(CAMERA_ID, props, opts);
|
|
918
|
+
}
|
|
919
|
+
|
|
866
920
|
// ../core/src/presets.ts
|
|
867
921
|
var PRESET_NAMES = [
|
|
868
922
|
"draw-bloom",
|
|
@@ -1089,6 +1143,11 @@ function deviceBounds(name, opts = {}) {
|
|
|
1089
1143
|
const b = BOUNDS[name];
|
|
1090
1144
|
return isLandscape(name, opts) ? { width: b.height, height: b.width } : { ...b };
|
|
1091
1145
|
}
|
|
1146
|
+
function deviceScreenPoint(name, opts, local) {
|
|
1147
|
+
const c = deviceScreenCenter(name, opts);
|
|
1148
|
+
const s = opts.scale ?? 1;
|
|
1149
|
+
return [(opts.x ?? 0) + s * (c.x + local[0]), (opts.y ?? 0) + s * (c.y + local[1])];
|
|
1150
|
+
}
|
|
1092
1151
|
function screenGroup(id, p, o, cx, cy, dims, content) {
|
|
1093
1152
|
return group({ id: `${id}-screen`, x: cx, y: cy, clip: { kind: "rect", x: -dims.width / 2, y: -dims.height / 2, width: dims.width, height: dims.height, radius: dims.radius } }, [
|
|
1094
1153
|
rect({ id: `${id}-screenbg`, x: 0, y: 0, anchor: "center", width: dims.width, height: dims.height, fill: o.screen ?? p.screen }),
|
|
@@ -1263,6 +1322,73 @@ function devicePreset(name, opts = {}) {
|
|
|
1263
1322
|
);
|
|
1264
1323
|
}
|
|
1265
1324
|
|
|
1325
|
+
// ../core/src/cursor.ts
|
|
1326
|
+
var ARROW_D = "M0 0 L0 30 L8 23 L12.6 33 L17 31 L12.4 21.4 L21 21.4 Z";
|
|
1327
|
+
function cursor(opts = {}) {
|
|
1328
|
+
const id = opts.id ?? "cursor";
|
|
1329
|
+
const style = opts.style ?? "arrow";
|
|
1330
|
+
const fill = opts.fill ?? "#FFFFFF";
|
|
1331
|
+
const accent = opts.accent ?? "#FF5A1F";
|
|
1332
|
+
const art = style === "arrow" ? [path({ id: `${id}-arrow`, d: ARROW_D, x: 0, y: 0, fill, stroke: "#15171E", strokeWidth: 2 })] : style === "dot" ? [ellipse({ id: `${id}-dot`, x: 0, y: 0, width: 18, height: 18, fill: accent, anchor: "center" })] : [ellipse({ id: `${id}-ring`, x: 0, y: 0, width: 22, height: 22, fill: "none", stroke: accent, strokeWidth: 3, anchor: "center" })];
|
|
1333
|
+
return group(
|
|
1334
|
+
{ id, x: opts.x ?? 0, y: opts.y ?? 0, scale: opts.scale ?? 1, opacity: opts.opacity ?? 1 },
|
|
1335
|
+
[
|
|
1336
|
+
// ripple ring (behind the pointer), emanates from the hotspot on click
|
|
1337
|
+
ellipse({ id: `${id}-ripple`, x: 0, y: 0, width: 30, height: 30, fill: "none", stroke: accent, strokeWidth: 3, opacity: 0, scale: 0, anchor: "center" }),
|
|
1338
|
+
// the pointer art lives in its own group so a click "tap" can scale it
|
|
1339
|
+
// independently of the cursor's resting scale
|
|
1340
|
+
group({ id: `${id}-art`, x: 0, y: 0 }, art)
|
|
1341
|
+
]
|
|
1342
|
+
);
|
|
1343
|
+
}
|
|
1344
|
+
var clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
1345
|
+
function cursorTo(id, from, to2, opts = {}) {
|
|
1346
|
+
const dx = to2[0] - from[0], dy = to2[1] - from[1];
|
|
1347
|
+
const dist = Math.hypot(dx, dy) || 1;
|
|
1348
|
+
const arc = opts.arc ?? 0.12;
|
|
1349
|
+
const mid = [(from[0] + to2[0]) / 2 + -dy / dist * arc * dist, (from[1] + to2[1]) / 2 + dx / dist * arc * dist];
|
|
1350
|
+
const duration = opts.duration ?? clamp(dist / 1400, 0.4, 0.9);
|
|
1351
|
+
return motionPath(id, [from, mid, to2], { duration, ease: opts.ease ?? "easeInOutCubic", curviness: 1, ...opts.label && { label: opts.label } });
|
|
1352
|
+
}
|
|
1353
|
+
function cursorPath(id, points, opts = {}) {
|
|
1354
|
+
return motionPath(id, points, {
|
|
1355
|
+
duration: opts.duration ?? clamp(points.length * 0.5, 0.5, 4),
|
|
1356
|
+
ease: opts.ease ?? "easeInOutCubic",
|
|
1357
|
+
curviness: opts.curviness ?? 1,
|
|
1358
|
+
...opts.label && { label: opts.label }
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
function clickBody(id, o) {
|
|
1362
|
+
const sp = Math.max(0.25, o.speed ?? 1);
|
|
1363
|
+
const d = (b) => b / sp;
|
|
1364
|
+
const out = [
|
|
1365
|
+
// the pointer taps
|
|
1366
|
+
seq(tween(`${id}-art`, { scale: 0.82 }, { duration: d(0.08), ease: "easeOutQuad" }), tween(`${id}-art`, { scale: 1 }, { duration: d(0.1), ease: "easeOutBack" }))
|
|
1367
|
+
];
|
|
1368
|
+
if (o.ripple !== false) {
|
|
1369
|
+
out.push(seq(
|
|
1370
|
+
tween(`${id}-ripple`, { scale: 0.2, opacity: 0.55 }, { duration: 1e-3 }),
|
|
1371
|
+
par(
|
|
1372
|
+
tween(`${id}-ripple`, { scale: 5 }, { duration: d(0.5), ease: "easeOutCubic" }),
|
|
1373
|
+
tween(`${id}-ripple`, { opacity: 0 }, { duration: d(0.5), ease: "easeOutQuad" })
|
|
1374
|
+
)
|
|
1375
|
+
));
|
|
1376
|
+
}
|
|
1377
|
+
if (o.press) {
|
|
1378
|
+
out.push(seq(tween(o.press, { scale: 0.94 }, { duration: d(0.08), ease: "easeOutQuad" }), tween(o.press, { scale: 1 }, { duration: d(0.14), ease: "easeOutBack" })));
|
|
1379
|
+
}
|
|
1380
|
+
return out;
|
|
1381
|
+
}
|
|
1382
|
+
function cursorClick(id, opts = {}) {
|
|
1383
|
+
return beat(opts.label ?? "cursor-click", {}, [par(...clickBody(id, opts))]);
|
|
1384
|
+
}
|
|
1385
|
+
function cursorDouble(id, opts = {}) {
|
|
1386
|
+
const sp = Math.max(0.25, opts.speed ?? 1);
|
|
1387
|
+
return beat(opts.label ?? "cursor-double", {}, [
|
|
1388
|
+
seq(par(...clickBody(id, { ...opts, ripple: false })), wait(0.12 / sp), par(...clickBody(id, opts)))
|
|
1389
|
+
]);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1266
1392
|
// ../core/src/rig.ts
|
|
1267
1393
|
var DEFAULT_LINE = "#FFE3D2";
|
|
1268
1394
|
var DEFAULT_FILL = "#0E1424";
|
|
@@ -2008,7 +2134,7 @@ function splitText(textStr, opts) {
|
|
|
2008
2134
|
chars.forEach((ch, i) => {
|
|
2009
2135
|
total += advance(ch, weight, fontSize) + (i < chars.length - 1 ? ls : 0);
|
|
2010
2136
|
});
|
|
2011
|
-
let
|
|
2137
|
+
let cursor2 = align === "center" ? x - total / 2 : x;
|
|
2012
2138
|
const glyphs = [];
|
|
2013
2139
|
const nodes = [];
|
|
2014
2140
|
const mk = (ch, cx, adv, lsProp) => {
|
|
@@ -2034,13 +2160,13 @@ function splitText(textStr, opts) {
|
|
|
2034
2160
|
let i = 0;
|
|
2035
2161
|
while (i < chars.length) {
|
|
2036
2162
|
if (chars[i] === " ") {
|
|
2037
|
-
|
|
2163
|
+
cursor2 += advance(" ", weight, fontSize) + ls;
|
|
2038
2164
|
i++;
|
|
2039
2165
|
continue;
|
|
2040
2166
|
}
|
|
2041
2167
|
let word = "";
|
|
2042
2168
|
let w = 0;
|
|
2043
|
-
const startCursor =
|
|
2169
|
+
const startCursor = cursor2;
|
|
2044
2170
|
while (i < chars.length && chars[i] !== " ") {
|
|
2045
2171
|
const a = advance(chars[i], weight, fontSize);
|
|
2046
2172
|
word += chars[i];
|
|
@@ -2048,13 +2174,13 @@ function splitText(textStr, opts) {
|
|
|
2048
2174
|
i++;
|
|
2049
2175
|
}
|
|
2050
2176
|
mk(word, startCursor + w / 2, w, ls);
|
|
2051
|
-
|
|
2177
|
+
cursor2 = startCursor + w + ls;
|
|
2052
2178
|
}
|
|
2053
2179
|
} else {
|
|
2054
2180
|
chars.forEach((ch) => {
|
|
2055
2181
|
const a = advance(ch, weight, fontSize);
|
|
2056
|
-
if (ch !== " ") mk(ch,
|
|
2057
|
-
|
|
2182
|
+
if (ch !== " ") mk(ch, cursor2 + a / 2, a);
|
|
2183
|
+
cursor2 += a + ls;
|
|
2058
2184
|
});
|
|
2059
2185
|
}
|
|
2060
2186
|
return { nodes, glyphs, ids: glyphs.map((g) => g.id), width: total, x, y, fontSize };
|
|
@@ -2629,6 +2755,17 @@ var ANCHOR_FACTORS = {
|
|
|
2629
2755
|
};
|
|
2630
2756
|
var TEXT_ALIGN = { 0: "left", 0.5: "center", 1: "right" };
|
|
2631
2757
|
var TEXT_BASELINE = { 0: "top", 0.5: "middle", 1: "bottom" };
|
|
2758
|
+
function formatNumber(value, decimals, thousands) {
|
|
2759
|
+
const fixed = value.toFixed(decimals);
|
|
2760
|
+
if (!thousands) return fixed;
|
|
2761
|
+
const neg = fixed.startsWith("-");
|
|
2762
|
+
const body = neg ? fixed.slice(1) : fixed;
|
|
2763
|
+
const dot = body.indexOf(".");
|
|
2764
|
+
const intPart = dot === -1 ? body : body.slice(0, dot);
|
|
2765
|
+
const frac = dot === -1 ? "" : body.slice(dot);
|
|
2766
|
+
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
2767
|
+
return (neg ? "-" : "") + grouped + frac;
|
|
2768
|
+
}
|
|
2632
2769
|
function behaviorEnvelope(b, t) {
|
|
2633
2770
|
const from = b.from ?? Number.NEGATIVE_INFINITY;
|
|
2634
2771
|
const until = b.until ?? Number.POSITIVE_INFINITY;
|
|
@@ -2848,7 +2985,7 @@ function evaluate(compiled, t) {
|
|
|
2848
2985
|
id,
|
|
2849
2986
|
transform: matrix,
|
|
2850
2987
|
opacity,
|
|
2851
|
-
content: typeof raw === "number" ? raw.
|
|
2988
|
+
content: typeof raw === "number" ? formatNumber(raw, decimals, node.props.contentThousands === true) : raw,
|
|
2852
2989
|
fontFamily: str(id, "fontFamily", node.props.fontFamily),
|
|
2853
2990
|
fontSize: num(id, "fontSize", node.props.fontSize),
|
|
2854
2991
|
fontWeight: num(id, "fontWeight", node.props.fontWeight ?? 400),
|
|
@@ -2862,7 +2999,19 @@ function evaluate(compiled, t) {
|
|
|
2862
2999
|
}
|
|
2863
3000
|
}
|
|
2864
3001
|
};
|
|
2865
|
-
|
|
3002
|
+
const cameraRoot = compiled.hasCamera ? cameraMatrix(
|
|
3003
|
+
{
|
|
3004
|
+
x: num("camera", "x", compiled.ir.size.width / 2),
|
|
3005
|
+
y: num("camera", "y", compiled.ir.size.height / 2),
|
|
3006
|
+
zoom: num("camera", "zoom", 1),
|
|
3007
|
+
rotation: num("camera", "rotation", 0)
|
|
3008
|
+
},
|
|
3009
|
+
compiled.ir.size
|
|
3010
|
+
) : IDENTITY;
|
|
3011
|
+
for (const node of compiled.ir.nodes) {
|
|
3012
|
+
const root = compiled.hasCamera && node.props.fixed ? IDENTITY : cameraRoot;
|
|
3013
|
+
walk(node, root, 1, []);
|
|
3014
|
+
}
|
|
2866
3015
|
return ops;
|
|
2867
3016
|
}
|
|
2868
3017
|
|
|
@@ -2943,6 +3092,8 @@ function sketchToTimeline(sketch, nodeIds) {
|
|
|
2943
3092
|
return par(...steps);
|
|
2944
3093
|
}
|
|
2945
3094
|
export {
|
|
3095
|
+
CAMERA_ID,
|
|
3096
|
+
CAMERA_PROPS2 as CAMERA_PROPS,
|
|
2946
3097
|
CHARACTER_PRESET_NAMES,
|
|
2947
3098
|
DEFAULT_CROSSFADE,
|
|
2948
3099
|
DEFAULT_FPS,
|
|
@@ -2957,16 +3108,24 @@ export {
|
|
|
2957
3108
|
SFX_DURATION,
|
|
2958
3109
|
SceneValidationError,
|
|
2959
3110
|
beat,
|
|
3111
|
+
cameraMatrix,
|
|
3112
|
+
cameraTo,
|
|
2960
3113
|
characterPreset,
|
|
2961
3114
|
collectImageSrcs,
|
|
2962
3115
|
compileComposition,
|
|
2963
3116
|
compileScene,
|
|
2964
3117
|
composeScene,
|
|
2965
3118
|
composition,
|
|
3119
|
+
cursor,
|
|
3120
|
+
cursorClick,
|
|
3121
|
+
cursorDouble,
|
|
3122
|
+
cursorPath,
|
|
3123
|
+
cursorTo,
|
|
2966
3124
|
deviceBounds,
|
|
2967
3125
|
devicePreset,
|
|
2968
3126
|
deviceScreen,
|
|
2969
3127
|
deviceScreenCenter,
|
|
3128
|
+
deviceScreenPoint,
|
|
2970
3129
|
ellipse,
|
|
2971
3130
|
evaluate,
|
|
2972
3131
|
figure,
|
package/dist/labels.js
CHANGED
|
@@ -134,6 +134,14 @@ function compileScene(ir) {
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
|
+
const cameraIsNode = nodeById.has("camera");
|
|
138
|
+
if (!cameraIsNode) {
|
|
139
|
+
const cam = ir.camera ?? {};
|
|
140
|
+
initialValues.set(key("camera", "x"), cam.x ?? ir.size.width / 2);
|
|
141
|
+
initialValues.set(key("camera", "y"), cam.y ?? ir.size.height / 2);
|
|
142
|
+
initialValues.set(key("camera", "zoom"), cam.zoom ?? 1);
|
|
143
|
+
initialValues.set(key("camera", "rotation"), cam.rotation ?? 0);
|
|
144
|
+
}
|
|
137
145
|
const segments = /* @__PURE__ */ new Map();
|
|
138
146
|
const motionPaths = /* @__PURE__ */ new Map();
|
|
139
147
|
const current = new Map(initialValues);
|
|
@@ -294,6 +302,7 @@ function compileScene(ir) {
|
|
|
294
302
|
const inferredEnd = ir.timeline ? walk(ir.timeline, 0) : 0;
|
|
295
303
|
for (const list of segments.values()) list.sort((a, b) => a.t0 - b.t0);
|
|
296
304
|
for (const list of motionPaths.values()) list.sort((a, b) => a.t0 - b.t0);
|
|
305
|
+
const hasCamera = !cameraIsNode && (ir.camera !== void 0 || motionPaths.has("camera") || [...segments.keys()].some((k) => k.startsWith("camera.")));
|
|
297
306
|
return {
|
|
298
307
|
ir,
|
|
299
308
|
duration: ir.duration ?? inferredEnd,
|
|
@@ -303,17 +312,19 @@ function compileScene(ir) {
|
|
|
303
312
|
nodeById,
|
|
304
313
|
nodeOrder,
|
|
305
314
|
labelTimes,
|
|
306
|
-
beatTimes
|
|
315
|
+
beatTimes,
|
|
316
|
+
hasCamera
|
|
307
317
|
};
|
|
308
318
|
}
|
|
309
319
|
|
|
310
320
|
// ../core/src/validate.ts
|
|
311
|
-
var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor"];
|
|
321
|
+
var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed"];
|
|
322
|
+
var CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
|
|
312
323
|
var PROPS_BY_TYPE = {
|
|
313
324
|
rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
|
|
314
325
|
ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
|
|
315
326
|
line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
|
|
316
|
-
text: [...COMMON_PROPS, "content", "contentDecimals", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
|
|
327
|
+
text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
|
|
317
328
|
image: [...COMMON_PROPS, "src", "width", "height"],
|
|
318
329
|
path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
|
|
319
330
|
group: COMMON_PROPS
|
|
@@ -352,6 +363,14 @@ function validateScene(ir) {
|
|
|
352
363
|
};
|
|
353
364
|
collect(ir.nodes);
|
|
354
365
|
const checkProps = (where, nodeId, props) => {
|
|
366
|
+
if (nodeId === "camera" && !nodeById.has("camera")) {
|
|
367
|
+
for (const key2 of Object.keys(props)) {
|
|
368
|
+
if (!CAMERA_PROPS.includes(key2)) {
|
|
369
|
+
problems.push(`${where}: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
355
374
|
const node = nodeById.get(nodeId);
|
|
356
375
|
if (!node) {
|
|
357
376
|
problems.push(
|
|
@@ -419,12 +438,15 @@ function validateScene(ir) {
|
|
|
419
438
|
break;
|
|
420
439
|
case "motionPath": {
|
|
421
440
|
const node = nodeById.get(tl.target);
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
441
|
+
const isSceneCamera = tl.target === "camera" && !node;
|
|
442
|
+
if (!isSceneCamera) {
|
|
443
|
+
if (!node) {
|
|
444
|
+
problems.push(
|
|
445
|
+
`${path3}: motionPath targets unknown node "${tl.target}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
|
|
446
|
+
);
|
|
447
|
+
} else if (node.type === "line") {
|
|
448
|
+
problems.push(`${path3}: motionPath cannot target a line (no x/y) \u2014 "${tl.target}"`);
|
|
449
|
+
}
|
|
428
450
|
}
|
|
429
451
|
if (tl.points.length < 1) problems.push(`${path3}: motionPath "${tl.target}" needs at least 1 point`);
|
|
430
452
|
if (tl.duration !== void 0 && tl.duration <= 0) {
|
|
@@ -469,6 +491,18 @@ function validateScene(ir) {
|
|
|
469
491
|
if (ir.duration !== void 0 && ir.duration <= 0) {
|
|
470
492
|
problems.push("scene duration must be > 0");
|
|
471
493
|
}
|
|
494
|
+
if (ir.camera) {
|
|
495
|
+
if (nodeById.has("camera")) {
|
|
496
|
+
problems.push(`camera: a node is already named "camera" \u2014 rename that node or drop the scene camera (the id "camera" can't be both)`);
|
|
497
|
+
}
|
|
498
|
+
for (const [key2, value] of Object.entries(ir.camera)) {
|
|
499
|
+
if (!CAMERA_PROPS.includes(key2)) {
|
|
500
|
+
problems.push(`camera: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
|
|
501
|
+
} else if (typeof value !== "number") {
|
|
502
|
+
problems.push(`camera.${key2} must be a number`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
472
506
|
const SFX_NAMES = ["whoosh", "pop", "tick", "rise", "shimmer", "thud"];
|
|
473
507
|
for (const [i, cue] of (ir.audio?.cues ?? []).entries()) {
|
|
474
508
|
if (typeof cue.at === "string" && !labels.has(cue.at)) {
|
package/dist/player.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
|
|
3
|
+
// ../render-cli/src/player.ts
|
|
4
|
+
import { build } from "esbuild";
|
|
5
|
+
import { mkdir, writeFile, readFile } from "node:fs/promises";
|
|
6
|
+
import { basename, dirname, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
var PACKAGED = true;
|
|
9
|
+
var HERE = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
var CORE = PACKAGED ? resolve(HERE, "index.js") : resolve(HERE, "..", "..", "core", "src", "index.ts");
|
|
11
|
+
var RENDERER = PACKAGED ? resolve(HERE, "renderer-canvas.js") : resolve(HERE, "..", "..", "renderer-canvas", "src", "index.ts");
|
|
12
|
+
var FONTS = PACKAGED ? resolve(HERE, "..", "assets", "fonts") : resolve(HERE, "..", "..", "..", "assets", "fonts");
|
|
13
|
+
async function fontFace(weight) {
|
|
14
|
+
try {
|
|
15
|
+
const b64 = (await readFile(resolve(FONTS, `inter-${weight}.woff2`))).toString("base64");
|
|
16
|
+
return `@font-face{font-family:Inter;font-style:normal;font-weight:${weight};font-display:block;src:url(data:font/woff2;base64,${b64}) format('woff2')}`;
|
|
17
|
+
} catch {
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function main() {
|
|
22
|
+
const [scenePath, outPath] = process.argv.slice(2);
|
|
23
|
+
if (!scenePath || !outPath) {
|
|
24
|
+
console.error("usage: player <scene.ts|.json> <out.html>");
|
|
25
|
+
process.exit(2);
|
|
26
|
+
}
|
|
27
|
+
const entry = `
|
|
28
|
+
import { compileScene } from "@reframe/core";
|
|
29
|
+
import { renderFrame } from "@reframe/renderer-canvas";
|
|
30
|
+
import sceneIR from ${JSON.stringify(scenePath)};
|
|
31
|
+
const compiled = compileScene(sceneIR);
|
|
32
|
+
const canvas = document.getElementById("c");
|
|
33
|
+
const ctx = canvas.getContext("2d");
|
|
34
|
+
canvas.width = sceneIR.size.width;
|
|
35
|
+
canvas.height = sceneIR.size.height;
|
|
36
|
+
const dur = compiled.duration || 1;
|
|
37
|
+
function frame(now){ renderFrame(ctx, compiled, (now / 1000) % dur); requestAnimationFrame(frame); }
|
|
38
|
+
const ff = document.fonts;
|
|
39
|
+
if (ff && ff.ready) ff.ready.then(() => requestAnimationFrame(frame));
|
|
40
|
+
else requestAnimationFrame(frame);
|
|
41
|
+
`;
|
|
42
|
+
let bundle;
|
|
43
|
+
try {
|
|
44
|
+
bundle = await build({
|
|
45
|
+
stdin: { contents: entry, resolveDir: dirname(scenePath), loader: "ts" },
|
|
46
|
+
bundle: true,
|
|
47
|
+
format: "iife",
|
|
48
|
+
platform: "browser",
|
|
49
|
+
target: "es2022",
|
|
50
|
+
write: false,
|
|
51
|
+
logLevel: "silent",
|
|
52
|
+
alias: { "@reframe/core": CORE, "@reframe/renderer-canvas": RENDERER, "reframe-video": CORE }
|
|
53
|
+
});
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.error(`failed to bundle ${scenePath}:
|
|
56
|
+
${err instanceof Error ? err.message : String(err)}`);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
const js = bundle.outputFiles[0].text;
|
|
60
|
+
const faces = (await Promise.all([400, 700, 800].map(fontFace))).join("");
|
|
61
|
+
const html = `<!doctype html>
|
|
62
|
+
<html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
63
|
+
<title>${basename(scenePath)} \xB7 reframe</title>
|
|
64
|
+
<style>${faces}
|
|
65
|
+
html,body{margin:0;height:100%;background:#06070b;display:grid;place-items:center;font-family:Inter,system-ui}
|
|
66
|
+
canvas{max-width:94vw;max-height:94vh;border-radius:16px;box-shadow:0 24px 90px rgba(0,0,0,.55)}</style></head>
|
|
67
|
+
<body><canvas id="c"></canvas><script>${js}</script></body></html>`;
|
|
68
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
69
|
+
await writeFile(outPath, html);
|
|
70
|
+
console.log(outPath);
|
|
71
|
+
}
|
|
72
|
+
void main();
|
package/dist/trace-cli.js
CHANGED
|
@@ -6,12 +6,12 @@ import { resolve as resolve2 } from "node:path";
|
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
7
|
|
|
8
8
|
// ../core/src/validate.ts
|
|
9
|
-
var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor"];
|
|
9
|
+
var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed"];
|
|
10
10
|
var PROPS_BY_TYPE = {
|
|
11
11
|
rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
|
|
12
12
|
ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
|
|
13
13
|
line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
|
|
14
|
-
text: [...COMMON_PROPS, "content", "contentDecimals", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
|
|
14
|
+
text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
|
|
15
15
|
image: [...COMMON_PROPS, "src", "width", "height"],
|
|
16
16
|
path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
|
|
17
17
|
group: COMMON_PROPS
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scene camera — a single affine viewport applied to the whole scene.
|
|
3
|
+
*
|
|
4
|
+
* Semantics (look-at): `camera.{x,y}` is the scene point centered in frame,
|
|
5
|
+
* `zoom` scales about it, `rotation` (degrees) turns about it. Defaults
|
|
6
|
+
* (`x=W/2, y=H/2, zoom=1, rotation=0`) are the identity, so a scene without a
|
|
7
|
+
* camera renders byte-identically.
|
|
8
|
+
*
|
|
9
|
+
* The camera is animated through the SAME machinery as nodes: tween / motionPath /
|
|
10
|
+
* behaviors targeting the reserved id `"camera"` with props x/y/zoom/rotation.
|
|
11
|
+
* `cameraTo` is a thin readable wrapper. Because those are ordinary labeled
|
|
12
|
+
* timeline steps, camera keyframes are overlay-addressable for free, so human
|
|
13
|
+
* edits survive AI regeneration.
|
|
14
|
+
*/
|
|
15
|
+
import type { CameraIR, Ease, Size, TimelineIR } from "./ir.js";
|
|
16
|
+
import type { Mat2D } from "./evaluate.js";
|
|
17
|
+
/** Reserved timeline/behavior target id for the camera. */
|
|
18
|
+
export declare const CAMERA_ID = "camera";
|
|
19
|
+
/** The animatable camera props (look-at point + zoom + rotation). */
|
|
20
|
+
export declare const CAMERA_PROPS: readonly ["x", "y", "zoom", "rotation"];
|
|
21
|
+
/**
|
|
22
|
+
* The camera's affine matrix: `T(W/2,H/2) · R(rotation) · S(zoom) · T(-x,-y)`,
|
|
23
|
+
* i.e. center the focal point, then zoom/rotate about the frame centre. Defaults
|
|
24
|
+
* collapse to the identity.
|
|
25
|
+
*/
|
|
26
|
+
export declare function cameraMatrix(cam: CameraIR, size: Size): Mat2D;
|
|
27
|
+
/** Keyframe the camera: a `tween` on the reserved "camera" target. */
|
|
28
|
+
export declare function cameraTo(props: CameraIR, opts?: {
|
|
29
|
+
duration?: number;
|
|
30
|
+
ease?: Ease;
|
|
31
|
+
label?: string;
|
|
32
|
+
}): TimelineIR;
|
package/dist/types/compile.d.ts
CHANGED
|
@@ -49,5 +49,7 @@ export interface CompiledScene {
|
|
|
49
49
|
labelTimes: Map<string, LabelSpan>;
|
|
50
50
|
/** The subset of label spans that come from beat nodes — keyed by beat name. */
|
|
51
51
|
beatTimes: Map<string, LabelSpan>;
|
|
52
|
+
/** True iff the scene declares or animates a `camera` (gates the camera matrix). */
|
|
53
|
+
hasCamera: boolean;
|
|
52
54
|
}
|
|
53
55
|
export declare function compileScene(ir: SceneIR): CompiledScene;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor / pointer motion — a vector mouse pointer that glides across the scene
|
|
3
|
+
* and clicks things (the UI-demo staple). `cursor()` returns a NodeIR (like
|
|
4
|
+
* `devicePreset`); `cursorTo` / `cursorPath` / `cursorClick` return TimelineIR
|
|
5
|
+
* (like `characterPreset`). The pointer's HOTSPOT is the group origin (0,0), so a
|
|
6
|
+
* move lands the tip exactly on a target. Pairs with `deviceScreenPoint` to click
|
|
7
|
+
* UI inside a `devicePreset` screen.
|
|
8
|
+
*
|
|
9
|
+
* nodes: [devicePreset("browser", { id: "d", x, y, scale, content }), cursor({ id: "cur" })]
|
|
10
|
+
* timeline: seq(cursorTo("cur", [start], deviceScreenPoint("browser", dOpts, [lx, ly])),
|
|
11
|
+
* cursorClick("cur", { press: "d-ui-cta" }))
|
|
12
|
+
*/
|
|
13
|
+
import type { Ease, NodeIR, TimelineIR } from "./ir.js";
|
|
14
|
+
export type CursorStyle = "arrow" | "dot" | "ring";
|
|
15
|
+
export interface CursorOpts {
|
|
16
|
+
id?: string;
|
|
17
|
+
x?: number;
|
|
18
|
+
y?: number;
|
|
19
|
+
scale?: number;
|
|
20
|
+
opacity?: number;
|
|
21
|
+
style?: CursorStyle;
|
|
22
|
+
/** Pointer body colour (default white for arrow). */
|
|
23
|
+
fill?: string;
|
|
24
|
+
/** Accent for dot/ring body and the click ripple. */
|
|
25
|
+
accent?: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function cursor(opts?: CursorOpts): NodeIR;
|
|
28
|
+
export interface CursorToOpts {
|
|
29
|
+
duration?: number;
|
|
30
|
+
ease?: Ease;
|
|
31
|
+
/** perpendicular bow as a fraction of distance (default 0.12; 0 = straight). */
|
|
32
|
+
arc?: number;
|
|
33
|
+
label?: string;
|
|
34
|
+
}
|
|
35
|
+
/** Glide the cursor from `from` to `to` along a gentle human arc. */
|
|
36
|
+
export declare function cursorTo(id: string, from: [number, number], to: [number, number], opts?: CursorToOpts): TimelineIR;
|
|
37
|
+
export interface CursorPathOpts {
|
|
38
|
+
duration?: number;
|
|
39
|
+
ease?: Ease;
|
|
40
|
+
curviness?: number;
|
|
41
|
+
label?: string;
|
|
42
|
+
}
|
|
43
|
+
/** Move the cursor through a tour of waypoints (one smooth path). */
|
|
44
|
+
export declare function cursorPath(id: string, points: [number, number][], opts?: CursorPathOpts): TimelineIR;
|
|
45
|
+
export interface CursorClickOpts {
|
|
46
|
+
/** overall click duration scale (default 1). */
|
|
47
|
+
speed?: number;
|
|
48
|
+
/** node id to "press" (a quick scale dip) when the cursor clicks it. */
|
|
49
|
+
press?: string;
|
|
50
|
+
/** show the expanding ripple ring (default true). */
|
|
51
|
+
ripple?: boolean;
|
|
52
|
+
label?: string;
|
|
53
|
+
}
|
|
54
|
+
/** A click: the pointer taps, a ripple ring expands, and an optional target presses. */
|
|
55
|
+
export declare function cursorClick(id: string, opts?: CursorClickOpts): TimelineIR;
|
|
56
|
+
/** Two quick clicks. */
|
|
57
|
+
export declare function cursorDouble(id: string, opts?: CursorClickOpts): TimelineIR;
|
|
@@ -61,5 +61,9 @@ export declare function deviceBounds(name: DevicePresetName, opts?: DevicePreset
|
|
|
61
61
|
width: number;
|
|
62
62
|
height: number;
|
|
63
63
|
};
|
|
64
|
+
/** Map a SCREEN-LOCAL point (origin = screen centre, the coords `content` is
|
|
65
|
+
* authored in) to absolute SCENE coords, given the same `opts` passed to
|
|
66
|
+
* `devicePreset`. For aiming a `cursor` at on-screen UI. */
|
|
67
|
+
export declare function deviceScreenPoint(name: DevicePresetName, opts: DevicePresetOpts, local: [number, number]): [number, number];
|
|
64
68
|
/** Build a device-mockup frame (a group) with a clipped screen content slot. */
|
|
65
69
|
export declare function devicePreset(name: DevicePresetName, opts?: DevicePresetOpts): NodeIR;
|
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, CompositionIR, CompositionSceneEntry, Ease, EllipseProps, GroupProps, ImageProps, LineProps, NodeIR, PathProps, PropValue, RectProps, SceneIR, Size, StateOverride, TextProps, TimelineIR } from "./ir.js";
|
|
5
|
+
import type { AudioIR, BehaviorIR, CameraIR, CompositionIR, CompositionSceneEntry, Ease, EllipseProps, GroupProps, ImageProps, LineProps, NodeIR, PathProps, PropValue, RectProps, SceneIR, Size, StateOverride, TextProps, TimelineIR } from "./ir.js";
|
|
6
6
|
export interface SceneInput {
|
|
7
7
|
id: string;
|
|
8
8
|
size: Size;
|
|
@@ -10,6 +10,7 @@ export interface SceneInput {
|
|
|
10
10
|
duration?: number;
|
|
11
11
|
background?: string;
|
|
12
12
|
nodes: NodeIR[];
|
|
13
|
+
camera?: CameraIR;
|
|
13
14
|
states?: Record<string, StateOverride>;
|
|
14
15
|
initial?: string;
|
|
15
16
|
timeline?: TimelineIR;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -5,8 +5,10 @@ export { compileComposition, type CompiledComposition, type ScenePlacement, } fr
|
|
|
5
5
|
export { composeScene, formatComposeReport, type OverlayDoc, type ComposeReport, } from "./compose.js";
|
|
6
6
|
export { compileScene, type CompiledScene, type PropertySegment, type LabelSpan, type MotionDriver } from "./compile.js";
|
|
7
7
|
export { pathPoint, pathTangentAngle, type Pt } from "./path.js";
|
|
8
|
+
export { cameraTo, cameraMatrix, CAMERA_ID, CAMERA_PROPS } from "./camera.js";
|
|
8
9
|
export { motionPreset, PRESET_NAMES, type PresetName, type PresetRig, type PresetOpts } from "./presets.js";
|
|
9
|
-
export { devicePreset, deviceScreen, deviceScreenCenter, deviceBounds, DEVICE_PRESET_NAMES, type DevicePresetName, type DevicePresetOpts } from "./devicePreset.js";
|
|
10
|
+
export { devicePreset, deviceScreen, deviceScreenCenter, deviceBounds, deviceScreenPoint, DEVICE_PRESET_NAMES, type DevicePresetName, type DevicePresetOpts } from "./devicePreset.js";
|
|
11
|
+
export { cursor, cursorTo, cursorPath, cursorClick, cursorDouble, type CursorStyle, type CursorOpts, type CursorToOpts, type CursorPathOpts, type CursorClickOpts } from "./cursor.js";
|
|
10
12
|
export { rig, rigPose, poseTo, ikReach, humanoid, ovalPath, type Bone, type RigOpts, type Pose, type HumanoidOpts } from "./rig.js";
|
|
11
13
|
export { characterPreset, CHARACTER_PRESET_NAMES, type CharacterPresetName, type CharacterPresetOpts } from "./characterPreset.js";
|
|
12
14
|
export { figure, type FigureStyle, type FigureOpts, type FigurePalette } from "./figure.js";
|
package/dist/types/ir.d.ts
CHANGED
|
@@ -31,6 +31,11 @@ export interface BaseProps {
|
|
|
31
31
|
skewX?: number;
|
|
32
32
|
skewY?: number;
|
|
33
33
|
anchor?: Anchor;
|
|
34
|
+
/**
|
|
35
|
+
* Pin a TOP-LEVEL node to the screen so the scene `camera` does not move it —
|
|
36
|
+
* for HUD / titles / watermark layers. No-op when the scene has no camera.
|
|
37
|
+
*/
|
|
38
|
+
fixed?: boolean;
|
|
34
39
|
}
|
|
35
40
|
export interface RectProps extends BaseProps {
|
|
36
41
|
width: number;
|
|
@@ -57,12 +62,16 @@ export interface LineProps {
|
|
|
57
62
|
opacity?: number;
|
|
58
63
|
/** 0..1 — how much of the line is drawn (for draw-on effects). */
|
|
59
64
|
progress?: number;
|
|
65
|
+
/** Pin to the screen so the scene `camera` does not move it (top-level only). */
|
|
66
|
+
fixed?: boolean;
|
|
60
67
|
}
|
|
61
68
|
export interface TextProps extends BaseProps {
|
|
62
69
|
/** Numbers interpolate (count-up) and render via toFixed(contentDecimals). */
|
|
63
70
|
content: string | number;
|
|
64
71
|
/** Decimal places when content is numeric (default 0). */
|
|
65
72
|
contentDecimals?: number;
|
|
73
|
+
/** Group the integer part with thousands separators (e.g. 35,786). */
|
|
74
|
+
contentThousands?: boolean;
|
|
66
75
|
fontFamily: string;
|
|
67
76
|
fontSize: number;
|
|
68
77
|
fontWeight?: number;
|
|
@@ -302,6 +311,19 @@ export interface AudioIR {
|
|
|
302
311
|
};
|
|
303
312
|
cues?: AudioCueIR[];
|
|
304
313
|
}
|
|
314
|
+
/**
|
|
315
|
+
* The scene camera: a viewport over the whole scene. `(x,y)` is the scene point
|
|
316
|
+
* centered in frame (defaults to the frame centre), `zoom` scales about it,
|
|
317
|
+
* `rotation` (degrees) turns about it. Defaults (`x=W/2, y=H/2, zoom=1, rotation=0`)
|
|
318
|
+
* are the identity. Animate it by tweening the reserved target `"camera"`
|
|
319
|
+
* (or the `cameraTo` helper); pin layers out of it with a node's `fixed` flag.
|
|
320
|
+
*/
|
|
321
|
+
export interface CameraIR {
|
|
322
|
+
x?: number;
|
|
323
|
+
y?: number;
|
|
324
|
+
zoom?: number;
|
|
325
|
+
rotation?: number;
|
|
326
|
+
}
|
|
305
327
|
export interface SceneIR {
|
|
306
328
|
version: 1;
|
|
307
329
|
id: string;
|
|
@@ -312,6 +334,8 @@ export interface SceneIR {
|
|
|
312
334
|
duration?: number;
|
|
313
335
|
background?: string;
|
|
314
336
|
nodes: NodeIR[];
|
|
337
|
+
/** A viewport over the scene, keyframable via the reserved target "camera". */
|
|
338
|
+
camera?: CameraIR;
|
|
315
339
|
states?: Record<string, StateOverride>;
|
|
316
340
|
/** State applied at t=0. */
|
|
317
341
|
initial?: string;
|
package/guides/edsl-guide.md
CHANGED
|
@@ -121,6 +121,35 @@ bound — e.g. a pulse only during the hold:
|
|
|
121
121
|
`oscillate("title", "scale", { amplitude: 0.04, frequency: 1.2 }, { from: 1.5, until: 3.5 })`.
|
|
122
122
|
Omit the window to run for the whole scene.
|
|
123
123
|
|
|
124
|
+
## Camera (one keyframable viewport)
|
|
125
|
+
|
|
126
|
+
A scene-level camera moves the whole scene at once: a look-at point + zoom +
|
|
127
|
+
rotation, animated over the timeline. Add it as a top-level `camera` field and
|
|
128
|
+
keyframe it with `cameraTo` (or by tweening the reserved target `"camera"`):
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
scene({
|
|
132
|
+
// ...
|
|
133
|
+
camera: { x: W/2, y: H/2, zoom: 1, rotation: 0 }, // (x,y) = scene point centred in frame; defaults = frame centre, zoom 1, rot 0 (= no camera)
|
|
134
|
+
timeline: seq(
|
|
135
|
+
cameraTo({ x: 300, y: 400, zoom: 4 }, { duration: 1.5, ease: "easeInOutCubic", label: "push-in" }), // zoom into a detail
|
|
136
|
+
cameraTo({ x: 800, y: 200, zoom: 2, rotation: -5 }, { duration: 1.2 }), // pan + slight bank
|
|
137
|
+
cameraTo({ x: W/2, y: H/2, zoom: 1, rotation: 0 }, { duration: 1.6 }), // pull back
|
|
138
|
+
),
|
|
139
|
+
})
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- `cameraTo(props, { duration?, ease?, label? })` keyframes the camera; it is a
|
|
143
|
+
`tween` on the `"camera"` target, so `motionPath("camera", pts, …)` (pan along
|
|
144
|
+
a curve) and `oscillate/wiggle("camera", "rotation"|"x"|…)` (handheld drift)
|
|
145
|
+
also work.
|
|
146
|
+
- **Pin HUD/titles to the screen** with `fixed: true` on a TOP-LEVEL node — the
|
|
147
|
+
camera won't move it (for overlays, watermarks, captions).
|
|
148
|
+
- Defaults are the identity, so a scene without a camera is unchanged. Don't name
|
|
149
|
+
a node `"camera"` if you use the scene camera (the id can't be both).
|
|
150
|
+
|
|
151
|
+
See `examples/scenes/camera-demo.ts`.
|
|
152
|
+
|
|
124
153
|
## Character rig (skeleton, poses, IK)
|
|
125
154
|
|
|
126
155
|
A first-class, declarative character rig that **compiles to plain IR** (nested
|
|
@@ -199,6 +228,33 @@ const T = splitText("MOTION IS DATA", { id: "t", x: 960, y: 470, fontSize: 130 }
|
|
|
199
228
|
Every effect is seeded (same `seed` → identical) and pure keyframes. To time a
|
|
200
229
|
`textLoop` window, add up the `textIn` beat length (≈ `(n-1)·stagger + glyphDur`).
|
|
201
230
|
|
|
231
|
+
## Cursor (UI demos)
|
|
232
|
+
|
|
233
|
+
A vector mouse pointer that glides across the scene and clicks things — for app
|
|
234
|
+
walkthroughs. `cursor()` returns a node; the moves/clicks return timeline steps.
|
|
235
|
+
The pointer's **hotspot is the group origin**, so a move lands the tip on a target.
|
|
236
|
+
|
|
237
|
+
- `cursor({ id, x, y, scale?, opacity?, style?, accent? }) → NodeIR` — styles
|
|
238
|
+
`arrow` (default), `dot`, `ring`. Draw it LAST so it sits on top. Carries a
|
|
239
|
+
hidden `${id}-ripple` ring for clicks.
|
|
240
|
+
- `cursorTo(id, from, to, { duration?, ease?, arc? }) → TimelineIR` — glide along
|
|
241
|
+
a gentle human arc (`arc` is the bow, default 0.12). Thread the position: start
|
|
242
|
+
= the node's `x/y`, each `to` becomes the next `from`.
|
|
243
|
+
- `cursorPath(id, points, opts)` — a multi-stop tour through waypoints.
|
|
244
|
+
- `cursorClick(id, { press?, ripple?, label? })` / `cursorDouble(...)` — the
|
|
245
|
+
pointer taps, a ripple ring expands, and the `press` node (a button) dips. Pass
|
|
246
|
+
a unique `label` when you click more than once in a scene.
|
|
247
|
+
- `deviceScreenPoint(name, deviceOpts, [lx, ly]) → [x, y]` — map a UI element's
|
|
248
|
+
screen-local coords (the coords `devicePreset` `content` is authored in) to
|
|
249
|
+
scene coords, so the cursor clicks on-screen UI precisely (account for the
|
|
250
|
+
device's `scale` at click time and any `slot` offset).
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
// nodes: devicePreset("browser", { id:"d", x, y, scale:0.88, content }), cursor({ id:"cur" })
|
|
254
|
+
const cta = deviceScreenPoint("browser", { x, y, scale: 0.88 }, [lx, ly]);
|
|
255
|
+
seq(cursorTo("cur", [sx, sy], cta), cursorClick("cur", { press: "browser-ui-cta" }))
|
|
256
|
+
```
|
|
257
|
+
|
|
202
258
|
## Audio (optional)
|
|
203
259
|
|
|
204
260
|
Label-anchored sound design — cues follow retiming and regeneration:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reframe-video",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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",
|