motionrender 0.2.7 → 0.2.9
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/blocks/fx-paper-collage.json +212 -0
- package/dist/cli.js +111 -23
- package/dist/index.js +27 -15
- package/dist/player.js +21 -21
- package/dist/sfx/ambient-pad.wav +0 -0
- package/dist/sfx/impact.wav +0 -0
- package/dist/sfx/shimmer.wav +0 -0
- package/dist/sfx/sparkle.wav +0 -0
- package/dist/types/packages/cli/src/args.d.ts +1 -0
- package/dist/types/packages/core/src/review.d.ts +2 -0
- package/dist/types/packages/renderer/src/index.d.ts +6 -0
- package/dist/types/packages/schema/src/index.d.ts +1 -1
- package/dist/types/packages/timeline/src/index.d.ts +1 -0
- package/package.json +2 -2
- package/skills/ove-effects/SKILL.md +1 -0
- package/skills/ove-paper-collage/SKILL.md +78 -0
- package/templates/collage-broll/composition.json +213 -0
- package/templates/collage-broll/template.json +9 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "fx-paper-collage",
|
|
3
|
+
"name": "Paper collage",
|
|
4
|
+
"category": "effect",
|
|
5
|
+
"kind": "scene",
|
|
6
|
+
"durationFrames": 150,
|
|
7
|
+
"transition": {
|
|
8
|
+
"type": "fade",
|
|
9
|
+
"durationFrames": 12
|
|
10
|
+
},
|
|
11
|
+
"description": "Editorial paper-collage B-roll: stop-motion cutouts drop and slide over cardstock backing with tape strips and punchy label tags.",
|
|
12
|
+
"params": {
|
|
13
|
+
"subject": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"default": "",
|
|
16
|
+
"description": "Subject cutout image (asset id or path)"
|
|
17
|
+
},
|
|
18
|
+
"label": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"default": "KEY TAKEAWAY",
|
|
21
|
+
"description": "Punchy kicker on tape tag"
|
|
22
|
+
},
|
|
23
|
+
"headline": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"default": "Visual metaphor in motion",
|
|
26
|
+
"description": "Editorial caption text"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"layers": [
|
|
30
|
+
{
|
|
31
|
+
"id": "bg",
|
|
32
|
+
"type": "rect",
|
|
33
|
+
"x": 0,
|
|
34
|
+
"y": 0,
|
|
35
|
+
"width": "{{canvas.width}}",
|
|
36
|
+
"height": "{{canvas.height}}",
|
|
37
|
+
"fill": "{{bg}}"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"id": "backing-paper",
|
|
41
|
+
"type": "rect",
|
|
42
|
+
"x": "{{round(canvas.width * 0.5)}}",
|
|
43
|
+
"y": "{{round(canvas.height * 0.48)}}",
|
|
44
|
+
"width": "{{round(min(canvas.width, canvas.height) * 0.76)}}",
|
|
45
|
+
"height": "{{round(min(canvas.width, canvas.height) * 0.95)}}",
|
|
46
|
+
"rotation": -2.5,
|
|
47
|
+
"fill": "{{surface}}",
|
|
48
|
+
"radius": 4,
|
|
49
|
+
"shadow": {
|
|
50
|
+
"color": "rgba(0,0,0,0.2)",
|
|
51
|
+
"blur": 26,
|
|
52
|
+
"offsetX": 6,
|
|
53
|
+
"offsetY": 12
|
|
54
|
+
},
|
|
55
|
+
"animations": [
|
|
56
|
+
{
|
|
57
|
+
"property": "y",
|
|
58
|
+
"keyframes": [
|
|
59
|
+
{ "frame": 0, "value": "{{canvas.height + 200}}", "easing": "easeOutBack" },
|
|
60
|
+
{ "frame": 18, "value": "{{round(canvas.height * 0.48)}}" }
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"if": "{{subject}}",
|
|
67
|
+
"id": "subject-image",
|
|
68
|
+
"type": "image",
|
|
69
|
+
"src": "{{subject}}",
|
|
70
|
+
"x": "{{round(canvas.width * 0.5)}}",
|
|
71
|
+
"y": "{{round(canvas.height * 0.45)}}",
|
|
72
|
+
"width": "{{round(min(canvas.width, canvas.height) * 0.62)}}",
|
|
73
|
+
"height": "{{round(min(canvas.width, canvas.height) * 0.62)}}",
|
|
74
|
+
"rotation": 3,
|
|
75
|
+
"filter": "grayscale(1) contrast(1.75) brightness(1.05)",
|
|
76
|
+
"shadow": {
|
|
77
|
+
"color": "rgba(0,0,0,0.25)",
|
|
78
|
+
"blur": 18,
|
|
79
|
+
"offsetX": 5,
|
|
80
|
+
"offsetY": 9
|
|
81
|
+
},
|
|
82
|
+
"animations": [
|
|
83
|
+
{
|
|
84
|
+
"property": "scale",
|
|
85
|
+
"keyframes": [
|
|
86
|
+
{ "frame": 10, "value": 0.4, "easing": "easeOutBack" },
|
|
87
|
+
{ "frame": 24, "value": 1.0 }
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"if": "{{!subject}}",
|
|
94
|
+
"id": "subject-placeholder",
|
|
95
|
+
"type": "rect",
|
|
96
|
+
"x": "{{round(canvas.width * 0.5)}}",
|
|
97
|
+
"y": "{{round(canvas.height * 0.45)}}",
|
|
98
|
+
"width": "{{round(min(canvas.width, canvas.height) * 0.58)}}",
|
|
99
|
+
"height": "{{round(min(canvas.width, canvas.height) * 0.58)}}",
|
|
100
|
+
"rotation": 2,
|
|
101
|
+
"fill": "{{muted}}",
|
|
102
|
+
"radius": 8,
|
|
103
|
+
"shadow": {
|
|
104
|
+
"color": "rgba(0,0,0,0.2)",
|
|
105
|
+
"blur": 16,
|
|
106
|
+
"offsetX": 4,
|
|
107
|
+
"offsetY": 8
|
|
108
|
+
},
|
|
109
|
+
"animations": [
|
|
110
|
+
{
|
|
111
|
+
"property": "scale",
|
|
112
|
+
"keyframes": [
|
|
113
|
+
{ "frame": 10, "value": 0.4, "easing": "easeOutBack" },
|
|
114
|
+
{ "frame": 24, "value": 1.0 }
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"id": "tape-strip",
|
|
121
|
+
"type": "rect",
|
|
122
|
+
"x": "{{round(canvas.width * 0.38)}}",
|
|
123
|
+
"y": "{{round(canvas.height * 0.48 - min(canvas.width, canvas.height) * 0.45)}}",
|
|
124
|
+
"width": "{{round(min(canvas.width, canvas.height) * 0.24)}}",
|
|
125
|
+
"height": "{{round(min(canvas.width, canvas.height) * 0.055)}}",
|
|
126
|
+
"rotation": -16,
|
|
127
|
+
"fill": "rgba(240, 230, 210, 0.85)",
|
|
128
|
+
"startFrame": 20,
|
|
129
|
+
"shadow": {
|
|
130
|
+
"color": "rgba(0,0,0,0.1)",
|
|
131
|
+
"blur": 4,
|
|
132
|
+
"offsetX": 1,
|
|
133
|
+
"offsetY": 2
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"id": "label-tag",
|
|
138
|
+
"type": "text",
|
|
139
|
+
"content": "{{label}}",
|
|
140
|
+
"fontFamily": "Inter",
|
|
141
|
+
"fontWeight": 900,
|
|
142
|
+
"fontSize": "{{round(min(canvas.width, canvas.height) * 0.042)}}",
|
|
143
|
+
"color": "#111111",
|
|
144
|
+
"background": "{{accent}}",
|
|
145
|
+
"padding": "{{round(min(canvas.width, canvas.height) * 0.015)}}",
|
|
146
|
+
"rotation": -3.5,
|
|
147
|
+
"x": "{{round(canvas.width * 0.5)}}",
|
|
148
|
+
"y": "{{round(canvas.height * 0.72)}}",
|
|
149
|
+
"startFrame": 28,
|
|
150
|
+
"shadow": {
|
|
151
|
+
"color": "rgba(0,0,0,0.2)",
|
|
152
|
+
"blur": 12,
|
|
153
|
+
"offsetX": 4,
|
|
154
|
+
"offsetY": 6
|
|
155
|
+
},
|
|
156
|
+
"animations": [
|
|
157
|
+
{
|
|
158
|
+
"property": "y",
|
|
159
|
+
"keyframes": [
|
|
160
|
+
{ "frame": 28, "value": "{{round(canvas.height * 0.78)}}", "easing": "easeOutCubic" },
|
|
161
|
+
{ "frame": 38, "value": "{{round(canvas.height * 0.72)}}" }
|
|
162
|
+
]
|
|
163
|
+
}
|
|
164
|
+
]
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
"id": "headline-text",
|
|
168
|
+
"type": "text",
|
|
169
|
+
"content": "{{headline}}",
|
|
170
|
+
"fontFamily": "Inter",
|
|
171
|
+
"fontWeight": 700,
|
|
172
|
+
"fontSize": "{{round(min(canvas.width, canvas.height) * 0.038)}}",
|
|
173
|
+
"color": "{{text}}",
|
|
174
|
+
"maxWidth": "{{round(min(canvas.width, canvas.height) * 0.75)}}",
|
|
175
|
+
"textAlign": "center",
|
|
176
|
+
"x": "{{round(canvas.width * 0.5)}}",
|
|
177
|
+
"y": "{{round(canvas.height * 0.81)}}",
|
|
178
|
+
"startFrame": 36,
|
|
179
|
+
"animations": [
|
|
180
|
+
{
|
|
181
|
+
"property": "opacity",
|
|
182
|
+
"keyframes": [
|
|
183
|
+
{ "frame": 36, "value": 0 },
|
|
184
|
+
{ "frame": 46, "value": 1 }
|
|
185
|
+
]
|
|
186
|
+
}
|
|
187
|
+
]
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
"id": "sfx-paper-drop",
|
|
191
|
+
"type": "audio",
|
|
192
|
+
"src": "sfx:thump",
|
|
193
|
+
"startFrame": 14,
|
|
194
|
+
"volume": 0.85
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
"id": "sfx-subject-slide",
|
|
198
|
+
"type": "audio",
|
|
199
|
+
"src": "sfx:whoosh",
|
|
200
|
+
"startFrame": 18,
|
|
201
|
+
"volume": 0.6
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
"id": "sfx-tag-snap",
|
|
205
|
+
"type": "audio",
|
|
206
|
+
"src": "sfx:pop",
|
|
207
|
+
"startFrame": 28,
|
|
208
|
+
"volume": 0.65
|
|
209
|
+
}
|
|
210
|
+
]
|
|
211
|
+
}
|
|
212
|
+
|
package/dist/cli.js
CHANGED
|
@@ -659,7 +659,7 @@ var init_src = __esm({
|
|
|
659
659
|
spanSchema = z3.object({ text: z3.string(), color: paint.optional(), fontSize: z3.number().positive().optional(), fontWeight: fontWeight.optional(), fontStyle: fontStyle.optional(), fontFamily: z3.string().optional(), background: color.optional().describe("Highlight behind the span"), underline: z3.boolean().optional() });
|
|
660
660
|
cssFilter = z3.string().regex(/^\s*((blur|brightness|contrast|grayscale|hue-rotate|invert|opacity|saturate|sepia|drop-shadow)\([^()]*(\([^()]*\)[^()]*)*\)\s*)+$/, 'Use CSS filter functions such as "brightness(1.2) saturate(0.5)"');
|
|
661
661
|
blendModes = ["normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", "luminosity"];
|
|
662
|
-
sfxNames = ["click", "tick", "pop", "whoosh", "thump", "ding", "riser"];
|
|
662
|
+
sfxNames = ["click", "tick", "pop", "whoosh", "thump", "ding", "riser", "shimmer", "sparkle", "impact", "ambient-pad"];
|
|
663
663
|
isBuiltinSound = (src) => src.startsWith("sfx:");
|
|
664
664
|
builtinSoundName = (src) => src.slice(4);
|
|
665
665
|
gradeLooks = ["warm", "cool", "cinematic", "teal-orange", "noir", "vivid", "faded"];
|
|
@@ -813,7 +813,7 @@ var init_src = __esm({
|
|
|
813
813
|
}),
|
|
814
814
|
baseLayer.extend({ type: z3.literal("image"), src: z3.string().min(1), objectFit: z3.enum(["cover", "contain", "fill"]).default("cover"), radius: z3.number().nonnegative().default(0).describe("Rounded corners, clipping the picture") }),
|
|
815
815
|
baseLayer.extend({ type: z3.literal("video"), src: z3.string().min(1), objectFit: z3.enum(["cover", "contain", "fill"]).default("cover"), radius: z3.number().nonnegative().default(0).describe("Rounded corners, clipping the picture"), trimStart: z3.number().int().nonnegative().default(0).describe("Frames of the source to skip, at the composition fps"), playbackRate: z3.number().positive().max(16).default(1), loop: z3.boolean().default(false).describe("Restart from the first source frame when the source ends; otherwise hold the last frame"), volume: z3.number().min(0).max(4).default(1).describe("Volume of the clip's own sound; 1 is unchanged; animatable"), muted: z3.boolean().default(false), duck: duckSchema.optional() }),
|
|
816
|
-
baseLayer.extend({ type: z3.literal("audio"), src: z3.string().min(1).refine((src) => !isBuiltinSound(src) || sfxNames.includes(builtinSoundName(src)), { message: `Built-in sounds are ${sfxNames.map((n) => `sfx:${n}`).join(", ")}` }).describe("An audio asset id, a file relative to the composition, or a built-in sound: sfx:click, sfx:tick, sfx:pop, sfx:whoosh, sfx:thump, sfx:ding, sfx:riser"), volume: z3.number().min(0).max(4).default(1).describe("1 is unchanged, 0.5 is half, 2 is double; animatable with keyframes"), duck: duckSchema.optional(), trimStart: z3.number().int().nonnegative().default(0).describe("Frames of the source to skip, at the composition fps"), playbackRate: z3.number().positive().max(16).default(1), loop: z3.boolean().default(false).describe("Restart from the start of the source when it ends; otherwise silence"), fadeInFrames: z3.number().int().nonnegative().default(0), fadeOutFrames: z3.number().int().nonnegative().default(0) }),
|
|
816
|
+
baseLayer.extend({ type: z3.literal("audio"), src: z3.string().min(1).refine((src) => !isBuiltinSound(src) || sfxNames.includes(builtinSoundName(src)), { message: `Built-in sounds are ${sfxNames.map((n) => `sfx:${n}`).join(", ")}` }).describe("An audio asset id, a file relative to the composition, or a built-in sound: sfx:click, sfx:tick, sfx:pop, sfx:whoosh, sfx:thump, sfx:ding, sfx:riser, sfx:shimmer, sfx:sparkle, sfx:impact, sfx:ambient-pad"), volume: z3.number().min(0).max(4).default(1).describe("1 is unchanged, 0.5 is half, 2 is double; animatable with keyframes"), duck: duckSchema.optional(), trimStart: z3.number().int().nonnegative().default(0).describe("Frames of the source to skip, at the composition fps"), playbackRate: z3.number().positive().max(16).default(1), loop: z3.boolean().default(false).describe("Restart from the start of the source when it ends; otherwise silence"), fadeInFrames: z3.number().int().nonnegative().default(0), fadeOutFrames: z3.number().int().nonnegative().default(0) }),
|
|
817
817
|
// Captions: shows the line being spoken at each moment. With word timings the current word is highlighted; spoken words keep
|
|
818
818
|
// `color`, words still to come use `upcomingColor`. x, y is the anchor of the first line (centre for textAlign center).
|
|
819
819
|
baseLayer.extend({
|
|
@@ -1132,16 +1132,16 @@ function videoFrameIndex(layer, ownFrame, frameCount) {
|
|
|
1132
1132
|
}
|
|
1133
1133
|
function layerWindows(composition, include) {
|
|
1134
1134
|
const out = [];
|
|
1135
|
-
const walk = (layers, offset, limit) => {
|
|
1135
|
+
const walk = (layers, offset, limit, scene) => {
|
|
1136
1136
|
for (const layer of layers) {
|
|
1137
1137
|
if (!layer.visible) continue;
|
|
1138
1138
|
const start = offset + layer.startFrame, end = Math.min(limit, layer.endFrame === void 0 ? Infinity : offset + layer.endFrame);
|
|
1139
1139
|
if (end <= start) continue;
|
|
1140
|
-
if (layer.type === "group") walk(layer.children, start, end);
|
|
1141
|
-
else if (include(layer)) out.push({ layer, startFrame: start, endFrame: end });
|
|
1140
|
+
if (layer.type === "group") walk(layer.children, start, end, scene);
|
|
1141
|
+
else if (include(layer)) out.push({ layer, startFrame: start, endFrame: end, scene });
|
|
1142
1142
|
}
|
|
1143
1143
|
};
|
|
1144
|
-
for (const scene of composition.scenes) walk(scene.layers, scene.startFrame, Math.min(scene.startFrame + scene.durationFrames, composition.durationFrames));
|
|
1144
|
+
for (const scene of composition.scenes) walk(scene.layers, scene.startFrame, Math.min(scene.startFrame + scene.durationFrames, composition.durationFrames), scene);
|
|
1145
1145
|
return out;
|
|
1146
1146
|
}
|
|
1147
1147
|
function soundIntervals(composition, media = {}) {
|
|
@@ -1577,7 +1577,11 @@ var init_src5 = __esm({
|
|
|
1577
1577
|
thump: tone("0.9*sin(2*PI*(110*t-80*t*t))*exp(-t*9)+0.3*sin(2*PI*55*t)*exp(-t*6)", 0.6),
|
|
1578
1578
|
ding: tone("0.5*sin(2*PI*1318*t)*exp(-t*3)+0.25*sin(2*PI*3640*t)*exp(-t*5)+0.12*sin(2*PI*7110*t)*exp(-t*8)", 1.8),
|
|
1579
1579
|
riser: tone("(t/1.6)*(t/1.6)*(0.6*sin(2*PI*(180*t+380*t*t))+0.25*sin(2*PI*(360*t+760*t*t)))", 1.6),
|
|
1580
|
-
whoosh: ["-f", "lavfi", "-i", "anoisesrc=d=0.7:c=pink:a=0.9:r=48000:seed=11", "-af", "highpass=f=300,lowpass=f=3500,volume='sin(PI*t/0.7)*sin(PI*t/0.7)*sin(PI*t/0.7)':eval=frame,volume=3"]
|
|
1580
|
+
whoosh: ["-f", "lavfi", "-i", "anoisesrc=d=0.7:c=pink:a=0.9:r=48000:seed=11", "-af", "highpass=f=300,lowpass=f=3500,volume='sin(PI*t/0.7)*sin(PI*t/0.7)*sin(PI*t/0.7)':eval=frame,volume=3"],
|
|
1581
|
+
shimmer: tone("0.3*sin(2*PI*1318*t)*exp(-t*2.5)+0.25*sin(2*PI*1661*t)*exp(-t*3)+0.2*sin(2*PI*1975*t)*exp(-t*3.5)+0.15*sin(2*PI*2637*t)*exp(-t*4)", 1.5),
|
|
1582
|
+
sparkle: tone("0.35*sin(2*PI*2093*t)*exp(-t*4)+0.25*sin(2*PI*2637*t)*exp(-t*5)+0.2*sin(2*PI*3136*t)*exp(-t*6)+0.15*sin(2*PI*4186*t)*exp(-t*7)", 1.2),
|
|
1583
|
+
impact: tone("0.9*sin(2*PI*(160*t-120*t*t))*exp(-t*4)+0.6*sin(2*PI*(70*t-30*t*t))*exp(-t*2)+0.3*sin(2*PI*40*t)*exp(-t*1.5)", 1),
|
|
1584
|
+
"ambient-pad": tone("0.25*(sin(2*PI*130.8*t)+0.2*sin(2*PI*131.2*t))+0.2*(sin(2*PI*196*t)+0.2*sin(2*PI*196.4*t))+0.2*(sin(2*PI*261.6*t)+0.15*sin(2*PI*329.6*t))*sin(PI*t/3.0)", 3)
|
|
1581
1585
|
};
|
|
1582
1586
|
sfxArgs = (name2, output) => {
|
|
1583
1587
|
const recipe = sfxRecipes[name2];
|
|
@@ -2112,7 +2116,7 @@ async function createRenderer(composition, options3 = {}) {
|
|
|
2112
2116
|
try {
|
|
2113
2117
|
const page = await context.newPage();
|
|
2114
2118
|
await page.setContent(sheetPage);
|
|
2115
|
-
return Buffer.from(await page.evaluate((a) => window.sheet(a), { images, labels, columns, cell }), "base64");
|
|
2119
|
+
return Buffer.from(await page.evaluate((a) => window.sheet(a), { images, labels, columns, cell, safeBands: options4.safeBands }), "base64");
|
|
2116
2120
|
} finally {
|
|
2117
2121
|
await context.close();
|
|
2118
2122
|
}
|
|
@@ -2167,8 +2171,8 @@ var init_src7 = __esm({
|
|
|
2167
2171
|
${canvasRuntimeSource}
|
|
2168
2172
|
const rt=createCanvasRuntime(document.querySelector('#c'),document.querySelector('#measure'));
|
|
2169
2173
|
window.OVE={load:rt.load,draw:async(p)=>{await rt.draw(p);return rt.canvas.toDataURL('image/png').slice('data:image/png;base64,'.length)},inspect:async(p)=>{await rt.draw({...p,report:true});return rt.report()}};</script>`;
|
|
2170
|
-
sheetPage = `<!doctype html><canvas id="s"></canvas><script>window.sheet=async({images,labels,columns,cell})=>{const c=document.querySelector('#s'),rows=Math.ceil(images.length/columns),gap=8,label=26,ctx=c.getContext('2d');c.width=columns*cell.w+(columns+1)*gap;c.height=rows*(cell.h+label)+(rows+1)*gap;ctx.fillStyle='#0b0d14';ctx.fillRect(0,0,c.width,c.height);ctx.font='600 15px system-ui,sans-serif';ctx.textBaseline='middle';
|
|
2171
|
-
for(let i=0;i<images.length;i++){const img=new Image();img.src=images[i];await img.decode();const x=gap+(i%columns)*(cell.w+gap),y=gap+Math.floor(i/columns)*(cell.h+label+gap);ctx.drawImage(img,x,y,cell.w,cell.h);ctx.fillStyle='#c9cfdd';ctx.fillText(labels[i],x+2,y+cell.h+label/2)}return c.toDataURL('image/png').slice(22)}</script>`;
|
|
2174
|
+
sheetPage = `<!doctype html><canvas id="s"></canvas><script>window.sheet=async({images,labels,columns,cell,safeBands})=>{const c=document.querySelector('#s'),rows=Math.ceil(images.length/columns),gap=8,label=26,ctx=c.getContext('2d');c.width=columns*cell.w+(columns+1)*gap;c.height=rows*(cell.h+label)+(rows+1)*gap;ctx.fillStyle='#0b0d14';ctx.fillRect(0,0,c.width,c.height);ctx.font='600 15px system-ui,sans-serif';ctx.textBaseline='middle';
|
|
2175
|
+
for(let i=0;i<images.length;i++){const img=new Image();img.src=images[i];await img.decode();const x=gap+(i%columns)*(cell.w+gap),y=gap+Math.floor(i/columns)*(cell.h+label+gap);ctx.drawImage(img,x,y,cell.w,cell.h);if(safeBands){for(const b of safeBands){const bx=x+b.x0*cell.w,by=y+b.y0*cell.h,bw=(b.x1-b.x0)*cell.w,bh=(b.y1-b.y0)*cell.h;ctx.fillStyle='rgba(239,68,68,0.18)';ctx.fillRect(bx,by,bw,bh);ctx.lineWidth=1.5;ctx.strokeStyle='rgba(239,68,68,0.55)';ctx.strokeRect(bx,by,bw,bh);}}ctx.fillStyle='#c9cfdd';ctx.fillText(labels[i],x+2,y+cell.h+label/2)}return c.toDataURL('image/png').slice(22)}</script>`;
|
|
2172
2176
|
allLayers = (composition) => composition.scenes.flatMap((scene) => scene.layers);
|
|
2173
2177
|
browserArgs = ["--use-angle=swiftshader", "--enable-unsafe-swiftshader", ...(process.env.OVE_CHROMIUM_ARGS ?? "").split(/\s+/).filter(Boolean)];
|
|
2174
2178
|
browserInstallHint = `Install one with "ove browser install" (Playwright's Chromium, about 150 MB), install Google Chrome, or set OVE_CHROMIUM_EXECUTABLE to a Chromium-based browser.`;
|
|
@@ -2221,7 +2225,7 @@ async function planAudio(composition, baseDir, probe2 = hasAudioStream) {
|
|
|
2221
2225
|
clips.push({ source: soundtrack, start: 0, duration, loop: true, volume: composition.export.audioVolume, envelope: volumeEnvelope({ layer: { volume: composition.export.audioVolume }, fps, start: 0, duration, duck: composition.export.duck, others }) });
|
|
2222
2226
|
}
|
|
2223
2227
|
const hasAudio = /* @__PURE__ */ new Map();
|
|
2224
|
-
for (const { layer, startFrame, endFrame } of windows) {
|
|
2228
|
+
for (const { layer, startFrame, endFrame, scene } of windows) {
|
|
2225
2229
|
const source = await sourceOf(composition, layer.src, baseDir);
|
|
2226
2230
|
await ensureExists(source);
|
|
2227
2231
|
if (layer.type === "video") {
|
|
@@ -2229,7 +2233,11 @@ async function planAudio(composition, baseDir, probe2 = hasAudioStream) {
|
|
|
2229
2233
|
if (!await hasAudio.get(source)) continue;
|
|
2230
2234
|
}
|
|
2231
2235
|
const start = startFrame / fps, duration = (endFrame - startFrame) / fps;
|
|
2232
|
-
|
|
2236
|
+
let fadeIn = layer.type === "audio" ? (layer.fadeInFrames ?? 0) / fps : 0;
|
|
2237
|
+
let fadeOut = layer.type === "audio" ? (layer.fadeOutFrames ?? 0) / fps : 0;
|
|
2238
|
+
if (scene?.transitionIn?.type === "fade" && fadeIn === 0 && startFrame === scene.startFrame) fadeIn = scene.transitionIn.durationFrames / fps;
|
|
2239
|
+
if (scene?.transitionOut?.type === "fade" && fadeOut === 0 && endFrame === Math.min(scene.startFrame + scene.durationFrames, composition.durationFrames)) fadeOut = scene.transitionOut.durationFrames / fps;
|
|
2240
|
+
clips.push({ source, start, duration, trim: layer.trimStart / fps, rate: layer.playbackRate, loop: layer.loop, volume: layer.volume, envelope: volumeEnvelope({ layer, fps, start, duration, duck: layer.duck, others }), ...layer.type === "audio" ? { fadeIn, fadeOut } : fadeIn || fadeOut ? { fadeIn, fadeOut } : {} });
|
|
2233
2241
|
}
|
|
2234
2242
|
return clips.length ? { clips, durationSeconds: composition.durationFrames / fps } : void 0;
|
|
2235
2243
|
}
|
|
@@ -2301,6 +2309,8 @@ async function reviewComposition(composition, options3 = {}) {
|
|
|
2301
2309
|
for (let f = 0; f < durationFrames; f += every) frames.push(f);
|
|
2302
2310
|
if (frames.at(-1) !== durationFrames - 1) frames.push(durationFrames - 1);
|
|
2303
2311
|
const sheetCount = Math.max(1, Math.min(options3.sheetFrames ?? 20, durationFrames)), sheetFrames = Array.from({ length: sheetCount }, (_, i) => Math.min(durationFrames - 1, Math.round((i + 0.5) * durationFrames / sheetCount)));
|
|
2312
|
+
const bands = safeZoneBands(width, height, options3.safeZone);
|
|
2313
|
+
const safeBands = options3.showSafeZones ? bands.map((b) => ({ x0: b.x0 / width, y0: b.y0 / height, x1: b.x1 / width, y1: b.y1 / height })) : void 0;
|
|
2304
2314
|
const renderer = await createRenderer(composition, { baseDir, concurrency: options3.concurrency ?? defaultConcurrency(), scale: Math.min(1, 1280 / Math.max(width, height)), allowCode: options3.allowCode });
|
|
2305
2315
|
let reports = [], sheet;
|
|
2306
2316
|
try {
|
|
@@ -2309,7 +2319,7 @@ async function reviewComposition(composition, options3 = {}) {
|
|
|
2309
2319
|
options3.onProgress?.(++done, frames.length);
|
|
2310
2320
|
return r2;
|
|
2311
2321
|
})));
|
|
2312
|
-
sheet = await renderer.contactSheet(sheetFrames, { columns: options3.columns ?? (width >= height ? 4 : 6) });
|
|
2322
|
+
sheet = await renderer.contactSheet(sheetFrames, { columns: options3.columns ?? (width >= height ? 4 : 6), safeBands });
|
|
2313
2323
|
} finally {
|
|
2314
2324
|
await renderer.close();
|
|
2315
2325
|
}
|
|
@@ -2343,7 +2353,6 @@ async function reviewComposition(composition, options3 = {}) {
|
|
|
2343
2353
|
const other = report.texts.find((o) => o !== t && o.id !== t.id && visible(o) && overlap(t, o) > 0.15 * Math.min(area(t), area(o)) && (t.kind === "captions" || o.kind === "captions" || t.id < o.id));
|
|
2344
2354
|
return other ? t.kind === "captions" || other.kind === "captions" ? `"${(t.kind === "captions" ? other : t).text}" sits under the captions: keep content out of the captions band` : `"${t.text}" overlaps "${other.text}"` : void 0;
|
|
2345
2355
|
}, "error");
|
|
2346
|
-
const bands = safeZoneBands(width, height, options3.safeZone);
|
|
2347
2356
|
if (bands.length) persistent("safe-zone", hold, (t) => {
|
|
2348
2357
|
if (!visible(t)) return;
|
|
2349
2358
|
const band = bands.find((b) => overlap(t, b) > 0.25 * area(t));
|
|
@@ -2360,7 +2369,10 @@ async function reviewComposition(composition, options3 = {}) {
|
|
|
2360
2369
|
}
|
|
2361
2370
|
const near = steps2.filter((s) => s.length > 1);
|
|
2362
2371
|
if (steps2.length > 8) issues.push({ severity: "warning", code: "type-scale", message: `${steps2.length} steps of font size (${steps2.map((s) => s.join("/")).join(", ")}): pick a scale of 5\u20138 sizes and reuse them` });
|
|
2363
|
-
else if (near.length)
|
|
2372
|
+
else if (near.length) {
|
|
2373
|
+
const replace = Object.fromEntries(near.flatMap((group) => group.slice(1).map((s) => [String(s), group[0]])));
|
|
2374
|
+
issues.push({ severity: "warning", code: "type-scale", message: `Nearly equal font sizes (${near.map((s) => s.join("/")).join(", ")}): use one size for each step of the type scale`, suggestion: { replace } });
|
|
2375
|
+
}
|
|
2364
2376
|
if (colours.length > 5) issues.push({ severity: "warning", code: "text-colours", message: `${colours.length} text colours (${colours.join(", ")}): use a heading colour, a body colour and one or two accents` });
|
|
2365
2377
|
const change = reports.slice(1).map((r2, i) => {
|
|
2366
2378
|
const a = reports[i].thumb.pixels, b = r2.thumb.pixels;
|
|
@@ -11183,7 +11195,33 @@ async function initProject(dir, options3 = {}) {
|
|
|
11183
11195
|
loadComposition(composition);
|
|
11184
11196
|
await writeFile14(compositionPath, JSON.stringify(composition, null, 2) + "\n");
|
|
11185
11197
|
await writeFile14(join19(target, "composition.schema.json"), JSON.stringify(compositionJsonSchema, null, 2) + "\n");
|
|
11186
|
-
if (options3.skill !== false)
|
|
11198
|
+
if (options3.skill !== false) {
|
|
11199
|
+
await cp(skillsRoot, join19(target, ".claude", "skills"), { recursive: true, force: true });
|
|
11200
|
+
await cp(skillsRoot, join19(target, ".agent", "skills"), { recursive: true, force: true });
|
|
11201
|
+
const agentsMd = `# MotionRender (OVE) Agent Instructions
|
|
11202
|
+
|
|
11203
|
+
MotionRender is a frame-deterministic motion graphics video engine.
|
|
11204
|
+
|
|
11205
|
+
## Essential Commands
|
|
11206
|
+
- \`mr dev\`: Start local hot-reloading preview server
|
|
11207
|
+
- \`mr render\`: Headless video rendering
|
|
11208
|
+
- \`mr still --frame <n>\`: Export a single frame as image
|
|
11209
|
+
- \`mr review\`: Automated design, type scale, safe-zone, and audio check
|
|
11210
|
+
- \`mr review --show-safe-zones\`: Generate contact sheet with social UI overlays
|
|
11211
|
+
|
|
11212
|
+
## Composition Grammar
|
|
11213
|
+
Full skill documentation and guidelines are located in:
|
|
11214
|
+
- \`.agent/skills/ove/SKILL.md\`
|
|
11215
|
+
- \`.claude/skills/ove/SKILL.md\`
|
|
11216
|
+
`;
|
|
11217
|
+
await writeFile14(join19(target, "AGENTS.md"), agentsMd);
|
|
11218
|
+
const cursorRules = `# MotionRender Rules
|
|
11219
|
+
- Motion graphics compositions are defined in \`composition.json\` adhering to \`composition.schema.json\`.
|
|
11220
|
+
- Use \`mr review\` to verify visual hierarchy, text safety, and audio loudness.
|
|
11221
|
+
- Detailed agent skills are located in \`.agent/skills/\`.
|
|
11222
|
+
`;
|
|
11223
|
+
await writeFile14(join19(target, ".cursorrules"), cursorRules);
|
|
11224
|
+
}
|
|
11187
11225
|
return (await listFiles(target)).map((file2) => relative2(target, file2)).sort();
|
|
11188
11226
|
}
|
|
11189
11227
|
|
|
@@ -11211,7 +11249,7 @@ var commandSpecs = {
|
|
|
11211
11249
|
inspect: { values: variables, positionals: 1 },
|
|
11212
11250
|
captions: { values: ["--output", ...variables], positionals: 1 },
|
|
11213
11251
|
still: { values: ["--frame", "--output", ...variables], flags: ["--transparent", "--allow-code"], positionals: 1 },
|
|
11214
|
-
review: { values: ["--output", "--every", "--sheet-frames", "--frames", ...variables], flags: ["--json", "--allow-code"], positionals: 1 },
|
|
11252
|
+
review: { values: ["--output", "--every", "--sheet-frames", "--frames", ...variables], flags: ["--json", "--allow-code", "--show-safe-zones"], positionals: 1 },
|
|
11215
11253
|
render: { values: [...renderValues, ...variables], flags: ["--transparent", "--no-motion-blur", "--allow-code"], positionals: 1 },
|
|
11216
11254
|
ai: {
|
|
11217
11255
|
values: ["--public-port", "--host", "--allow-host", "--instruction", "--prompt", "--provider", "--model", "--base-url", "--brief", "--kind", "--aspect", "--duration", "--fps", "--out", "--voice", "--language", "--size", "--composition", "--id", "--start", "--output", "--template", "--out-dir", "--fields", "--max-attempts", "--tail", "--asset", "--limit", "--port", "--crf", "--bitrate", "--audio-bitrate", "--scale", "--loudness", "--concurrency", "--preset", "--codec", "--format"],
|
|
@@ -11226,6 +11264,7 @@ var distance = (a, b) => {
|
|
|
11226
11264
|
return d[a.length][b.length];
|
|
11227
11265
|
};
|
|
11228
11266
|
var wantsHelp = (argv2) => argv2.includes("--help") || argv2.includes("-h") || argv2[0] === "help";
|
|
11267
|
+
var wantsVersion = (argv2) => argv2.includes("--version") || argv2.includes("-v") || argv2[0] === "version";
|
|
11229
11268
|
function checkArgs(argv2) {
|
|
11230
11269
|
const args = argv2.flatMap((arg) => /^--[a-z-]+=/.test(arg) ? [arg.slice(0, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1)] : [arg]);
|
|
11231
11270
|
const [command2] = args;
|
|
@@ -11386,6 +11425,21 @@ if (wantsHelp(process.argv.slice(2))) {
|
|
|
11386
11425
|
console.log(usage2);
|
|
11387
11426
|
process.exit(0);
|
|
11388
11427
|
}
|
|
11428
|
+
if (wantsVersion(process.argv.slice(2))) {
|
|
11429
|
+
let version = "0.2.7";
|
|
11430
|
+
try {
|
|
11431
|
+
const pkg = JSON.parse(await readFile26(new URL("../package.json", import.meta.url), "utf8"));
|
|
11432
|
+
version = pkg.version || version;
|
|
11433
|
+
} catch {
|
|
11434
|
+
try {
|
|
11435
|
+
const pkg = JSON.parse(await readFile26(new URL("../../package.json", import.meta.url), "utf8"));
|
|
11436
|
+
version = pkg.version || version;
|
|
11437
|
+
} catch {
|
|
11438
|
+
}
|
|
11439
|
+
}
|
|
11440
|
+
console.log(`motionrender ${version}`);
|
|
11441
|
+
process.exit(0);
|
|
11442
|
+
}
|
|
11389
11443
|
var argv;
|
|
11390
11444
|
try {
|
|
11391
11445
|
argv = checkArgs(process.argv.slice(2));
|
|
@@ -11452,7 +11506,8 @@ var workerToken = () => {
|
|
|
11452
11506
|
};
|
|
11453
11507
|
async function renderVideo(composition, baseDir, output, settings) {
|
|
11454
11508
|
const started = Date.now();
|
|
11455
|
-
let lastReport = 0;
|
|
11509
|
+
let lastReport = 0, lastPct = -1;
|
|
11510
|
+
const isTty = Boolean(process.stdout.isTTY);
|
|
11456
11511
|
const workers = option4("--workers");
|
|
11457
11512
|
if (workers) {
|
|
11458
11513
|
const chunkSize = option4("--chunk-size") === void 0 ? void 0 : Number(option4("--chunk-size"));
|
|
@@ -11482,9 +11537,19 @@ async function renderVideo(composition, baseDir, output, settings) {
|
|
|
11482
11537
|
return;
|
|
11483
11538
|
}
|
|
11484
11539
|
const result = await renderMedia(composition, { ...settings, baseDir, output, allowCode: flag4("--allow-code"), onProgress: ({ rendered, total }) => {
|
|
11485
|
-
|
|
11486
|
-
|
|
11487
|
-
|
|
11540
|
+
const pct = Math.floor(rendered / total * 100);
|
|
11541
|
+
if (isTty) {
|
|
11542
|
+
if (rendered === total || Date.now() - lastReport > 200) {
|
|
11543
|
+
lastReport = Date.now();
|
|
11544
|
+
process.stdout.write(`Rendered ${rendered}/${total} (${pct}%)\r`);
|
|
11545
|
+
if (rendered === total) process.stdout.write("\n");
|
|
11546
|
+
}
|
|
11547
|
+
} else {
|
|
11548
|
+
if (rendered === total || pct >= lastPct + 10 && Date.now() - lastReport > 1e3) {
|
|
11549
|
+
lastReport = Date.now();
|
|
11550
|
+
lastPct = pct;
|
|
11551
|
+
console.log(`Rendered ${rendered}/${total} (${pct}%)`);
|
|
11552
|
+
}
|
|
11488
11553
|
}
|
|
11489
11554
|
} });
|
|
11490
11555
|
console.log(`Created ${result.output} (${result.format}, ${result.frames} frames, ${result.durationSeconds.toFixed(2)} seconds; rendered in ${((Date.now() - started) / 1e3).toFixed(1)}s with ${settings.concurrency} page${settings.concurrency === 1 ? "" : "s"})`);
|
|
@@ -11643,13 +11708,36 @@ Saving the file reloads the preview. Press Ctrl+C to stop.`);
|
|
|
11643
11708
|
const frame = Number(option4("--frame") ?? 0);
|
|
11644
11709
|
if (!Number.isInteger(frame) || frame < 0 || frame >= composition.durationFrames) throw new Error(`--frame must be an integer from 0 to ${composition.durationFrames - 1}`);
|
|
11645
11710
|
const output = resolve26(option4("--output") ?? `still-${frame}.png`);
|
|
11646
|
-
|
|
11711
|
+
let done = false;
|
|
11712
|
+
if (!flag4("--transparent")) {
|
|
11713
|
+
const targetFile = resolve26(file);
|
|
11714
|
+
for (let p2 = 3e3; p2 <= 3020; p2++) {
|
|
11715
|
+
try {
|
|
11716
|
+
const c = new AbortController(), t = setTimeout(() => c.abort(), 120);
|
|
11717
|
+
const res = await fetch(`http://127.0.0.1:${p2}/api/state`, { signal: c.signal });
|
|
11718
|
+
clearTimeout(t);
|
|
11719
|
+
if (res.ok) {
|
|
11720
|
+
const s = await res.json();
|
|
11721
|
+
if (s?.file && resolve26(s.file) === targetFile && !s.error) {
|
|
11722
|
+
const fr = await fetch(`http://127.0.0.1:${p2}/api/frame?f=${frame}`);
|
|
11723
|
+
if (fr.ok) {
|
|
11724
|
+
await writeFile24(output, Buffer.from(await fr.arrayBuffer()));
|
|
11725
|
+
done = true;
|
|
11726
|
+
break;
|
|
11727
|
+
}
|
|
11728
|
+
}
|
|
11729
|
+
}
|
|
11730
|
+
} catch {
|
|
11731
|
+
}
|
|
11732
|
+
}
|
|
11733
|
+
}
|
|
11734
|
+
if (!done) await renderStill(composition, { frame, output, baseDir, allowCode: flag4("--allow-code"), transparent: flag4("--transparent") ? true : void 0 });
|
|
11647
11735
|
console.log(`Created ${output}`);
|
|
11648
11736
|
} else if (command === "review") {
|
|
11649
11737
|
const output = resolve26(option4("--output") ?? "review.png"), every = option4("--every") === void 0 ? void 0 : Number(option4("--every")), sheetOption = option4("--sheet-frames") ?? option4("--frames"), sheetFrames = sheetOption === void 0 ? void 0 : Number(sheetOption);
|
|
11650
11738
|
if (every !== void 0 && !(Number.isInteger(every) && every > 0)) throw new Error("--every must be a whole number of frames");
|
|
11651
11739
|
if (sheetFrames !== void 0 && !(Number.isInteger(sheetFrames) && sheetFrames > 0 && sheetFrames <= 60)) throw new Error("--sheet-frames must be from 1 to 60");
|
|
11652
|
-
const result = await reviewComposition(composition, { baseDir, allowCode: flag4("--allow-code"), every, sheetFrames, onProgress: (done, total) => {
|
|
11740
|
+
const result = await reviewComposition(composition, { baseDir, allowCode: flag4("--allow-code"), every, sheetFrames, showSafeZones: flag4("--show-safe-zones"), onProgress: (done, total) => {
|
|
11653
11741
|
if (!flag4("--json") && (done === total || done % 10 === 0)) process.stderr.write(`Checked ${done}/${total} frames\r`);
|
|
11654
11742
|
} });
|
|
11655
11743
|
await writeFile24(output, result.sheet);
|
package/dist/index.js
CHANGED
|
@@ -553,7 +553,7 @@ var splitSchema = z3.object({
|
|
|
553
553
|
var spanSchema = z3.object({ text: z3.string(), color: paint.optional(), fontSize: z3.number().positive().optional(), fontWeight: fontWeight.optional(), fontStyle: fontStyle.optional(), fontFamily: z3.string().optional(), background: color.optional().describe("Highlight behind the span"), underline: z3.boolean().optional() });
|
|
554
554
|
var cssFilter = z3.string().regex(/^\s*((blur|brightness|contrast|grayscale|hue-rotate|invert|opacity|saturate|sepia|drop-shadow)\([^()]*(\([^()]*\)[^()]*)*\)\s*)+$/, 'Use CSS filter functions such as "brightness(1.2) saturate(0.5)"');
|
|
555
555
|
var blendModes = ["normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", "luminosity"];
|
|
556
|
-
var sfxNames = ["click", "tick", "pop", "whoosh", "thump", "ding", "riser"];
|
|
556
|
+
var sfxNames = ["click", "tick", "pop", "whoosh", "thump", "ding", "riser", "shimmer", "sparkle", "impact", "ambient-pad"];
|
|
557
557
|
var isBuiltinSound = (src) => src.startsWith("sfx:");
|
|
558
558
|
var builtinSoundName = (src) => src.slice(4);
|
|
559
559
|
var gradeLooks = ["warm", "cool", "cinematic", "teal-orange", "noir", "vivid", "faded"];
|
|
@@ -707,7 +707,7 @@ var layerSchema = z3.discriminatedUnion("type", [
|
|
|
707
707
|
}),
|
|
708
708
|
baseLayer.extend({ type: z3.literal("image"), src: z3.string().min(1), objectFit: z3.enum(["cover", "contain", "fill"]).default("cover"), radius: z3.number().nonnegative().default(0).describe("Rounded corners, clipping the picture") }),
|
|
709
709
|
baseLayer.extend({ type: z3.literal("video"), src: z3.string().min(1), objectFit: z3.enum(["cover", "contain", "fill"]).default("cover"), radius: z3.number().nonnegative().default(0).describe("Rounded corners, clipping the picture"), trimStart: z3.number().int().nonnegative().default(0).describe("Frames of the source to skip, at the composition fps"), playbackRate: z3.number().positive().max(16).default(1), loop: z3.boolean().default(false).describe("Restart from the first source frame when the source ends; otherwise hold the last frame"), volume: z3.number().min(0).max(4).default(1).describe("Volume of the clip's own sound; 1 is unchanged; animatable"), muted: z3.boolean().default(false), duck: duckSchema.optional() }),
|
|
710
|
-
baseLayer.extend({ type: z3.literal("audio"), src: z3.string().min(1).refine((src) => !isBuiltinSound(src) || sfxNames.includes(builtinSoundName(src)), { message: `Built-in sounds are ${sfxNames.map((n) => `sfx:${n}`).join(", ")}` }).describe("An audio asset id, a file relative to the composition, or a built-in sound: sfx:click, sfx:tick, sfx:pop, sfx:whoosh, sfx:thump, sfx:ding, sfx:riser"), volume: z3.number().min(0).max(4).default(1).describe("1 is unchanged, 0.5 is half, 2 is double; animatable with keyframes"), duck: duckSchema.optional(), trimStart: z3.number().int().nonnegative().default(0).describe("Frames of the source to skip, at the composition fps"), playbackRate: z3.number().positive().max(16).default(1), loop: z3.boolean().default(false).describe("Restart from the start of the source when it ends; otherwise silence"), fadeInFrames: z3.number().int().nonnegative().default(0), fadeOutFrames: z3.number().int().nonnegative().default(0) }),
|
|
710
|
+
baseLayer.extend({ type: z3.literal("audio"), src: z3.string().min(1).refine((src) => !isBuiltinSound(src) || sfxNames.includes(builtinSoundName(src)), { message: `Built-in sounds are ${sfxNames.map((n) => `sfx:${n}`).join(", ")}` }).describe("An audio asset id, a file relative to the composition, or a built-in sound: sfx:click, sfx:tick, sfx:pop, sfx:whoosh, sfx:thump, sfx:ding, sfx:riser, sfx:shimmer, sfx:sparkle, sfx:impact, sfx:ambient-pad"), volume: z3.number().min(0).max(4).default(1).describe("1 is unchanged, 0.5 is half, 2 is double; animatable with keyframes"), duck: duckSchema.optional(), trimStart: z3.number().int().nonnegative().default(0).describe("Frames of the source to skip, at the composition fps"), playbackRate: z3.number().positive().max(16).default(1), loop: z3.boolean().default(false).describe("Restart from the start of the source when it ends; otherwise silence"), fadeInFrames: z3.number().int().nonnegative().default(0), fadeOutFrames: z3.number().int().nonnegative().default(0) }),
|
|
711
711
|
// Captions: shows the line being spoken at each moment. With word timings the current word is highlighted; spoken words keep
|
|
712
712
|
// `color`, words still to come use `upcomingColor`. x, y is the anchor of the first line (centre for textAlign center).
|
|
713
713
|
baseLayer.extend({
|
|
@@ -1098,16 +1098,16 @@ function videoFrameIndex(layer, ownFrame, frameCount) {
|
|
|
1098
1098
|
}
|
|
1099
1099
|
function layerWindows(composition, include) {
|
|
1100
1100
|
const out = [];
|
|
1101
|
-
const walk = (layers, offset, limit) => {
|
|
1101
|
+
const walk = (layers, offset, limit, scene) => {
|
|
1102
1102
|
for (const layer of layers) {
|
|
1103
1103
|
if (!layer.visible) continue;
|
|
1104
1104
|
const start = offset + layer.startFrame, end = Math.min(limit, layer.endFrame === void 0 ? Infinity : offset + layer.endFrame);
|
|
1105
1105
|
if (end <= start) continue;
|
|
1106
|
-
if (layer.type === "group") walk(layer.children, start, end);
|
|
1107
|
-
else if (include(layer)) out.push({ layer, startFrame: start, endFrame: end });
|
|
1106
|
+
if (layer.type === "group") walk(layer.children, start, end, scene);
|
|
1107
|
+
else if (include(layer)) out.push({ layer, startFrame: start, endFrame: end, scene });
|
|
1108
1108
|
}
|
|
1109
1109
|
};
|
|
1110
|
-
for (const scene of composition.scenes) walk(scene.layers, scene.startFrame, Math.min(scene.startFrame + scene.durationFrames, composition.durationFrames));
|
|
1110
|
+
for (const scene of composition.scenes) walk(scene.layers, scene.startFrame, Math.min(scene.startFrame + scene.durationFrames, composition.durationFrames), scene);
|
|
1111
1111
|
return out;
|
|
1112
1112
|
}
|
|
1113
1113
|
var audioWindows = (composition) => layerWindows(composition, (layer) => layer.type === "audio" ? layer.volume > 0 : layer.type === "video" && !layer.muted && layer.volume > 0);
|
|
@@ -1410,7 +1410,11 @@ var sfxRecipes = {
|
|
|
1410
1410
|
thump: tone("0.9*sin(2*PI*(110*t-80*t*t))*exp(-t*9)+0.3*sin(2*PI*55*t)*exp(-t*6)", 0.6),
|
|
1411
1411
|
ding: tone("0.5*sin(2*PI*1318*t)*exp(-t*3)+0.25*sin(2*PI*3640*t)*exp(-t*5)+0.12*sin(2*PI*7110*t)*exp(-t*8)", 1.8),
|
|
1412
1412
|
riser: tone("(t/1.6)*(t/1.6)*(0.6*sin(2*PI*(180*t+380*t*t))+0.25*sin(2*PI*(360*t+760*t*t)))", 1.6),
|
|
1413
|
-
whoosh: ["-f", "lavfi", "-i", "anoisesrc=d=0.7:c=pink:a=0.9:r=48000:seed=11", "-af", "highpass=f=300,lowpass=f=3500,volume='sin(PI*t/0.7)*sin(PI*t/0.7)*sin(PI*t/0.7)':eval=frame,volume=3"]
|
|
1413
|
+
whoosh: ["-f", "lavfi", "-i", "anoisesrc=d=0.7:c=pink:a=0.9:r=48000:seed=11", "-af", "highpass=f=300,lowpass=f=3500,volume='sin(PI*t/0.7)*sin(PI*t/0.7)*sin(PI*t/0.7)':eval=frame,volume=3"],
|
|
1414
|
+
shimmer: tone("0.3*sin(2*PI*1318*t)*exp(-t*2.5)+0.25*sin(2*PI*1661*t)*exp(-t*3)+0.2*sin(2*PI*1975*t)*exp(-t*3.5)+0.15*sin(2*PI*2637*t)*exp(-t*4)", 1.5),
|
|
1415
|
+
sparkle: tone("0.35*sin(2*PI*2093*t)*exp(-t*4)+0.25*sin(2*PI*2637*t)*exp(-t*5)+0.2*sin(2*PI*3136*t)*exp(-t*6)+0.15*sin(2*PI*4186*t)*exp(-t*7)", 1.2),
|
|
1416
|
+
impact: tone("0.9*sin(2*PI*(160*t-120*t*t))*exp(-t*4)+0.6*sin(2*PI*(70*t-30*t*t))*exp(-t*2)+0.3*sin(2*PI*40*t)*exp(-t*1.5)", 1),
|
|
1417
|
+
"ambient-pad": tone("0.25*(sin(2*PI*130.8*t)+0.2*sin(2*PI*131.2*t))+0.2*(sin(2*PI*196*t)+0.2*sin(2*PI*196.4*t))+0.2*(sin(2*PI*261.6*t)+0.15*sin(2*PI*329.6*t))*sin(PI*t/3.0)", 3)
|
|
1414
1418
|
};
|
|
1415
1419
|
var sfxArgs = (name2, output) => {
|
|
1416
1420
|
const recipe = sfxRecipes[name2];
|
|
@@ -1847,8 +1851,8 @@ var runtime = `<!doctype html><canvas id="c"></canvas><script>${lottieSource}</s
|
|
|
1847
1851
|
${canvasRuntimeSource}
|
|
1848
1852
|
const rt=createCanvasRuntime(document.querySelector('#c'),document.querySelector('#measure'));
|
|
1849
1853
|
window.OVE={load:rt.load,draw:async(p)=>{await rt.draw(p);return rt.canvas.toDataURL('image/png').slice('data:image/png;base64,'.length)},inspect:async(p)=>{await rt.draw({...p,report:true});return rt.report()}};</script>`;
|
|
1850
|
-
var sheetPage = `<!doctype html><canvas id="s"></canvas><script>window.sheet=async({images,labels,columns,cell})=>{const c=document.querySelector('#s'),rows=Math.ceil(images.length/columns),gap=8,label=26,ctx=c.getContext('2d');c.width=columns*cell.w+(columns+1)*gap;c.height=rows*(cell.h+label)+(rows+1)*gap;ctx.fillStyle='#0b0d14';ctx.fillRect(0,0,c.width,c.height);ctx.font='600 15px system-ui,sans-serif';ctx.textBaseline='middle';
|
|
1851
|
-
for(let i=0;i<images.length;i++){const img=new Image();img.src=images[i];await img.decode();const x=gap+(i%columns)*(cell.w+gap),y=gap+Math.floor(i/columns)*(cell.h+label+gap);ctx.drawImage(img,x,y,cell.w,cell.h);ctx.fillStyle='#c9cfdd';ctx.fillText(labels[i],x+2,y+cell.h+label/2)}return c.toDataURL('image/png').slice(22)}</script>`;
|
|
1854
|
+
var sheetPage = `<!doctype html><canvas id="s"></canvas><script>window.sheet=async({images,labels,columns,cell,safeBands})=>{const c=document.querySelector('#s'),rows=Math.ceil(images.length/columns),gap=8,label=26,ctx=c.getContext('2d');c.width=columns*cell.w+(columns+1)*gap;c.height=rows*(cell.h+label)+(rows+1)*gap;ctx.fillStyle='#0b0d14';ctx.fillRect(0,0,c.width,c.height);ctx.font='600 15px system-ui,sans-serif';ctx.textBaseline='middle';
|
|
1855
|
+
for(let i=0;i<images.length;i++){const img=new Image();img.src=images[i];await img.decode();const x=gap+(i%columns)*(cell.w+gap),y=gap+Math.floor(i/columns)*(cell.h+label+gap);ctx.drawImage(img,x,y,cell.w,cell.h);if(safeBands){for(const b of safeBands){const bx=x+b.x0*cell.w,by=y+b.y0*cell.h,bw=(b.x1-b.x0)*cell.w,bh=(b.y1-b.y0)*cell.h;ctx.fillStyle='rgba(239,68,68,0.18)';ctx.fillRect(bx,by,bw,bh);ctx.lineWidth=1.5;ctx.strokeStyle='rgba(239,68,68,0.55)';ctx.strokeRect(bx,by,bw,bh);}}ctx.fillStyle='#c9cfdd';ctx.fillText(labels[i],x+2,y+cell.h+label/2)}return c.toDataURL('image/png').slice(22)}</script>`;
|
|
1852
1856
|
var allLayers = (composition) => composition.scenes.flatMap((scene) => scene.layers);
|
|
1853
1857
|
var browserArgs = ["--use-angle=swiftshader", "--enable-unsafe-swiftshader", ...(process.env.OVE_CHROMIUM_ARGS ?? "").split(/\s+/).filter(Boolean)];
|
|
1854
1858
|
var browserInstallHint = `Install one with "ove browser install" (Playwright's Chromium, about 150 MB), install Google Chrome, or set OVE_CHROMIUM_EXECUTABLE to a Chromium-based browser.`;
|
|
@@ -1999,7 +2003,7 @@ async function createRenderer(composition, options = {}) {
|
|
|
1999
2003
|
try {
|
|
2000
2004
|
const page = await context.newPage();
|
|
2001
2005
|
await page.setContent(sheetPage);
|
|
2002
|
-
return Buffer.from(await page.evaluate((a) => window.sheet(a), { images, labels, columns, cell }), "base64");
|
|
2006
|
+
return Buffer.from(await page.evaluate((a) => window.sheet(a), { images, labels, columns, cell, safeBands: options2.safeBands }), "base64");
|
|
2003
2007
|
} finally {
|
|
2004
2008
|
await context.close();
|
|
2005
2009
|
}
|
|
@@ -2098,7 +2102,7 @@ async function planAudio(composition, baseDir, probe2 = hasAudioStream) {
|
|
|
2098
2102
|
clips.push({ source: soundtrack, start: 0, duration, loop: true, volume: composition.export.audioVolume, envelope: volumeEnvelope({ layer: { volume: composition.export.audioVolume }, fps, start: 0, duration, duck: composition.export.duck, others }) });
|
|
2099
2103
|
}
|
|
2100
2104
|
const hasAudio = /* @__PURE__ */ new Map();
|
|
2101
|
-
for (const { layer, startFrame, endFrame } of windows) {
|
|
2105
|
+
for (const { layer, startFrame, endFrame, scene } of windows) {
|
|
2102
2106
|
const source = await sourceOf(composition, layer.src, baseDir);
|
|
2103
2107
|
await ensureExists(source);
|
|
2104
2108
|
if (layer.type === "video") {
|
|
@@ -2106,7 +2110,11 @@ async function planAudio(composition, baseDir, probe2 = hasAudioStream) {
|
|
|
2106
2110
|
if (!await hasAudio.get(source)) continue;
|
|
2107
2111
|
}
|
|
2108
2112
|
const start = startFrame / fps, duration = (endFrame - startFrame) / fps;
|
|
2109
|
-
|
|
2113
|
+
let fadeIn = layer.type === "audio" ? (layer.fadeInFrames ?? 0) / fps : 0;
|
|
2114
|
+
let fadeOut = layer.type === "audio" ? (layer.fadeOutFrames ?? 0) / fps : 0;
|
|
2115
|
+
if (scene?.transitionIn?.type === "fade" && fadeIn === 0 && startFrame === scene.startFrame) fadeIn = scene.transitionIn.durationFrames / fps;
|
|
2116
|
+
if (scene?.transitionOut?.type === "fade" && fadeOut === 0 && endFrame === Math.min(scene.startFrame + scene.durationFrames, composition.durationFrames)) fadeOut = scene.transitionOut.durationFrames / fps;
|
|
2117
|
+
clips.push({ source, start, duration, trim: layer.trimStart / fps, rate: layer.playbackRate, loop: layer.loop, volume: layer.volume, envelope: volumeEnvelope({ layer, fps, start, duration, duck: layer.duck, others }), ...layer.type === "audio" ? { fadeIn, fadeOut } : fadeIn || fadeOut ? { fadeIn, fadeOut } : {} });
|
|
2110
2118
|
}
|
|
2111
2119
|
return clips.length ? { clips, durationSeconds: composition.durationFrames / fps } : void 0;
|
|
2112
2120
|
}
|
|
@@ -2155,6 +2163,8 @@ async function reviewComposition(composition, options = {}) {
|
|
|
2155
2163
|
for (let f = 0; f < durationFrames; f += every) frames.push(f);
|
|
2156
2164
|
if (frames.at(-1) !== durationFrames - 1) frames.push(durationFrames - 1);
|
|
2157
2165
|
const sheetCount = Math.max(1, Math.min(options.sheetFrames ?? 20, durationFrames)), sheetFrames = Array.from({ length: sheetCount }, (_, i) => Math.min(durationFrames - 1, Math.round((i + 0.5) * durationFrames / sheetCount)));
|
|
2166
|
+
const bands = safeZoneBands(width, height, options.safeZone);
|
|
2167
|
+
const safeBands = options.showSafeZones ? bands.map((b) => ({ x0: b.x0 / width, y0: b.y0 / height, x1: b.x1 / width, y1: b.y1 / height })) : void 0;
|
|
2158
2168
|
const renderer = await createRenderer(composition, { baseDir, concurrency: options.concurrency ?? defaultConcurrency(), scale: Math.min(1, 1280 / Math.max(width, height)), allowCode: options.allowCode });
|
|
2159
2169
|
let reports = [], sheet;
|
|
2160
2170
|
try {
|
|
@@ -2163,7 +2173,7 @@ async function reviewComposition(composition, options = {}) {
|
|
|
2163
2173
|
options.onProgress?.(++done, frames.length);
|
|
2164
2174
|
return r;
|
|
2165
2175
|
})));
|
|
2166
|
-
sheet = await renderer.contactSheet(sheetFrames, { columns: options.columns ?? (width >= height ? 4 : 6) });
|
|
2176
|
+
sheet = await renderer.contactSheet(sheetFrames, { columns: options.columns ?? (width >= height ? 4 : 6), safeBands });
|
|
2167
2177
|
} finally {
|
|
2168
2178
|
await renderer.close();
|
|
2169
2179
|
}
|
|
@@ -2197,7 +2207,6 @@ async function reviewComposition(composition, options = {}) {
|
|
|
2197
2207
|
const other = report.texts.find((o) => o !== t && o.id !== t.id && visible(o) && overlap(t, o) > 0.15 * Math.min(area(t), area(o)) && (t.kind === "captions" || o.kind === "captions" || t.id < o.id));
|
|
2198
2208
|
return other ? t.kind === "captions" || other.kind === "captions" ? `"${(t.kind === "captions" ? other : t).text}" sits under the captions: keep content out of the captions band` : `"${t.text}" overlaps "${other.text}"` : void 0;
|
|
2199
2209
|
}, "error");
|
|
2200
|
-
const bands = safeZoneBands(width, height, options.safeZone);
|
|
2201
2210
|
if (bands.length) persistent("safe-zone", hold, (t) => {
|
|
2202
2211
|
if (!visible(t)) return;
|
|
2203
2212
|
const band = bands.find((b) => overlap(t, b) > 0.25 * area(t));
|
|
@@ -2214,7 +2223,10 @@ async function reviewComposition(composition, options = {}) {
|
|
|
2214
2223
|
}
|
|
2215
2224
|
const near = steps.filter((s) => s.length > 1);
|
|
2216
2225
|
if (steps.length > 8) issues.push({ severity: "warning", code: "type-scale", message: `${steps.length} steps of font size (${steps.map((s) => s.join("/")).join(", ")}): pick a scale of 5\u20138 sizes and reuse them` });
|
|
2217
|
-
else if (near.length)
|
|
2226
|
+
else if (near.length) {
|
|
2227
|
+
const replace = Object.fromEntries(near.flatMap((group) => group.slice(1).map((s) => [String(s), group[0]])));
|
|
2228
|
+
issues.push({ severity: "warning", code: "type-scale", message: `Nearly equal font sizes (${near.map((s) => s.join("/")).join(", ")}): use one size for each step of the type scale`, suggestion: { replace } });
|
|
2229
|
+
}
|
|
2218
2230
|
if (colours.length > 5) issues.push({ severity: "warning", code: "text-colours", message: `${colours.length} text colours (${colours.join(", ")}): use a heading colour, a body colour and one or two accents` });
|
|
2219
2231
|
const change = reports.slice(1).map((r, i) => {
|
|
2220
2232
|
const a = reports[i].thumb.pixels, b = r.thumb.pixels;
|