@machinal/scenes 0.0.0-stage → 0.1.1
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 +27 -0
- package/LICENSE +201 -0
- package/LICENSING.md +13 -0
- package/NOTICE +8 -0
- package/README.md +183 -2
- package/bin/machinal-scenes.mjs +89 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/runtime/artifact.d.ts +18 -0
- package/dist/runtime/artifact.js +26 -0
- package/dist/runtime/bridge.d.ts +8 -0
- package/dist/runtime/bridge.js +76 -0
- package/dist/runtime/choreography.d.ts +32 -0
- package/dist/runtime/choreography.js +81 -0
- package/dist/runtime/controller.d.ts +5 -0
- package/dist/runtime/controller.js +93 -0
- package/dist/runtime/geometry.d.ts +20 -0
- package/dist/runtime/geometry.js +34 -0
- package/dist/runtime/index.d.ts +14 -0
- package/dist/runtime/index.js +14 -0
- package/dist/runtime/layout.d.ts +17 -0
- package/dist/runtime/layout.js +48 -0
- package/dist/runtime/math.d.ts +4 -0
- package/dist/runtime/math.js +23 -0
- package/dist/runtime/media.d.ts +2 -0
- package/dist/runtime/media.js +69 -0
- package/dist/runtime/models.d.ts +14 -0
- package/dist/runtime/models.js +370 -0
- package/dist/runtime/objects.d.ts +8 -0
- package/dist/runtime/objects.js +24 -0
- package/dist/runtime/parameters.d.ts +17 -0
- package/dist/runtime/parameters.js +35 -0
- package/dist/runtime/resources.d.ts +5 -0
- package/dist/runtime/resources.js +63 -0
- package/dist/runtime/types.d.ts +76 -0
- package/dist/runtime/types.js +1 -0
- package/dist/runtime/validation.d.ts +4 -0
- package/dist/runtime/validation.js +37 -0
- package/dist/runtime/video.d.ts +90 -0
- package/dist/runtime/video.js +46 -0
- package/dist/svelte/SceneCanvas.svelte +15 -0
- package/dist/svelte/SceneCanvas.svelte.d.ts +8 -0
- package/dist/svelte/SceneThree.svelte +61 -0
- package/dist/svelte/SceneThree.svelte.d.ts +10 -0
- package/dist/svelte/SceneVideo.svelte +38 -0
- package/dist/svelte/SceneVideo.svelte.d.ts +18 -0
- package/dist/svelte/context.d.ts +12 -0
- package/dist/svelte/context.js +8 -0
- package/dist/svelte/index.d.ts +9 -0
- package/dist/svelte/index.js +8 -0
- package/dist/svelte/model-assets.d.ts +4 -0
- package/dist/svelte/model-assets.js +42 -0
- package/dist/svelte/mount.svelte.d.ts +3 -0
- package/dist/svelte/mount.svelte.js +42 -0
- package/dist/svelte/three-rigs.d.ts +20 -0
- package/dist/svelte/three-rigs.js +22 -0
- package/dist/svelte/three-session.d.ts +6 -0
- package/dist/svelte/three-session.js +66 -0
- package/dist/svelte/three.d.ts +15 -0
- package/dist/svelte/three.js +33 -0
- package/examples/quickstart/gitignore +6 -0
- package/examples/quickstart/index.html +48 -0
- package/examples/quickstart/package.json +27 -0
- package/examples/quickstart/scene.config.json +11 -0
- package/examples/quickstart/scene.html +13 -0
- package/examples/quickstart/src/ExportPanel.svelte +74 -0
- package/examples/quickstart/src/Title.svelte +22 -0
- package/examples/quickstart/src/export-api.ts +30 -0
- package/examples/quickstart/src/main.ts +114 -0
- package/examples/quickstart/src/playback.ts +6 -0
- package/examples/quickstart/src/request.ts +5 -0
- package/examples/quickstart/src/scene.ts +12 -0
- package/examples/quickstart/src/useLocalExport.svelte.ts +46 -0
- package/examples/quickstart/src/workspace.css +79 -0
- package/examples/quickstart/svelte.config.js +1 -0
- package/examples/quickstart/tools/local-export.mjs +120 -0
- package/examples/quickstart/tsconfig.json +15 -0
- package/examples/quickstart/vite.config.js +17 -0
- package/native/THIRD_PARTY_NOTICES.txt +158 -0
- package/native/darwin-arm64/scene-render +0 -0
- package/native/darwin-x64/scene-render +0 -0
- package/native/linux-x64/scene-render +0 -0
- package/native/manifest.json +33 -0
- package/native/win32-x64/scene-render.exe +0 -0
- package/node/cli-output.mjs +10 -0
- package/node/download.mjs +37 -0
- package/node/ffmpeg-releases.mjs +24 -0
- package/node/index.d.ts +50 -0
- package/node/index.mjs +2 -0
- package/node/platforms.mjs +14 -0
- package/node/render.mjs +59 -0
- package/node/toolchain.mjs +118 -0
- package/package.json +89 -4
- package/renderer/browser.go +116 -0
- package/renderer/browser_log.go +36 -0
- package/renderer/browser_log_test.go +48 -0
- package/renderer/browser_test.go +307 -0
- package/renderer/cmd/scene-render/main.go +128 -0
- package/renderer/cmd/scene-render/main_test.go +37 -0
- package/renderer/cmd/scene-render/server.go +42 -0
- package/renderer/encoder.go +116 -0
- package/renderer/encoder_test.go +22 -0
- package/renderer/go.mod +11 -0
- package/renderer/go.sum +10 -0
- package/renderer/network.go +50 -0
- package/renderer/output.go +93 -0
- package/renderer/pipeline.go +108 -0
- package/renderer/pipeline_test.go +108 -0
- package/renderer/render.go +141 -0
- package/renderer/request.go +140 -0
- package/renderer/request_test.go +94 -0
- package/renderer/types.go +113 -0
- package/renderer/worker.go +110 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "machinal-scenes-quickstart",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite --host 127.0.0.1",
|
|
8
|
+
"setup": "machinal-scenes setup",
|
|
9
|
+
"doctor": "machinal-scenes doctor",
|
|
10
|
+
"render": "vite build && machinal-scenes render",
|
|
11
|
+
"build": "vite build",
|
|
12
|
+
"preview": "vite preview --host 127.0.0.1",
|
|
13
|
+
"check": "svelte-check --tsconfig ./tsconfig.json && tsc --noEmit"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@machinal/scenes": "^0.1.1",
|
|
17
|
+
"@fontsource-variable/ibm-plex-sans": "^5.3.0",
|
|
18
|
+
"svelte": "^5.15.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
|
22
|
+
"@types/node": "^24.0.0",
|
|
23
|
+
"svelte-check": "^4.7.6",
|
|
24
|
+
"typescript": "^5.9.3",
|
|
25
|
+
"vite": "^6.4.4"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Scene capture</title>
|
|
7
|
+
<style>html,body{margin:0;padding:0;background:transparent;overflow:hidden}</style>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="scene"></div>
|
|
11
|
+
<script type="module" src="/src/scene.ts"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -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>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { SceneCanvas, interpolate, useScene } from '@machinal/scenes';
|
|
3
|
+
|
|
4
|
+
let { title = 'Make a scene.' }: { title?: string } = $props();
|
|
5
|
+
const scene = useScene();
|
|
6
|
+
const x = $derived(interpolate(scene.frame.frame, [0, 60], [0, scene.frame.config.width / 4]));
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<SceneCanvas background="#f5f2eb">
|
|
10
|
+
<div class="subject" data-scene-object="title" style:transform="translateX({x}px)" style:font-size="{scene.frame.config.width / 16}px">
|
|
11
|
+
<span class="marker"></span>
|
|
12
|
+
<h2>{title}</h2>
|
|
13
|
+
<p>Frame {scene.frame.frame} / {scene.frame.config.durationInFrames - 1}</p>
|
|
14
|
+
</div>
|
|
15
|
+
</SceneCanvas>
|
|
16
|
+
|
|
17
|
+
<style>
|
|
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
|
+
</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
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { mount, unmount } from 'svelte';
|
|
2
|
+
import { exposeScene } from '@machinal/scenes/runtime';
|
|
3
|
+
import { mountSvelteScene } from '@machinal/scenes/svelte';
|
|
4
|
+
import '@fontsource-variable/ibm-plex-sans';
|
|
5
|
+
import './workspace.css';
|
|
6
|
+
import ExportPanel from './ExportPanel.svelte';
|
|
7
|
+
import Title from './Title.svelte';
|
|
8
|
+
import { request } from './request';
|
|
9
|
+
import { playbackFrame } from './playback';
|
|
10
|
+
|
|
11
|
+
const target = document.querySelector<HTMLElement>('#scene')!;
|
|
12
|
+
const viewport = document.querySelector<HTMLElement>('#viewport')!;
|
|
13
|
+
const play = document.querySelector<HTMLButtonElement>('#play')!;
|
|
14
|
+
const reset = document.querySelector<HTMLButtonElement>('#reset')!;
|
|
15
|
+
const slider = document.querySelector<HTMLInputElement>('#frame')!;
|
|
16
|
+
const status = document.querySelector<HTMLElement>('#status')!;
|
|
17
|
+
const counter = document.querySelector<HTMLOutputElement>('#counter')!;
|
|
18
|
+
const panel = mount(ExportPanel, { target: document.querySelector('#export')! });
|
|
19
|
+
const controller = mountSvelteScene(Title, target, request, {});
|
|
20
|
+
const disconnect = exposeScene(controller);
|
|
21
|
+
let animation = 0;
|
|
22
|
+
let generation = 0;
|
|
23
|
+
let frame = 0;
|
|
24
|
+
let playing = false;
|
|
25
|
+
|
|
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);
|
|
37
|
+
|
|
38
|
+
function stop() {
|
|
39
|
+
playing = false;
|
|
40
|
+
generation += 1;
|
|
41
|
+
cancelAnimationFrame(animation);
|
|
42
|
+
play.textContent = 'Play';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function fail(error: unknown) {
|
|
46
|
+
stop();
|
|
47
|
+
status.textContent = `Preview failed: ${error instanceof Error ? error.message : String(error)}. Check the scene source and reload to retry.`;
|
|
48
|
+
play.disabled = true;
|
|
49
|
+
reset.disabled = true;
|
|
50
|
+
slider.disabled = true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function showFrame(next: number) {
|
|
54
|
+
frame = next;
|
|
55
|
+
slider.value = String(next);
|
|
56
|
+
counter.textContent = `${String(next).padStart(3, '0')} / ${request.config.durationInFrames - 1}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
play.addEventListener('click', () => {
|
|
60
|
+
if (playing) {
|
|
61
|
+
stop();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (frame === request.config.durationInFrames - 1) frame = 0;
|
|
65
|
+
playing = true;
|
|
66
|
+
play.textContent = 'Pause';
|
|
67
|
+
const active = ++generation;
|
|
68
|
+
const startFrame = frame;
|
|
69
|
+
const started = performance.now();
|
|
70
|
+
|
|
71
|
+
const advance = async (now: number) => {
|
|
72
|
+
if (generation !== active) return;
|
|
73
|
+
const next = playbackFrame(now, started, startFrame, request.config);
|
|
74
|
+
try {
|
|
75
|
+
await controller.seek(next, { capture: false });
|
|
76
|
+
if (generation !== active) return;
|
|
77
|
+
showFrame(next);
|
|
78
|
+
if (next === request.config.durationInFrames - 1) stop();
|
|
79
|
+
else animation = requestAnimationFrame(now => { void advance(now); });
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (generation === active) fail(error);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
animation = requestAnimationFrame(now => { void advance(now); });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
function seek(next: number) {
|
|
88
|
+
stop();
|
|
89
|
+
const active = ++generation;
|
|
90
|
+
void controller.seek(next).then(() => {
|
|
91
|
+
if (generation === active) showFrame(next);
|
|
92
|
+
}).catch(error => {
|
|
93
|
+
if (generation === active) fail(error);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
slider.addEventListener('input', () => seek(Number(slider.value)));
|
|
98
|
+
reset.addEventListener('click', () => seek(0));
|
|
99
|
+
|
|
100
|
+
window.addEventListener('pagehide', () => {
|
|
101
|
+
stop();
|
|
102
|
+
resize.disconnect();
|
|
103
|
+
disconnect();
|
|
104
|
+
void unmount(panel);
|
|
105
|
+
void controller.dispose().catch(() => undefined);
|
|
106
|
+
}, { once: true });
|
|
107
|
+
|
|
108
|
+
void controller.ready().then(() => {
|
|
109
|
+
play.disabled = false;
|
|
110
|
+
reset.disabled = false;
|
|
111
|
+
slider.disabled = false;
|
|
112
|
+
status.textContent = 'Preview ready. Press Play or scrub to inspect a frame.';
|
|
113
|
+
showFrame(0);
|
|
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,12 @@
|
|
|
1
|
+
import { exposeScene } from '@machinal/scenes/runtime';
|
|
2
|
+
import { mountSvelteScene } from '@machinal/scenes/svelte';
|
|
3
|
+
import Title from './Title.svelte';
|
|
4
|
+
import { request } from './request';
|
|
5
|
+
|
|
6
|
+
const controller = mountSvelteScene(Title, document.getElementById('scene')!, request, {});
|
|
7
|
+
const disconnect = exposeScene(controller);
|
|
8
|
+
void controller.ready().catch(error => { window.__sceneError = error.message; });
|
|
9
|
+
window.addEventListener('pagehide', () => {
|
|
10
|
+
disconnect();
|
|
11
|
+
void controller.dispose().catch(() => undefined);
|
|
12
|
+
}, { once: true });
|
|
@@ -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 @@
|
|
|
1
|
+
export default {};
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"resolveJsonModule": true,
|
|
7
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"verbatimModuleSyntax": true,
|
|
10
|
+
"isolatedModules": true,
|
|
11
|
+
"skipLibCheck": false,
|
|
12
|
+
"types": ["vite/client"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*.ts", "src/**/*.svelte"]
|
|
15
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { build, defineConfig } from 'vite';
|
|
2
|
+
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
|
3
|
+
import { doctor, renderVideo, setup } from '@machinal/scenes/node';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { localExportPlugin } from './tools/local-export.mjs';
|
|
6
|
+
|
|
7
|
+
export default defineConfig({
|
|
8
|
+
plugins: [svelte(), localExportPlugin({ build, checkTools: doctor, installTools: setup, render: renderVideo })],
|
|
9
|
+
build: {
|
|
10
|
+
rollupOptions: {
|
|
11
|
+
input: {
|
|
12
|
+
index: fileURLToPath(new URL('./index.html', import.meta.url)),
|
|
13
|
+
scene: fileURLToPath(new URL('./scene.html', import.meta.url)),
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
});
|