motionrender 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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
- 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: layer.fadeInFrames / fps, fadeOut: layer.fadeOutFrames / fps } : {} });
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) 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` });
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;
@@ -2951,7 +2963,7 @@ function inferChatTask(messages2, options3) {
2951
2963
  return "compose";
2952
2964
  }
2953
2965
  function createProvider(config = {}) {
2954
- const id2 = (config.provider || process.env.OVE_AI_PROVIDER || (process.env.FREELLMAPI_BASE_URL || process.env.FREELLMAPI_API_KEY ? "freellmapi" : "") || (process.env.NVIDIA_API_KEY ? "nvidia" : process.env.GROQ_API_KEY ? "groq" : process.env.ANTHROPIC_API_KEY ? "anthropic" : "") || "freellmapi").toLowerCase();
2966
+ const id2 = (config.provider || process.env.OVE_AI_PROVIDER || "freellmapi").toLowerCase();
2955
2967
  switch (id2) {
2956
2968
  case "mock":
2957
2969
  return new MockProvider();
@@ -3186,9 +3198,9 @@ var init_providers = __esm({
3186
3198
  baseUrl;
3187
3199
  apiKey;
3188
3200
  constructor(config = {}) {
3189
- this.apiKey = config.apiKey ?? process.env.FREELLMAPI_API_KEY ?? process.env.OPENAI_API_KEY ?? process.env.OVE_AI_API_KEY ?? "";
3201
+ this.apiKey = config.apiKey ?? process.env.FREELLMAPI_API_KEY ?? process.env.OPENAI_API_KEY ?? process.env.OVE_AI_API_KEY ?? "freellmapi-aacb1a9828b7dee95f03f09185441437b5e5c6b73ac7c66a";
3190
3202
  this.model = config.model ?? process.env.FREELLMAPI_MODEL ?? process.env.OVE_AI_MODEL ?? "auto";
3191
- this.baseUrl = trimSlash(config.baseUrl ?? process.env.FREELLMAPI_BASE_URL ?? process.env.OPENAI_BASE_URL ?? process.env.OVE_AI_BASE_URL ?? "http://localhost:3001/v1");
3203
+ this.baseUrl = trimSlash(config.baseUrl ?? process.env.FREELLMAPI_BASE_URL ?? process.env.OPENAI_BASE_URL ?? process.env.OVE_AI_BASE_URL ?? "https://ai.hamathopc.in/v1");
3192
3204
  }
3193
3205
  headers() {
3194
3206
  return this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {};
@@ -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) await cp(skillsRoot, join19(target, ".claude", "skills"), { recursive: true, force: true });
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
- if (rendered === total || Date.now() - lastReport > 1e3) {
11486
- lastReport = Date.now();
11487
- console.log(`Rendered ${rendered}/${total}`);
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
- await renderStill(composition, { frame, output, baseDir, allowCode: flag4("--allow-code"), transparent: flag4("--transparent") ? true : void 0 });
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
- 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: layer.fadeInFrames / fps, fadeOut: layer.fadeOutFrames / fps } : {} });
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) 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` });
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;