@machinal/scenes 0.1.0 → 0.1.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.2
4
+
5
+ - Fix `init` failing with `Target already exists: cp returned EEXIST` on newer Node.js releases: copy starter entries into the newly created directory instead of copying over the directory itself. Existing destinations are still refused without changing their files.
6
+
7
+ ## 0.1.1
8
+
9
+ - Show readable setup/doctor status and a concise render result by default; use `--json` for complete tool and scene metadata. The Node API and saved scene manifests are unchanged.
10
+ - Report the native renderer's real version instead of its help banner.
11
+ - Reuse cached tools without announcing another Chrome or FFmpeg installation; FFmpeg integrity and license checks still run.
12
+ - Fix negative preview frames when an animation timestamp precedes the Play click, including pause/resume and end-of-scene replay.
13
+ - Default the starter scene to 1280×720 and show the complete local export path; clarify that the standalone tool has no S3 upload or project connection.
14
+ - Styled, responsive quickstart workspace with preview transport and an in-browser local export panel.
15
+ - Explicit rendering-tool download, prerequisite checks, build/render status, actionable errors and completed MP4 downloads.
16
+ - Browser exports save unique videos and manifests in `renders/` without replacing earlier exports; CLI export remains available.
17
+
3
18
  ## 0.1.0
4
19
 
5
20
  - Initial public `@machinal/scenes` distribution, combining the runtime and Svelte integration in one package.
package/README.md CHANGED
@@ -12,7 +12,7 @@ In a Svelte 5 project with a Svelte-aware bundler, such as Vite:
12
12
  npm install @machinal/scenes
13
13
  ```
14
14
 
15
- Requires Node.js 22.12+ and Svelte 5.15+. Three.js and its public TypeScript types are installed as dependencies. There are no install scripts: Chromium and FFmpeg are downloaded only when you explicitly run `setup`. The native renderer is already in the package.
15
+ Requires Node.js 22.12+ and Svelte 5.15+. Three.js and its public TypeScript types are installed as dependencies. There are no install scripts: Chromium and FFmpeg are downloaded only when you explicitly run `setup` or click **Download rendering tools** in the starter. The native renderer is already in the package. Run `npm install` inside a project directory, not a drive root such as `D:\`.
16
16
 
17
17
  The library is ESM. The root and `/svelte` entrypoints contain Svelte components and runes, so they must be processed by a Svelte-aware bundler. Use `/runtime` for framework-independent code, plain Node.js imports and manifest validation.
18
18
 
@@ -28,7 +28,18 @@ npm run render
28
28
 
29
29
  This creates **`scene.mp4`** and **`scene.mp4.scene.json`**, a manifest containing the output's dimensions, duration, frame count and SHA-256. `setup` is a one-time download of pinned Chrome Headless Shell and FFmpeg into a user cache; it checks that the tools execute. `npm run render` builds the scene with Vite and exports the video locally using the included renderer.
30
30
 
31
- Edit `src/Title.svelte` to change the artwork and `scene.config.json` to change dimensions, FPS, duration, props or audio. For interactive preview, run `npm run dev`; preview does not require `setup`. `npm run doctor` checks rendering prerequisites without downloading anything.
31
+ The CLI shows readable tool status and a short export summary, not the internal metadata report. For scripts that need the complete JSON result, use `npm run setup -- --json`, `npm run doctor -- --json` or `npm run render -- --json`. Progress stays on stderr; call `machinal-scenes render --json` directly for JSON-only stdout (the npm script also prints Vite build output). The Node API and saved manifests always retain the full metadata.
32
+
33
+ For the browser workflow, run `npm run dev` and open the local URL shown in the terminal:
34
+
35
+ 1. **Preview**: play, pause, reset or scrub the default **1280 × 720** scene, scaled to fit the available preview space. No rendering tools are required.
36
+ 2. **Download rendering tools**: the export panel checks the included renderer, Chrome and FFmpeg. If tools are missing, this explicit action downloads them once to your user cache. Already installed tools are marked **Ready**.
37
+ 3. **Render MP4**: builds the current scene source and exports it locally, with visible status and renderer progress.
38
+ 4. **Download MP4**: saves the completed video through your browser. A copy and its manifest also remain in a **local** `renders/` directory, created inside the starter next to its `package.json` when you render. For example, a starter at `D:\my-scene` saves browser exports under `D:\my-scene\renders\`. The completed export shows the full local path. Each export uses a unique filename; previous exports are preserved. Nothing is uploaded to S3 or Machinal, and the tool does not connect to your application or any Machinal project.
39
+
40
+ The browser export actions run only through the loopback Vite development server (`npm run dev`), not on a statically hosted build or `npm run preview`. They require same-origin requests; there is no cloud render service or automatic download on page load. The terminal workflow above remains available.
41
+
42
+ Edit `src/Title.svelte` to change the artwork and `scene.config.json` to change dimensions, FPS, duration, props or audio. `npm run doctor` checks rendering prerequisites without downloading anything.
32
43
 
33
44
  The initializer refuses to overwrite an existing directory. Rendering also refuses to replace an existing video or manifest. Choose another output filename for another export:
34
45
 
@@ -148,6 +159,26 @@ Linux requires glibc, `unzip`, and Chromium's system libraries (NSS, ATK, X11, G
148
159
 
149
160
  Scenes are executable code, not a security sandbox. Use trusted scenes or provide your own execution isolation, authorization and resource limits.
150
161
 
162
+ ## VS Code reports missing children or rejects onclick
163
+
164
+ These two diagnostics can appear together when the Svelte extension is running in **Restricted Mode**:
165
+
166
+ ```text
167
+ Property 'children' is missing in type '{ background: string; }'
168
+ '"onclick"' does not exist in type 'HTMLProps<"button", HTMLAttributes<any>>'
169
+ ```
170
+
171
+ In an untrusted workspace, the extension avoids loading the project's local compiler and can fall back to its bundled Svelte 4 compiler/types. The starter uses Svelte 5: nested component content supplies the `children` snippet, and `onclick` is a supported button attribute.
172
+
173
+ Open the generated starter directory itself in VS Code and run `npm install` there. If you have reviewed and trust its code, use **Workspaces: Manage Workspace Trust** to trust only that folder, then **Svelte: Restart Language Server**. Do not disable Workspace Trust globally. You can also validate the project independently of the editor:
174
+
175
+ ```sh
176
+ npm run check
177
+ npm ls svelte
178
+ ```
179
+
180
+ With the local Svelte 5 compiler, these starter components should pass the check. If the terminal also reports errors, investigate those diagnostics and the installed dependency versions rather than treating them as an editor-only issue. Do not make `SceneCanvas.children` optional or replace Svelte 5 event handlers just to suppress a mismatched editor diagnostic.
181
+
151
182
  ## License
152
183
 
153
184
  Apache-2.0 for the library and renderer sources. See [LICENSE](LICENSE), [NOTICE](NOTICE), [LICENSING.md](LICENSING.md) and the included native third-party notices. Downloaded Chrome/FFmpeg and dependencies retain their own licenses; in particular, the downloaded FFmpeg builds have separate GPL/LGPL terms. This package license does not relicense those tools, the surrounding Machinal application or its private services.
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { cp, mkdir, readFile } from 'node:fs/promises';
2
+ import { readFile } from 'node:fs/promises';
3
3
  import { resolve } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
4
  import { parseArgs } from 'node:util';
6
5
  import { doctor, renderVideo, setup } from '../node/index.mjs';
6
+ import { formatRenderResult, formatToolchainStatus } from '../node/cli-output.mjs';
7
+ import { initProject } from '../node/init.mjs';
7
8
 
8
9
  const help = `@machinal/scenes
9
10
 
@@ -25,6 +26,7 @@ Render options:
25
26
  --no-sandbox Explicitly disable Chromium sandbox (isolated containers only)
26
27
 
27
28
  Setup/doctor/render options:
29
+ --json Print complete metadata instead of a readable summary
28
30
  --browser <file> Use an existing Chrome/Chromium executable
29
31
  --ffmpeg <file> Use an existing FFmpeg executable
30
32
  --cache-dir <dir> Override the native-tool cache
@@ -50,31 +52,23 @@ async function run(args) {
50
52
  if (args.length !== 2 || args[0] !== 'init' || !args[1].trim() || args[1].startsWith('-')) throw new Error(`Expected a supported command.\n\n${help}`);
51
53
 
52
54
  const destination = resolve(args[1]);
53
- try {
54
- await mkdir(destination);
55
- } catch (error) {
56
- if (error.code === 'EEXIST') throw new Error(`Destination already exists: ${destination}. No files were changed.`);
57
- throw error;
58
- }
59
- await cp(fileURLToPath(new URL('../examples/quickstart/', import.meta.url)), destination, {
60
- recursive: true, errorOnExist: true, force: false,
61
- });
62
- console.log(`Created ${destination}\n\nOpen this directory, then run:\n npm install\n npm run setup\n npm run dev\n\nExport a video with:\n npm run render`);
55
+ await initProject(destination);
56
+ console.log(`Created ${destination}\n\nOpen this directory, then run:\n npm install\n npm run dev\n\nIn the browser, use Download rendering tools (once), Render MP4, then Download MP4.\n\nTerminal alternative:\n npm run setup\n npm run render`);
63
57
  }
64
58
 
65
59
  async function runTools(command, args) {
66
- const common = { help: { type: 'boolean' }, browser: { type: 'string' }, ffmpeg: { type: 'string' }, 'cache-dir': { type: 'string' } };
60
+ const common = { help: { type: 'boolean' }, json: { type: 'boolean' }, browser: { type: 'string' }, ffmpeg: { type: 'string' }, 'cache-dir': { type: 'string' } };
67
61
  const rendering = { request: { type: 'string' }, serve: { type: 'string' }, output: { type: 'string' }, workers: { type: 'string' }, lossless: { type: 'boolean' }, gpu: { type: 'boolean' }, 'no-sandbox': { type: 'boolean' } };
68
62
  const { values } = parseArgs({ args, options: { ...common, ...(command === 'render' ? rendering : {}) }, strict: true, allowPositionals: false });
69
63
  if (values.help) { console.log(help); return; }
70
64
  const options = { browserExecutable: values.browser, ffmpegExecutable: values.ffmpeg, cacheDirectory: values['cache-dir'] };
71
65
  if (command === 'render') {
72
66
  const result = await renderVideo({ ...options, request: values.request ?? 'scene.config.json', serveDirectory: values.serve ?? 'dist', output: values.output ?? 'scene.mp4', workers: values.workers === undefined ? undefined : Number(values.workers), lossless: values.lossless, gpu: values.gpu, noSandbox: values['no-sandbox'], onProgress: message => process.stderr.write(message) });
73
- console.log(JSON.stringify(result, null, 2));
67
+ console.log(values.json ? JSON.stringify(result, null, 2) : formatRenderResult(result));
74
68
  return;
75
69
  }
76
70
  const result = command === 'setup' ? await setup({ ...options, onProgress: message => console.error(message) }) : await doctor(options);
77
- console.log(JSON.stringify(result, null, 2));
71
+ console.log(values.json ? JSON.stringify(result, null, 2) : formatToolchainStatus(result));
78
72
  if (!result.ready) process.exitCode = 1;
79
73
  }
80
74
 
@@ -0,0 +1,6 @@
1
+ node_modules/
2
+ dist/
3
+ renders/
4
+ *.mp4
5
+ *.webm
6
+ *.scene.json
@@ -6,15 +6,43 @@
6
6
  <title>Machinal scenes quickstart</title>
7
7
  </head>
8
8
  <body>
9
+ <header class="command-bar">
10
+ <span class="brand">Machinal <span>Scenes</span></span>
11
+ <span>Local scene workspace</span>
12
+ </header>
9
13
  <main>
10
- <h1>@machinal/scenes</h1>
11
- <div id="scene"></div>
12
- <p>
13
- <button id="play" type="button" disabled>Play</button>
14
- <label>Frame <input id="frame" type="range" min="0" max="179" value="0" disabled /></label>
15
- <output id="status" role="status">Preparing scene...</output>
16
- </p>
14
+ <div class="heading">
15
+ <h1>Your first scene, ready to move.</h1>
16
+ <p>Preview the animation. Render it locally. Take the MP4 with you.</p>
17
+ </div>
18
+ <div class="workbench">
19
+ <section class="viewer" aria-labelledby="preview-title">
20
+ <div class="panel-heading">
21
+ <h2 id="preview-title">Scene preview</h2>
22
+ <span id="dimensions" class="measurement"></span>
23
+ </div>
24
+ <div id="viewport"><div id="scene"></div></div>
25
+ <div class="transport">
26
+ <button id="play" type="button" disabled>Play</button>
27
+ <button id="reset" type="button" disabled>Reset</button>
28
+ <label class="scrubber"><span class="sr-only">Frame</span><input id="frame" type="range" min="0" max="179" value="0" disabled /></label>
29
+ <output id="counter" class="measurement" aria-live="off">000 / 179</output>
30
+ </div>
31
+ <p id="status" class="preview-status" role="status">Preparing scene...</p>
32
+ </section>
33
+ <aside id="export" aria-label="Export video"></aside>
34
+ </div>
35
+ <section class="source-guide" aria-labelledby="source-title">
36
+ <h2 id="source-title">Make it your own</h2>
37
+ <p>Edit <code>src/Title.svelte</code> for the artwork, or <code>scene.config.json</code> for dimensions, duration and props. Save to update the preview, then render again.</p>
38
+ <details>
39
+ <summary>Prefer the terminal?</summary>
40
+ <p>In this project directory, run <code>npm run setup</code> once, then <code>npm run render</code>. The CLI saves <code>scene.mp4</code> and its manifest in this directory.</p>
41
+ <p>For another export, use <code>npm run render -- --output second.mp4</code>. Existing files are never replaced.</p>
42
+ </details>
43
+ </section>
17
44
  </main>
45
+ <footer>Standalone local tool. No account, API key, project connection or cloud upload.</footer>
18
46
  <script type="module" src="/src/main.ts"></script>
19
47
  </body>
20
48
  </html>
@@ -13,7 +13,8 @@
13
13
  "check": "svelte-check --tsconfig ./tsconfig.json && tsc --noEmit"
14
14
  },
15
15
  "dependencies": {
16
- "@machinal/scenes": "^0.1.0",
16
+ "@machinal/scenes": "^0.1.2",
17
+ "@fontsource-variable/ibm-plex-sans": "^5.3.0",
17
18
  "svelte": "^5.15.0"
18
19
  },
19
20
  "devDependencies": {
@@ -3,8 +3,8 @@
3
3
  "url": "/scene.html",
4
4
  "sceneId": "title",
5
5
  "config": {
6
- "width": 640,
7
- "height": 360,
6
+ "width": 1280,
7
+ "height": 720,
8
8
  "fps": 30,
9
9
  "durationInFrames": 180
10
10
  }
@@ -0,0 +1,74 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import { useLocalExport } from './useLocalExport.svelte';
4
+
5
+ const exporter = useLocalExport();
6
+ const status = $derived(exporter.connection.phase === 'connected' ? exporter.connection.status : null);
7
+ const tools = $derived(status ? [
8
+ { label: 'Native renderer', tool: status.tools.renderer },
9
+ { label: 'Chrome Headless Shell', tool: status.tools.browser },
10
+ { label: 'FFmpeg', tool: status.tools.ffmpeg },
11
+ ] : []);
12
+
13
+ onMount(() => {
14
+ void exporter.refresh();
15
+ return exporter.dispose;
16
+ });
17
+ </script>
18
+
19
+ <div class="panel-heading"><h2>Export video</h2><span class="measurement">MP4 · local</span></div>
20
+ <div class="export-body">
21
+ <section class="export-step" aria-labelledby="tools-title">
22
+ <h3 id="tools-title">1. Prepare this computer</h3>
23
+ <p>The renderer is included. Chrome and FFmpeg are needed to capture frames and encode your video.</p>
24
+ <ul class="tool-list" aria-label="Rendering tools">
25
+ {#each tools as { label, tool }}
26
+ <li><span>{label}</span><span class:ready={tool.available} class="tool-state">{tool.available ? 'Ready' : 'Not ready'}</span></li>
27
+ {/each}
28
+ </ul>
29
+ {#if status?.tools.ready}
30
+ <p>All tools are ready. No download needed.</p>
31
+ {:else}
32
+ <p>One-time download from Chrome for Testing and the FFmpeg release archive. Tools are saved in your user cache, not installed system-wide.</p>
33
+ <button class="primary" disabled={exporter.busy || !status} onclick={() => exporter.refresh('setup')}>
34
+ {status?.job.phase === 'installing' ? 'Downloading tools…' : 'Download rendering tools'}
35
+ </button>
36
+ {/if}
37
+ </section>
38
+ <section class="export-step" aria-labelledby="render-title">
39
+ <h3 id="render-title">2. Render your scene</h3>
40
+ <p>Builds your current source and saves the video on this computer. Nothing is uploaded to S3 or Machinal.</p>
41
+ <button class="primary" disabled={exporter.busy || !status?.tools.ready} onclick={() => exporter.refresh('render')}>
42
+ {status?.job.phase === 'building' ? 'Building scene…' : status?.job.phase === 'rendering' ? 'Rendering MP4…' : status?.job.phase === 'done' ? 'Render another MP4' : 'Render MP4'}
43
+ </button>
44
+ {#if status && !status.tools.ready}<p>Download the tools above to enable rendering.</p>{/if}
45
+ {#if status?.job.phase === 'done'}
46
+ <div class="render-result">
47
+ <a class="download" href={status.job.download} download={status.job.filename}>
48
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12m-5-5 5 5 5-5M5 16v5h14v-5" /></svg>
49
+ Download MP4
50
+ </a>
51
+ <p>Saved on this computer: <code>{status.job.localFile}</code></p>
52
+ <p>The local <code>renders/</code> folder is created inside this starter, next to <code>package.json</code>. Each render gets a new filename and manifest.</p>
53
+ </div>
54
+ {/if}
55
+ </section>
56
+ <div class="operation-status">
57
+ <div role="status" aria-live="polite">
58
+ {#if exporter.connection.phase === 'checking'}
59
+ <p>Checking local rendering tools…</p>
60
+ {:else if status?.job.phase === 'installing' || status?.job.phase === 'building' || status?.job.phase === 'rendering'}
61
+ <p>{status.job.message}</p>
62
+ {:else if status?.job.phase === 'done'}
63
+ <p>Your video is ready to download.</p>
64
+ {/if}
65
+ </div>
66
+ {#if exporter.connection.phase === 'error'}
67
+ <p class="export-error" role="alert">{exporter.connection.message}</p>
68
+ <button disabled={exporter.busy} onclick={() => exporter.refresh()}>Retry connection</button>
69
+ {:else if status?.job.phase === 'error'}
70
+ <p class="export-error" role="alert">{status.job.message}</p>
71
+ <p>Check the message above, then retry the download or render. Terminal alternative: <code>npm run doctor</code>.</p>
72
+ {/if}
73
+ </div>
74
+ </div>
@@ -3,11 +3,11 @@
3
3
 
4
4
  let { title = 'Make a scene.' }: { title?: string } = $props();
5
5
  const scene = useScene();
6
- const x = $derived(interpolate(scene.frame.frame, [0, 60], [0, 160]));
6
+ const x = $derived(interpolate(scene.frame.frame, [0, 60], [0, scene.frame.config.width / 4]));
7
7
  </script>
8
8
 
9
9
  <SceneCanvas background="#f5f2eb">
10
- <div class="subject" data-scene-object="title" style:transform="translateX({x}px)">
10
+ <div class="subject" data-scene-object="title" style:transform="translateX({x}px)" style:font-size="{scene.frame.config.width / 16}px">
11
11
  <span class="marker"></span>
12
12
  <h2>{title}</h2>
13
13
  <p>Frame {scene.frame.frame} / {scene.frame.config.durationInFrames - 1}</p>
@@ -15,8 +15,8 @@
15
15
  </SceneCanvas>
16
16
 
17
17
  <style>
18
- .subject { position:absolute; left:40px; top:110px; color:#262a30; font-family:system-ui,sans-serif; }
19
- .marker { display:block; width:28px; height:28px; background:#7047c7; }
20
- h2 { margin:16px 0 8px; font-size:40px; }
21
- p { margin:0; font-variant-numeric:tabular-nums; }
18
+ .subject { position:absolute; left:6.25%; top:30%; color:#262a30; font-family:system-ui,sans-serif; }
19
+ .marker { display:block; width:0.7em; height:0.7em; background:#7047c7; }
20
+ h2 { margin:0.4em 0 0.2em; font-size:1em; }
21
+ p { margin:0; font-size:0.4em; font-variant-numeric:tabular-nums; }
22
22
  </style>
@@ -0,0 +1,30 @@
1
+ export type ToolReport = {
2
+ ready: boolean;
3
+ renderer: { available: boolean; error?: string };
4
+ browser: { available: boolean; error?: string };
5
+ ffmpeg: { available: boolean; error?: string };
6
+ };
7
+
8
+ export type ExportJob =
9
+ | { phase: 'idle' }
10
+ | { phase: 'installing' | 'building' | 'rendering'; message: string }
11
+ | { phase: 'done'; filename: string; download: string; localFile: string }
12
+ | { phase: 'error'; message: string };
13
+
14
+ export type ExportStatus = { tools: ToolReport; job: ExportJob };
15
+
16
+ export async function exportRequest(action: 'status' | 'setup' | 'render', signal: AbortSignal): Promise<ExportStatus> {
17
+ const response = await fetch(`/__machinal/export/${action}`, {
18
+ method: action === 'status' ? 'GET' : 'POST',
19
+ headers: { 'X-Machinal-Export': '1' },
20
+ signal,
21
+ });
22
+
23
+ if (!response.headers.get('content-type')?.includes('application/json')) {
24
+ throw new Error('Local export is unavailable. Open this project with npm run dev. You can also use npm run setup and npm run render in the terminal.');
25
+ }
26
+
27
+ const result = await response.json();
28
+ if (!response.ok) throw new Error(result.error ?? `Local export failed (${response.status}). Please retry.`);
29
+ return result;
30
+ }
@@ -1,11 +1,21 @@
1
+ import { mount, unmount } from 'svelte';
1
2
  import { exposeScene } from '@machinal/scenes/runtime';
2
3
  import { mountSvelteScene } from '@machinal/scenes/svelte';
4
+ import '@fontsource-variable/ibm-plex-sans';
5
+ import './workspace.css';
6
+ import ExportPanel from './ExportPanel.svelte';
3
7
  import Title from './Title.svelte';
4
8
  import { request } from './request';
9
+ import { playbackFrame } from './playback';
10
+
5
11
  const target = document.querySelector<HTMLElement>('#scene')!;
12
+ const viewport = document.querySelector<HTMLElement>('#viewport')!;
6
13
  const play = document.querySelector<HTMLButtonElement>('#play')!;
14
+ const reset = document.querySelector<HTMLButtonElement>('#reset')!;
7
15
  const slider = document.querySelector<HTMLInputElement>('#frame')!;
8
- const status = document.querySelector<HTMLOutputElement>('#status')!;
16
+ const status = document.querySelector<HTMLElement>('#status')!;
17
+ const counter = document.querySelector<HTMLOutputElement>('#counter')!;
18
+ const panel = mount(ExportPanel, { target: document.querySelector('#export')! });
9
19
  const controller = mountSvelteScene(Title, target, request, {});
10
20
  const disconnect = exposeScene(controller);
11
21
  let animation = 0;
@@ -14,6 +24,16 @@ let frame = 0;
14
24
  let playing = false;
15
25
 
16
26
  slider.max = String(request.config.durationInFrames - 1);
27
+ viewport.style.aspectRatio = `${request.config.width} / ${request.config.height}`;
28
+ target.style.width = `${request.config.width}px`;
29
+ target.style.height = `${request.config.height}px`;
30
+ document.querySelector('#dimensions')!.textContent = `${request.config.width} × ${request.config.height} · ${request.config.fps} fps`;
31
+
32
+ const resize = new ResizeObserver(() => {
33
+ target.style.transform = `scale(${viewport.clientWidth / request.config.width})`;
34
+ });
35
+
36
+ resize.observe(viewport);
17
37
 
18
38
  function stop() {
19
39
  playing = false;
@@ -24,15 +44,16 @@ function stop() {
24
44
 
25
45
  function fail(error: unknown) {
26
46
  stop();
27
- status.textContent = error instanceof Error ? error.message : String(error);
47
+ status.textContent = `Preview failed: ${error instanceof Error ? error.message : String(error)}. Check the scene source and reload to retry.`;
28
48
  play.disabled = true;
49
+ reset.disabled = true;
29
50
  slider.disabled = true;
30
51
  }
31
52
 
32
53
  function showFrame(next: number) {
33
54
  frame = next;
34
55
  slider.value = String(next);
35
- status.textContent = `Frame ${next}`;
56
+ counter.textContent = `${String(next).padStart(3, '0')} / ${request.config.durationInFrames - 1}`;
36
57
  }
37
58
 
38
59
  play.addEventListener('click', () => {
@@ -44,11 +65,12 @@ play.addEventListener('click', () => {
44
65
  playing = true;
45
66
  play.textContent = 'Pause';
46
67
  const active = ++generation;
47
- const started = performance.now() - frame / request.config.fps * 1000;
68
+ const startFrame = frame;
69
+ const started = performance.now();
48
70
 
49
71
  const advance = async (now: number) => {
50
72
  if (generation !== active) return;
51
- const next = Math.min(request.config.durationInFrames - 1, Math.floor((now - started) / 1000 * request.config.fps));
73
+ const next = playbackFrame(now, started, startFrame, request.config);
52
74
  try {
53
75
  await controller.seek(next, { capture: false });
54
76
  if (generation !== active) return;
@@ -62,25 +84,31 @@ play.addEventListener('click', () => {
62
84
  animation = requestAnimationFrame(now => { void advance(now); });
63
85
  });
64
86
 
65
- slider.addEventListener('input', () => {
87
+ function seek(next: number) {
66
88
  stop();
67
- const active = generation;
68
- const next = Number(slider.value);
89
+ const active = ++generation;
69
90
  void controller.seek(next).then(() => {
70
91
  if (generation === active) showFrame(next);
71
92
  }).catch(error => {
72
93
  if (generation === active) fail(error);
73
94
  });
74
- });
95
+ }
96
+
97
+ slider.addEventListener('input', () => seek(Number(slider.value)));
98
+ reset.addEventListener('click', () => seek(0));
75
99
 
76
100
  window.addEventListener('pagehide', () => {
77
101
  stop();
102
+ resize.disconnect();
78
103
  disconnect();
104
+ void unmount(panel);
79
105
  void controller.dispose().catch(() => undefined);
80
106
  }, { once: true });
81
107
 
82
108
  void controller.ready().then(() => {
83
109
  play.disabled = false;
110
+ reset.disabled = false;
84
111
  slider.disabled = false;
112
+ status.textContent = 'Preview ready. Press Play or scrub to inspect a frame.';
85
113
  showFrame(0);
86
114
  }).catch(fail);
@@ -0,0 +1,6 @@
1
+ import type { SceneConfig } from '@machinal/scenes/runtime';
2
+
3
+ export function playbackFrame(timestamp: number, startedAt: number, startFrame: number, config: SceneConfig): number {
4
+ const elapsed = Math.max(0, timestamp - startedAt);
5
+ return Math.min(config.durationInFrames - 1, startFrame + Math.floor(elapsed * config.fps / 1000));
6
+ }
@@ -0,0 +1,46 @@
1
+ import { exportRequest, type ExportStatus } from './export-api';
2
+
3
+ type Connection =
4
+ | { phase: 'checking' }
5
+ | { phase: 'connected'; status: ExportStatus }
6
+ | { phase: 'error'; message: string };
7
+
8
+ export function useLocalExport() {
9
+ let connection = $state<Connection>({ phase: 'checking' });
10
+ let submitting = $state(false);
11
+ let request: AbortController | undefined;
12
+ let timer: ReturnType<typeof setTimeout> | undefined;
13
+
14
+ function dispose() {
15
+ request?.abort();
16
+ clearTimeout(timer);
17
+ }
18
+
19
+ async function refresh(action: 'status' | 'setup' | 'render' = 'status') {
20
+ dispose();
21
+ const controller = new AbortController();
22
+ request = controller;
23
+ submitting = true;
24
+ try {
25
+ const status = await exportRequest(action, controller.signal);
26
+ if (controller.signal.aborted) return;
27
+ connection = { phase: 'connected', status };
28
+ if (['installing', 'building', 'rendering'].includes(status.job.phase)) {
29
+ timer = setTimeout(() => { void refresh(); }, 800);
30
+ }
31
+ } catch (error) {
32
+ if (!controller.signal.aborted) connection = { phase: 'error', message: error instanceof Error ? error.message : String(error) };
33
+ } finally {
34
+ if (!controller.signal.aborted) submitting = false;
35
+ }
36
+ }
37
+
38
+ return {
39
+ refresh,
40
+ dispose,
41
+ get connection() { return connection; },
42
+ get busy() {
43
+ return submitting || connection.phase === 'checking' || connection.phase === 'connected' && ['installing', 'building', 'rendering'].includes(connection.status.job.phase);
44
+ },
45
+ };
46
+ }
@@ -0,0 +1,79 @@
1
+ :root {
2
+ font-family: 'IBM Plex Sans Variable', sans-serif;
3
+ color: #262a30;
4
+ background: #f3f2ee;
5
+ font-synthesis: none;
6
+ --rule: #cbcbc5;
7
+ --accent: #7047c7;
8
+ --secondary: #5e646b;
9
+ }
10
+
11
+ * { box-sizing: border-box; }
12
+ body { margin: 0; }
13
+ button, input { font: inherit; }
14
+ button, .download { border: 1px solid var(--rule); border-radius: 3px; padding: 10px 14px; background: #faf9f6; color: inherit; cursor: pointer; text-decoration: none; text-align: center; }
15
+ button:hover:not(:disabled), .download:hover { border-color: var(--accent); background: #eae3f2; }
16
+ button:disabled { opacity: 0.55; cursor: not-allowed; }
17
+ button.primary, .download { background: #c6adff; border-color: #ad91e6; font-weight: 550; }
18
+ button.primary:hover:not(:disabled), .download:hover { background: #d5c2ff; }
19
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
20
+ ::selection { background: #d5c2ff; color: #262a30; }
21
+ input { accent-color: var(--accent); caret-color: var(--accent); }
22
+ a { color: #593495; text-underline-offset: 3px; }
23
+ .command-bar { min-height: 64px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 5%; background: #222833; color: #f3f2ee; }
24
+ .brand { font-size: 21px; font-weight: 600; }
25
+ .brand span { font-weight: 400; color: #d9dce2; }
26
+ .command-bar > span:last-child { font-size: 14px; }
27
+ main { max-width: 1320px; margin: auto; padding: 44px 5%; }
28
+ h1 { font-size: clamp(28px, 3.2vw, 42px); font-weight: 550; letter-spacing: -0.025em; line-height: 1.15; margin: 0; text-wrap: balance; }
29
+ .heading > p { color: var(--secondary); line-height: 1.6; margin: 12px 0 28px; }
30
+ .workbench { display: grid; grid-template-columns: minmax(0, 1fr) 330px; border: 1px solid var(--rule); background: #faf9f6; }
31
+ .viewer, #export { min-width: 0; }
32
+ .panel-heading { min-height: 62px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; padding: 17px 20px; border-bottom: 1px solid var(--rule); }
33
+ h2 { font-size: 18px; line-height: 1.3; font-weight: 550; margin: 0; }
34
+ h3 { font-size: 16px; font-weight: 550; line-height: 1.4; margin: 0; }
35
+ .measurement { font-family: ui-monospace, monospace; font-size: 12px; font-variant-numeric: tabular-nums; color: var(--secondary); }
36
+ #viewport { position: relative; overflow: hidden; background: #f5f2eb; }
37
+ #scene { position: absolute; left: 0; top: 0; transform-origin: top left; }
38
+ .transport { display: flex; align-items: center; gap: 10px; border-top: 1px solid var(--rule); padding: 14px 20px; }
39
+ .scrubber { flex: 1; min-width: 40px; }
40
+ .scrubber input { width: 100%; min-height: 32px; margin: 0; }
41
+ .preview-status { padding: 0 20px 16px; font-size: 13px; color: var(--secondary); margin: 0; line-height: 1.5; }
42
+ #export { border-left: 1px solid var(--rule); }
43
+ .export-body { display: grid; gap: 22px; padding: 22px 20px; }
44
+ .export-step { display: grid; gap: 10px; }
45
+ .export-step + .export-step { border-top: 1px solid var(--rule); padding-top: 22px; }
46
+ .export-body p { font-size: 14px; color: var(--secondary); line-height: 1.6; margin: 0; }
47
+ .tool-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
48
+ .tool-list li { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; font-size: 14px; }
49
+ .tool-state { color: var(--secondary); font-size: 13px; }
50
+ .tool-state.ready { color: #386143; }
51
+ .export-body .export-error { color: #a02e32; white-space: pre-wrap; overflow-wrap: anywhere; }
52
+ .operation-status { padding-top: 2px; }
53
+ .render-result { display: grid; gap: 12px; }
54
+ .download { display: block; }
55
+ .download svg { width: 18px; height: 18px; vertical-align: -3px; margin-right: 7px; }
56
+ .source-guide { margin-top: 32px; max-width: 72ch; }
57
+ .source-guide p { color: var(--secondary); font-size: 14px; line-height: 1.75; margin: 12px 0; }
58
+ code { font-family: ui-monospace, monospace; font-size: 0.88em; color: #593495; background: #eae6ef; padding: 2px 4px; overflow-wrap: anywhere; }
59
+ summary { cursor: pointer; font-size: 14px; padding: 8px 0; text-underline-offset: 4px; }
60
+ summary:hover { color: #593495; text-decoration: underline; }
61
+ footer { border-top: 1px solid var(--rule); padding: 20px 5%; font-size: 13px; color: var(--secondary); line-height: 1.6; }
62
+ .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
63
+ pre { scrollbar-color: #b7abc9 #eae6ef; }
64
+ @media (max-width: 900px) {
65
+ .workbench { grid-template-columns: 1fr; }
66
+ #export { border-left: 0; border-top: 1px solid var(--rule); }
67
+ .export-body { grid-template-columns: 1fr 1fr; }
68
+ .export-step + .export-step { border-top: 0; padding-top: 0; }
69
+ .operation-status { grid-column: 1 / -1; }
70
+ }
71
+ @media (max-width: 580px) {
72
+ main { padding-top: 28px; }
73
+ .command-bar > span:last-child { display: none; }
74
+ .panel-heading { padding: 14px; }
75
+ .transport { padding: 12px; flex-wrap: wrap; }
76
+ .transport output { width: 100%; text-align: right; }
77
+ .export-body { grid-template-columns: 1fr; padding: 20px 14px; }
78
+ .export-step + .export-step { border-top: 1px solid var(--rule); padding-top: 22px; }
79
+ }
@@ -0,0 +1,120 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+
7
+ const prefix = '/__machinal/export/';
8
+ const busyPhases = new Set(['installing', 'building', 'rendering']);
9
+
10
+ export function localExportPlugin({ build, checkTools, installTools, render }) {
11
+ return {
12
+ name: 'machinal-local-export',
13
+ apply: 'serve',
14
+ configureServer(server) {
15
+ const root = server.config.root;
16
+ let tools;
17
+ let inspection;
18
+ let job = { phase: 'idle' };
19
+ let output;
20
+
21
+ async function inspect() {
22
+ inspection ??= checkTools().then(result => { tools = result; }).finally(() => { inspection = undefined; });
23
+ await inspection;
24
+ }
25
+
26
+ function progress(message) {
27
+ job = { ...job, message: String(message).trim().split(/\r?\n/).at(-1).slice(-1200) };
28
+ }
29
+
30
+ async function run(action) {
31
+ try {
32
+ if (action === 'setup') {
33
+ tools = await installTools({ onProgress: progress });
34
+ job = { phase: 'idle' };
35
+ } else {
36
+ await exportVideo();
37
+ }
38
+ } catch (error) {
39
+ job = { phase: 'error', message: (error instanceof Error ? error.message : String(error)).slice(-4000) };
40
+ }
41
+ }
42
+
43
+ async function exportVideo() {
44
+ const temporary = await mkdtemp(join(tmpdir(), 'machinal-preview-'));
45
+ try {
46
+ const request = JSON.parse(await readFile(join(root, 'scene.config.json'), 'utf8'));
47
+ const audio = request.audio?.map(track => ({ ...track, file: resolve(root, track.file) }));
48
+ const filename = `scene-${randomUUID()}.mp4`;
49
+ await build({ root, configFile: server.config.configFile, logLevel: 'warn', build: { outDir: temporary, emptyOutDir: true } });
50
+ await mkdir(join(root, 'renders'), { recursive: true });
51
+ const destination = join(root, 'renders', filename);
52
+ job = { phase: 'rendering', message: 'Capturing frames and encoding MP4…' };
53
+ await render({ request: { ...request, ...(audio ? { audio } : {}) }, serveDirectory: temporary, output: destination, onProgress: progress });
54
+ output = destination;
55
+ job = { phase: 'done', filename, download: `${prefix}video`, localFile: destination };
56
+ } finally {
57
+ await rm(temporary, { recursive: true, force: true });
58
+ }
59
+ }
60
+
61
+ server.middlewares.use((request, response, next) => {
62
+ if (!request.url?.startsWith(prefix)) return next();
63
+ void handle(request, response).catch(error => {
64
+ if (response.headersSent) response.destroy(error);
65
+ else json(response, 500, { error: error instanceof Error ? error.message : String(error) });
66
+ });
67
+ });
68
+
69
+ async function handle(request, response) {
70
+ const host = request.headers.host;
71
+ const port = server.httpServer?.address()?.port;
72
+ const allowedHosts = [`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`];
73
+ if (!allowedHosts.includes(host) || request.headers.origin && request.headers.origin !== `http://${host}` || request.headers['sec-fetch-site'] === 'cross-site') {
74
+ return json(response, 403, { error: 'Local export only accepts same-origin requests on the loopback address.' });
75
+ }
76
+ const action = request.url.slice(prefix.length);
77
+ if (action === 'video' && request.method === 'GET') return download(response);
78
+ if (action === 'status' && request.method === 'GET') {
79
+ if (!tools) await inspect();
80
+ return json(response, 200, { tools, job });
81
+ }
82
+ if (!['setup', 'render'].includes(action)) return json(response, 404, { error: 'Unknown local export action.' });
83
+ if (request.method !== 'POST') return json(response, 405, { error: 'Use POST to start a local export action.' });
84
+ if (request.headers['x-machinal-export'] !== '1') return json(response, 403, { error: 'Missing local export request header.' });
85
+ if (busyPhases.has(job.phase)) return json(response, 409, { error: 'A local operation is already running. Wait for it to finish.' });
86
+ if (!tools) await inspect();
87
+ if (busyPhases.has(job.phase)) return json(response, 409, { error: 'A local operation is already running. Wait for it to finish.' });
88
+ if (action === 'render' && !tools.ready) return json(response, 409, { error: 'Download rendering tools before rendering. Use the button above or npm run setup.' });
89
+ job = action === 'setup'
90
+ ? { phase: 'installing', message: 'Downloading Chrome Headless Shell and FFmpeg…' }
91
+ : { phase: 'building', message: 'Building the current scene source…' };
92
+ json(response, 202, { tools, job });
93
+ void run(action);
94
+ }
95
+
96
+ async function download(response) {
97
+ if (job.phase !== 'done' || !output) return json(response, 404, { error: 'No completed video. Render your scene first.' });
98
+ const filename = job.filename;
99
+ const destination = output;
100
+ const file = await stat(destination);
101
+ response.writeHead(200, {
102
+ 'Content-Type': 'video/mp4',
103
+ 'Content-Length': file.size,
104
+ 'Content-Disposition': `attachment; filename="${filename}"`,
105
+ 'Cache-Control': 'no-store',
106
+ 'X-Content-Type-Options': 'nosniff',
107
+ });
108
+ const stream = createReadStream(destination);
109
+ stream.once('error', error => response.destroy(error));
110
+ response.once('close', () => stream.destroy());
111
+ stream.pipe(response);
112
+ }
113
+ },
114
+ };
115
+ }
116
+
117
+ function json(response, code, value) {
118
+ response.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
119
+ response.end(JSON.stringify(value));
120
+ }
@@ -1,9 +1,11 @@
1
- import { defineConfig } from 'vite';
1
+ import { build, defineConfig } from 'vite';
2
2
  import { svelte } from '@sveltejs/vite-plugin-svelte';
3
+ import { doctor, renderVideo, setup } from '@machinal/scenes/node';
3
4
  import { fileURLToPath } from 'node:url';
5
+ import { localExportPlugin } from './tools/local-export.mjs';
4
6
 
5
7
  export default defineConfig({
6
- plugins: [svelte()],
8
+ plugins: [svelte(), localExportPlugin({ build, checkTools: doctor, installTools: setup, render: renderVideo })],
7
9
  build: {
8
10
  rollupOptions: {
9
11
  input: {
Binary file
Binary file
Binary file
@@ -5,29 +5,29 @@
5
5
  "platform": "win32",
6
6
  "arch": "x64",
7
7
  "file": "win32-x64/scene-render.exe",
8
- "size": 8543232,
9
- "sha256": "8aa4ea2aeb13e7cf0fa1ba63f93b181f0d2538f3244babbf33b2c9fe6a8c4d27"
8
+ "size": 8544256,
9
+ "sha256": "462d422cfec1724eb700effeb758c6ad9dd9f695a577464a205f4946682159c6"
10
10
  },
11
11
  {
12
12
  "platform": "linux",
13
13
  "arch": "x64",
14
14
  "file": "linux-x64/scene-render",
15
15
  "size": 8274080,
16
- "sha256": "e3d81f2d47a8311d316b8cfa0bf0bc2b0558e3457853371545f62386d62ae9fd"
16
+ "sha256": "b898cdf3a64d078e132cdd33eb4c00df42e9a335b29c012f6fe112e7d91341b7"
17
17
  },
18
18
  {
19
19
  "platform": "darwin",
20
20
  "arch": "x64",
21
21
  "file": "darwin-x64/scene-render",
22
- "size": 8497728,
23
- "sha256": "cccb20e6eca747d97475fa153041e4cd88613f9a22e9aa16d4ad31125568a07d"
22
+ "size": 8497744,
23
+ "sha256": "e96ee02651f6e1c06e14fe7df80f38ae3beabcc884d4a6d3081013cea28bb534"
24
24
  },
25
25
  {
26
26
  "platform": "darwin",
27
27
  "arch": "arm64",
28
28
  "file": "darwin-arm64/scene-render",
29
- "size": 7940434,
30
- "sha256": "8d81cc4bfab2946d263f7ecb550387fccdac4c151e009e767e007fb239f6611d"
29
+ "size": 7940450,
30
+ "sha256": "97b15efacd64557f469775f0dd64ef6fd7ecec45462a6ca3a9e6b586373baa3e"
31
31
  }
32
32
  ]
33
33
  }
Binary file
@@ -0,0 +1,10 @@
1
+ export function formatToolchainStatus(status) {
2
+ const heading = status.ready ? 'Rendering tools are ready.' : 'Rendering tools are not ready.';
3
+ const tools = [['Renderer', status.renderer], ['Chrome Headless Shell', status.browser], ['FFmpeg', status.ffmpeg]];
4
+ const lines = tools.map(([name, tool]) => ` ${name}: ${tool.available ? 'ready' : tool.error ?? 'not available'}`);
5
+ return [heading, ...lines].join('\n');
6
+ }
7
+
8
+ export function formatRenderResult(asset) {
9
+ return `Rendered ${asset.file}\n${asset.width} × ${asset.height} · ${asset.fps} fps · ${asset.duration} s`;
10
+ }
package/node/init.mjs ADDED
@@ -0,0 +1,20 @@
1
+ import { cp, mkdir, readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const template = fileURLToPath(new URL('../examples/quickstart/', import.meta.url));
6
+
7
+ export async function initProject(destination) {
8
+ try {
9
+ await mkdir(destination);
10
+ } catch (error) {
11
+ if (error.code === 'EEXIST') throw new Error(`Destination already exists: ${destination}. No files were changed.`);
12
+ throw error;
13
+ }
14
+ for (const name of await readdir(template)) {
15
+ const target = name === 'gitignore' ? '.gitignore' : name;
16
+ await cp(join(template, name), join(destination, target), {
17
+ recursive: true, errorOnExist: true, force: false,
18
+ });
19
+ }
20
+ }
@@ -59,7 +59,7 @@ async function probe(path, args) {
59
59
  export async function doctor(options = {}) {
60
60
  const paths = toolPaths(options);
61
61
  let renderer;
62
- try { renderer = await probe(await rendererPath(), ['-h']); }
62
+ try { renderer = await probe(await rendererPath(), ['-version']); }
63
63
  catch (error) { renderer = { available: false, error: error.message }; }
64
64
  const [browser, ffmpeg] = await Promise.all([probe(paths.browser, ['--version']), probe(paths.ffmpeg, ['-version'])]);
65
65
  return { ready: renderer.available && browser.available && ffmpeg.available, cacheDirectory: paths.cache, renderer, browser, ffmpeg };
@@ -69,20 +69,24 @@ export async function setup(options = {}) {
69
69
  const paths = toolPaths(options);
70
70
  await rendererPath();
71
71
  const report = options.onProgress ?? (() => {});
72
- if (!options.browserExecutable && !process.env.SCENE_BROWSER) {
72
+ if (!options.browserExecutable && !process.env.SCENE_BROWSER && !await isInstalled(paths.browser)) {
73
73
  report(`Installing Chrome Headless Shell ${browserBuild} (official Chrome for Testing download)...`);
74
74
  await install({ browser: Browser.CHROMEHEADLESSSHELL, buildId: browserBuild, cacheDir: join(paths.cache, 'browser'), installDeps: false });
75
75
  }
76
76
  if (!options.ffmpegExecutable && !process.env.SCENE_FFMPEG) {
77
- report(`Installing FFmpeg ${ffmpegRelease} (checksum-verified release, including license and build information)...`);
78
- await installFFmpeg(paths.ffmpeg);
77
+ await installFFmpeg(paths.ffmpeg, report);
79
78
  }
80
79
  const status = await doctor(options);
81
80
  if (!status.ready) throw new Error(`Rendering tools are not ready:\n${[status.renderer, status.browser, status.ffmpeg].filter(tool => !tool.available).map(tool => `${tool.path ?? 'renderer'}: ${tool.error}`).join('\n')}\nOn Linux, Chromium also requires system shared libraries; see the README.`);
82
81
  return status;
83
82
  }
84
83
 
85
- async function installFFmpeg(executable) {
84
+ async function isInstalled(executable) {
85
+ try { await access(executable); return true; }
86
+ catch (error) { if (error.code === 'ENOENT') return false; throw error; }
87
+ }
88
+
89
+ async function installFFmpeg(executable, report) {
86
90
  const target = `${process.platform}-${process.arch}`;
87
91
  const hashes = ffmpegReleases[target];
88
92
  const directory = dirname(executable);
@@ -94,6 +98,7 @@ async function installFFmpeg(executable) {
94
98
  } catch (error) {
95
99
  if (error.code !== 'ENOENT') throw error;
96
100
  }
101
+ report(`Installing FFmpeg ${ffmpegRelease} (checksum-verified release, including license and build information)...`);
97
102
  await mkdir(dirname(directory), { recursive: true });
98
103
  const temporary = await mkdtemp(join(dirname(directory), '.install-'));
99
104
  const base = `https://github.com/eugeneware/ffmpeg-static/releases/download/${ffmpegRelease}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@machinal/scenes",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Author, preview and render frame-driven Svelte, HTML and Three.js scenes to MP4 and WebM",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -39,6 +39,8 @@
39
39
  "renderer/go.sum",
40
40
  "dist/",
41
41
  "examples/quickstart/src/",
42
+ "examples/quickstart/tools/",
43
+ "examples/quickstart/gitignore",
42
44
  "examples/quickstart/index.html",
43
45
  "examples/quickstart/scene.html",
44
46
  "examples/quickstart/scene.config.json",
@@ -63,7 +65,7 @@
63
65
  "check": "pnpm --filter @direction/scene-runtime check && pnpm --filter @direction/scene-svelte check && pnpm --filter @direction/scene-studio check",
64
66
  "test": "vitest run",
65
67
  "test:package": "pnpm run build:package && pnpm run build:renderer && node --test tests/package.test.mjs",
66
- "test:cli": "node --test tests/cli.test.mjs",
68
+ "test:cli": "node --test tests/cli.test.mjs tests/init.test.mjs tests/local-export.test.mjs",
67
69
  "test:evaluation": "node --test scripts/evaluate-production.test.mjs",
68
70
  "test:browser": "playwright test",
69
71
  "test:go": "go -C renderer test ./...",
@@ -16,7 +16,10 @@ import (
16
16
  sceneengine "github.com/catalinfl/viral-server/scene-engine/renderer"
17
17
  )
18
18
 
19
+ var version = "dev"
20
+
19
21
  type cliOptions struct {
22
+ printVersion bool
20
23
  requestFile string
21
24
  output string
22
25
  serveDirectory string
@@ -36,6 +39,7 @@ func main() {
36
39
  func parseFlags() cliOptions {
37
40
  var options cliOptions
38
41
  var allowedOrigins string
42
+ flag.BoolVar(&options.printVersion, "version", false, "print the renderer version")
39
43
  flag.StringVar(&options.requestFile, "request", "", "JSON/YAML job file (required)")
40
44
  flag.StringVar(&options.output, "output", "", "new .mp4 or .webm output file (required)")
41
45
  flag.StringVar(&options.serveDirectory, "serve", "", "serve a built scene directory on an ephemeral loopback port")
@@ -59,6 +63,12 @@ func parseFlags() cliOptions {
59
63
  }
60
64
 
61
65
  func run(ctx context.Context, options cliOptions, stdout, stderr io.Writer) error {
66
+ if options.printVersion {
67
+ if _, err := fmt.Fprintf(stdout, "machinal-scene-render %s\n", version); err != nil {
68
+ return fmt.Errorf("write renderer version: %w", err)
69
+ }
70
+ return nil
71
+ }
62
72
  if options.requestFile == "" || options.output == "" {
63
73
  return fmt.Errorf("-request and -output are required")
64
74
  }
@@ -0,0 +1,37 @@
1
+ package main
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "errors"
7
+ "io"
8
+ "strings"
9
+ "testing"
10
+ )
11
+
12
+ func TestVersionDoesNotRequireRenderingTools(t *testing.T) {
13
+ var stdout, stderr bytes.Buffer
14
+ err := run(context.Background(), cliOptions{printVersion: true}, &stdout, &stderr)
15
+ if err != nil {
16
+ t.Fatal(err)
17
+ }
18
+ if got, want := stdout.String(), "machinal-scene-render "+version+"\n"; got != want {
19
+ t.Fatalf("version output = %q, want %q", got, want)
20
+ }
21
+ if stderr.Len() != 0 {
22
+ t.Fatalf("unexpected diagnostics: %s", stderr.String())
23
+ }
24
+ }
25
+
26
+ func TestVersionReportsWriteFailure(t *testing.T) {
27
+ err := run(context.Background(), cliOptions{printVersion: true}, failingWriter{}, io.Discard)
28
+ if !errors.Is(err, io.ErrClosedPipe) || !strings.Contains(err.Error(), "write renderer version") {
29
+ t.Fatalf("expected a wrapped write error, got %v", err)
30
+ }
31
+ }
32
+
33
+ type failingWriter struct{}
34
+
35
+ func (failingWriter) Write([]byte) (int, error) {
36
+ return 0, io.ErrClosedPipe
37
+ }