multiplane 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/LICENSE +202 -0
  2. package/README.en.md +322 -0
  3. package/README.md +321 -2
  4. package/THIRD_PARTY_NOTICES.md +30 -0
  5. package/bin/multiplane +98 -0
  6. package/examples/alpha.html +18 -0
  7. package/examples/animation.html +17 -0
  8. package/examples/assets/OFL.txt +93 -0
  9. package/examples/assets/Roboto-Variable.ttf +0 -0
  10. package/examples/assets/card.svg +6 -0
  11. package/examples/assets/readme-card-0.svg +1 -0
  12. package/examples/assets/readme-card-1.svg +1 -0
  13. package/examples/assets/readme-card-2.svg +1 -0
  14. package/examples/assets/readme-detail.svg +1 -0
  15. package/examples/readme-cards.html +23 -0
  16. package/examples/readme-cut.html +13 -0
  17. package/examples/readme-depth.html +15 -0
  18. package/examples/readme-wheel.html +14 -0
  19. package/examples/three-cards.html +17 -0
  20. package/examples/typography.html +17 -0
  21. package/licenses/GPL-3.0.txt +232 -0
  22. package/licenses/LGPL-3.0.txt +304 -0
  23. package/licenses/acorn-LICENSE +21 -0
  24. package/licenses/colour-LICENSE.md +82 -0
  25. package/licenses/detect-libc-LICENSE +201 -0
  26. package/licenses/entities-LICENSE +11 -0
  27. package/licenses/nanoid-LICENSE +20 -0
  28. package/licenses/native/README.ijg +258 -0
  29. package/licenses/native/aom-LICENSE +27 -0
  30. package/licenses/native/aom-PATENTS +108 -0
  31. package/licenses/native/archive-COPYING +65 -0
  32. package/licenses/native/cairo-COPYING +33 -0
  33. package/licenses/native/cairo-COPYING-MPL-1.1 +470 -0
  34. package/licenses/native/cgif-LICENSE +21 -0
  35. package/licenses/native/exif-COPYING +504 -0
  36. package/licenses/native/expat-COPYING +21 -0
  37. package/licenses/native/ffi-LICENSE +21 -0
  38. package/licenses/native/fontconfig-COPYING +200 -0
  39. package/licenses/native/freetype-FTL.TXT +169 -0
  40. package/licenses/native/fribidi-COPYING +501 -0
  41. package/licenses/native/glib-COPYING +175 -0
  42. package/licenses/native/harfbuzz-COPYING +42 -0
  43. package/licenses/native/heif-COPYING +871 -0
  44. package/licenses/native/highway-LICENSE +371 -0
  45. package/licenses/native/imagequant-COPYRIGHT +36 -0
  46. package/licenses/native/lcms-COPYING +22 -0
  47. package/licenses/native/mozjpeg-LICENSE.md +135 -0
  48. package/licenses/native/nsgif-COPYING +21 -0
  49. package/licenses/native/pango-COPYING +482 -0
  50. package/licenses/native/pixman-COPYING +42 -0
  51. package/licenses/native/png-LICENSE +134 -0
  52. package/licenses/native/proxy-libintl-COPYING +482 -0
  53. package/licenses/native/rsvg-COPYING.LIB +502 -0
  54. package/licenses/native/sources.json +240 -0
  55. package/licenses/native/tiff-LICENSE.md +49 -0
  56. package/licenses/native/uhdr-LICENSE +230 -0
  57. package/licenses/native/vips-COPYING +502 -0
  58. package/licenses/native/webp-COPYING +30 -0
  59. package/licenses/native/webp-PATENTS +23 -0
  60. package/licenses/native/xml2-Copyright +24 -0
  61. package/licenses/native/zlib-ng-LICENSE.md +19 -0
  62. package/licenses/parse5-LICENSE +19 -0
  63. package/licenses/picocolors-LICENSE +15 -0
  64. package/licenses/playwright-LICENSE +202 -0
  65. package/licenses/playwright-NOTICE +5 -0
  66. package/licenses/playwright-ThirdPartyNotices.txt +14 -0
  67. package/licenses/playwright-core-ThirdPartyNotices.txt +13 -0
  68. package/licenses/postcss-LICENSE +20 -0
  69. package/licenses/postcss-value-parser-LICENSE +22 -0
  70. package/licenses/semver-LICENSE +15 -0
  71. package/licenses/sharp-LICENSE +191 -0
  72. package/licenses/sharp-win32-x64-LICENSE +191 -0
  73. package/licenses/sharp-win32-x64-NOTICES.md +59 -0
  74. package/licenses/source-map-js-LICENSE +28 -0
  75. package/licenses/three-LICENSE +21 -0
  76. package/package.json +92 -6
  77. package/skills/multiplane/SKILL.md +213 -0
  78. package/src/alpha.mjs +69 -0
  79. package/src/artifacts.mjs +61 -0
  80. package/src/benchmark.mjs +140 -0
  81. package/src/browser-recovery.mjs +26 -0
  82. package/src/capture.mjs +224 -0
  83. package/src/config.mjs +79 -0
  84. package/src/doctor.mjs +56 -0
  85. package/src/draft.mjs +30 -0
  86. package/src/encoder.mjs +16 -0
  87. package/src/environment.mjs +56 -0
  88. package/src/errors.mjs +51 -0
  89. package/src/events.mjs +252 -0
  90. package/src/ffmpeg.mjs +32 -0
  91. package/src/format.mjs +27 -0
  92. package/src/frame-rate.mjs +28 -0
  93. package/src/home-migration.mjs +54 -0
  94. package/src/hosted.mjs +134 -0
  95. package/src/index.mjs +14 -0
  96. package/src/inspect.mjs +20 -0
  97. package/src/jobs.mjs +51 -0
  98. package/src/lint.mjs +248 -0
  99. package/src/output-alpha.mjs +36 -0
  100. package/src/output-check.mjs +110 -0
  101. package/src/pixel-comparison.mjs +43 -0
  102. package/src/platform.mjs +114 -0
  103. package/src/png-native.mjs +12 -0
  104. package/src/png.mjs +74 -0
  105. package/src/post.mjs +433 -0
  106. package/src/preview.mjs +23 -0
  107. package/src/process-identity.mjs +17 -0
  108. package/src/process.mjs +9 -0
  109. package/src/render-pool.mjs +93 -0
  110. package/src/render-worker.mjs +59 -0
  111. package/src/render.mjs +157 -0
  112. package/src/resource-path.mjs +29 -0
  113. package/src/resources.mjs +27 -0
  114. package/src/runtime/camera.js +37 -0
  115. package/src/runtime/shot.css +9 -0
  116. package/src/runtime/shot.js +71 -0
  117. package/src/serve.mjs +23 -0
  118. package/src/server.mjs +93 -0
  119. package/src/setup.mjs +51 -0
  120. package/src/skills.mjs +128 -0
  121. package/src/static-reuse.mjs +265 -0
  122. package/src/stills.mjs +81 -0
  123. package/src/three/index.js +348 -0
  124. package/src/user-home.mjs +105 -0
  125. package/src/verify.mjs +161 -0
  126. package/src/video-pipe.mjs +6 -0
  127. package/src/workers.mjs +62 -0
  128. package/tools/bench/shots/canvas-2d.html +1 -0
  129. package/tools/bench/shots/dom-light.html +1 -0
  130. package/tools/bench/shots/scene.js +45 -0
  131. package/tools/bench/shots/webgl.html +1 -0
  132. package/tools/install-browser.mjs +18 -0
package/src/render.mjs ADDED
@@ -0,0 +1,157 @@
1
+ import { readBenchmark, validBenchmark, benchmarkMachine, selectWorkers, classifyShot } from './workers.mjs';
2
+ import { recoveryBudget } from './browser-recovery.mjs';
3
+ import path from 'node:path';
4
+ import { mkdir, rm, writeFile } from 'node:fs/promises';
5
+ import { createHash } from 'node:crypto';
6
+ import { spawn } from 'node:child_process';
7
+ import { stopChild } from './process.mjs';
8
+ import { inspectFFmpeg } from './ffmpeg.mjs';
9
+ import { openShot, number } from './capture.mjs';
10
+ import { createPostCapture, resolveScale } from './post.mjs';
11
+ import { createRenderPool } from './render-pool.mjs';
12
+ import { resolveFormat, videoEncoding } from './format.mjs';
13
+ import { checkAlphaVideo } from './output-alpha.mjs';
14
+ import { writeFrame } from './video-pipe.mjs';
15
+ import { runCommand } from './events.mjs';
16
+ import { loaded, observeBrowser, environment } from './environment.mjs';
17
+ import { MultiplaneError, invalidOption } from './errors.mjs';
18
+ import { requireNewOutput, publishVideo } from './artifacts.mjs';
19
+ import { createPreview } from './preview.mjs';
20
+ import { checkVideo } from './output-check.mjs';
21
+ import { frameRate } from './frame-rate.mjs';
22
+
23
+ export async function renderShot(shot, options = {}) {
24
+ return runCommand('render', shot, options, async events => {
25
+ options = { ...events.options };
26
+ delete options.onEvent;
27
+ const probe = options._probeVideo;
28
+ delete options._probeVideo;
29
+ const inspect = options._inspectFFmpeg ?? inspectFFmpeg;
30
+ delete options._inspectFFmpeg;
31
+ options._browserRecovery = recoveryBudget();
32
+ const name = path.basename(shot, path.extname(shot));
33
+ let result;
34
+
35
+ let capture, pool, encoder, temporary, cleaning, encoderClosed = false;
36
+ function cleanup() { return cleaning ??= cleanResources(); }
37
+ async function cleanResources() {
38
+ try {
39
+ if (encoder && !encoderClosed) await stopChild(encoder);
40
+ } finally {
41
+ try { await capture?.close(); }
42
+ finally { try { await pool?.close(); } finally { if (temporary) await rm(temporary, { force: true }); } }
43
+ }
44
+ }
45
+
46
+ try {
47
+ const out = path.resolve(options.out ?? path.join(events.directory, `${name}.${options.format}`));
48
+ if (!options.overwrite) await requireNewOutput(out);
49
+ events.check();
50
+ events.progress('launch');
51
+ const { scale, renderScale } = resolveScale(options);
52
+ const encoding = videoEncoding(options);
53
+ let videoArgs = encoding.args;
54
+ const ffmpegInfo = await inspect(options.ffmpeg, process.env, { x264: 'libx264', 'nvenc-h264': 'h264_nvenc', 'nvenc-hevc': 'hevc_nvenc' }[encoding.encoder] ?? encoding.encoder);
55
+ const ffmpeg = ffmpegInfo.path;
56
+ options.transport = 'raw';
57
+ const audioOffset = number(options['audio-offset'] ?? 0, '--audio-offset');
58
+ if (options['audio-offset'] !== undefined && !options.audio) throw invalidOption('--audio-offset', '--audio-offset requires --audio.');
59
+ capture = await openShot(shot, renderScale, { ...options, onWarning: events.warning, check: events.check, onBrowser: observeBrowser(events, ffmpegInfo) });
60
+ const record = ['option', 'config'].includes(events.workersSource) ? null : readBenchmark();
61
+ let machine, type;
62
+ if (record) {
63
+ try {
64
+ machine = benchmarkMachine(await environment(capture.browser), options.raster);
65
+ if (validBenchmark(record, machine)) type = await classifyShot(capture);
66
+ } catch { /* Optional tuning must not prevent an otherwise valid render. */ }
67
+ }
68
+ const selection = selectWorkers({ workers: options.workers, source: events.workersSource, record, machine, type,
69
+ scale, memoryScale: Math.max(scale, renderScale) });
70
+ const workers = selection.workers;
71
+ options.workers = events.options.workers = workers;
72
+ // TODO(design): Document workersSource on start and result.data.resources.
73
+ events.workersSource = selection.source;
74
+ options.alpha = events.options.alpha = capture.alpha;
75
+ resolveFormat(options);
76
+ Object.assign(encoding, videoEncoding(options));
77
+ videoArgs = encoding.args;
78
+ await loaded(events, capture, ffmpegInfo);
79
+ const fps = number(options.fps ?? capture.fps, '--fps', { positive: true });
80
+ const rate = frameRate(fps);
81
+ const processed = createPostCapture(capture, options, fps);
82
+ const from = number(options.from ?? 0, '--from');
83
+ const to = number(options.to ?? capture.duration, '--to');
84
+ if (to <= from || to > capture.duration) throw invalidOption('--to', 'Require 0 <= from < to <= shot duration.');
85
+ const first = Math.ceil(from * fps - 1e-9);
86
+ const end = Math.ceil(to * fps - 1e-9);
87
+ const total = end - first;
88
+ if (!total) throw invalidOption('--from', 'Range contains no frames.');
89
+ const { width, height } = capture;
90
+ events.progress('capture', 0, total);
91
+ await capture.close(); capture = undefined;
92
+ events.check();
93
+ const actualWorkers = Math.min(workers, Math.ceil(total / 4));
94
+ events.resources.setWorkers(actualWorkers);
95
+ const started = performance.now();
96
+ const frameHashes = process.env.RENDER_FRAME_HASHES ? [] : null;
97
+ pool = createRenderPool(shot, options, fps, actualWorkers, { width, height }, events.warning);
98
+ events.onCancel(() => pool?.cancel());
99
+ await mkdir(path.dirname(out), { recursive: true });
100
+ events.check();
101
+ temporary = `${out}.${events.jobId}.partial.${options.format}`;
102
+ const audioArgs = options.audio
103
+ ? ['-i', path.resolve(options.audio), '-map', '0:v:0', '-map', '1:a:0', '-af', `atrim=start=${audioOffset + rate.seconds(first)},asetpts=PTS-STARTPTS,apad,atrim=duration=${rate.seconds(total)}`, ...encoding.audio]
104
+ : ['-an'];
105
+ // Supported since FFmpeg 5.1; older FFmpeg uses the global -vsync 0.
106
+ // Do not add output -r: passthrough must neither drop nor duplicate frames.
107
+ encoder = spawn(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-y', '-f', 'rawvideo', '-pixel_format', 'rgba', '-video_size', `${width * scale}x${height * scale}`, '-framerate', rate.argument, '-i', 'pipe:0', ...audioArgs, ...videoArgs, '-fps_mode', 'passthrough', '-pix_fmt', encoding.pixelFormat, ...encoding.mux, temporary], { windowsHide: true, stdio: ['pipe', 'ignore', 'pipe'] });
108
+ encoder.once('close', () => { encoderClosed = true; });
109
+ events.onCancel(() => stopChild(encoder));
110
+ let stderr = '', failure;
111
+ encoder.stderr.on('data', data => { stderr = (stderr + data).slice(-8000); });
112
+ encoder.stdin.on('error', error => { failure = error; });
113
+ const finished = new Promise((resolve, reject) => {
114
+ encoder.once('error', cause => reject(new MultiplaneError('encode_failed', undefined, { cause, details: { reason: cause.message } })));
115
+ encoder.once('close', code => code === 0 ? resolve() : reject(new MultiplaneError('encode_failed', undefined, { details: { exitCode: code, stderr } })));
116
+ });
117
+ finished.catch(error => { failure = error; });
118
+ const alphaReferences = new Map();
119
+ let encoderWriteMs = 0, framesSent = 0;
120
+ // Internal switch for the pixel-equivalence and overhead tests, not a CLI option.
121
+ const preview = createPreview(events, width * scale, height * scale, { enabled: options._preview !== false });
122
+ for await (const { frame, png } of pool.frames(first, end, async ({ frame, png }) => {
123
+ events.check();
124
+ await preview(png, frame);
125
+ events.progress('capture', frame - first + 1, total);
126
+ })) {
127
+ events.check();
128
+ if (failure) throw new MultiplaneError('encode_failed', undefined, { cause: failure });
129
+ if (options.alpha && (framesSent === 0 || framesSent === Math.floor(total / 2))) alphaReferences.set(framesSent, Buffer.from(png.filter((_, i) => i % 4 === 3)));
130
+ frameHashes?.push({ frame, sha256: createHash('sha256').update(png).digest('hex') });
131
+ const writeStarted = performance.now();
132
+ await writeFrame(encoder.stdin, png).catch(cause => { throw new MultiplaneError('encode_failed', undefined, { cause, details: { reason: cause.message } }); });
133
+ encoderWriteMs += performance.now() - writeStarted;
134
+ framesSent++;
135
+ }
136
+ const drainStarted = performance.now();
137
+ events.progress('encode', total, total);
138
+ encoder.stdin.end();
139
+ await finished;
140
+ events.check();
141
+ const encoderDrainMs = performance.now() - drainStarted;
142
+ const probeStarted = performance.now();
143
+ const outputCheck = await checkVideo(ffmpeg, temporary, { frames: framesSent, durationSeconds: rate.seconds(framesSent), width: width * scale, height: height * scale, fps: rate.fps, ...(options.alpha ? { alpha: true, format: options.format } : {}) }, events, probe);
144
+ if (options.alpha) await checkAlphaVideo(ffmpeg, temporary, options.format, alphaReferences, width * scale, height * scale, events);
145
+ const outputCheckMs = performance.now() - probeStarted;
146
+ await publishVideo(temporary, out, options.overwrite);
147
+ temporary = undefined;
148
+ const elapsed = (performance.now() - started) / 1000;
149
+ if (frameHashes) await writeFile(process.env.RENDER_FRAME_HASHES, JSON.stringify(frameHashes, null, 2));
150
+ if (process.env.RENDER_PROFILE) await writeFile(process.env.RENDER_PROFILE, JSON.stringify({elapsed,frames:total,workers:actualWorkers,options,stats:pool.stats,encoderWriteMs,encoderDrainMs,outputCheckMs},null,2));
151
+ events.artifact(out, 'video');
152
+ result = { staticReuse: pool.staticReuse, blurSamples: pool.blurSamples, output: out, frames: framesSent, fps, from, to, width: width * scale, height: height * scale, encoder: encoding.encoder, format: options.format, alpha: options.alpha, post: { ...processed.post, adaptiveBlur: Number(options['adaptive-blur'] ?? 0.5) }, renderScale, scale, outputCheck };
153
+ } finally { await cleanup(); }
154
+
155
+ return options.draft ? { ...result, draft: true } : result;
156
+ });
157
+ }
@@ -0,0 +1,29 @@
1
+ import path from 'node:path';
2
+ import { realpath } from 'node:fs';
3
+ import { promisify } from 'node:util';
4
+
5
+ const nativeRealpath = promisify(realpath.native);
6
+
7
+ // Resolve aliases (including Windows 8.3 names) even for missing descendants.
8
+ export async function normalizeResourcePath(file) {
9
+ const absolute = path.resolve(file);
10
+ try { return await nativeRealpath(absolute); }
11
+ catch (error) {
12
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
13
+ const parent = path.dirname(absolute);
14
+ if (parent === absolute) return absolute;
15
+ return path.join(await normalizeResourcePath(parent), path.basename(absolute));
16
+ }
17
+ }
18
+
19
+ export function relativeResourcePath(root, target, platform = process.platform) {
20
+ const paths = platform === 'win32' ? path.win32 : path.posix;
21
+ return paths.relative(platform === 'win32' ? root.toLowerCase() : root,
22
+ platform === 'win32' ? target.toLowerCase() : target);
23
+ }
24
+
25
+ export async function isInsideResourceRoot(root, target) {
26
+ const [base, file] = await Promise.all([normalizeResourcePath(root), normalizeResourcePath(target)]);
27
+ const relative = relativeResourcePath(base, file);
28
+ return relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative);
29
+ }
@@ -0,0 +1,27 @@
1
+ import { cpus } from 'node:os';
2
+ import { createProcessTreeReader } from './platform.mjs';
3
+
4
+ // Diagnostics only: never pass these values to frame generation.
5
+ export function createResourceSampler({ sample, intervalMs = 1000, onSample = () => {}, enabled = true } = {}) {
6
+ const reader = !sample && enabled ? createProcessTreeReader() : undefined;
7
+ sample ??= reader?.sample;
8
+ let pending, timer, stopped = false, peakMemoryBytes = null, memorySamples = 0, workers = 1;
9
+ const take = () => {
10
+ if (pending || stopped || !enabled) return pending ?? Promise.resolve();
11
+ clearTimeout(timer);
12
+ pending = (async () => {
13
+ let bytes = null;
14
+ try { const value = await sample(); if (Number.isSafeInteger(value) && value > 0) bytes = value; } catch {}
15
+ if (bytes !== null) { peakMemoryBytes = Math.max(peakMemoryBytes ?? 0, bytes); memorySamples++; }
16
+ try { onSample(bytes); } catch {}
17
+ })().finally(() => { pending = undefined; if (!stopped) { timer = setTimeout(take, intervalMs); timer.unref(); } });
18
+ return pending;
19
+ };
20
+ void take();
21
+ return {
22
+ sample: take,
23
+ setWorkers(value) { workers = value; },
24
+ snapshot: () => ({ peakMemoryBytes, memorySamples, workers, cpuCount: cpus().length || null }),
25
+ async stop() { stopped = true; clearTimeout(timer); await pending; await reader?.close(); },
26
+ };
27
+ }
@@ -0,0 +1,37 @@
1
+ (function () {
2
+ const E = Shot.ease;
3
+
4
+ function interp(a, b, k) {
5
+ const s = a.s * Math.pow(b.s / a.s, k);
6
+ if (b.s >= a.s) {
7
+ const px = (b.f[0] - a.f[0]) * a.s * (1 - k);
8
+ const py = (b.f[1] - a.f[1]) * a.s * (1 - k);
9
+ return { f: [b.f[0] - px / s, b.f[1] - py / s], s };
10
+ }
11
+ const px = (a.f[0] - b.f[0]) * b.s * k;
12
+ const py = (a.f[1] - b.f[1]) * b.s * k;
13
+ return { f: [a.f[0] - px / s, a.f[1] - py / s], s };
14
+ }
15
+
16
+ function track(keys) {
17
+ function at(t) {
18
+ if (t <= keys[0].t) return { f: keys[0].f, s: keys[0].s };
19
+ for (let i = 1; i < keys.length; i++) {
20
+ const a = keys[i - 1], b = keys[i];
21
+ if (b.t === a.t) continue;
22
+ if (t < b.t) return interp(a, b, (b.ease || E.inOutCubic)((t - a.t) / (b.t - a.t)));
23
+ }
24
+ const z = keys[keys.length - 1];
25
+ return { f: z.f, s: z.s };
26
+ }
27
+ return {
28
+ at,
29
+ apply(node, t, w = Shot.size.w, h = Shot.size.h) {
30
+ const { f, s } = at(t);
31
+ node.setAttribute('transform', `translate(${w / 2} ${h / 2}) scale(${s}) translate(${-f[0]} ${-f[1]})`);
32
+ },
33
+ };
34
+ }
35
+
36
+ window.Camera = { track, linear: k => k };
37
+ })();
@@ -0,0 +1,9 @@
1
+ html, body { margin: 0; height: 100%; overflow: hidden; background: var(--multiplane-page-background, #000); }
2
+ #stage {
3
+ position: absolute; left: 0; top: 0;
4
+ width: 1920px; height: 1080px;
5
+ transform-origin: 0 0;
6
+ overflow: hidden;
7
+ background: var(--paper, #fff);
8
+ }
9
+ #stage > svg { display: block; }
@@ -0,0 +1,71 @@
1
+ (function () {
2
+ const params = new URLSearchParams(location.search);
3
+ const mode = params.get('mode') || 'standalone';
4
+ const stage = document.getElementById('stage');
5
+ const css = stage && getComputedStyle(stage);
6
+ const size = css ? { w: parseFloat(css.width), h: parseFloat(css.height) } : { w: 1920, h: 1080 };
7
+
8
+ const E = {
9
+ clamp: (x, a = 0, b = 1) => Math.min(b, Math.max(a, x)),
10
+ lerp: (a, b, k) => a + (b - a) * k,
11
+ prog: (t, start, dur) => E.clamp((t - start) / dur),
12
+ inCubic: k => k * k * k,
13
+ outCubic: k => 1 - Math.pow(1 - k, 3),
14
+ outQuart: k => 1 - Math.pow(1 - k, 4),
15
+ outExpo: k => (k >= 1 ? 1 : 1 - Math.pow(2, -10 * k)),
16
+ inOutCubic: k => (k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2),
17
+ inOutQuart: k => (k < 0.5 ? 8 * k ** 4 : 1 - Math.pow(-2 * k + 2, 4) / 2),
18
+ };
19
+
20
+ function fitStage(stage) {
21
+ if (mode === 'render') return;
22
+ const s = Math.min(innerWidth / size.w, innerHeight / size.h);
23
+ const x = (innerWidth - size.w * s) / 2;
24
+ const y = (innerHeight - size.h * s) / 2;
25
+ stage.style.transform = `translate(${x}px, ${y}px) scale(${s})`;
26
+ }
27
+
28
+ // spec: { duration, fps?, fonts?: [[font, text?]...], setup?(), render(t) }
29
+ function define(spec) {
30
+ const stage = document.getElementById('stage');
31
+ document.documentElement.dataset.mode = mode;
32
+ fitStage(stage);
33
+ addEventListener('resize', () => fitStage(stage));
34
+
35
+ const fontLoads = (spec.fonts || []).map(([f, text]) => document.fonts.load(f, text));
36
+ const ready = Promise.all(fontLoads)
37
+ .then(() => document.fonts.ready)
38
+ .then(() => (spec.setup ? spec.setup() : null))
39
+ .then(() => spec.render(0));
40
+ // The host awaits ready after navigation; preserve its rejection without
41
+ // reporting an early unhandled rejection as a generic script load failure.
42
+ ready.catch(() => {});
43
+
44
+ window.SHOT = {
45
+ get duration() { return spec.duration; },
46
+ fps: spec.fps || 30,
47
+ get cuts() { return spec.cuts || []; },
48
+ post: spec.post || {},
49
+ meta: spec.meta === undefined ? null : spec.meta,
50
+ ready,
51
+ ...(spec.blurAt ? { blurAt: t => spec.blurAt(t) } : {}),
52
+ render: t => spec.render(Math.min(Math.max(t, 0), spec.duration)),
53
+ };
54
+
55
+ if (mode === 'standalone') {
56
+ ready.then(() => {
57
+ const fixed = params.get('t');
58
+ if (fixed !== null) return window.SHOT.render(+fixed);
59
+ const t0 = performance.now();
60
+ const loop = now => {
61
+ const t = ((now - t0) / 1000) % (spec.duration + 1);
62
+ window.SHOT.render(t);
63
+ requestAnimationFrame(loop);
64
+ };
65
+ requestAnimationFrame(loop);
66
+ });
67
+ }
68
+ }
69
+
70
+ window.Shot = { define, ease: E, mode, size };
71
+ })();
package/src/serve.mjs ADDED
@@ -0,0 +1,23 @@
1
+ import { runCommand } from './events.mjs';
2
+ import { startServer } from './server.mjs';
3
+ import { stopSignals } from './platform.mjs';
4
+
5
+ export async function serve(options = {}) {
6
+ return runCommand('serve', null, options, async events => {
7
+ let server;
8
+ let stop;
9
+ const stopped = new Promise(resolve => { stop = resolve; });
10
+ const signals = stopSignals();
11
+ for (const signal of signals) process.on(signal, stop);
12
+ try {
13
+ server = await startServer(events.options.port ?? 0, events.options.root);
14
+ const { url, root, port } = server;
15
+ events.emit('listening', { url, root, port });
16
+ await stopped;
17
+ return {};
18
+ } finally {
19
+ try { await server?.close(); }
20
+ finally { for (const signal of signals) process.off(signal, stop); }
21
+ }
22
+ }, false);
23
+ }
package/src/server.mjs ADDED
@@ -0,0 +1,93 @@
1
+ import { normalizeResourcePath, isInsideResourceRoot } from './resource-path.mjs';
2
+ import http from 'node:http';
3
+ import { createReadStream } from 'node:fs';
4
+ import { stat, readdir } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { MultiplaneError, invalidOption } from './errors.mjs';
8
+
9
+ export const root = process.cwd();
10
+ const packageRoot = fileURLToPath(new URL('../', import.meta.url));
11
+ const mime = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.avif': 'image/avif', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.json': 'application/json' };
12
+
13
+ export async function startServer(port = 0, root = process.cwd()) {
14
+ if (typeof root !== 'string' || !root.trim()) throw invalidOption('--root', '--root must be a directory path.');
15
+ if (!String(port).trim() || !Number.isInteger(Number(port)) || Number(port) < 0 || Number(port) > 65535) throw invalidOption('--port', '--port must be an integer from 0 to 65535.');
16
+ port = Number(port);
17
+ const base = await normalizeResourcePath(root);
18
+ try { if (!(await stat(base)).isDirectory()) throw new Error(); }
19
+ catch { throw invalidOption('--root', '--root must be an existing directory.'); }
20
+ root = base;
21
+ async function shots(directory = base, ancestors = new Set()) {
22
+ const canonical = await normalizeResourcePath(directory);
23
+ if (ancestors.has(canonical) || !await isInsideResourceRoot(base, canonical)) return [];
24
+ const parents = new Set([...ancestors, canonical]), found = [];
25
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
26
+ if (entry.name.startsWith('.') || ['node_modules', 'out'].includes(entry.name)) continue;
27
+ const file = path.join(directory, entry.name);
28
+ if (!await isInsideResourceRoot(base, file)) continue;
29
+ let info;
30
+ try { info = await stat(file); } catch (error) { if (error.code === 'ENOENT') continue; throw error; }
31
+ if (info.isDirectory()) found.push(...await shots(file, parents));
32
+ else if (info.isFile() && path.extname(file).toLowerCase() === '.html') found.push(path.relative(base, file).split(path.sep).join('/'));
33
+ }
34
+ return found;
35
+ }
36
+ const server = http.createServer(async (req, res) => {
37
+ res.setHeader('Cache-Control', 'no-store');
38
+ try {
39
+ if (!['GET', 'HEAD'].includes(req.method)) { res.writeHead(405); return res.end(); }
40
+ const pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
41
+
42
+ const segments = pathname.replaceAll('\\', '/').split('/');
43
+ if (segments.some(p => p.startsWith('.') || p === 'node_modules')) { res.writeHead(403); return res.end(); }
44
+ let resourceRoot = root, resourceBase = base, resourcePath = pathname;
45
+ // /multiplane/ is reserved for runtime resources.
46
+ if (pathname.startsWith('/multiplane/')) {
47
+ if (pathname === '/multiplane/shots.json') {
48
+ const body = JSON.stringify((await shots()).sort());
49
+ res.writeHead(200, { 'Content-Type': mime['.json'], 'Content-Length': Buffer.byteLength(body) });
50
+ return res.end(req.method === 'HEAD' ? undefined : body);
51
+ }
52
+ if (pathname.startsWith('/multiplane/runtime/')) { resourceRoot = path.join(packageRoot, 'src/runtime'); resourcePath = pathname.slice('/multiplane/runtime'.length); }
53
+ else if (pathname === '/multiplane/three.js') { resourceRoot = path.join(packageRoot, 'src/three'); resourcePath = '/index.js'; }
54
+ else if (pathname.startsWith('/multiplane/vendor/three/')) {
55
+ resourceRoot = path.dirname(path.dirname(fileURLToPath(import.meta.resolve('three'))));
56
+ resourcePath = pathname.slice('/multiplane/vendor/three'.length);
57
+ if (!/^\/(build|examples\/jsm)\/.*\.js$/.test(resourcePath)) { res.writeHead(403); return res.end(); }
58
+ } else { res.writeHead(404); return res.end(); }
59
+ resourceBase = await normalizeResourcePath(resourceRoot);
60
+ }
61
+ let target = path.resolve(resourceRoot, `.${resourcePath}`);
62
+ if (!await isInsideResourceRoot(resourceRoot, target)) { res.writeHead(403); return res.end(); }
63
+ if ((await stat(target)).isDirectory()) target = path.join(target, 'index.html');
64
+ target = await normalizeResourcePath(target);
65
+ if (!await isInsideResourceRoot(resourceBase, target)) { res.writeHead(403); return res.end(); }
66
+ const info = await stat(target);
67
+ const contentType = { '.wav': 'audio/wav', '.srt': 'text/plain; charset=utf-8', '.mp4': 'video/mp4' }[path.extname(target)] ?? mime[path.extname(target)] ?? 'application/octet-stream';
68
+ const headers = { 'Content-Type': contentType, 'Content-Length': info.size, 'Accept-Ranges': 'bytes' };
69
+ if (req.headers.range) {
70
+ const match = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range);
71
+ let start = match?.[1] ? Number(match[1]) : Math.max(0, info.size - Number(match?.[2]));
72
+ let end = match?.[1] && match[2] ? Math.min(Number(match[2]), info.size - 1) : info.size - 1;
73
+ if (!match || (!match[1] && !match[2]) || !Number.isSafeInteger(start) || start < 0 || start > end) {
74
+ res.writeHead(416, { 'Content-Range': `bytes */${info.size}` }); return res.end();
75
+ }
76
+ res.writeHead(206, { ...headers, 'Content-Length': end - start + 1, 'Content-Range': `bytes ${start}-${end}/${info.size}` });
77
+ if (req.method === 'HEAD') return res.end();
78
+ return createReadStream(target, { start, end }).on('error', () => res.destroy()).pipe(res);
79
+ }
80
+ res.writeHead(200, headers);
81
+ if (req.method === 'HEAD') return res.end();
82
+ createReadStream(target).on('error', () => res.destroy()).pipe(res);
83
+ } catch (error) {
84
+ if (error.code === 'ERR_MODULE_NOT_FOUND') { res.writeHead(503, { 'x-multiplane-error': 'three_not_installed' }); return res.end('Optional three.js is not installed.'); }
85
+ res.writeHead(error.code === 'ENOENT' ? 404 : 400);
86
+ res.end('Cannot read the requested file.');
87
+ }
88
+ });
89
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); })
90
+ .catch(cause => { if (cause.code === 'EADDRINUSE') throw new MultiplaneError('port_in_use', undefined, { cause }); throw cause; });
91
+ return { url: `http://127.0.0.1:${server.address().port}`, root: base, port: server.address().port,
92
+ close: () => new Promise((resolve, reject) => { server.close(error => error ? reject(error) : resolve()); server.closeAllConnections(); }) };
93
+ }
package/src/setup.mjs ADDED
@@ -0,0 +1,51 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { Configuration } from './config.mjs';
3
+ import { Skills } from './skills.mjs';
4
+ import { createEvents, version } from './events.mjs';
5
+ import { asError, invalidOption, MultiplaneError } from './errors.mjs';
6
+
7
+ export async function setup(command, args, onEvent) {
8
+ const events = createEvents(command, null, { onEvent });
9
+ events.start();
10
+ try {
11
+ const positional = [], options = {};
12
+ for (let i = 0; i < args.length; i++) {
13
+ const token = args[i];
14
+ if (!token.startsWith('--')) { positional.push(token); continue; }
15
+ const key = token.slice(2);
16
+ if (!['json', ...(command === 'skill' ? ['host', 'force', 'expect'] : [])].includes(key) || key in options) throw invalidOption(token, 'Invalid or duplicate option.');
17
+ if (['json', 'force'].includes(key)) options[key] = true;
18
+ else { if (args[i + 1] === undefined || args[i + 1].startsWith('--')) throw invalidOption(token, 'Missing option value.'); options[key] = args[++i]; }
19
+ }
20
+ const [action, key, value] = positional;
21
+ let data;
22
+ if (command === 'config') {
23
+ if (!['get', 'set', 'unset'].includes(action) || (action === 'get' ? positional.length > 2 : positional.length !== (action === 'set' ? 3 : 2))) throw invalidOption('action', 'Use config get [key], set <key> <value>, or unset <key>.');
24
+ const config = new Configuration(undefined, process.env, warning => events.warning(warning));
25
+ data = action === 'get' ? await config.read(key) : await config.save(key, action === 'unset' ? null : value);
26
+ if (data.check && !data.check.usable) events.warning({ code: 'ffmpeg_unusable', message: 'The saved FFmpeg path is unusable.', details: { reason: data.check.reason } });
27
+ } else {
28
+ if (!['status', 'install', 'remove'].includes(action) || positional.length !== 1 || (action !== 'status' && !options.host) || !['all', 'claude', 'codex'].includes(options.host ?? 'all') || (action === 'status' && ('force' in options || 'expect' in options))) throw invalidOption('host', 'Use skill status, install, or remove with --host claude|codex|all.');
29
+ const skills = new Skills(fileURLToPath(new URL('../skills/multiplane/', import.meta.url)), version);
30
+ const hosts = await skills.list(!options.host || options.host === 'all' ? ['claude', 'codex'] : [options.host]);
31
+ if (action === 'status') data = { hosts };
32
+ else {
33
+ data = { hosts: [] }; let first;
34
+ for (const status of hosts) {
35
+ const item = { host: status.host, action, ok: true, code: null, message: null, target: status.target, version: action === 'install' ? version : null, backup: null };
36
+ try {
37
+ if (status.blocked) throw new MultiplaneError('skill_blocked', status.error);
38
+ // TODO(design): Document --expect null for the missing-copy signature.
39
+ const expected = options.expect === 'null' ? null : options.expect;
40
+ Object.assign(item, await skills.change(status.host, action, expected, undefined, !!options.force));
41
+ } catch (failure) { const error = asError(failure); first ??= error; Object.assign(item, { ok: false, code: error.code, message: error.message, version: status.version }); }
42
+ data.hosts.push(item);
43
+ }
44
+ if (first) { first.result = { data }; throw first; }
45
+ }
46
+ }
47
+ return events.finish(data);
48
+ } catch (failure) {
49
+ const error = asError(failure); error.result = events.finish(error.result?.data ?? {}, error); throw error;
50
+ } finally { events.dispose(); }
51
+ }
package/src/skills.mjs ADDED
@@ -0,0 +1,128 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import * as fs from 'node:fs/promises';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { MultiplaneError } from './errors.mjs';
6
+ export async function findCommand(name, env = process.env) {
7
+ const extensions = process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
8
+ const search = Object.entries(env).find(([k]) => k.toLowerCase() === 'path')?.[1] ?? '';
9
+ for (const dir of search.split(path.delimiter).filter(Boolean)) for (const ext of extensions) {
10
+ const file = path.resolve(dir.replace(/^"|"$/g, ''), name + ext);
11
+ try { if ((await fs.stat(file)).isFile()) { await fs.access(file, process.platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK); return file; } } catch {}
12
+ }
13
+ return null;
14
+ }
15
+ const marker = '.multiplane.json';
16
+ const hash = bytes => createHash('sha256').update(bytes).digest('hex');
17
+ async function exists(file) { try { await fs.lstat(file); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } }
18
+ // Resolve existing ancestors without creating directories during inspection.
19
+ async function realRoot(root) {
20
+ try {
21
+ const resolved = await fs.realpath(root);
22
+ if (!(await fs.stat(resolved)).isDirectory()) throw Object.assign(Error('Skill root is not a directory'), { code: 'ENOTDIR' });
23
+ return resolved;
24
+ }
25
+ catch (error) {
26
+ if (error.code !== 'ENOENT' || await exists(root)) throw error;
27
+ const parent = path.dirname(root);
28
+ if (parent === root) throw error;
29
+ return path.join(await realRoot(parent), path.basename(root));
30
+ }
31
+ }
32
+ const linkedSkill = () => Object.assign(Error('Skill directories cannot contain symbolic links'), { code: 'SKILL_LINK' });
33
+ export async function treeHashes(root) {
34
+ const files = Object.create(null);
35
+ async function walk(dir, prefix = '') {
36
+ if ((await fs.lstat(dir)).isSymbolicLink()) throw linkedSkill();
37
+ for (const entry of (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) => a.name < b.name ? -1 : 1)) {
38
+ const name = prefix + entry.name;
39
+ if (entry.isSymbolicLink()) throw linkedSkill();
40
+ if (entry.isDirectory()) { files[name + '/'] = null; await walk(path.join(dir, entry.name), name + '/'); }
41
+ else if (entry.isFile()) { if (name !== marker) files[name] = hash(await fs.readFile(path.join(dir, entry.name))); }
42
+ else throw Object.assign(Error('Unsupported skill file type'), { code: 'SKILL_LINK' });
43
+ }
44
+ }
45
+ await walk(root); return files;
46
+ }
47
+ export class Skills {
48
+ queue = Promise.resolve();
49
+ constructor(source, version, { user = process.env.MULTIPLANE_AGENT_HOME || os.homedir(), env = process.env } = {}) { this.source = source; this.version = version; this.user = user; this.env = env; }
50
+ target(host) {
51
+ if (!['claude', 'codex'].includes(host)) throw Error('Invalid agent');
52
+ return path.join(this.user, host === 'claude' ? '.claude' : '.agents', 'skills', 'multiplane');
53
+ }
54
+ async status(host) {
55
+ const target = path.join(await realRoot(path.dirname(this.target(host))), 'multiplane');
56
+ const detected = await exists(path.join(this.user, host === 'claude' ? '.claude' : '.codex')) || !!await findCommand(host, this.env);
57
+ const base = { host, target, detected, availableVersion: this.version, version: null, state: 'missing', signature: null, blocked: false, error: null };
58
+ if (!await exists(target)) return base;
59
+ let files;
60
+ try { files = await treeHashes(target); }
61
+ catch (error) { if (error.code === 'SKILL_LINK') return { ...base, state: 'modified', blocked: true, error: error.message }; throw error; }
62
+ const signature = hash(JSON.stringify(files));
63
+ let record;
64
+ try { record = JSON.parse(await fs.readFile(path.join(target, marker), 'utf8')); } catch {}
65
+ const current = await treeHashes(this.source);
66
+ const known = record?.v === 1 && record.host === host && record.skill === 'multiplane' && Array.isArray(record.releases)
67
+ ? record.releases.filter(r => r && typeof r.version === 'string' && r.files && typeof r.files === 'object' && !Array.isArray(r.files)) : [];
68
+ const release = [{ version: this.version, files: current }, ...known].find(r => JSON.stringify(r.files) === JSON.stringify(files));
69
+ return { ...base, signature, version: release?.version ?? record?.version ?? null, state: release ? release.version === this.version ? 'latest' : 'update' : 'modified' };
70
+ }
71
+ list(hosts = ['claude', 'codex']) { return Promise.all(hosts.map(async host => {
72
+ try { return await this.status(host); }
73
+ catch (error) { return { host, target: this.target(host), detected: false, availableVersion: this.version, version: null, state: 'error', blocked: true, error: error.message }; }
74
+ })); }
75
+ async backupName(host, date = new Date()) {
76
+ const pad = n => String(n).padStart(2, '0');
77
+ const base = `multiplane.bak-${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
78
+ const parent = await realRoot(path.dirname(this.target(host)));
79
+ let name = base;
80
+ for (let i = 2; await exists(path.join(parent, name)); i++) name = `${base}-${i}`;
81
+ return name;
82
+ }
83
+ change(host, action, expected, backupName, force = false) {
84
+ const operation = this.queue.then(async () => {
85
+ if (!['install', 'remove'].includes(action)) throw Error('Invalid skill action');
86
+ backupName ??= await this.backupName(host);
87
+ if (!/^multiplane\.bak-\d{8}-\d{6}(?:-[2-9]\d*|-1\d+)?$/.test(backupName)) throw Error('Invalid backup name');
88
+ const before = await this.status(host);
89
+ if (before.blocked) throw new MultiplaneError('skill_blocked', before.error);
90
+ if (expected !== undefined && before.signature !== expected) throw new MultiplaneError('skill_changed');
91
+ if (before.state === 'modified' && !force) throw new MultiplaneError('skill_modified', undefined, { hint: 'Use --force to back up the modified directory before continuing.' });
92
+ const parent = path.dirname(before.target);
93
+ await fs.mkdir(parent, { recursive: true });
94
+ if (await fs.realpath(parent) !== parent) throw Error('Skill path changed');
95
+ const token = randomUUID(), stage = path.join(parent, '.multiplane-new-' + token);
96
+ const old = path.join(parent, backupName);
97
+ if (await exists(old)) throw Error('Backup already exists');
98
+ let moved = false, installed = false;
99
+ const remove = async target => {
100
+ if (path.dirname(path.resolve(target)) !== path.resolve(parent) || path.resolve(target) === path.resolve(parent)) throw Error('Invalid removal target');
101
+ await fs.rm(target, { recursive: true, force: true });
102
+ };
103
+ try {
104
+ if (action === 'install') {
105
+ const files = await treeHashes(this.source);
106
+ await fs.cp(this.source, stage, { recursive: true });
107
+ let previous;
108
+ try { previous = JSON.parse(await fs.readFile(path.join(before.target, marker), 'utf8')); } catch {}
109
+ const releases = previous?.v === 1 && previous.host === host && Array.isArray(previous.releases)
110
+ ? previous.releases.filter(r => r && typeof r.version === 'string' && r.files && typeof r.files === 'object' && !Array.isArray(r.files)) : [];
111
+ await fs.writeFile(path.join(stage, marker), JSON.stringify({ v: 1, host, skill: 'multiplane', version: this.version, installedAt: Date.now(), files, releases: [...releases.filter(r => r.version !== this.version), { version: this.version, files }] }, null, 2));
112
+ if (JSON.stringify(await treeHashes(stage)) !== JSON.stringify(files)) throw Error('Skill source changed during copy');
113
+ }
114
+ const latest = await this.status(host);
115
+ if (latest.blocked || latest.target !== before.target || latest.signature !== before.signature || await fs.realpath(parent) !== parent) throw new MultiplaneError('skill_changed');
116
+ if (before.state !== 'missing') { await fs.rename(before.target, old); moved = true; }
117
+ if (action === 'install') { await fs.rename(stage, before.target); installed = true; }
118
+ } catch (error) {
119
+ if (installed) await remove(before.target);
120
+ if (moved) await fs.rename(old, before.target);
121
+ throw error;
122
+ } finally { await remove(stage); }
123
+ if (moved && before.state !== 'modified') await remove(old);
124
+ return { backup: moved && before.state === 'modified' ? old : null };
125
+ });
126
+ this.queue = operation.catch(() => {}); return operation;
127
+ }
128
+ }