reframe-video 0.5.0 → 0.6.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/dist/bin.js +103 -10
- package/dist/browserEntry.js +120 -16
- package/dist/cli.js +65 -9
- package/dist/index.js +178 -16
- package/dist/labels.js +65 -9
- package/dist/player.js +72 -0
- package/dist/renderer-canvas.js +34 -6
- 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/dsl.d.ts +2 -1
- package/dist/types/evaluate.d.ts +9 -7
- package/dist/types/gradient.d.ts +28 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/ir.d.ts +59 -6
- package/dist/types/path.d.ts +6 -0
- package/guides/edsl-guide.md +51 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -6,6 +6,22 @@ var DEFAULT_MOTIONPATH_DURATION = 1;
|
|
|
6
6
|
var DEFAULT_FPS = 30;
|
|
7
7
|
|
|
8
8
|
// ../core/src/path.ts
|
|
9
|
+
function pathBBox(d) {
|
|
10
|
+
const nums = d.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi);
|
|
11
|
+
if (!nums || nums.length < 2) return [0, 0, 1, 1];
|
|
12
|
+
let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
|
|
13
|
+
for (let i = 0; i + 1 < nums.length; i += 2) {
|
|
14
|
+
const x = parseFloat(nums[i]);
|
|
15
|
+
const y = parseFloat(nums[i + 1]);
|
|
16
|
+
if (x < minx) minx = x;
|
|
17
|
+
if (x > maxx) maxx = x;
|
|
18
|
+
if (y < miny) miny = y;
|
|
19
|
+
if (y > maxy) maxy = y;
|
|
20
|
+
}
|
|
21
|
+
const w = maxx - minx;
|
|
22
|
+
const h = maxy - miny;
|
|
23
|
+
return [minx, miny, w > 0 ? w : 1, h > 0 ? h : 1];
|
|
24
|
+
}
|
|
9
25
|
function locate(segCount, u) {
|
|
10
26
|
if (segCount <= 0) return { i: 0, t: 0 };
|
|
11
27
|
const clamped = Math.max(0, Math.min(1, u));
|
|
@@ -134,6 +150,14 @@ function compileScene(ir) {
|
|
|
134
150
|
}
|
|
135
151
|
}
|
|
136
152
|
}
|
|
153
|
+
const cameraIsNode = nodeById.has("camera");
|
|
154
|
+
if (!cameraIsNode) {
|
|
155
|
+
const cam = ir.camera ?? {};
|
|
156
|
+
initialValues.set(key("camera", "x"), cam.x ?? ir.size.width / 2);
|
|
157
|
+
initialValues.set(key("camera", "y"), cam.y ?? ir.size.height / 2);
|
|
158
|
+
initialValues.set(key("camera", "zoom"), cam.zoom ?? 1);
|
|
159
|
+
initialValues.set(key("camera", "rotation"), cam.rotation ?? 0);
|
|
160
|
+
}
|
|
137
161
|
const segments = /* @__PURE__ */ new Map();
|
|
138
162
|
const motionPaths = /* @__PURE__ */ new Map();
|
|
139
163
|
const current = new Map(initialValues);
|
|
@@ -294,6 +318,7 @@ function compileScene(ir) {
|
|
|
294
318
|
const inferredEnd = ir.timeline ? walk(ir.timeline, 0) : 0;
|
|
295
319
|
for (const list of segments.values()) list.sort((a, b) => a.t0 - b.t0);
|
|
296
320
|
for (const list of motionPaths.values()) list.sort((a, b) => a.t0 - b.t0);
|
|
321
|
+
const hasCamera = !cameraIsNode && (ir.camera !== void 0 || motionPaths.has("camera") || [...segments.keys()].some((k) => k.startsWith("camera.")));
|
|
297
322
|
return {
|
|
298
323
|
ir,
|
|
299
324
|
duration: ir.duration ?? inferredEnd,
|
|
@@ -303,17 +328,19 @@ function compileScene(ir) {
|
|
|
303
328
|
nodeById,
|
|
304
329
|
nodeOrder,
|
|
305
330
|
labelTimes,
|
|
306
|
-
beatTimes
|
|
331
|
+
beatTimes,
|
|
332
|
+
hasCamera
|
|
307
333
|
};
|
|
308
334
|
}
|
|
309
335
|
|
|
310
336
|
// ../core/src/validate.ts
|
|
311
|
-
var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor"];
|
|
337
|
+
var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed"];
|
|
338
|
+
var CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
|
|
312
339
|
var PROPS_BY_TYPE = {
|
|
313
340
|
rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
|
|
314
341
|
ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
|
|
315
342
|
line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
|
|
316
|
-
text: [...COMMON_PROPS, "content", "contentDecimals", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
|
|
343
|
+
text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
|
|
317
344
|
image: [...COMMON_PROPS, "src", "width", "height"],
|
|
318
345
|
path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
|
|
319
346
|
group: COMMON_PROPS
|
|
@@ -330,12 +357,34 @@ ${problems.map((p) => ` - ${p}`).join("\n")}`);
|
|
|
330
357
|
function validateScene(ir) {
|
|
331
358
|
const problems = [];
|
|
332
359
|
const nodeById = /* @__PURE__ */ new Map();
|
|
360
|
+
const checkPaint = (where, value) => {
|
|
361
|
+
if (typeof value !== "object" || value === null) return;
|
|
362
|
+
const g = value;
|
|
363
|
+
if (g.kind !== "linear" && g.kind !== "radial" && g.kind !== "conic") {
|
|
364
|
+
problems.push(`${where}: a paint object must be a gradient with kind "linear" / "radial" / "conic"`);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (!Array.isArray(g.stops) || g.stops.length === 0) {
|
|
368
|
+
problems.push(`${where}: gradient "${g.kind}" needs at least one color stop`);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
g.stops.forEach((s, i) => {
|
|
372
|
+
const st = s;
|
|
373
|
+
if (typeof st?.color !== "string") problems.push(`${where}: gradient stop ${i} needs a color string`);
|
|
374
|
+
if (typeof st?.offset !== "number" || st.offset < 0 || st.offset > 1) {
|
|
375
|
+
problems.push(`${where}: gradient stop ${i} "offset" must be a number in 0..1`);
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
};
|
|
333
379
|
const collect = (nodes) => {
|
|
334
380
|
for (const node of nodes) {
|
|
335
381
|
if (nodeById.has(node.id)) {
|
|
336
382
|
problems.push(`duplicate node id "${node.id}" \u2014 every node id must be unique`);
|
|
337
383
|
}
|
|
338
384
|
nodeById.set(node.id, node);
|
|
385
|
+
const props = node.props;
|
|
386
|
+
checkPaint(`node "${node.id}" fill`, props.fill);
|
|
387
|
+
checkPaint(`node "${node.id}" stroke`, props.stroke);
|
|
339
388
|
if (node.type === "group") {
|
|
340
389
|
const clip = node.props.clip;
|
|
341
390
|
if (clip) {
|
|
@@ -352,6 +401,14 @@ function validateScene(ir) {
|
|
|
352
401
|
};
|
|
353
402
|
collect(ir.nodes);
|
|
354
403
|
const checkProps = (where, nodeId, props) => {
|
|
404
|
+
if (nodeId === "camera" && !nodeById.has("camera")) {
|
|
405
|
+
for (const key2 of Object.keys(props)) {
|
|
406
|
+
if (!CAMERA_PROPS.includes(key2)) {
|
|
407
|
+
problems.push(`${where}: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
355
412
|
const node = nodeById.get(nodeId);
|
|
356
413
|
if (!node) {
|
|
357
414
|
problems.push(
|
|
@@ -419,12 +476,15 @@ function validateScene(ir) {
|
|
|
419
476
|
break;
|
|
420
477
|
case "motionPath": {
|
|
421
478
|
const node = nodeById.get(tl.target);
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
479
|
+
const isSceneCamera = tl.target === "camera" && !node;
|
|
480
|
+
if (!isSceneCamera) {
|
|
481
|
+
if (!node) {
|
|
482
|
+
problems.push(
|
|
483
|
+
`${path2}: motionPath targets unknown node "${tl.target}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
|
|
484
|
+
);
|
|
485
|
+
} else if (node.type === "line") {
|
|
486
|
+
problems.push(`${path2}: motionPath cannot target a line (no x/y) \u2014 "${tl.target}"`);
|
|
487
|
+
}
|
|
428
488
|
}
|
|
429
489
|
if (tl.points.length < 1) problems.push(`${path2}: motionPath "${tl.target}" needs at least 1 point`);
|
|
430
490
|
if (tl.duration !== void 0 && tl.duration <= 0) {
|
|
@@ -469,6 +529,18 @@ function validateScene(ir) {
|
|
|
469
529
|
if (ir.duration !== void 0 && ir.duration <= 0) {
|
|
470
530
|
problems.push("scene duration must be > 0");
|
|
471
531
|
}
|
|
532
|
+
if (ir.camera) {
|
|
533
|
+
if (nodeById.has("camera")) {
|
|
534
|
+
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)`);
|
|
535
|
+
}
|
|
536
|
+
for (const [key2, value] of Object.entries(ir.camera)) {
|
|
537
|
+
if (!CAMERA_PROPS.includes(key2)) {
|
|
538
|
+
problems.push(`camera: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
|
|
539
|
+
} else if (typeof value !== "number") {
|
|
540
|
+
problems.push(`camera.${key2} must be a number`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
472
544
|
const SFX_NAMES = ["whoosh", "pop", "tick", "rise", "shimmer", "thud"];
|
|
473
545
|
for (const [i, cue] of (ir.audio?.cues ?? []).entries()) {
|
|
474
546
|
if (typeof cue.at === "string" && !labels.has(cue.at)) {
|
|
@@ -863,6 +935,58 @@ function formatComposeReport(report) {
|
|
|
863
935
|
return lines.join("\n");
|
|
864
936
|
}
|
|
865
937
|
|
|
938
|
+
// ../core/src/camera.ts
|
|
939
|
+
var CAMERA_ID = "camera";
|
|
940
|
+
var CAMERA_PROPS2 = ["x", "y", "zoom", "rotation"];
|
|
941
|
+
function cameraMatrix(cam, size) {
|
|
942
|
+
const W = size.width;
|
|
943
|
+
const H = size.height;
|
|
944
|
+
const x = cam.x ?? W / 2;
|
|
945
|
+
const y = cam.y ?? H / 2;
|
|
946
|
+
const zoom = cam.zoom ?? 1;
|
|
947
|
+
const r = (cam.rotation ?? 0) * Math.PI / 180;
|
|
948
|
+
const a = Math.cos(r) * zoom;
|
|
949
|
+
const b = Math.sin(r) * zoom;
|
|
950
|
+
const c = b === 0 ? 0 : -b;
|
|
951
|
+
const d = Math.cos(r) * zoom;
|
|
952
|
+
return [a, b, c, d, W / 2 - a * x - c * y, H / 2 - b * x - d * y];
|
|
953
|
+
}
|
|
954
|
+
function cameraTo(props, opts = {}) {
|
|
955
|
+
return tween(CAMERA_ID, props, opts);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// ../core/src/gradient.ts
|
|
959
|
+
function isGradient(p) {
|
|
960
|
+
return typeof p === "object" && p !== null && (p.kind === "linear" || p.kind === "radial" || p.kind === "conic");
|
|
961
|
+
}
|
|
962
|
+
function toStops(stops) {
|
|
963
|
+
if (stops.length > 0 && typeof stops[0] === "object") return stops;
|
|
964
|
+
const cs = stops;
|
|
965
|
+
const n3 = cs.length;
|
|
966
|
+
return cs.map((color, i) => ({ offset: n3 <= 1 ? 0 : i / (n3 - 1), color }));
|
|
967
|
+
}
|
|
968
|
+
function linearGradient(stops, opts = {}) {
|
|
969
|
+
return { kind: "linear", ...opts.angle !== void 0 && { angle: opts.angle }, stops: toStops(stops) };
|
|
970
|
+
}
|
|
971
|
+
function radialGradient(stops, opts = {}) {
|
|
972
|
+
return {
|
|
973
|
+
kind: "radial",
|
|
974
|
+
...opts.cx !== void 0 && { cx: opts.cx },
|
|
975
|
+
...opts.cy !== void 0 && { cy: opts.cy },
|
|
976
|
+
...opts.r !== void 0 && { r: opts.r },
|
|
977
|
+
stops: toStops(stops)
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
function conicGradient(stops, opts = {}) {
|
|
981
|
+
return {
|
|
982
|
+
kind: "conic",
|
|
983
|
+
...opts.angle !== void 0 && { angle: opts.angle },
|
|
984
|
+
...opts.cx !== void 0 && { cx: opts.cx },
|
|
985
|
+
...opts.cy !== void 0 && { cy: opts.cy },
|
|
986
|
+
stops: toStops(stops)
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
|
|
866
990
|
// ../core/src/presets.ts
|
|
867
991
|
var PRESET_NAMES = [
|
|
868
992
|
"draw-bloom",
|
|
@@ -2701,6 +2825,17 @@ var ANCHOR_FACTORS = {
|
|
|
2701
2825
|
};
|
|
2702
2826
|
var TEXT_ALIGN = { 0: "left", 0.5: "center", 1: "right" };
|
|
2703
2827
|
var TEXT_BASELINE = { 0: "top", 0.5: "middle", 1: "bottom" };
|
|
2828
|
+
function formatNumber(value, decimals, thousands) {
|
|
2829
|
+
const fixed = value.toFixed(decimals);
|
|
2830
|
+
if (!thousands) return fixed;
|
|
2831
|
+
const neg = fixed.startsWith("-");
|
|
2832
|
+
const body = neg ? fixed.slice(1) : fixed;
|
|
2833
|
+
const dot = body.indexOf(".");
|
|
2834
|
+
const intPart = dot === -1 ? body : body.slice(0, dot);
|
|
2835
|
+
const frac = dot === -1 ? "" : body.slice(dot);
|
|
2836
|
+
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
2837
|
+
return (neg ? "-" : "") + grouped + frac;
|
|
2838
|
+
}
|
|
2704
2839
|
function behaviorEnvelope(b, t) {
|
|
2705
2840
|
const from = b.from ?? Number.NEGATIVE_INFINITY;
|
|
2706
2841
|
const until = b.until ?? Number.POSITIVE_INFINITY;
|
|
@@ -2854,8 +2989,10 @@ function evaluate(compiled, t) {
|
|
|
2854
2989
|
const height = num(id, "height", node.props.height);
|
|
2855
2990
|
const [ax, ay] = ANCHOR_FACTORS[node.props.anchor ?? "top-left"];
|
|
2856
2991
|
const strokeWidth = num(id, "strokeWidth", node.props.strokeWidth ?? 1);
|
|
2857
|
-
const
|
|
2858
|
-
const
|
|
2992
|
+
const fillP = node.props.fill;
|
|
2993
|
+
const strokeP = node.props.stroke;
|
|
2994
|
+
const fill = isGradient(fillP) ? fillP : opt(id, "fill", fillP);
|
|
2995
|
+
const stroke = isGradient(strokeP) ? strokeP : opt(id, "stroke", strokeP);
|
|
2859
2996
|
ops.push({
|
|
2860
2997
|
type: node.type,
|
|
2861
2998
|
id,
|
|
@@ -2893,17 +3030,22 @@ function evaluate(compiled, t) {
|
|
|
2893
3030
|
case "path": {
|
|
2894
3031
|
const ox = num(id, "originX", node.props.originX ?? 0);
|
|
2895
3032
|
const oy = num(id, "originY", node.props.originY ?? 0);
|
|
2896
|
-
const
|
|
2897
|
-
const
|
|
3033
|
+
const fillP = node.props.fill;
|
|
3034
|
+
const strokeP = node.props.stroke;
|
|
3035
|
+
const fill = isGradient(fillP) ? fillP : opt(id, "fill", fillP);
|
|
3036
|
+
const stroke = isGradient(strokeP) ? strokeP : opt(id, "stroke", strokeP);
|
|
3037
|
+
const dStr = str(id, "d", node.props.d);
|
|
3038
|
+
const needsBox = isGradient(fill) || isGradient(stroke);
|
|
2898
3039
|
ops.push({
|
|
2899
3040
|
type: "path",
|
|
2900
3041
|
id,
|
|
2901
3042
|
transform: ox === 0 && oy === 0 ? matrix : multiply(matrix, [1, 0, 0, 1, -ox, -oy]),
|
|
2902
3043
|
opacity,
|
|
2903
|
-
d:
|
|
3044
|
+
d: dStr,
|
|
2904
3045
|
progress: Math.max(0, Math.min(1, num(id, "progress", node.props.progress ?? 1))),
|
|
2905
3046
|
...fill !== void 0 && { fill },
|
|
2906
3047
|
...stroke !== void 0 && { stroke, strokeWidth: num(id, "strokeWidth", node.props.strokeWidth ?? 1) },
|
|
3048
|
+
...needsBox && { bbox: pathBBox(dStr) },
|
|
2907
3049
|
...clipSpread
|
|
2908
3050
|
});
|
|
2909
3051
|
return;
|
|
@@ -2920,7 +3062,7 @@ function evaluate(compiled, t) {
|
|
|
2920
3062
|
id,
|
|
2921
3063
|
transform: matrix,
|
|
2922
3064
|
opacity,
|
|
2923
|
-
content: typeof raw === "number" ? raw.
|
|
3065
|
+
content: typeof raw === "number" ? formatNumber(raw, decimals, node.props.contentThousands === true) : raw,
|
|
2924
3066
|
fontFamily: str(id, "fontFamily", node.props.fontFamily),
|
|
2925
3067
|
fontSize: num(id, "fontSize", node.props.fontSize),
|
|
2926
3068
|
fontWeight: num(id, "fontWeight", node.props.fontWeight ?? 400),
|
|
@@ -2934,7 +3076,19 @@ function evaluate(compiled, t) {
|
|
|
2934
3076
|
}
|
|
2935
3077
|
}
|
|
2936
3078
|
};
|
|
2937
|
-
|
|
3079
|
+
const cameraRoot = compiled.hasCamera ? cameraMatrix(
|
|
3080
|
+
{
|
|
3081
|
+
x: num("camera", "x", compiled.ir.size.width / 2),
|
|
3082
|
+
y: num("camera", "y", compiled.ir.size.height / 2),
|
|
3083
|
+
zoom: num("camera", "zoom", 1),
|
|
3084
|
+
rotation: num("camera", "rotation", 0)
|
|
3085
|
+
},
|
|
3086
|
+
compiled.ir.size
|
|
3087
|
+
) : IDENTITY;
|
|
3088
|
+
for (const node of compiled.ir.nodes) {
|
|
3089
|
+
const root = compiled.hasCamera && node.props.fixed ? IDENTITY : cameraRoot;
|
|
3090
|
+
walk(node, root, 1, []);
|
|
3091
|
+
}
|
|
2938
3092
|
return ops;
|
|
2939
3093
|
}
|
|
2940
3094
|
|
|
@@ -3015,6 +3169,8 @@ function sketchToTimeline(sketch, nodeIds) {
|
|
|
3015
3169
|
return par(...steps);
|
|
3016
3170
|
}
|
|
3017
3171
|
export {
|
|
3172
|
+
CAMERA_ID,
|
|
3173
|
+
CAMERA_PROPS2 as CAMERA_PROPS,
|
|
3018
3174
|
CHARACTER_PRESET_NAMES,
|
|
3019
3175
|
DEFAULT_CROSSFADE,
|
|
3020
3176
|
DEFAULT_FPS,
|
|
@@ -3029,12 +3185,15 @@ export {
|
|
|
3029
3185
|
SFX_DURATION,
|
|
3030
3186
|
SceneValidationError,
|
|
3031
3187
|
beat,
|
|
3188
|
+
cameraMatrix,
|
|
3189
|
+
cameraTo,
|
|
3032
3190
|
characterPreset,
|
|
3033
3191
|
collectImageSrcs,
|
|
3034
3192
|
compileComposition,
|
|
3035
3193
|
compileScene,
|
|
3036
3194
|
composeScene,
|
|
3037
3195
|
composition,
|
|
3196
|
+
conicGradient,
|
|
3038
3197
|
cursor,
|
|
3039
3198
|
cursorClick,
|
|
3040
3199
|
cursorDouble,
|
|
@@ -3054,8 +3213,10 @@ export {
|
|
|
3054
3213
|
ikReach,
|
|
3055
3214
|
image,
|
|
3056
3215
|
isColor,
|
|
3216
|
+
isGradient,
|
|
3057
3217
|
lerpValue,
|
|
3058
3218
|
line,
|
|
3219
|
+
linearGradient,
|
|
3059
3220
|
motionOp,
|
|
3060
3221
|
motionOpLabel,
|
|
3061
3222
|
motionPath,
|
|
@@ -3068,6 +3229,7 @@ export {
|
|
|
3068
3229
|
pathPoint,
|
|
3069
3230
|
pathTangentAngle,
|
|
3070
3231
|
poseTo,
|
|
3232
|
+
radialGradient,
|
|
3071
3233
|
rect,
|
|
3072
3234
|
resolveAudioPlan,
|
|
3073
3235
|
resolveCompositionAudioPlan,
|
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
|
|
@@ -330,12 +341,34 @@ ${problems.map((p) => ` - ${p}`).join("\n")}`);
|
|
|
330
341
|
function validateScene(ir) {
|
|
331
342
|
const problems = [];
|
|
332
343
|
const nodeById = /* @__PURE__ */ new Map();
|
|
344
|
+
const checkPaint = (where, value) => {
|
|
345
|
+
if (typeof value !== "object" || value === null) return;
|
|
346
|
+
const g = value;
|
|
347
|
+
if (g.kind !== "linear" && g.kind !== "radial" && g.kind !== "conic") {
|
|
348
|
+
problems.push(`${where}: a paint object must be a gradient with kind "linear" / "radial" / "conic"`);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (!Array.isArray(g.stops) || g.stops.length === 0) {
|
|
352
|
+
problems.push(`${where}: gradient "${g.kind}" needs at least one color stop`);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
g.stops.forEach((s, i) => {
|
|
356
|
+
const st = s;
|
|
357
|
+
if (typeof st?.color !== "string") problems.push(`${where}: gradient stop ${i} needs a color string`);
|
|
358
|
+
if (typeof st?.offset !== "number" || st.offset < 0 || st.offset > 1) {
|
|
359
|
+
problems.push(`${where}: gradient stop ${i} "offset" must be a number in 0..1`);
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
};
|
|
333
363
|
const collect = (nodes) => {
|
|
334
364
|
for (const node of nodes) {
|
|
335
365
|
if (nodeById.has(node.id)) {
|
|
336
366
|
problems.push(`duplicate node id "${node.id}" \u2014 every node id must be unique`);
|
|
337
367
|
}
|
|
338
368
|
nodeById.set(node.id, node);
|
|
369
|
+
const props = node.props;
|
|
370
|
+
checkPaint(`node "${node.id}" fill`, props.fill);
|
|
371
|
+
checkPaint(`node "${node.id}" stroke`, props.stroke);
|
|
339
372
|
if (node.type === "group") {
|
|
340
373
|
const clip = node.props.clip;
|
|
341
374
|
if (clip) {
|
|
@@ -352,6 +385,14 @@ function validateScene(ir) {
|
|
|
352
385
|
};
|
|
353
386
|
collect(ir.nodes);
|
|
354
387
|
const checkProps = (where, nodeId, props) => {
|
|
388
|
+
if (nodeId === "camera" && !nodeById.has("camera")) {
|
|
389
|
+
for (const key2 of Object.keys(props)) {
|
|
390
|
+
if (!CAMERA_PROPS.includes(key2)) {
|
|
391
|
+
problems.push(`${where}: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
355
396
|
const node = nodeById.get(nodeId);
|
|
356
397
|
if (!node) {
|
|
357
398
|
problems.push(
|
|
@@ -419,12 +460,15 @@ function validateScene(ir) {
|
|
|
419
460
|
break;
|
|
420
461
|
case "motionPath": {
|
|
421
462
|
const node = nodeById.get(tl.target);
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
463
|
+
const isSceneCamera = tl.target === "camera" && !node;
|
|
464
|
+
if (!isSceneCamera) {
|
|
465
|
+
if (!node) {
|
|
466
|
+
problems.push(
|
|
467
|
+
`${path3}: motionPath targets unknown node "${tl.target}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
|
|
468
|
+
);
|
|
469
|
+
} else if (node.type === "line") {
|
|
470
|
+
problems.push(`${path3}: motionPath cannot target a line (no x/y) \u2014 "${tl.target}"`);
|
|
471
|
+
}
|
|
428
472
|
}
|
|
429
473
|
if (tl.points.length < 1) problems.push(`${path3}: motionPath "${tl.target}" needs at least 1 point`);
|
|
430
474
|
if (tl.duration !== void 0 && tl.duration <= 0) {
|
|
@@ -469,6 +513,18 @@ function validateScene(ir) {
|
|
|
469
513
|
if (ir.duration !== void 0 && ir.duration <= 0) {
|
|
470
514
|
problems.push("scene duration must be > 0");
|
|
471
515
|
}
|
|
516
|
+
if (ir.camera) {
|
|
517
|
+
if (nodeById.has("camera")) {
|
|
518
|
+
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)`);
|
|
519
|
+
}
|
|
520
|
+
for (const [key2, value] of Object.entries(ir.camera)) {
|
|
521
|
+
if (!CAMERA_PROPS.includes(key2)) {
|
|
522
|
+
problems.push(`camera: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
|
|
523
|
+
} else if (typeof value !== "number") {
|
|
524
|
+
problems.push(`camera.${key2} must be a number`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
472
528
|
const SFX_NAMES = ["whoosh", "pop", "tick", "rise", "shimmer", "thud"];
|
|
473
529
|
for (const [i, cue] of (ir.audio?.cues ?? []).entries()) {
|
|
474
530
|
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/renderer-canvas.js
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
// ../renderer-canvas/src/index.ts
|
|
2
2
|
import { evaluate } from "@reframe/core";
|
|
3
|
+
function resolvePaint(ctx, paint, box) {
|
|
4
|
+
if (typeof paint === "string") return paint;
|
|
5
|
+
const { x, y, w, h } = box;
|
|
6
|
+
let g;
|
|
7
|
+
if (paint.kind === "linear") {
|
|
8
|
+
const a = (paint.angle ?? 0) * Math.PI / 180;
|
|
9
|
+
const dx = Math.cos(a);
|
|
10
|
+
const dy = Math.sin(a);
|
|
11
|
+
const cx = x + w / 2;
|
|
12
|
+
const cy = y + h / 2;
|
|
13
|
+
const half = Math.abs(dx) * (w / 2) + Math.abs(dy) * (h / 2);
|
|
14
|
+
g = ctx.createLinearGradient(cx - dx * half, cy - dy * half, cx + dx * half, cy + dy * half);
|
|
15
|
+
} else if (paint.kind === "radial") {
|
|
16
|
+
const cx = x + (paint.cx ?? 0.5) * w;
|
|
17
|
+
const cy = y + (paint.cy ?? 0.5) * h;
|
|
18
|
+
const r = Math.max((paint.r ?? 0.5) * Math.max(w, h), 1e-4);
|
|
19
|
+
g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
|
|
20
|
+
} else {
|
|
21
|
+
const cx = x + (paint.cx ?? 0.5) * w;
|
|
22
|
+
const cy = y + (paint.cy ?? 0.5) * h;
|
|
23
|
+
g = ctx.createConicGradient((paint.angle ?? 0) * Math.PI / 180, cx, cy);
|
|
24
|
+
}
|
|
25
|
+
for (const s of paint.stops) g.addColorStop(Math.max(0, Math.min(1, s.offset)), s.color);
|
|
26
|
+
return g;
|
|
27
|
+
}
|
|
3
28
|
function renderFrame(ctx, compiled, t, images) {
|
|
4
29
|
const { size, background } = compiled.ir;
|
|
5
30
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
@@ -32,6 +57,7 @@ function drawDisplayList(ctx, ops, images) {
|
|
|
32
57
|
ctx.globalAlpha = Math.max(0, Math.min(1, op.opacity));
|
|
33
58
|
switch (op.type) {
|
|
34
59
|
case "rect": {
|
|
60
|
+
const box = { x: op.offsetX, y: op.offsetY, w: op.width, h: op.height };
|
|
35
61
|
ctx.beginPath();
|
|
36
62
|
if (op.radius && op.radius > 0) {
|
|
37
63
|
ctx.roundRect(op.offsetX, op.offsetY, op.width, op.height, op.radius);
|
|
@@ -39,17 +65,18 @@ function drawDisplayList(ctx, ops, images) {
|
|
|
39
65
|
ctx.rect(op.offsetX, op.offsetY, op.width, op.height);
|
|
40
66
|
}
|
|
41
67
|
if (op.fill) {
|
|
42
|
-
ctx.fillStyle = op.fill;
|
|
68
|
+
ctx.fillStyle = resolvePaint(ctx, op.fill, box);
|
|
43
69
|
ctx.fill();
|
|
44
70
|
}
|
|
45
71
|
if (op.stroke) {
|
|
46
|
-
ctx.strokeStyle = op.stroke;
|
|
72
|
+
ctx.strokeStyle = resolvePaint(ctx, op.stroke, box);
|
|
47
73
|
ctx.lineWidth = op.strokeWidth ?? 1;
|
|
48
74
|
ctx.stroke();
|
|
49
75
|
}
|
|
50
76
|
break;
|
|
51
77
|
}
|
|
52
78
|
case "ellipse": {
|
|
79
|
+
const box = { x: op.offsetX, y: op.offsetY, w: op.width, h: op.height };
|
|
53
80
|
ctx.beginPath();
|
|
54
81
|
ctx.ellipse(
|
|
55
82
|
op.offsetX + op.width / 2,
|
|
@@ -61,11 +88,11 @@ function drawDisplayList(ctx, ops, images) {
|
|
|
61
88
|
Math.PI * 2
|
|
62
89
|
);
|
|
63
90
|
if (op.fill) {
|
|
64
|
-
ctx.fillStyle = op.fill;
|
|
91
|
+
ctx.fillStyle = resolvePaint(ctx, op.fill, box);
|
|
65
92
|
ctx.fill();
|
|
66
93
|
}
|
|
67
94
|
if (op.stroke) {
|
|
68
|
-
ctx.strokeStyle = op.stroke;
|
|
95
|
+
ctx.strokeStyle = resolvePaint(ctx, op.stroke, box);
|
|
69
96
|
ctx.lineWidth = op.strokeWidth ?? 1;
|
|
70
97
|
ctx.stroke();
|
|
71
98
|
}
|
|
@@ -102,12 +129,13 @@ function drawDisplayList(ctx, ops, images) {
|
|
|
102
129
|
}
|
|
103
130
|
case "path": {
|
|
104
131
|
const p = new Path2D(op.d);
|
|
132
|
+
const box = op.bbox ? { x: op.bbox[0], y: op.bbox[1], w: op.bbox[2], h: op.bbox[3] } : { x: 0, y: 0, w: 1, h: 1 };
|
|
105
133
|
if (op.fill) {
|
|
106
|
-
ctx.fillStyle = op.fill;
|
|
134
|
+
ctx.fillStyle = resolvePaint(ctx, op.fill, box);
|
|
107
135
|
ctx.fill(p);
|
|
108
136
|
}
|
|
109
137
|
if (op.stroke && (op.strokeWidth ?? 1) > 0) {
|
|
110
|
-
ctx.strokeStyle = op.stroke;
|
|
138
|
+
ctx.strokeStyle = resolvePaint(ctx, op.stroke, box);
|
|
111
139
|
ctx.lineWidth = op.strokeWidth ?? 1;
|
|
112
140
|
ctx.lineJoin = "round";
|
|
113
141
|
ctx.lineCap = "round";
|
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
|