reframe-video 0.6.19 → 0.6.20
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/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +9 -0
- package/dist/bin.js +33 -0
- package/dist/frame.js +1149 -0
- package/package.json +4 -2
- package/skills/reframe/SKILL.md +91 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "reframe",
|
|
3
|
+
"owner": {
|
|
4
|
+
"name": "Kiyeon Jeon",
|
|
5
|
+
"url": "https://github.com/kiyeonjeon21"
|
|
6
|
+
},
|
|
7
|
+
"plugins": [
|
|
8
|
+
{
|
|
9
|
+
"name": "reframe",
|
|
10
|
+
"source": "./",
|
|
11
|
+
"description": "Motion-graphics videos as addressable data — generate, tweak, regenerate without losing human edits."
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "reframe",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Create and iterate motion-graphics videos as addressable data: deterministic mp4 renders, human edits that survive AI regeneration, label-anchored audio, data-driven batch rendering.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Kiyeon Jeon",
|
|
7
|
+
"url": "https://github.com/kiyeonjeon21"
|
|
8
|
+
}
|
|
9
|
+
}
|
package/dist/bin.js
CHANGED
|
@@ -2799,6 +2799,7 @@ var RENDER_CLI = PACKAGED ? join9(ROOT2, "dist", "cli.js") : join9(ROOT2, "packa
|
|
|
2799
2799
|
var LABELS = PACKAGED ? join9(ROOT2, "dist", "labels.js") : join9(ROOT2, "packages", "render-cli", "src", "labels.ts");
|
|
2800
2800
|
var COMPILE = PACKAGED ? join9(ROOT2, "dist", "compile.js") : join9(ROOT2, "packages", "render-cli", "src", "compile.ts");
|
|
2801
2801
|
var DIFF = PACKAGED ? join9(ROOT2, "dist", "diff.js") : join9(ROOT2, "packages", "render-cli", "src", "diff.ts");
|
|
2802
|
+
var FRAME = PACKAGED ? join9(ROOT2, "dist", "frame.js") : join9(ROOT2, "packages", "render-cli", "src", "frame.ts");
|
|
2802
2803
|
var PLAYER = PACKAGED ? join9(ROOT2, "dist", "player.js") : join9(ROOT2, "packages", "render-cli", "src", "player.ts");
|
|
2803
2804
|
var ANALYZE = PACKAGED ? join9(ROOT2, "dist", "analyze.js") : join9(ROOT2, "benchmark", "harness", "motion", "analyze.ts");
|
|
2804
2805
|
var TRACE = PACKAGED ? join9(ROOT2, "dist", "trace-cli.js") : join9(ROOT2, "benchmark", "harness", "motion", "trace-cli.ts");
|
|
@@ -2818,6 +2819,8 @@ usage:
|
|
|
2818
2819
|
${CMD} labels <scene.ts|.json> print the event clock (label \u2192 exact seconds; for sound design / timing)
|
|
2819
2820
|
${CMD} compile <scene.ts|.json> [-o out.json] [--stdin] [--code "<src>"] [--json]
|
|
2820
2821
|
bundle + validate a scene to SceneIR JSON, no render (fast; no ffmpeg/chromium)
|
|
2822
|
+
${CMD} frame <scene.ts|.json> [--t <sec>] [-o out.png] render ONE frame at time t to a PNG (no mp4; for a render-and-look loop)
|
|
2823
|
+
${CMD} skill [--path] print the authoring skill (SKILL.md) for an agent; --path prints the plugin dir to load
|
|
2821
2824
|
${CMD} motion <mp4|framesDir> motion-profile a rendered clip
|
|
2822
2825
|
${CMD} trace <ref.mp4> [--apply scene.ts] extract a video's motion structure \u2192 MotionSketch / timeline
|
|
2823
2826
|
${CMD} diff <ref-image> [<scene.ts>] [--t S] [--mode side|blend|diff|grid] compare/measure a render against a reference image
|
|
@@ -2982,6 +2985,26 @@ ${USAGE}`);
|
|
|
2982
2985
|
await (PACKAGED ? run2(process.execPath, [COMPILE, ...passed]) : run2("npx", ["tsx", COMPILE, ...passed]))
|
|
2983
2986
|
);
|
|
2984
2987
|
}
|
|
2988
|
+
case "frame": {
|
|
2989
|
+
const input = rest[0];
|
|
2990
|
+
if (!input || input.startsWith("-")) fail(`frame needs a scene file
|
|
2991
|
+
|
|
2992
|
+
${USAGE}`);
|
|
2993
|
+
const inputPath = userPath(input);
|
|
2994
|
+
if (!existsSync6(inputPath)) fail(`no such file: ${inputPath}`);
|
|
2995
|
+
const args = rest.slice(1);
|
|
2996
|
+
const outBase = PACKAGED ? join9(USER_CWD, "out") : join9(ROOT2, "out");
|
|
2997
|
+
const stem = `${basename(input).replace(/\.[^.]+$/, "")}.png`;
|
|
2998
|
+
let outArgs = args;
|
|
2999
|
+
if (args.indexOf("-o") === -1) {
|
|
3000
|
+
await mkdir4(outBase, { recursive: true });
|
|
3001
|
+
outArgs = [...args, "-o", join9(outBase, stem)];
|
|
3002
|
+
}
|
|
3003
|
+
outArgs = outArgs.map((a, i) => outArgs[i - 1] === "-o" ? userPath(a) : a);
|
|
3004
|
+
process.exit(
|
|
3005
|
+
await (PACKAGED ? run2(process.execPath, [FRAME, inputPath, ...outArgs]) : run2("npx", ["tsx", FRAME, inputPath, ...outArgs]))
|
|
3006
|
+
);
|
|
3007
|
+
}
|
|
2985
3008
|
case "player": {
|
|
2986
3009
|
const input = rest[0];
|
|
2987
3010
|
if (!input || input.startsWith("-")) fail(`player needs a scene file
|
|
@@ -3170,6 +3193,16 @@ ${results.length - failed} rendered (${orphaned} with orphans), ${failed} failed
|
|
|
3170
3193
|
process.stdout.write(await readFile7(file, "utf8"));
|
|
3171
3194
|
return;
|
|
3172
3195
|
}
|
|
3196
|
+
case "skill": {
|
|
3197
|
+
if (rest.includes("--path")) {
|
|
3198
|
+
process.stdout.write(`${ROOT2}
|
|
3199
|
+
`);
|
|
3200
|
+
return;
|
|
3201
|
+
}
|
|
3202
|
+
const { readFile: readFile7 } = await import("node:fs/promises");
|
|
3203
|
+
process.stdout.write(await readFile7(join9(ROOT2, "skills", "reframe", "SKILL.md"), "utf8"));
|
|
3204
|
+
return;
|
|
3205
|
+
}
|
|
3173
3206
|
case "demo":
|
|
3174
3207
|
if (PACKAGED) {
|
|
3175
3208
|
fail(
|
package/dist/frame.js
ADDED
|
@@ -0,0 +1,1149 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
|
|
3
|
+
// ../render-cli/src/frame.ts
|
|
4
|
+
import { writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname as dirname4, resolve as resolve4 } from "node:path";
|
|
6
|
+
|
|
7
|
+
// ../render-cli/src/frameLoop.ts
|
|
8
|
+
import { join as join3, dirname as dirname2 } from "node:path";
|
|
9
|
+
import { fileURLToPath as fileURLToPath2, pathToFileURL } from "node:url";
|
|
10
|
+
import { build } from "esbuild";
|
|
11
|
+
import { chromium } from "playwright";
|
|
12
|
+
|
|
13
|
+
// ../render-cli/src/fonts.ts
|
|
14
|
+
import { readFile } from "node:fs/promises";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
var FONTS_DIR = true ? join(dirname(fileURLToPath(import.meta.url)), "..", "assets", "fonts") : join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "assets", "fonts");
|
|
18
|
+
var WEIGHTS = [400, 700, 800];
|
|
19
|
+
var cssCache = null;
|
|
20
|
+
async function fontFaceCss() {
|
|
21
|
+
if (cssCache) return cssCache;
|
|
22
|
+
const rules = await Promise.all(
|
|
23
|
+
WEIGHTS.map(async (weight) => {
|
|
24
|
+
const data = await readFile(join(FONTS_DIR, `inter-${weight}.woff2`));
|
|
25
|
+
return `@font-face {
|
|
26
|
+
font-family: "Inter";
|
|
27
|
+
font-style: normal;
|
|
28
|
+
font-weight: ${weight};
|
|
29
|
+
src: url(data:font/woff2;base64,${data.toString("base64")}) format("woff2");
|
|
30
|
+
}`;
|
|
31
|
+
})
|
|
32
|
+
);
|
|
33
|
+
cssCache = rules.join("\n");
|
|
34
|
+
return cssCache;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ../render-cli/src/images.ts
|
|
38
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
39
|
+
import { existsSync } from "node:fs";
|
|
40
|
+
import { extname, isAbsolute, resolve } from "node:path";
|
|
41
|
+
|
|
42
|
+
// ../core/src/ir.ts
|
|
43
|
+
var DEFAULT_TO_DURATION = 0.5;
|
|
44
|
+
var DEFAULT_TWEEN_DURATION = 0.5;
|
|
45
|
+
var DEFAULT_MOTIONPATH_DURATION = 1;
|
|
46
|
+
var DEFAULT_STILL_DURATION = 1;
|
|
47
|
+
|
|
48
|
+
// ../core/src/path.ts
|
|
49
|
+
function locate(segCount, u) {
|
|
50
|
+
if (segCount <= 0) return { i: 0, t: 0 };
|
|
51
|
+
const clamped = Math.max(0, Math.min(1, u));
|
|
52
|
+
const scaled = clamped * segCount;
|
|
53
|
+
let i = Math.floor(scaled);
|
|
54
|
+
if (i >= segCount) i = segCount - 1;
|
|
55
|
+
return { i, t: scaled - i };
|
|
56
|
+
}
|
|
57
|
+
function controls(points, closed, i) {
|
|
58
|
+
const n = points.length;
|
|
59
|
+
const at = (k) => {
|
|
60
|
+
if (closed) return points[(k % n + n) % n];
|
|
61
|
+
return points[Math.max(0, Math.min(n - 1, k))];
|
|
62
|
+
};
|
|
63
|
+
return [at(i - 1), at(i), at(i + 1), at(i + 2)];
|
|
64
|
+
}
|
|
65
|
+
function segCountOf(points, closed) {
|
|
66
|
+
const n = points.length;
|
|
67
|
+
if (n < 2) return 0;
|
|
68
|
+
return closed ? n : n - 1;
|
|
69
|
+
}
|
|
70
|
+
function pathPoint(points, closed, u, curviness = 1) {
|
|
71
|
+
const n = points.length;
|
|
72
|
+
if (n === 0) return [0, 0];
|
|
73
|
+
if (n === 1) return [points[0][0], points[0][1]];
|
|
74
|
+
const segs = segCountOf(points, closed);
|
|
75
|
+
const { i, t } = locate(segs, u);
|
|
76
|
+
const [p0, p1, p2, p3] = controls(points, closed, i);
|
|
77
|
+
const t2 = t * t;
|
|
78
|
+
const t3 = t2 * t;
|
|
79
|
+
if (curviness === 1) {
|
|
80
|
+
const f = (a, b, c, d) => 0.5 * (2 * b + (-a + c) * t + (2 * a - 5 * b + 4 * c - d) * t2 + (-a + 3 * b - 3 * c + d) * t3);
|
|
81
|
+
return [f(p0[0], p1[0], p2[0], p3[0]), f(p0[1], p1[1], p2[1], p3[1])];
|
|
82
|
+
}
|
|
83
|
+
const h00 = 2 * t3 - 3 * t2 + 1;
|
|
84
|
+
const h10 = t3 - 2 * t2 + t;
|
|
85
|
+
const h01 = -2 * t3 + 3 * t2;
|
|
86
|
+
const h11 = t3 - t2;
|
|
87
|
+
const k = curviness * 0.5;
|
|
88
|
+
const H = (a, b, c, d) => h00 * b + h10 * k * (c - a) + h01 * c + h11 * k * (d - b);
|
|
89
|
+
return [H(p0[0], p1[0], p2[0], p3[0]), H(p0[1], p1[1], p2[1], p3[1])];
|
|
90
|
+
}
|
|
91
|
+
function pathTangentAngle(points, closed, u, curviness = 1) {
|
|
92
|
+
const n = points.length;
|
|
93
|
+
if (n < 2) return 0;
|
|
94
|
+
const segs = segCountOf(points, closed);
|
|
95
|
+
const { i, t } = locate(segs, u);
|
|
96
|
+
const [p0, p1, p2, p3] = controls(points, closed, i);
|
|
97
|
+
const t2 = t * t;
|
|
98
|
+
let dx;
|
|
99
|
+
let dy;
|
|
100
|
+
if (curviness === 1) {
|
|
101
|
+
const d = (a, b, c, e) => 0.5 * (-a + c + 2 * (2 * a - 5 * b + 4 * c - e) * t + 3 * (-a + 3 * b - 3 * c + e) * t2);
|
|
102
|
+
dx = d(p0[0], p1[0], p2[0], p3[0]);
|
|
103
|
+
dy = d(p0[1], p1[1], p2[1], p3[1]);
|
|
104
|
+
} else {
|
|
105
|
+
const g00 = 6 * t2 - 6 * t;
|
|
106
|
+
const g10 = 3 * t2 - 4 * t + 1;
|
|
107
|
+
const g01 = -6 * t2 + 6 * t;
|
|
108
|
+
const g11 = 3 * t2 - 2 * t;
|
|
109
|
+
const k = curviness * 0.5;
|
|
110
|
+
const D = (a, b, c, e) => g00 * b + g10 * k * (c - a) + g01 * c + g11 * k * (e - b);
|
|
111
|
+
dx = D(p0[0], p1[0], p2[0], p3[0]);
|
|
112
|
+
dy = D(p0[1], p1[1], p2[1], p3[1]);
|
|
113
|
+
}
|
|
114
|
+
if (dx === 0 && dy === 0) return 0;
|
|
115
|
+
return Math.atan2(dy, dx) * 180 / Math.PI;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ../core/src/compile.ts
|
|
119
|
+
var key = (target, prop) => `${target}.${prop}`;
|
|
120
|
+
function scaleTimeline(tl, k) {
|
|
121
|
+
switch (tl.kind) {
|
|
122
|
+
case "seq":
|
|
123
|
+
case "par":
|
|
124
|
+
return { ...tl, children: tl.children.map((c) => scaleTimeline(c, k)) };
|
|
125
|
+
case "stagger":
|
|
126
|
+
return { ...tl, interval: tl.interval * k, children: tl.children.map((c) => scaleTimeline(c, k)) };
|
|
127
|
+
case "wait":
|
|
128
|
+
return { ...tl, duration: tl.duration * k };
|
|
129
|
+
case "tween":
|
|
130
|
+
return { ...tl, duration: (tl.duration ?? DEFAULT_TWEEN_DURATION) * k };
|
|
131
|
+
case "motionPath":
|
|
132
|
+
return { ...tl, duration: (tl.duration ?? DEFAULT_MOTIONPATH_DURATION) * k };
|
|
133
|
+
case "to":
|
|
134
|
+
return {
|
|
135
|
+
...tl,
|
|
136
|
+
duration: (tl.duration ?? DEFAULT_TO_DURATION) * k,
|
|
137
|
+
...tl.stagger !== void 0 && { stagger: tl.stagger * k }
|
|
138
|
+
};
|
|
139
|
+
case "beat":
|
|
140
|
+
return {
|
|
141
|
+
...tl,
|
|
142
|
+
children: tl.children.map((c) => scaleTimeline(c, k)),
|
|
143
|
+
...tl.gap !== void 0 && { gap: tl.gap * k }
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function orderBeats(children) {
|
|
148
|
+
return children.map((c, i) => ({ c, i, key: c.kind === "beat" && c.order !== void 0 ? c.order : i })).sort((a, b) => a.key - b.key || a.i - b.i).map((x) => x.c);
|
|
149
|
+
}
|
|
150
|
+
function compileScene(ir) {
|
|
151
|
+
const nodeById = /* @__PURE__ */ new Map();
|
|
152
|
+
const nodeOrder = [];
|
|
153
|
+
const collect = (nodes) => {
|
|
154
|
+
for (const node of nodes) {
|
|
155
|
+
nodeById.set(node.id, node);
|
|
156
|
+
nodeOrder.push(node.id);
|
|
157
|
+
if (node.type === "group") collect(node.children);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
collect(ir.nodes);
|
|
161
|
+
const initialValues = /* @__PURE__ */ new Map();
|
|
162
|
+
for (const [id, node] of nodeById) {
|
|
163
|
+
for (const [prop, value] of Object.entries(node.props)) {
|
|
164
|
+
if (typeof value === "number" || typeof value === "string") {
|
|
165
|
+
initialValues.set(key(id, prop), value);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (ir.initial !== void 0) {
|
|
170
|
+
const override = ir.states?.[ir.initial] ?? {};
|
|
171
|
+
for (const [id, props] of Object.entries(override)) {
|
|
172
|
+
for (const [prop, value] of Object.entries(props)) {
|
|
173
|
+
initialValues.set(key(id, prop), value);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const cameraIsNode = nodeById.has("camera");
|
|
178
|
+
if (!cameraIsNode) {
|
|
179
|
+
const cam = ir.camera ?? {};
|
|
180
|
+
initialValues.set(key("camera", "x"), cam.x ?? ir.size.width / 2);
|
|
181
|
+
initialValues.set(key("camera", "y"), cam.y ?? ir.size.height / 2);
|
|
182
|
+
initialValues.set(key("camera", "zoom"), cam.zoom ?? 1);
|
|
183
|
+
initialValues.set(key("camera", "rotation"), cam.rotation ?? 0);
|
|
184
|
+
if (cam.perspective !== void 0) initialValues.set(key("camera", "perspective"), cam.perspective);
|
|
185
|
+
if (cam.focus !== void 0) initialValues.set(key("camera", "focus"), cam.focus);
|
|
186
|
+
if (cam.aperture !== void 0) initialValues.set(key("camera", "aperture"), cam.aperture);
|
|
187
|
+
}
|
|
188
|
+
const segments = /* @__PURE__ */ new Map();
|
|
189
|
+
const motionPaths = /* @__PURE__ */ new Map();
|
|
190
|
+
const current = new Map(initialValues);
|
|
191
|
+
const pushSegment = (seg) => {
|
|
192
|
+
const k = key(seg.target, seg.prop);
|
|
193
|
+
let list = segments.get(k);
|
|
194
|
+
if (!list) segments.set(k, list = []);
|
|
195
|
+
list.push(seg);
|
|
196
|
+
current.set(k, seg.to);
|
|
197
|
+
};
|
|
198
|
+
const currentValue = (target, prop) => {
|
|
199
|
+
const v = current.get(key(target, prop));
|
|
200
|
+
if (v !== void 0) return v;
|
|
201
|
+
if (prop === "opacity" || prop === "scale" || prop === "progress" || prop === "scaleX" || prop === "scaleY") return 1;
|
|
202
|
+
if (prop === "rotation" || prop === "skewX" || prop === "skewY") return 0;
|
|
203
|
+
throw new Error(`cannot animate "${prop}" of "${target}": no base value to start from`);
|
|
204
|
+
};
|
|
205
|
+
const labelTimes = /* @__PURE__ */ new Map();
|
|
206
|
+
const beatTimes = /* @__PURE__ */ new Map();
|
|
207
|
+
const durationOf = (tl, start) => {
|
|
208
|
+
switch (tl.kind) {
|
|
209
|
+
case "seq": {
|
|
210
|
+
let t = start;
|
|
211
|
+
for (const child of orderBeats(tl.children)) t = durationOf(child, t);
|
|
212
|
+
return t;
|
|
213
|
+
}
|
|
214
|
+
case "par": {
|
|
215
|
+
let end = start;
|
|
216
|
+
for (const child of tl.children) end = Math.max(end, durationOf(child, start));
|
|
217
|
+
return end;
|
|
218
|
+
}
|
|
219
|
+
case "stagger": {
|
|
220
|
+
let end = start;
|
|
221
|
+
tl.children.forEach((child, i) => {
|
|
222
|
+
end = Math.max(end, durationOf(child, start + i * tl.interval));
|
|
223
|
+
});
|
|
224
|
+
return end;
|
|
225
|
+
}
|
|
226
|
+
case "wait":
|
|
227
|
+
return start + tl.duration;
|
|
228
|
+
case "tween":
|
|
229
|
+
return start + (tl.duration ?? DEFAULT_TWEEN_DURATION);
|
|
230
|
+
case "motionPath":
|
|
231
|
+
return start + (tl.duration ?? DEFAULT_MOTIONPATH_DURATION);
|
|
232
|
+
case "to": {
|
|
233
|
+
const override = ir.states?.[tl.state] ?? {};
|
|
234
|
+
const duration = tl.duration ?? DEFAULT_TO_DURATION;
|
|
235
|
+
const si = tl.stagger ?? 0;
|
|
236
|
+
const targets = nodeOrder.filter(
|
|
237
|
+
(id) => id in override && (tl.filter === void 0 || tl.filter.includes(id))
|
|
238
|
+
);
|
|
239
|
+
return start + duration + Math.max(0, targets.length - 1) * si;
|
|
240
|
+
}
|
|
241
|
+
case "beat": {
|
|
242
|
+
const grouping = { kind: tl.parallel ? "par" : "seq", children: tl.children };
|
|
243
|
+
const natural = durationOf(grouping, 0);
|
|
244
|
+
const k = tl.scale ?? (tl.duration !== void 0 ? tl.duration / Math.max(1e-9, natural) : 1);
|
|
245
|
+
const beatStart = tl.at ?? start + (tl.gap ?? 0);
|
|
246
|
+
return beatStart + k * natural;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
const walk = (tl, start) => {
|
|
251
|
+
const end = walkInner(tl, start);
|
|
252
|
+
if ("label" in tl && tl.label !== void 0) labelTimes.set(tl.label, { t0: start, t1: end });
|
|
253
|
+
return end;
|
|
254
|
+
};
|
|
255
|
+
const walkInner = (tl, start) => {
|
|
256
|
+
switch (tl.kind) {
|
|
257
|
+
case "seq": {
|
|
258
|
+
let t = start;
|
|
259
|
+
for (const child of orderBeats(tl.children)) t = walk(child, t);
|
|
260
|
+
return t;
|
|
261
|
+
}
|
|
262
|
+
case "beat": {
|
|
263
|
+
const grouping = { kind: tl.parallel ? "par" : "seq", children: tl.children };
|
|
264
|
+
const k = tl.scale ?? (tl.duration !== void 0 ? tl.duration / Math.max(1e-9, durationOf(grouping, 0)) : 1);
|
|
265
|
+
const inner = k === 1 ? grouping : scaleTimeline(grouping, k);
|
|
266
|
+
const beatStart = tl.at ?? start + (tl.gap ?? 0);
|
|
267
|
+
const end = walk(inner, beatStart);
|
|
268
|
+
beatTimes.set(tl.name, { t0: beatStart, t1: end });
|
|
269
|
+
labelTimes.set(tl.name, { t0: beatStart, t1: end });
|
|
270
|
+
return end;
|
|
271
|
+
}
|
|
272
|
+
case "par": {
|
|
273
|
+
let end = start;
|
|
274
|
+
for (const child of tl.children) end = Math.max(end, walk(child, start));
|
|
275
|
+
return end;
|
|
276
|
+
}
|
|
277
|
+
case "stagger": {
|
|
278
|
+
let end = start;
|
|
279
|
+
tl.children.forEach((child, i) => {
|
|
280
|
+
end = Math.max(end, walk(child, start + i * tl.interval));
|
|
281
|
+
});
|
|
282
|
+
return end;
|
|
283
|
+
}
|
|
284
|
+
case "wait":
|
|
285
|
+
return start + tl.duration;
|
|
286
|
+
case "tween": {
|
|
287
|
+
const duration = tl.duration ?? DEFAULT_TWEEN_DURATION;
|
|
288
|
+
for (const [prop, toValue] of Object.entries(tl.props)) {
|
|
289
|
+
pushSegment({
|
|
290
|
+
target: tl.target,
|
|
291
|
+
prop,
|
|
292
|
+
t0: start,
|
|
293
|
+
t1: start + duration,
|
|
294
|
+
from: currentValue(tl.target, prop),
|
|
295
|
+
to: toValue,
|
|
296
|
+
...tl.ease !== void 0 && { ease: tl.ease }
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
return start + duration;
|
|
300
|
+
}
|
|
301
|
+
case "motionPath": {
|
|
302
|
+
const duration = tl.duration ?? DEFAULT_MOTIONPATH_DURATION;
|
|
303
|
+
const points = tl.points;
|
|
304
|
+
const closed = tl.closed ?? false;
|
|
305
|
+
const curviness = tl.curviness ?? 1;
|
|
306
|
+
const autoRotate = tl.autoRotate ?? false;
|
|
307
|
+
const rotateOffset = tl.rotateOffset ?? 0;
|
|
308
|
+
let list = motionPaths.get(tl.target);
|
|
309
|
+
if (!list) motionPaths.set(tl.target, list = []);
|
|
310
|
+
list.push({ t0: start, t1: start + duration, points, closed, curviness, autoRotate, rotateOffset, ...tl.ease !== void 0 && { ease: tl.ease } });
|
|
311
|
+
if (points.length > 0) {
|
|
312
|
+
const [ex, ey] = pathPoint(points, closed, 1, curviness);
|
|
313
|
+
current.set(key(tl.target, "x"), ex);
|
|
314
|
+
current.set(key(tl.target, "y"), ey);
|
|
315
|
+
if (autoRotate) current.set(key(tl.target, "rotation"), pathTangentAngle(points, closed, 1, curviness) + rotateOffset);
|
|
316
|
+
}
|
|
317
|
+
return start + duration;
|
|
318
|
+
}
|
|
319
|
+
case "to": {
|
|
320
|
+
const override = ir.states?.[tl.state] ?? {};
|
|
321
|
+
const duration = tl.duration ?? DEFAULT_TO_DURATION;
|
|
322
|
+
const staggerInterval = tl.stagger ?? 0;
|
|
323
|
+
const targets = nodeOrder.filter(
|
|
324
|
+
(id) => id in override && (tl.filter === void 0 || tl.filter.includes(id))
|
|
325
|
+
);
|
|
326
|
+
targets.forEach((id, i) => {
|
|
327
|
+
const t0 = start + i * staggerInterval;
|
|
328
|
+
for (const [prop, toValue] of Object.entries(override[id])) {
|
|
329
|
+
pushSegment({
|
|
330
|
+
target: id,
|
|
331
|
+
prop,
|
|
332
|
+
t0,
|
|
333
|
+
t1: t0 + duration,
|
|
334
|
+
from: currentValue(id, prop),
|
|
335
|
+
to: toValue,
|
|
336
|
+
...tl.ease !== void 0 && { ease: tl.ease }
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
const last = Math.max(0, targets.length - 1);
|
|
341
|
+
return start + duration + last * staggerInterval;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
const inferredEnd = (ir.timeline ? walk(ir.timeline, 0) : 0) || 0;
|
|
346
|
+
for (const list of segments.values()) list.sort((a, b) => a.t0 - b.t0);
|
|
347
|
+
for (const list of motionPaths.values()) list.sort((a, b) => a.t0 - b.t0);
|
|
348
|
+
const hasCamera = !cameraIsNode && (ir.camera !== void 0 || motionPaths.has("camera") || [...segments.keys()].some((k) => k.startsWith("camera.")));
|
|
349
|
+
const hasPerspective = !cameraIsNode && (ir.camera?.perspective !== void 0 || segments.has("camera.perspective"));
|
|
350
|
+
const zSort = !cameraIsNode && ir.camera?.zSort === true && hasPerspective;
|
|
351
|
+
return {
|
|
352
|
+
ir,
|
|
353
|
+
duration: ir.duration ?? (inferredEnd > 0 ? inferredEnd : DEFAULT_STILL_DURATION),
|
|
354
|
+
segments,
|
|
355
|
+
motionPaths,
|
|
356
|
+
initialValues,
|
|
357
|
+
nodeById,
|
|
358
|
+
nodeOrder,
|
|
359
|
+
labelTimes,
|
|
360
|
+
beatTimes,
|
|
361
|
+
hasCamera,
|
|
362
|
+
hasPerspective,
|
|
363
|
+
zSort
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ../core/src/validate.ts
|
|
368
|
+
var FX_PROPS = ["blur", "shadowColor", "shadowBlur", "shadowX", "shadowY", "blend"];
|
|
369
|
+
var BLEND_MODES = /* @__PURE__ */ new Set([
|
|
370
|
+
"normal",
|
|
371
|
+
"multiply",
|
|
372
|
+
"screen",
|
|
373
|
+
"overlay",
|
|
374
|
+
"lighten",
|
|
375
|
+
"darken",
|
|
376
|
+
"add",
|
|
377
|
+
"color-dodge",
|
|
378
|
+
"soft-light",
|
|
379
|
+
"hard-light",
|
|
380
|
+
"difference"
|
|
381
|
+
]);
|
|
382
|
+
var IMAGE_FITS = /* @__PURE__ */ new Set(["fill", "cover"]);
|
|
383
|
+
var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "z", "rotateX", "rotateY", "anchor", "fixed", ...FX_PROPS];
|
|
384
|
+
var CAMERA_PROPS = ["x", "y", "zoom", "rotation", "perspective", "focus", "aperture"];
|
|
385
|
+
var PROPS_BY_TYPE = {
|
|
386
|
+
rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
|
|
387
|
+
ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
|
|
388
|
+
line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
|
|
389
|
+
text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "prefix", "suffix", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
|
|
390
|
+
image: [...COMMON_PROPS, "src", "width", "height", "fit"],
|
|
391
|
+
video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart", "volume", "fadeIn", "pan"],
|
|
392
|
+
path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
|
|
393
|
+
group: COMMON_PROPS
|
|
394
|
+
};
|
|
395
|
+
var SceneValidationError = class extends Error {
|
|
396
|
+
constructor(problems) {
|
|
397
|
+
super(`Scene validation failed:
|
|
398
|
+
${problems.map((p) => ` - ${p}`).join("\n")}`);
|
|
399
|
+
this.problems = problems;
|
|
400
|
+
this.name = "SceneValidationError";
|
|
401
|
+
}
|
|
402
|
+
problems;
|
|
403
|
+
};
|
|
404
|
+
function validateScene(ir) {
|
|
405
|
+
const problems = [];
|
|
406
|
+
const nodeById = /* @__PURE__ */ new Map();
|
|
407
|
+
const checkPaint = (where, value) => {
|
|
408
|
+
if (typeof value !== "object" || value === null) return;
|
|
409
|
+
const g = value;
|
|
410
|
+
if (g.kind !== "linear" && g.kind !== "radial" && g.kind !== "conic") {
|
|
411
|
+
problems.push(`${where}: a paint object must be a gradient with kind "linear" / "radial" / "conic"`);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (!Array.isArray(g.stops) || g.stops.length === 0) {
|
|
415
|
+
problems.push(`${where}: gradient "${g.kind}" needs at least one color stop`);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
g.stops.forEach((s, i) => {
|
|
419
|
+
const st = s;
|
|
420
|
+
if (typeof st?.color !== "string") problems.push(`${where}: gradient stop ${i} needs a color string`);
|
|
421
|
+
if (typeof st?.offset !== "number" || st.offset < 0 || st.offset > 1) {
|
|
422
|
+
problems.push(`${where}: gradient stop ${i} "offset" must be a number in 0..1`);
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
};
|
|
426
|
+
const collect = (nodes) => {
|
|
427
|
+
for (const node of nodes) {
|
|
428
|
+
if (nodeById.has(node.id)) {
|
|
429
|
+
problems.push(`duplicate node id "${node.id}" \u2014 every node id must be unique`);
|
|
430
|
+
}
|
|
431
|
+
nodeById.set(node.id, node);
|
|
432
|
+
const props = node.props;
|
|
433
|
+
checkPaint(`node "${node.id}" fill`, props.fill);
|
|
434
|
+
checkPaint(`node "${node.id}" stroke`, props.stroke);
|
|
435
|
+
if (typeof props.blur === "number" && props.blur < 0) problems.push(`node "${node.id}": blur must be >= 0`);
|
|
436
|
+
if (typeof props.shadowBlur === "number" && props.shadowBlur < 0) problems.push(`node "${node.id}": shadowBlur must be >= 0`);
|
|
437
|
+
if (typeof props.blend === "string" && !BLEND_MODES.has(props.blend)) problems.push(`node "${node.id}": unknown blend "${props.blend}" \u2014 use ${[...BLEND_MODES].join(", ")}`);
|
|
438
|
+
if (typeof props.fit === "string" && !IMAGE_FITS.has(props.fit)) problems.push(`node "${node.id}": unknown fit "${props.fit}" \u2014 use ${[...IMAGE_FITS].join(", ")}`);
|
|
439
|
+
if (node.type === "group") {
|
|
440
|
+
const clip = node.props.clip;
|
|
441
|
+
if (clip) {
|
|
442
|
+
if (clip.kind !== "rect" && clip.kind !== "ellipse") {
|
|
443
|
+
problems.push(`group "${node.id}" clip: unknown kind "${clip.kind}" \u2014 use "rect" or "ellipse"`);
|
|
444
|
+
}
|
|
445
|
+
if (!(clip.width > 0) || !(clip.height > 0)) {
|
|
446
|
+
problems.push(`group "${node.id}" clip: width and height must be > 0`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const matte = node.props.matte;
|
|
450
|
+
if (matte !== void 0) {
|
|
451
|
+
if (matte !== "alpha" && matte !== "luma") {
|
|
452
|
+
problems.push(`group "${node.id}" matte: unknown mode "${String(matte)}" \u2014 use "alpha" or "luma"`);
|
|
453
|
+
} else if (node.children.length < 2) {
|
|
454
|
+
problems.push(`group "${node.id}" matte: needs \u22652 children (first masks the rest)`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
collect(node.children);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
collect(ir.nodes);
|
|
462
|
+
const checkProps = (where, nodeId, props) => {
|
|
463
|
+
if (nodeId === "camera" && !nodeById.has("camera")) {
|
|
464
|
+
for (const key2 of Object.keys(props)) {
|
|
465
|
+
if (!CAMERA_PROPS.includes(key2)) {
|
|
466
|
+
problems.push(`${where}: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const node = nodeById.get(nodeId);
|
|
472
|
+
if (!node) {
|
|
473
|
+
problems.push(
|
|
474
|
+
`${where} targets unknown node "${nodeId}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
|
|
475
|
+
);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const allowed = PROPS_BY_TYPE[node.type];
|
|
479
|
+
for (const key2 of Object.keys(props)) {
|
|
480
|
+
if (!allowed.includes(key2)) {
|
|
481
|
+
problems.push(
|
|
482
|
+
`${where}: "${key2}" is not a prop of ${node.type} "${nodeId}" \u2014 valid props: ${allowed.join(", ")}`
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
const states = ir.states ?? {};
|
|
488
|
+
for (const [stateName, overrides] of Object.entries(states)) {
|
|
489
|
+
for (const [nodeId, props] of Object.entries(overrides)) {
|
|
490
|
+
checkProps(`state "${stateName}"`, nodeId, props);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (ir.initial !== void 0 && !(ir.initial in states)) {
|
|
494
|
+
problems.push(
|
|
495
|
+
`initial state "${ir.initial}" is not defined \u2014 defined states: ${Object.keys(states).join(", ") || "(none)"}`
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
const labels = /* @__PURE__ */ new Set();
|
|
499
|
+
const checkTimeline = (tl, path2) => {
|
|
500
|
+
if ("label" in tl && tl.label !== void 0) {
|
|
501
|
+
if (labels.has(tl.label)) {
|
|
502
|
+
problems.push(
|
|
503
|
+
`${path2}: duplicate timeline label "${tl.label}" \u2014 labels are overlay addresses and must be unique`
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
labels.add(tl.label);
|
|
507
|
+
}
|
|
508
|
+
switch (tl.kind) {
|
|
509
|
+
case "seq":
|
|
510
|
+
case "par":
|
|
511
|
+
tl.children.forEach((c, i) => checkTimeline(c, `${path2}.${tl.kind}[${i}]`));
|
|
512
|
+
break;
|
|
513
|
+
case "stagger":
|
|
514
|
+
if (tl.interval < 0) problems.push(`${path2}: stagger interval must be >= 0`);
|
|
515
|
+
tl.children.forEach((c, i) => checkTimeline(c, `${path2}.stagger[${i}]`));
|
|
516
|
+
break;
|
|
517
|
+
case "to":
|
|
518
|
+
if (!(tl.state in states)) {
|
|
519
|
+
problems.push(
|
|
520
|
+
`${path2}: to("${tl.state}") references an undefined state \u2014 defined states: ${Object.keys(states).join(", ") || "(none)"}`
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
if (tl.duration !== void 0 && tl.duration <= 0) {
|
|
524
|
+
problems.push(`${path2}: to("${tl.state}") duration must be > 0`);
|
|
525
|
+
}
|
|
526
|
+
for (const id of tl.filter ?? []) {
|
|
527
|
+
if (!nodeById.has(id)) problems.push(`${path2}: filter contains unknown node "${id}"`);
|
|
528
|
+
}
|
|
529
|
+
break;
|
|
530
|
+
case "tween":
|
|
531
|
+
checkProps(path2, tl.target, tl.props);
|
|
532
|
+
if (tl.duration !== void 0 && tl.duration <= 0) {
|
|
533
|
+
problems.push(`${path2}: tween duration must be > 0`);
|
|
534
|
+
}
|
|
535
|
+
break;
|
|
536
|
+
case "motionPath": {
|
|
537
|
+
const node = nodeById.get(tl.target);
|
|
538
|
+
const isSceneCamera = tl.target === "camera" && !node;
|
|
539
|
+
if (!isSceneCamera) {
|
|
540
|
+
if (!node) {
|
|
541
|
+
problems.push(
|
|
542
|
+
`${path2}: motionPath targets unknown node "${tl.target}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
|
|
543
|
+
);
|
|
544
|
+
} else if (node.type === "line") {
|
|
545
|
+
problems.push(`${path2}: motionPath cannot target a line (no x/y) \u2014 "${tl.target}"`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (tl.points.length < 1) problems.push(`${path2}: motionPath "${tl.target}" needs at least 1 point`);
|
|
549
|
+
if (tl.duration !== void 0 && tl.duration <= 0) {
|
|
550
|
+
problems.push(`${path2}: motionPath "${tl.target}" duration must be > 0`);
|
|
551
|
+
}
|
|
552
|
+
if (tl.curviness !== void 0 && tl.curviness < 0) {
|
|
553
|
+
problems.push(`${path2}: motionPath "${tl.target}" curviness must be >= 0`);
|
|
554
|
+
}
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
case "wait":
|
|
558
|
+
if (tl.duration < 0) problems.push(`${path2}: wait duration must be >= 0`);
|
|
559
|
+
break;
|
|
560
|
+
case "beat":
|
|
561
|
+
if (labels.has(tl.name)) {
|
|
562
|
+
problems.push(
|
|
563
|
+
`${path2}: duplicate timeline label "${tl.name}" (beat name) \u2014 labels are overlay addresses and must be unique`
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
labels.add(tl.name);
|
|
567
|
+
if (tl.duration !== void 0 && tl.duration <= 0) {
|
|
568
|
+
problems.push(`${path2}: beat "${tl.name}" duration must be > 0`);
|
|
569
|
+
}
|
|
570
|
+
if (tl.scale !== void 0 && tl.scale <= 0) {
|
|
571
|
+
problems.push(`${path2}: beat "${tl.name}" scale must be > 0`);
|
|
572
|
+
}
|
|
573
|
+
for (const id of tl.nodes ?? []) {
|
|
574
|
+
if (!nodeById.has(id)) {
|
|
575
|
+
problems.push(
|
|
576
|
+
`${path2}: beat "${tl.name}" owns unknown node "${id}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
tl.children.forEach((c, i) => checkTimeline(c, `${path2}.beat(${tl.name})[${i}]`));
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
if (ir.timeline) checkTimeline(ir.timeline, "timeline");
|
|
585
|
+
for (const [i, b] of (ir.behaviors ?? []).entries()) {
|
|
586
|
+
checkProps(`behaviors[${i}]`, b.target, { [b.prop]: 0 });
|
|
587
|
+
}
|
|
588
|
+
if (ir.duration !== void 0 && ir.duration <= 0) {
|
|
589
|
+
problems.push("scene duration must be > 0");
|
|
590
|
+
}
|
|
591
|
+
if (ir.camera) {
|
|
592
|
+
if (nodeById.has("camera")) {
|
|
593
|
+
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)`);
|
|
594
|
+
}
|
|
595
|
+
for (const [key2, value] of Object.entries(ir.camera)) {
|
|
596
|
+
if (key2 === "zSort") {
|
|
597
|
+
if (typeof value !== "boolean") problems.push(`camera.zSort must be a boolean`);
|
|
598
|
+
} else if (!CAMERA_PROPS.includes(key2)) {
|
|
599
|
+
problems.push(`camera: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}, zSort`);
|
|
600
|
+
} else if (typeof value !== "number") {
|
|
601
|
+
problems.push(`camera.${key2} must be a number`);
|
|
602
|
+
} else if (key2 === "perspective" && value <= 0) {
|
|
603
|
+
problems.push(`camera.perspective must be > 0 (focal distance in px) \u2014 drop it to disable perspective`);
|
|
604
|
+
} else if (key2 === "aperture" && value < 0) {
|
|
605
|
+
problems.push(`camera.aperture must be >= 0 (blur px per unit depth) \u2014 0 disables depth of field`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
const SFX_NAMES = ["whoosh", "pop", "tick", "rise", "shimmer", "thud"];
|
|
610
|
+
for (const [i, cue] of (ir.audio?.cues ?? []).entries()) {
|
|
611
|
+
if (typeof cue.at === "string" && !labels.has(cue.at)) {
|
|
612
|
+
problems.push(
|
|
613
|
+
`audio.cues[${i}]: unknown timeline label "${cue.at}" \u2014 known labels: ${[...labels].join(", ") || "(none)"}`
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
if (typeof cue.at === "number" && cue.at < 0) {
|
|
617
|
+
problems.push(`audio.cues[${i}]: "at" must be >= 0`);
|
|
618
|
+
}
|
|
619
|
+
if (cue.sfx === void 0 === (cue.file === void 0)) {
|
|
620
|
+
problems.push(`audio.cues[${i}]: exactly one of "sfx" or "file" is required`);
|
|
621
|
+
}
|
|
622
|
+
if (cue.sfx !== void 0 && !SFX_NAMES.includes(cue.sfx)) {
|
|
623
|
+
problems.push(`audio.cues[${i}]: unknown sfx "${cue.sfx}" \u2014 valid: ${SFX_NAMES.join(", ")}`);
|
|
624
|
+
}
|
|
625
|
+
if (cue.gain !== void 0 && cue.gain < 0) {
|
|
626
|
+
problems.push(`audio.cues[${i}]: gain must be >= 0`);
|
|
627
|
+
}
|
|
628
|
+
if (cue.fadeIn !== void 0 && cue.fadeIn < 0) {
|
|
629
|
+
problems.push(`audio.cues[${i}]: fadeIn must be >= 0`);
|
|
630
|
+
}
|
|
631
|
+
if (cue.fadeOut !== void 0 && cue.fadeOut < 0) {
|
|
632
|
+
problems.push(`audio.cues[${i}]: fadeOut must be >= 0`);
|
|
633
|
+
}
|
|
634
|
+
if (cue.pan !== void 0 && (cue.pan < -1 || cue.pan > 1)) {
|
|
635
|
+
problems.push(`audio.cues[${i}]: pan must be in [-1, 1] (-1 left \u2026 +1 right)`);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
const duck = ir.audio?.bgm?.duck;
|
|
639
|
+
if (typeof duck === "object" && duck !== null && duck.depth !== void 0 && (duck.depth < 0 || duck.depth > 1)) {
|
|
640
|
+
problems.push("audio.bgm.duck.depth must be in [0, 1]");
|
|
641
|
+
}
|
|
642
|
+
if (ir.audio?.bgm?.file !== void 0 && ir.audio.bgm.synth !== void 0) {
|
|
643
|
+
problems.push('audio.bgm: use either "file" or "synth", not both');
|
|
644
|
+
}
|
|
645
|
+
if (problems.length > 0) throw new SceneValidationError(problems);
|
|
646
|
+
}
|
|
647
|
+
var TRANSITIONS = ["cut", "crossfade"];
|
|
648
|
+
function validateComposition(comp) {
|
|
649
|
+
const problems = [];
|
|
650
|
+
if (comp.scenes.length === 0) problems.push("composition has no scenes");
|
|
651
|
+
const seen = /* @__PURE__ */ new Set();
|
|
652
|
+
for (const [i, entry] of comp.scenes.entries()) {
|
|
653
|
+
const where = `scenes[${i}]`;
|
|
654
|
+
try {
|
|
655
|
+
validateScene(entry.scene);
|
|
656
|
+
} catch (err) {
|
|
657
|
+
if (err instanceof SceneValidationError) {
|
|
658
|
+
for (const p of err.problems) problems.push(`${where} (scene "${entry.scene.id}"): ${p}`);
|
|
659
|
+
} else throw err;
|
|
660
|
+
}
|
|
661
|
+
if (seen.has(entry.scene.id)) {
|
|
662
|
+
problems.push(`${where}: duplicate scene id "${entry.scene.id}" \u2014 scene ids must be unique in a composition`);
|
|
663
|
+
}
|
|
664
|
+
seen.add(entry.scene.id);
|
|
665
|
+
if (entry.transition !== void 0 && !TRANSITIONS.includes(entry.transition)) {
|
|
666
|
+
problems.push(`${where}: unknown transition "${entry.transition}" \u2014 valid: ${TRANSITIONS.join(", ")}`);
|
|
667
|
+
}
|
|
668
|
+
if (typeof entry.at === "string" && Number.isNaN(Number(entry.at))) {
|
|
669
|
+
problems.push(`${where}: "at" string "${entry.at}" is not a number (use "-0.5"/"+0.5" or a number)`);
|
|
670
|
+
}
|
|
671
|
+
if (typeof entry.at === "number" && entry.at < 0) {
|
|
672
|
+
problems.push(`${where}: absolute "at" must be >= 0`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
if (problems.length > 0) throw new SceneValidationError(problems);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ../core/src/presets.ts
|
|
679
|
+
var SET = 1 / 120;
|
|
680
|
+
|
|
681
|
+
// ../core/src/interpolate.ts
|
|
682
|
+
var BACK_C1 = 1.70158;
|
|
683
|
+
var BACK_C2 = BACK_C1 * 1.525;
|
|
684
|
+
var BACK_C3 = BACK_C1 + 1;
|
|
685
|
+
var ELASTIC_C4 = 2 * Math.PI / 3;
|
|
686
|
+
var ELASTIC_C5 = 2 * Math.PI / 4.5;
|
|
687
|
+
function springEase(stiffness, damping, velocity) {
|
|
688
|
+
const K = 5;
|
|
689
|
+
const zeta = Math.min(0.999, Math.max(0.05, damping / (2 * Math.sqrt(Math.max(1e-6, stiffness)))));
|
|
690
|
+
const wd = K / zeta * Math.sqrt(1 - zeta * zeta);
|
|
691
|
+
const coef = (K - velocity) / wd;
|
|
692
|
+
return (u) => {
|
|
693
|
+
if (u <= 0) return 0;
|
|
694
|
+
if (u >= 1) return 1;
|
|
695
|
+
return 1 - Math.exp(-K * u) * (Math.cos(wd * u) + coef * Math.sin(wd * u));
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
function easeOutBounce(u) {
|
|
699
|
+
const n1 = 7.5625;
|
|
700
|
+
const d1 = 2.75;
|
|
701
|
+
if (u < 1 / d1) return n1 * u * u;
|
|
702
|
+
if (u < 2 / d1) return n1 * (u -= 1.5 / d1) * u + 0.75;
|
|
703
|
+
if (u < 2.5 / d1) return n1 * (u -= 2.25 / d1) * u + 0.9375;
|
|
704
|
+
return n1 * (u -= 2.625 / d1) * u + 0.984375;
|
|
705
|
+
}
|
|
706
|
+
var EASE_TABLE = {
|
|
707
|
+
linear: (u) => u,
|
|
708
|
+
easeInQuad: (u) => u * u,
|
|
709
|
+
easeOutQuad: (u) => 1 - (1 - u) * (1 - u),
|
|
710
|
+
easeInOutQuad: (u) => u < 0.5 ? 2 * u * u : 1 - (-2 * u + 2) ** 2 / 2,
|
|
711
|
+
easeInCubic: (u) => u ** 3,
|
|
712
|
+
easeOutCubic: (u) => 1 - (1 - u) ** 3,
|
|
713
|
+
easeInOutCubic: (u) => u < 0.5 ? 4 * u ** 3 : 1 - (-2 * u + 2) ** 3 / 2,
|
|
714
|
+
easeInQuart: (u) => u ** 4,
|
|
715
|
+
easeOutQuart: (u) => 1 - (1 - u) ** 4,
|
|
716
|
+
easeInOutQuart: (u) => u < 0.5 ? 8 * u ** 4 : 1 - (-2 * u + 2) ** 4 / 2,
|
|
717
|
+
easeInExpo: (u) => u === 0 ? 0 : 2 ** (10 * u - 10),
|
|
718
|
+
easeOutExpo: (u) => u === 1 ? 1 : 1 - 2 ** (-10 * u),
|
|
719
|
+
easeInOutExpo: (u) => u === 0 ? 0 : u === 1 ? 1 : u < 0.5 ? 2 ** (20 * u - 10) / 2 : (2 - 2 ** (-20 * u + 10)) / 2,
|
|
720
|
+
// --- expressive eases (GSAP's signature feel) — standard Penner equations ---
|
|
721
|
+
// back: overshoots past the target then settles (pop / snap)
|
|
722
|
+
easeInBack: (u) => BACK_C3 * u ** 3 - BACK_C1 * u * u,
|
|
723
|
+
easeOutBack: (u) => 1 + BACK_C3 * (u - 1) ** 3 + BACK_C1 * (u - 1) ** 2,
|
|
724
|
+
easeInOutBack: (u) => u < 0.5 ? (2 * u) ** 2 * ((BACK_C2 + 1) * 2 * u - BACK_C2) / 2 : ((2 * u - 2) ** 2 * ((BACK_C2 + 1) * (2 * u - 2) + BACK_C2) + 2) / 2,
|
|
725
|
+
// elastic: rings around the target before settling (playful spring)
|
|
726
|
+
easeInElastic: (u) => u === 0 ? 0 : u === 1 ? 1 : -(2 ** (10 * u - 10)) * Math.sin((u * 10 - 10.75) * ELASTIC_C4),
|
|
727
|
+
easeOutElastic: (u) => u === 0 ? 0 : u === 1 ? 1 : 2 ** (-10 * u) * Math.sin((u * 10 - 0.75) * ELASTIC_C4) + 1,
|
|
728
|
+
easeInOutElastic: (u) => u === 0 ? 0 : u === 1 ? 1 : u < 0.5 ? -(2 ** (20 * u - 10) * Math.sin((20 * u - 11.125) * ELASTIC_C5)) / 2 : 2 ** (-20 * u + 10) * Math.sin((20 * u - 11.125) * ELASTIC_C5) / 2 + 1,
|
|
729
|
+
// bounce: drops and bounces to rest (lands without overshoot)
|
|
730
|
+
easeInBounce: (u) => 1 - easeOutBounce(1 - u),
|
|
731
|
+
easeOutBounce,
|
|
732
|
+
easeInOutBounce: (u) => u < 0.5 ? (1 - easeOutBounce(1 - 2 * u)) / 2 : (1 + easeOutBounce(2 * u - 1)) / 2,
|
|
733
|
+
// damped-spring presets (ζ from damping/(2√stiffness)): 0.5 / 0.30 / 0.90
|
|
734
|
+
spring: springEase(100, 10, 0),
|
|
735
|
+
springBouncy: springEase(180, 8, 0),
|
|
736
|
+
springStiff: springEase(210, 26, 0)
|
|
737
|
+
};
|
|
738
|
+
var EASE_NAMES = Object.keys(EASE_TABLE);
|
|
739
|
+
|
|
740
|
+
// ../core/src/evaluate.ts
|
|
741
|
+
var DEG = Math.PI / 180;
|
|
742
|
+
|
|
743
|
+
// ../core/src/assets.ts
|
|
744
|
+
function collectSrcs(ir, type) {
|
|
745
|
+
const srcs = /* @__PURE__ */ new Set();
|
|
746
|
+
const ids = /* @__PURE__ */ new Set();
|
|
747
|
+
const walkNodes = (nodes) => {
|
|
748
|
+
for (const node of nodes) {
|
|
749
|
+
if (node.type === type) {
|
|
750
|
+
ids.add(node.id);
|
|
751
|
+
srcs.add(node.props.src);
|
|
752
|
+
}
|
|
753
|
+
if (node.type === "group") walkNodes(node.children);
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
walkNodes(ir.nodes);
|
|
757
|
+
for (const overrides of Object.values(ir.states ?? {})) {
|
|
758
|
+
for (const [nodeId, props] of Object.entries(overrides)) {
|
|
759
|
+
if (ids.has(nodeId) && typeof props.src === "string") srcs.add(props.src);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
const walkTimeline = (step) => {
|
|
763
|
+
if (!step) return;
|
|
764
|
+
if (step.kind === "seq" || step.kind === "par" || step.kind === "stagger") {
|
|
765
|
+
for (const child of step.children) walkTimeline(child);
|
|
766
|
+
} else if (step.kind === "tween" && ids.has(step.target)) {
|
|
767
|
+
const src = step.props.src;
|
|
768
|
+
if (typeof src === "string") srcs.add(src);
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
walkTimeline(ir.timeline);
|
|
772
|
+
return [...srcs];
|
|
773
|
+
}
|
|
774
|
+
function collectImageSrcs(ir) {
|
|
775
|
+
return collectSrcs(ir, "image");
|
|
776
|
+
}
|
|
777
|
+
function collectVideoSrcs(ir) {
|
|
778
|
+
return collectSrcs(ir, "video");
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// ../render-cli/src/images.ts
|
|
782
|
+
var MIME = {
|
|
783
|
+
".png": "image/png",
|
|
784
|
+
".jpg": "image/jpeg",
|
|
785
|
+
".jpeg": "image/jpeg",
|
|
786
|
+
".webp": "image/webp"
|
|
787
|
+
};
|
|
788
|
+
async function buildImageAssets(ir, sceneDir) {
|
|
789
|
+
const assets = {};
|
|
790
|
+
for (const src of collectImageSrcs(ir)) {
|
|
791
|
+
const mime = MIME[extname(src).toLowerCase()];
|
|
792
|
+
if (!mime) {
|
|
793
|
+
throw new Error(
|
|
794
|
+
`image "${src}": unsupported format "${extname(src)}" \u2014 supported: ${Object.keys(MIME).join(" ")}`
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
const candidates = [isAbsolute(src) ? src : null, resolve(sceneDir, src)].filter(
|
|
798
|
+
(c) => c !== null
|
|
799
|
+
);
|
|
800
|
+
const found = candidates.find((c) => existsSync(c));
|
|
801
|
+
if (!found) {
|
|
802
|
+
throw new Error(`image "${src}" not found (tried: ${candidates.join(", ")})`);
|
|
803
|
+
}
|
|
804
|
+
const data = await readFile2(found);
|
|
805
|
+
assets[src] = `data:${mime};base64,${data.toString("base64")}`;
|
|
806
|
+
}
|
|
807
|
+
return assets;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// ../render-cli/src/videos.ts
|
|
811
|
+
import { spawn } from "node:child_process";
|
|
812
|
+
import { mkdtemp, readFile as readFile3, readdir, rm } from "node:fs/promises";
|
|
813
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
814
|
+
import { tmpdir } from "node:os";
|
|
815
|
+
import { extname as extname2, isAbsolute as isAbsolute2, join as join2, resolve as resolve2 } from "node:path";
|
|
816
|
+
var VIDEO_EXT = /* @__PURE__ */ new Set([".mp4", ".mov", ".webm", ".m4v", ".mkv"]);
|
|
817
|
+
function runFfmpeg(args) {
|
|
818
|
+
return new Promise((resolve5, reject) => {
|
|
819
|
+
const proc = spawn("ffmpeg", args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
820
|
+
let stderr = "";
|
|
821
|
+
proc.stderr.on("data", (d) => stderr += d.toString());
|
|
822
|
+
proc.on(
|
|
823
|
+
"close",
|
|
824
|
+
(code) => code === 0 ? resolve5() : reject(new Error(`ffmpeg exited ${code}:
|
|
825
|
+
${stderr.slice(-2e3)}`))
|
|
826
|
+
);
|
|
827
|
+
proc.on("error", reject);
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
function neededSeconds(node, duration) {
|
|
831
|
+
const start = node.props.start ?? 0;
|
|
832
|
+
const rate = node.props.rate ?? 1;
|
|
833
|
+
const clipStart = node.props.clipStart ?? 0;
|
|
834
|
+
return clipStart + Math.max(0, duration - start) * Math.max(0, rate) + 1 / 30;
|
|
835
|
+
}
|
|
836
|
+
function videoNodes(ir) {
|
|
837
|
+
const out = [];
|
|
838
|
+
const walk = (nodes) => {
|
|
839
|
+
for (const n of nodes) {
|
|
840
|
+
if (n.type === "video") out.push(n);
|
|
841
|
+
if (n.type === "group") walk(n.children);
|
|
842
|
+
}
|
|
843
|
+
};
|
|
844
|
+
walk(ir.nodes);
|
|
845
|
+
return out;
|
|
846
|
+
}
|
|
847
|
+
async function buildVideoFrameAssets(ir, sceneDir, fps, duration) {
|
|
848
|
+
const srcs = collectVideoSrcs(ir);
|
|
849
|
+
if (srcs.length === 0) return {};
|
|
850
|
+
const nodes = videoNodes(ir);
|
|
851
|
+
const reachBySrc = /* @__PURE__ */ new Map();
|
|
852
|
+
for (const n of nodes) {
|
|
853
|
+
const reach = neededSeconds(n, duration);
|
|
854
|
+
reachBySrc.set(n.props.src, Math.max(reachBySrc.get(n.props.src) ?? 0, reach));
|
|
855
|
+
}
|
|
856
|
+
const assets = {};
|
|
857
|
+
for (const src of srcs) {
|
|
858
|
+
if (!VIDEO_EXT.has(extname2(src).toLowerCase())) {
|
|
859
|
+
throw new Error(
|
|
860
|
+
`video "${src}": unsupported format "${extname2(src)}" \u2014 supported: ${[...VIDEO_EXT].join(" ")}`
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
const candidates = [isAbsolute2(src) ? src : null, resolve2(sceneDir, src)].filter(
|
|
864
|
+
(c) => c !== null
|
|
865
|
+
);
|
|
866
|
+
const found = candidates.find((c) => existsSync2(c));
|
|
867
|
+
if (!found) throw new Error(`video "${src}" not found (tried: ${candidates.join(", ")})`);
|
|
868
|
+
const dir = await mkdtemp(join2(tmpdir(), "reframe-vframes-"));
|
|
869
|
+
try {
|
|
870
|
+
const seconds = Math.max(1 / fps, reachBySrc.get(src) ?? duration);
|
|
871
|
+
await runFfmpeg([
|
|
872
|
+
"-y",
|
|
873
|
+
"-i",
|
|
874
|
+
found,
|
|
875
|
+
"-t",
|
|
876
|
+
seconds.toFixed(3),
|
|
877
|
+
"-vf",
|
|
878
|
+
`fps=${fps},scale='min(iw,1280)':-2`,
|
|
879
|
+
"-q:v",
|
|
880
|
+
"4",
|
|
881
|
+
join2(dir, "%05d.jpg")
|
|
882
|
+
]);
|
|
883
|
+
const files = (await readdir(dir)).filter((f) => f.endsWith(".jpg")).sort();
|
|
884
|
+
assets[src] = await Promise.all(
|
|
885
|
+
files.map(async (f) => `data:image/jpeg;base64,${(await readFile3(join2(dir, f))).toString("base64")}`)
|
|
886
|
+
);
|
|
887
|
+
if (assets[src].length === 0) throw new Error(`video "${src}": ffmpeg extracted no frames`);
|
|
888
|
+
} finally {
|
|
889
|
+
await rm(dir, { recursive: true, force: true });
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
return assets;
|
|
893
|
+
}
|
|
894
|
+
function resolveTiming(ir, opts) {
|
|
895
|
+
const fps = opts.fps ?? ir.fps ?? 30;
|
|
896
|
+
const duration = opts.duration ?? compileScene(ir).duration;
|
|
897
|
+
return { fps, duration };
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// ../render-cli/src/vclock.ts
|
|
901
|
+
var VCLOCK_SOURCE = String.raw`
|
|
902
|
+
(() => {
|
|
903
|
+
let now = 0;
|
|
904
|
+
let nextId = 1;
|
|
905
|
+
let rafQueue = [];
|
|
906
|
+
const timers = [];
|
|
907
|
+
|
|
908
|
+
Date.now = () => now;
|
|
909
|
+
performance.now = () => now;
|
|
910
|
+
|
|
911
|
+
window.requestAnimationFrame = (cb) => {
|
|
912
|
+
const id = nextId++;
|
|
913
|
+
rafQueue.push({ id, cb });
|
|
914
|
+
return id;
|
|
915
|
+
};
|
|
916
|
+
window.cancelAnimationFrame = (id) => {
|
|
917
|
+
rafQueue = rafQueue.filter((r) => r.id !== id);
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
const addTimer = (cb, delay, args, interval) => {
|
|
921
|
+
const id = nextId++;
|
|
922
|
+
timers.push({
|
|
923
|
+
id,
|
|
924
|
+
cb: () => cb(...args),
|
|
925
|
+
due: now + Math.max(Number(delay) || 0, 0),
|
|
926
|
+
interval: interval ? Math.max(Number(delay) || 0, 1) : undefined,
|
|
927
|
+
});
|
|
928
|
+
return id;
|
|
929
|
+
};
|
|
930
|
+
const removeTimer = (id) => {
|
|
931
|
+
const i = timers.findIndex((t) => t.id === id);
|
|
932
|
+
if (i >= 0) timers.splice(i, 1);
|
|
933
|
+
};
|
|
934
|
+
window.setTimeout = (cb, delay = 0, ...args) =>
|
|
935
|
+
typeof cb === "function" ? addTimer(cb, delay, args, false) : 0;
|
|
936
|
+
window.setInterval = (cb, delay = 0, ...args) =>
|
|
937
|
+
typeof cb === "function" ? addTimer(cb, delay, args, true) : 0;
|
|
938
|
+
window.clearTimeout = removeTimer;
|
|
939
|
+
window.clearInterval = removeTimer;
|
|
940
|
+
|
|
941
|
+
window.__vclock = {
|
|
942
|
+
now: () => now,
|
|
943
|
+
advanceTo(targetMs) {
|
|
944
|
+
// Fire due timers in order, letting fired callbacks schedule new ones.
|
|
945
|
+
for (;;) {
|
|
946
|
+
timers.sort((a, b) => a.due - b.due);
|
|
947
|
+
const next = timers[0];
|
|
948
|
+
if (!next || next.due > targetMs) break;
|
|
949
|
+
now = next.due;
|
|
950
|
+
if (next.interval !== undefined) next.due += next.interval;
|
|
951
|
+
else timers.shift();
|
|
952
|
+
next.cb();
|
|
953
|
+
}
|
|
954
|
+
now = targetMs;
|
|
955
|
+
// One rAF batch per frame; callbacks registered during the batch run
|
|
956
|
+
// on the next advanceTo (matching real browser semantics).
|
|
957
|
+
const batch = rafQueue;
|
|
958
|
+
rafQueue = [];
|
|
959
|
+
for (const { cb } of batch) cb(now);
|
|
960
|
+
},
|
|
961
|
+
};
|
|
962
|
+
})();
|
|
963
|
+
`;
|
|
964
|
+
|
|
965
|
+
// ../render-cli/src/frameLoop.ts
|
|
966
|
+
async function injectFonts(page) {
|
|
967
|
+
await page.addStyleTag({ content: await fontFaceCss() });
|
|
968
|
+
await page.evaluate(async () => {
|
|
969
|
+
await Promise.all([...document.fonts].map((f) => f.load()));
|
|
970
|
+
await document.fonts.ready;
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
async function withPage(size, fn) {
|
|
974
|
+
const browser = await chromium.launch({
|
|
975
|
+
args: ["--force-color-profile=srgb", "--font-render-hinting=none"]
|
|
976
|
+
});
|
|
977
|
+
try {
|
|
978
|
+
const page = await browser.newPage({ viewport: size, deviceScaleFactor: 1 });
|
|
979
|
+
return await fn(page);
|
|
980
|
+
} finally {
|
|
981
|
+
await browser.close();
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
var bundleCache = null;
|
|
985
|
+
async function browserBundle() {
|
|
986
|
+
if (bundleCache) return bundleCache;
|
|
987
|
+
if (true) {
|
|
988
|
+
const { readFile: readFile5 } = await import("node:fs/promises");
|
|
989
|
+
bundleCache = await readFile5(
|
|
990
|
+
join3(dirname2(fileURLToPath2(import.meta.url)), "browserEntry.js"),
|
|
991
|
+
"utf8"
|
|
992
|
+
);
|
|
993
|
+
return bundleCache;
|
|
994
|
+
}
|
|
995
|
+
const entry = join3(dirname2(fileURLToPath2(import.meta.url)), "browserEntry.ts");
|
|
996
|
+
const result = await build({
|
|
997
|
+
entryPoints: [entry],
|
|
998
|
+
bundle: true,
|
|
999
|
+
write: false,
|
|
1000
|
+
format: "iife",
|
|
1001
|
+
target: "es2022"
|
|
1002
|
+
});
|
|
1003
|
+
bundleCache = result.outputFiles[0].text;
|
|
1004
|
+
return bundleCache;
|
|
1005
|
+
}
|
|
1006
|
+
async function renderFrameAt(ir, t, opts = {}) {
|
|
1007
|
+
const sceneDir = opts.sceneDir ?? process.cwd();
|
|
1008
|
+
const assets = await buildImageAssets(ir, sceneDir);
|
|
1009
|
+
const { fps, duration } = resolveTiming(ir, {});
|
|
1010
|
+
const videoAssets = await buildVideoFrameAssets(ir, sceneDir, fps, duration);
|
|
1011
|
+
const bundle2 = await browserBundle();
|
|
1012
|
+
return withPage(ir.size, async (page) => {
|
|
1013
|
+
await page.setContent(`<!DOCTYPE html><html><body style="margin:0;background:#000"></body></html>`);
|
|
1014
|
+
await injectFonts(page);
|
|
1015
|
+
await page.addScriptTag({ content: bundle2 });
|
|
1016
|
+
await page.evaluate(
|
|
1017
|
+
([sceneIr, imageAssets, vAssets]) => window.__reframe.init(sceneIr, imageAssets, vAssets),
|
|
1018
|
+
[ir, assets, videoAssets]
|
|
1019
|
+
);
|
|
1020
|
+
const dataUrl = await page.evaluate((tt) => window.__reframe.renderFrame(tt), t);
|
|
1021
|
+
return Buffer.from(dataUrl.slice(22), "base64");
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// ../render-cli/src/loadScene.ts
|
|
1026
|
+
import { build as build2 } from "esbuild";
|
|
1027
|
+
import { readFile as readFile4 } from "node:fs/promises";
|
|
1028
|
+
import { dirname as dirname3, resolve as resolve3 } from "node:path";
|
|
1029
|
+
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
1030
|
+
var HERE = dirname3(fileURLToPath3(import.meta.url));
|
|
1031
|
+
var CORE_ENTRY = true ? resolve3(HERE, "index.js") : resolve3(HERE, "..", "..", "core", "src", "index.ts");
|
|
1032
|
+
var SceneLoadError = class extends Error {
|
|
1033
|
+
kind;
|
|
1034
|
+
constructor(kind, message, options) {
|
|
1035
|
+
super(message, options);
|
|
1036
|
+
this.name = "SceneLoadError";
|
|
1037
|
+
this.kind = kind;
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
var clean = (err) => (err instanceof Error ? err.message : String(err)).replace(
|
|
1041
|
+
/data:text\/javascript;base64,[A-Za-z0-9+/=]+/g,
|
|
1042
|
+
"<scene bundle>"
|
|
1043
|
+
);
|
|
1044
|
+
var ALIAS = { "@reframe/core": CORE_ENTRY, "reframe-video": CORE_ENTRY };
|
|
1045
|
+
async function bundle(input) {
|
|
1046
|
+
const common = {
|
|
1047
|
+
bundle: true,
|
|
1048
|
+
format: "esm",
|
|
1049
|
+
platform: "neutral",
|
|
1050
|
+
write: false,
|
|
1051
|
+
logLevel: "silent",
|
|
1052
|
+
sourcemap: "inline",
|
|
1053
|
+
alias: ALIAS
|
|
1054
|
+
};
|
|
1055
|
+
try {
|
|
1056
|
+
const out = await build2(
|
|
1057
|
+
"path" in input ? { ...common, entryPoints: [input.path] } : { ...common, stdin: { contents: input.code, resolveDir: input.resolveDir, loader: "ts", sourcefile: "scene.ts" } }
|
|
1058
|
+
);
|
|
1059
|
+
return out.outputFiles[0].text;
|
|
1060
|
+
} catch (err) {
|
|
1061
|
+
throw new SceneLoadError("bundle", clean(err), { cause: err });
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
async function importDefault(code, label) {
|
|
1065
|
+
let mod;
|
|
1066
|
+
try {
|
|
1067
|
+
mod = await import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`);
|
|
1068
|
+
} catch (err) {
|
|
1069
|
+
const kind = err instanceof Error && err.name === "SceneValidationError" ? "validation" : "eval";
|
|
1070
|
+
throw new SceneLoadError(kind, clean(err), { cause: err });
|
|
1071
|
+
}
|
|
1072
|
+
if (mod.default === void 0) throw new SceneLoadError("eval", `${label} must default-export a scene or composition`);
|
|
1073
|
+
return mod.default;
|
|
1074
|
+
}
|
|
1075
|
+
async function loadDefault(path2) {
|
|
1076
|
+
if (path2.endsWith(".json")) {
|
|
1077
|
+
try {
|
|
1078
|
+
return JSON.parse(await readFile4(path2, "utf8"));
|
|
1079
|
+
} catch (err) {
|
|
1080
|
+
throw new SceneLoadError("eval", `failed to read ${path2}: ${clean(err)}`, { cause: err });
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
return importDefault(await bundle({ path: path2 }), path2);
|
|
1084
|
+
}
|
|
1085
|
+
function isComposition(def) {
|
|
1086
|
+
return typeof def === "object" && def !== null && Array.isArray(def.scenes);
|
|
1087
|
+
}
|
|
1088
|
+
function asScene(def, label) {
|
|
1089
|
+
if (isComposition(def)) {
|
|
1090
|
+
throw new SceneLoadError("validation", `${label} is a composition \u2014 render it directly, not as a single scene`);
|
|
1091
|
+
}
|
|
1092
|
+
try {
|
|
1093
|
+
validateScene(def);
|
|
1094
|
+
} catch (err) {
|
|
1095
|
+
throw new SceneLoadError("validation", clean(err), { cause: err });
|
|
1096
|
+
}
|
|
1097
|
+
return def;
|
|
1098
|
+
}
|
|
1099
|
+
async function loadModule(path2) {
|
|
1100
|
+
const def = await loadDefault(path2);
|
|
1101
|
+
if (isComposition(def)) {
|
|
1102
|
+
try {
|
|
1103
|
+
validateComposition(def);
|
|
1104
|
+
} catch (err) {
|
|
1105
|
+
throw new SceneLoadError("validation", clean(err), { cause: err });
|
|
1106
|
+
}
|
|
1107
|
+
return { kind: "composition", ir: def };
|
|
1108
|
+
}
|
|
1109
|
+
return { kind: "scene", ir: asScene(def, path2) };
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// ../render-cli/src/frame.ts
|
|
1113
|
+
async function main() {
|
|
1114
|
+
const argv = process.argv.slice(2);
|
|
1115
|
+
const input = argv[0];
|
|
1116
|
+
if (!input || input.startsWith("-")) {
|
|
1117
|
+
console.error("usage: reframe frame <scene.ts|.json> [--t <sec>] [-o out.png]");
|
|
1118
|
+
process.exit(2);
|
|
1119
|
+
}
|
|
1120
|
+
let t = 0;
|
|
1121
|
+
let out = "";
|
|
1122
|
+
for (let i = 1; i < argv.length; i++) {
|
|
1123
|
+
const a = argv[i];
|
|
1124
|
+
if (a === "--t") t = Number(argv[++i]);
|
|
1125
|
+
else if (a === "-o") out = argv[++i];
|
|
1126
|
+
else {
|
|
1127
|
+
console.error(`unknown argument: ${a}`);
|
|
1128
|
+
process.exit(2);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
if (!Number.isFinite(t) || t < 0) {
|
|
1132
|
+
console.error("--t must be a non-negative number of seconds");
|
|
1133
|
+
process.exit(2);
|
|
1134
|
+
}
|
|
1135
|
+
const scenePath = resolve4(input);
|
|
1136
|
+
const loaded = await loadModule(scenePath);
|
|
1137
|
+
if (loaded.kind !== "scene") {
|
|
1138
|
+
console.error("frame needs a single scene (not a composition)");
|
|
1139
|
+
process.exit(2);
|
|
1140
|
+
}
|
|
1141
|
+
const buf = await renderFrameAt(loaded.ir, t, { sceneDir: dirname4(scenePath) });
|
|
1142
|
+
const outPath = out ? resolve4(out) : resolve4(`${loaded.ir.id}.png`);
|
|
1143
|
+
await writeFile(outPath, buf);
|
|
1144
|
+
console.log(outPath);
|
|
1145
|
+
}
|
|
1146
|
+
main().catch((err) => {
|
|
1147
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1148
|
+
process.exit(1);
|
|
1149
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reframe-video",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.20",
|
|
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",
|
|
@@ -43,7 +43,9 @@
|
|
|
43
43
|
"dist",
|
|
44
44
|
"assets",
|
|
45
45
|
"guides",
|
|
46
|
-
"preview"
|
|
46
|
+
"preview",
|
|
47
|
+
".claude-plugin",
|
|
48
|
+
"skills"
|
|
47
49
|
],
|
|
48
50
|
"engines": {
|
|
49
51
|
"node": ">=20"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: reframe
|
|
3
|
+
description: Create and iterate motion-graphics videos (mp4) — title cards, lower thirds, kinetic typography, product teasers, data-driven video batches. Use when the user asks to make, edit, retime, personalize, or add sound to an animated video. Scenes are declarative data; renders are deterministic; human edits survive regeneration.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# reframe — motion graphics as addressable data
|
|
7
|
+
|
|
8
|
+
All commands run through npx; no install or project setup is needed. The
|
|
9
|
+
runtime needs ffmpeg on PATH and a one-time `npx playwright install chromium`
|
|
10
|
+
(the render command prints an actionable hint if either is missing).
|
|
11
|
+
|
|
12
|
+
## Creating a scene
|
|
13
|
+
|
|
14
|
+
1. **Read the guide first** — it is the complete, current syntax (~1,700
|
|
15
|
+
tokens) and one read is enough to write valid scenes:
|
|
16
|
+
`npx -y reframe-video guide`
|
|
17
|
+
2. Write a single self-contained `<name>.ts` in the user's directory
|
|
18
|
+
(`npx -y reframe-video new <name>` scaffolds a documented starter).
|
|
19
|
+
Scenes must be pure functions of time: no `Math.random()`/`Date` — use
|
|
20
|
+
`wiggle` with a seed. Give every node a meaningful stable `id` and label
|
|
21
|
+
the key timeline moments — those names are addresses for everything below.
|
|
22
|
+
3. Render and verify: `npx -y reframe-video render <name>.ts` → `out/<name>.mp4`.
|
|
23
|
+
|
|
24
|
+
## Directing a high-end piece (cinematic / reference-faithful)
|
|
25
|
+
|
|
26
|
+
Simple jobs (a lower-third, a logo sting, a KPI card) just work from the guide.
|
|
27
|
+
But a CINEMATIC or REFERENCE-FAITHFUL piece (a product teaser, a UI/session
|
|
28
|
+
reproduction, a title sequence) needs a director's process — **read it first**:
|
|
29
|
+
`npx -y reframe-video guide --directing`. The short version:
|
|
30
|
+
|
|
31
|
+
1. Get the spec from the user: concept, **references** (screenshots / a reference
|
|
32
|
+
video / pasted real content — save them to disk), exact brand colors, length +
|
|
33
|
+
aspect, and tone. Vague prompts are why these take many rounds.
|
|
34
|
+
2. **Storyboard the beats** with `beat("setup"/"rising"/"climax"/…)` BEFORE animating.
|
|
35
|
+
3. **Match references with the `diff` tool** instead of eyeballing:
|
|
36
|
+
`npx -y reframe-video diff ref.png --mode grid` (measure a screenshot),
|
|
37
|
+
then `... diff ref.png scene.ts --mode side|diff` (compare a render) → fix → repeat.
|
|
38
|
+
4. Apply cinematic craft: camera push-in per beat (`cameraTo` in `par`), curved
|
|
39
|
+
entrances (`motionPath` + `easeOutBack`), fake/real depth, layered `oscillate`
|
|
40
|
+
idle, and label-anchored sound.
|
|
41
|
+
5. **Verify objectively**: `... labels` (exact beat seconds), `... motion out.mp4`
|
|
42
|
+
(makes "more dynamic" measurable), `... trace ref.mp4 --apply scene.ts` (borrow a
|
|
43
|
+
reference VIDEO's timing), `... preview` (hand-tune → overlay that survives regen).
|
|
44
|
+
|
|
45
|
+
## Modifying an existing scene — the contract
|
|
46
|
+
|
|
47
|
+
Before rewriting any existing scene, read the regeneration contract:
|
|
48
|
+
`npx -y reframe-video guide --regen`. The core rule: **never rename node ids,
|
|
49
|
+
state names, or timeline labels for concepts that survive the redesign** —
|
|
50
|
+
the user's overlay documents hold their hand edits at those addresses.
|
|
51
|
+
|
|
52
|
+
The user may keep personal edits in an overlay JSON and render with
|
|
53
|
+
`--overlay <file>`. Check the conversation for overlay usage. Two situations
|
|
54
|
+
to handle explicitly:
|
|
55
|
+
|
|
56
|
+
- After your rewrite, the render's compose report lists orphaned edits for
|
|
57
|
+
concepts that were genuinely removed — relay that report to the user; never
|
|
58
|
+
let an edit disappear silently.
|
|
59
|
+
- If the user asks you to change a property their overlay already overrides,
|
|
60
|
+
editing the scene alone will be invisible in their renders. Resolve the
|
|
61
|
+
mask (update the scene AND remove/update the superseded overlay entry) and
|
|
62
|
+
tell them why.
|
|
63
|
+
|
|
64
|
+
## Other capabilities
|
|
65
|
+
|
|
66
|
+
- **Batch**: `npx -y reframe-video batch scene.ts data.json` — one mp4 per
|
|
67
|
+
data row; row keys are overlay addresses (`nodes.<id>.<prop>`,
|
|
68
|
+
`timeline.<label>.duration`, ...). CSV works too (headers = addresses).
|
|
69
|
+
- **Preview editor**: `npx -y reframe-video preview` — scrub/play/knobs for
|
|
70
|
+
scenes in the current directory; the user's knob edits export as an overlay
|
|
71
|
+
JSON they can pass to render.
|
|
72
|
+
- **Audio**: `scene.audio` cues anchor to timeline labels, so sound follows
|
|
73
|
+
retiming and regeneration. Procedural sfx (whoosh/pop/tick/rise/shimmer/
|
|
74
|
+
thud) plus bundled CC0 samples (mechanical keypresses, clicks). The guide's
|
|
75
|
+
Audio section has the schema.
|
|
76
|
+
- **Motion check**: `npx -y reframe-video motion out/<name>.mp4` prints a
|
|
77
|
+
calibrated motion profile (speeds, static fraction, discontinuities) —
|
|
78
|
+
useful to verify a vague request like "make it more dynamic" objectively.
|
|
79
|
+
- **Image sequences** (the "glyph reveal" / stop-motion format): generated
|
|
80
|
+
stills become `image` nodes stacked in painter's order; hard cuts are
|
|
81
|
+
0.01s opacity steps every ~0.15s, a slow camera-group scale tween adds the
|
|
82
|
+
push-in, `wiggle` adds shake, and a label per cut anchors a tick sfx.
|
|
83
|
+
Keep frame ids stable (`frame-0..N`) so the user can swap any plate via
|
|
84
|
+
overlay or batch row (`nodes.frame-3.src`). Image `src` paths resolve
|
|
85
|
+
relative to the scene file.
|
|
86
|
+
|
|
87
|
+
## Verification habits
|
|
88
|
+
|
|
89
|
+
Render after every change. For visual checks, extract a few frames with
|
|
90
|
+
ffmpeg and look at them. Same input renders byte-identically, so "it changed"
|
|
91
|
+
or "it didn't change" is always provable.
|