@machinal/scenes 0.0.0-stage → 0.1.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.
- package/CHANGELOG.md +16 -0
- package/LICENSE +201 -0
- package/LICENSING.md +13 -0
- package/NOTICE +8 -0
- package/README.md +152 -2
- package/bin/machinal-scenes.mjs +86 -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/index.html +20 -0
- package/examples/quickstart/package.json +26 -0
- package/examples/quickstart/scene.config.json +11 -0
- package/examples/quickstart/scene.html +13 -0
- package/examples/quickstart/src/Title.svelte +22 -0
- package/examples/quickstart/src/main.ts +86 -0
- package/examples/quickstart/src/request.ts +5 -0
- package/examples/quickstart/src/scene.ts +12 -0
- package/examples/quickstart/svelte.config.js +1 -0
- package/examples/quickstart/tsconfig.json +15 -0
- package/examples/quickstart/vite.config.js +15 -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/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 +113 -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 +118 -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,90 @@
|
|
|
1
|
+
export interface VideoEffects {
|
|
2
|
+
brightness?: number;
|
|
3
|
+
contrast?: number;
|
|
4
|
+
saturation?: number;
|
|
5
|
+
hue?: number;
|
|
6
|
+
blur?: number;
|
|
7
|
+
grayscale?: number;
|
|
8
|
+
sepia?: number;
|
|
9
|
+
invert?: number;
|
|
10
|
+
vignette?: number;
|
|
11
|
+
scanlines?: number;
|
|
12
|
+
}
|
|
13
|
+
export declare const videoLooks: {
|
|
14
|
+
original: {};
|
|
15
|
+
cinematic: {
|
|
16
|
+
contrast: number;
|
|
17
|
+
saturation: number;
|
|
18
|
+
vignette: number;
|
|
19
|
+
};
|
|
20
|
+
noir: {
|
|
21
|
+
grayscale: number;
|
|
22
|
+
contrast: number;
|
|
23
|
+
vignette: number;
|
|
24
|
+
};
|
|
25
|
+
vintage: {
|
|
26
|
+
sepia: number;
|
|
27
|
+
saturation: number;
|
|
28
|
+
contrast: number;
|
|
29
|
+
vignette: number;
|
|
30
|
+
};
|
|
31
|
+
warm: {
|
|
32
|
+
sepia: number;
|
|
33
|
+
saturation: number;
|
|
34
|
+
brightness: number;
|
|
35
|
+
};
|
|
36
|
+
cool: {
|
|
37
|
+
hue: number;
|
|
38
|
+
saturation: number;
|
|
39
|
+
contrast: number;
|
|
40
|
+
};
|
|
41
|
+
vivid: {
|
|
42
|
+
saturation: number;
|
|
43
|
+
contrast: number;
|
|
44
|
+
};
|
|
45
|
+
faded: {
|
|
46
|
+
contrast: number;
|
|
47
|
+
brightness: number;
|
|
48
|
+
saturation: number;
|
|
49
|
+
};
|
|
50
|
+
dreamy: {
|
|
51
|
+
blur: number;
|
|
52
|
+
brightness: number;
|
|
53
|
+
saturation: number;
|
|
54
|
+
};
|
|
55
|
+
dramatic: {
|
|
56
|
+
contrast: number;
|
|
57
|
+
brightness: number;
|
|
58
|
+
vignette: number;
|
|
59
|
+
};
|
|
60
|
+
sepia: {
|
|
61
|
+
sepia: number;
|
|
62
|
+
contrast: number;
|
|
63
|
+
};
|
|
64
|
+
negative: {
|
|
65
|
+
invert: number;
|
|
66
|
+
};
|
|
67
|
+
night: {
|
|
68
|
+
brightness: number;
|
|
69
|
+
saturation: number;
|
|
70
|
+
hue: number;
|
|
71
|
+
vignette: number;
|
|
72
|
+
};
|
|
73
|
+
retro: {
|
|
74
|
+
saturation: number;
|
|
75
|
+
sepia: number;
|
|
76
|
+
contrast: number;
|
|
77
|
+
scanlines: number;
|
|
78
|
+
vignette: number;
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
export type VideoLook = keyof typeof videoLooks;
|
|
82
|
+
export declare function resolveVideoEffects(look?: VideoLook, effects?: VideoEffects): VideoEffects;
|
|
83
|
+
export declare function videoFilter(effects: VideoEffects): string;
|
|
84
|
+
export declare function videoTime(time: number, duration: number, options?: {
|
|
85
|
+
start?: number;
|
|
86
|
+
end?: number;
|
|
87
|
+
offset?: number;
|
|
88
|
+
playbackRate?: number;
|
|
89
|
+
loop?: boolean;
|
|
90
|
+
}): number;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { clamp } from './math.js';
|
|
2
|
+
export const videoLooks = {
|
|
3
|
+
original: {},
|
|
4
|
+
cinematic: { contrast: 1.2, saturation: 0.85, vignette: 0.35 },
|
|
5
|
+
noir: { grayscale: 1, contrast: 1.45, vignette: 0.45 },
|
|
6
|
+
vintage: { sepia: 0.5, saturation: 0.7, contrast: 0.9, vignette: 0.3 },
|
|
7
|
+
warm: { sepia: 0.25, saturation: 1.15, brightness: 1.06 },
|
|
8
|
+
cool: { hue: 20, saturation: 0.8, contrast: 1.1 },
|
|
9
|
+
vivid: { saturation: 1.65, contrast: 1.15 },
|
|
10
|
+
faded: { contrast: 0.75, brightness: 1.12, saturation: 0.65 },
|
|
11
|
+
dreamy: { blur: 2, brightness: 1.12, saturation: 0.85 },
|
|
12
|
+
dramatic: { contrast: 1.6, brightness: 0.85, vignette: 0.55 },
|
|
13
|
+
sepia: { sepia: 1, contrast: 1.1 },
|
|
14
|
+
negative: { invert: 1 },
|
|
15
|
+
night: { brightness: 0.65, saturation: 0.55, hue: 25, vignette: 0.5 },
|
|
16
|
+
retro: { saturation: 0.8, sepia: 0.2, contrast: 1.2, scanlines: 0.25, vignette: 0.3 },
|
|
17
|
+
};
|
|
18
|
+
export function resolveVideoEffects(look = 'original', effects = {}) {
|
|
19
|
+
return { ...videoLooks[look], ...effects };
|
|
20
|
+
}
|
|
21
|
+
function bounded(value, fallback, min, max) {
|
|
22
|
+
return clamp(value !== undefined && Number.isFinite(value) ? value : fallback, min, max);
|
|
23
|
+
}
|
|
24
|
+
export function videoFilter(effects) {
|
|
25
|
+
return [
|
|
26
|
+
`brightness(${bounded(effects.brightness, 1, 0, 4)})`,
|
|
27
|
+
`contrast(${bounded(effects.contrast, 1, 0, 4)})`,
|
|
28
|
+
`saturate(${bounded(effects.saturation, 1, 0, 4)})`,
|
|
29
|
+
`hue-rotate(${bounded(effects.hue, 0, -360, 360)}deg)`,
|
|
30
|
+
`blur(${bounded(effects.blur, 0, 0, 40)}px)`,
|
|
31
|
+
`grayscale(${bounded(effects.grayscale, 0, 0, 1)})`,
|
|
32
|
+
`sepia(${bounded(effects.sepia, 0, 0, 1)})`,
|
|
33
|
+
`invert(${bounded(effects.invert, 0, 0, 1)})`,
|
|
34
|
+
].join(' ');
|
|
35
|
+
}
|
|
36
|
+
export function videoTime(time, duration, options = {}) {
|
|
37
|
+
const start = bounded(options.start, 0, 0, duration);
|
|
38
|
+
const end = bounded(options.end, duration, start, duration);
|
|
39
|
+
const rate = bounded(options.playbackRate, 1, -8, 8);
|
|
40
|
+
const offset = options.offset !== undefined && Number.isFinite(options.offset) ? options.offset : 0;
|
|
41
|
+
const elapsed = Math.max(0, time - offset) * rate;
|
|
42
|
+
const span = end - start;
|
|
43
|
+
const position = rate < 0 ? span + elapsed : elapsed;
|
|
44
|
+
const local = options.loop && span > 0 && !(rate < 0 && elapsed === 0) ? ((position % span) + span) % span : clamp(position, 0, span);
|
|
45
|
+
return clamp(start + local, 0, Math.max(start, end - 0.000001));
|
|
46
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { useScene } from './context.js';
|
|
4
|
+
|
|
5
|
+
let { children, background = 'transparent' }: { children: Snippet; background?: string } = $props();
|
|
6
|
+
const scene = useScene();
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<div style:width="{scene.frame.config.width}px" style:height="{scene.frame.config.height}px" style:background={scene.frame.config.transparent ? 'transparent' : background}>
|
|
10
|
+
{@render children()}
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<style>
|
|
14
|
+
div { position: relative; overflow: hidden; }
|
|
15
|
+
</style>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
children: Snippet;
|
|
4
|
+
background?: string;
|
|
5
|
+
};
|
|
6
|
+
declare const SceneCanvas: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
|
+
type SceneCanvas = ReturnType<typeof SceneCanvas>;
|
|
8
|
+
export default SceneCanvas;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import * as THREE from 'three';
|
|
3
|
+
import { untrack } from 'svelte';
|
|
4
|
+
import { useScene } from './context.js';
|
|
5
|
+
import type { SceneThreeSetup } from './three.js';
|
|
6
|
+
import { createThreeSession } from './three-session.js';
|
|
7
|
+
|
|
8
|
+
let { setup, width, height, label = '3D scene' }: { setup: SceneThreeSetup; width?: number; height?: number; label?: string } = $props();
|
|
9
|
+
const scene = useScene();
|
|
10
|
+
let canvas = $state<HTMLCanvasElement>();
|
|
11
|
+
const size = $derived({ width: width ?? scene.frame.config.width, height: height ?? scene.frame.config.height });
|
|
12
|
+
let renderFrame: (() => void) | undefined;
|
|
13
|
+
|
|
14
|
+
$effect(() => {
|
|
15
|
+
if (!canvas) return;
|
|
16
|
+
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, preserveDrawingBuffer: true });
|
|
17
|
+
renderer.setPixelRatio(1);
|
|
18
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
19
|
+
const world = new THREE.Scene();
|
|
20
|
+
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
|
|
21
|
+
camera.position.z = 5;
|
|
22
|
+
let renderWidth = 0;
|
|
23
|
+
let renderHeight = 0;
|
|
24
|
+
renderFrame = () => {
|
|
25
|
+
const scale = scene.frame.renderScale ?? 1;
|
|
26
|
+
if (renderer.getPixelRatio() !== scale) renderer.setPixelRatio(scale);
|
|
27
|
+
if (renderWidth !== size.width || renderHeight !== size.height) {
|
|
28
|
+
renderer.setSize(size.width, size.height, false);
|
|
29
|
+
renderWidth = size.width;
|
|
30
|
+
renderHeight = size.height;
|
|
31
|
+
}
|
|
32
|
+
camera.aspect = size.width / size.height;
|
|
33
|
+
if (!session.update(scene.frame)) return;
|
|
34
|
+
camera.updateProjectionMatrix();
|
|
35
|
+
renderer.render(world, camera);
|
|
36
|
+
// Capture needs a completed GPU frame; ordinary playback must not block on it.
|
|
37
|
+
if (scene.frame.capture !== false) renderer.getContext().finish();
|
|
38
|
+
};
|
|
39
|
+
const session = createThreeSession(context => untrack(() => setup(context)),
|
|
40
|
+
{ THREE, renderer, world, camera, resources: scene.resources }, () => renderFrame?.(), () => {
|
|
41
|
+
renderer.dispose();
|
|
42
|
+
renderer.forceContextLoss();
|
|
43
|
+
});
|
|
44
|
+
return () => {
|
|
45
|
+
renderFrame = undefined;
|
|
46
|
+
session.dispose();
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
$effect(() => {
|
|
51
|
+
scene.frame;
|
|
52
|
+
size;
|
|
53
|
+
renderFrame?.();
|
|
54
|
+
});
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<canvas bind:this={canvas} aria-label={label} style:width="{size.width}px" style:height="{size.height}px"></canvas>
|
|
58
|
+
|
|
59
|
+
<style>
|
|
60
|
+
canvas { display:block; }
|
|
61
|
+
</style>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SceneThreeSetup } from './three.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
setup: SceneThreeSetup;
|
|
4
|
+
width?: number;
|
|
5
|
+
height?: number;
|
|
6
|
+
label?: string;
|
|
7
|
+
};
|
|
8
|
+
declare const SceneThree: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
9
|
+
type SceneThree = ReturnType<typeof SceneThree>;
|
|
10
|
+
export default SceneThree;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { clamp, resolveVideoEffects, videoFilter, videoTime, type VideoEffects, type VideoLook } from '../runtime/index.js';
|
|
3
|
+
import { useScene } from './context.js';
|
|
4
|
+
|
|
5
|
+
let {
|
|
6
|
+
src, width, height, start = 0, end, offset = 0, playbackRate = 1, loop = false,
|
|
7
|
+
fit = 'contain', look = 'original', effects = {}, label = 'Scene video',
|
|
8
|
+
}: {
|
|
9
|
+
src: string; width?: number; height?: number; start?: number; end?: number; offset?: number;
|
|
10
|
+
playbackRate?: number; loop?: boolean; fit?: 'contain' | 'cover' | 'fill';
|
|
11
|
+
look?: VideoLook; effects?: VideoEffects; label?: string;
|
|
12
|
+
} = $props();
|
|
13
|
+
|
|
14
|
+
const scene = useScene();
|
|
15
|
+
let duration = $state(0);
|
|
16
|
+
const treatment = $derived(resolveVideoEffects(look, effects));
|
|
17
|
+
const time = $derived(videoTime(scene.frame.time, duration, { start, end, offset, playbackRate, loop }));
|
|
18
|
+
const advancing = $derived(scene.frame.time >= offset && (loop || time < Math.min(end ?? duration, duration) - 0.000002));
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<div class="surface" style:width="{width ?? scene.frame.config.width}px" style:height="{height ?? scene.frame.config.height}px">
|
|
22
|
+
<!-- Scene media is silent; captions and narration are authored separately. -->
|
|
23
|
+
<!-- svelte-ignore a11y_media_has_caption -->
|
|
24
|
+
<video {src} muted playsinline preload="auto" aria-label={label} data-scene-video-time={time}
|
|
25
|
+
data-scene-video-rate={advancing ? playbackRate : 0}
|
|
26
|
+
onloadedmetadata={(event) => duration = event.currentTarget.duration}
|
|
27
|
+
style:object-fit={fit} style:filter={videoFilter(treatment)}></video>
|
|
28
|
+
{#if treatment.vignette}<div class="vignette" style:opacity={clamp(treatment.vignette)}></div>{/if}
|
|
29
|
+
{#if treatment.scanlines}<div class="scanlines" style:opacity={clamp(treatment.scanlines)}></div>{/if}
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<style>
|
|
33
|
+
.surface { position:relative; overflow:hidden; }
|
|
34
|
+
video { display:block; width:100%; height:100%; }
|
|
35
|
+
.vignette, .scanlines { position:absolute; inset:0; pointer-events:none; }
|
|
36
|
+
.vignette { background:radial-gradient(ellipse at center, transparent 35%, #000 100%); }
|
|
37
|
+
.scanlines { background:repeating-linear-gradient(to bottom, transparent 0 2px, #000 2px 3px); }
|
|
38
|
+
</style>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type VideoEffects, type VideoLook } from '../runtime/index.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
src: string;
|
|
4
|
+
width?: number;
|
|
5
|
+
height?: number;
|
|
6
|
+
start?: number;
|
|
7
|
+
end?: number;
|
|
8
|
+
offset?: number;
|
|
9
|
+
playbackRate?: number;
|
|
10
|
+
loop?: boolean;
|
|
11
|
+
fit?: 'contain' | 'cover' | 'fill';
|
|
12
|
+
look?: VideoLook;
|
|
13
|
+
effects?: VideoEffects;
|
|
14
|
+
label?: string;
|
|
15
|
+
};
|
|
16
|
+
declare const SceneVideo: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
17
|
+
type SceneVideo = ReturnType<typeof SceneVideo>;
|
|
18
|
+
export default SceneVideo;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { FrameContext, ResourceScope, SceneObjectRegistry } from '../runtime/index.js';
|
|
2
|
+
export declare const SCENE_CONTEXT: unique symbol;
|
|
3
|
+
export interface SvelteSceneContext {
|
|
4
|
+
readonly frame: FrameContext;
|
|
5
|
+
readonly resources: ResourceScope;
|
|
6
|
+
readonly objects: SceneObjectRegistry;
|
|
7
|
+
readonly time: FrameContext['time'];
|
|
8
|
+
readonly progress: FrameContext['progress'];
|
|
9
|
+
readonly config: FrameContext['config'];
|
|
10
|
+
readonly random: FrameContext['random'];
|
|
11
|
+
}
|
|
12
|
+
export declare function useScene(): SvelteSceneContext;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { getContext } from 'svelte';
|
|
2
|
+
export const SCENE_CONTEXT = Symbol('direction.scene');
|
|
3
|
+
export function useScene() {
|
|
4
|
+
const scene = getContext(SCENE_CONTEXT);
|
|
5
|
+
if (!scene)
|
|
6
|
+
throw new Error('useScene() must be used inside a mounted scene');
|
|
7
|
+
return scene;
|
|
8
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/// <reference types="@webgpu/types" />
|
|
2
|
+
export { mountSvelteScene } from './mount.svelte.js';
|
|
3
|
+
export { useScene, type SvelteSceneContext } from './context.js';
|
|
4
|
+
export { default as SceneCanvas } from './SceneCanvas.svelte';
|
|
5
|
+
export { default as SceneThree } from './SceneThree.svelte';
|
|
6
|
+
export { default as SceneVideo } from './SceneVideo.svelte';
|
|
7
|
+
export { type SceneThreeSetup, type SceneThreeContext, type SceneThreeAnimation } from './three.js';
|
|
8
|
+
export { setCameraOrbit, createStudioLighting, type OrbitPose, type StudioLightOptions } from './three-rigs.js';
|
|
9
|
+
export { loadSceneModel, loadSceneTexture } from './model-assets.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { mountSvelteScene } from './mount.svelte.js';
|
|
2
|
+
export { useScene } from './context.js';
|
|
3
|
+
export { default as SceneCanvas } from './SceneCanvas.svelte';
|
|
4
|
+
export { default as SceneThree } from './SceneThree.svelte';
|
|
5
|
+
export { default as SceneVideo } from './SceneVideo.svelte';
|
|
6
|
+
export {} from './three.js';
|
|
7
|
+
export { setCameraOrbit, createStudioLighting } from './three-rigs.js';
|
|
8
|
+
export { loadSceneModel, loadSceneTexture } from './model-assets.js';
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { type ResourceScope } from '../runtime/index.js';
|
|
3
|
+
export declare function loadSceneModel(source: string, resources: ResourceScope): Promise<THREE.Group>;
|
|
4
|
+
export declare function loadSceneTexture(source: string, resources: ResourceScope): Promise<THREE.Texture>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
3
|
+
import { decodeModelData, pngDimensions, validateGLB } from '../runtime/index.js';
|
|
4
|
+
import { disposeThreeWorld } from './three.js';
|
|
5
|
+
export function loadSceneModel(source, resources) {
|
|
6
|
+
resources.signal.throwIfAborted();
|
|
7
|
+
const bytes = decodeModelData(source);
|
|
8
|
+
validateGLB(bytes);
|
|
9
|
+
const manager = new THREE.LoadingManager();
|
|
10
|
+
manager.setURLModifier(url => {
|
|
11
|
+
if (!url.startsWith('blob:'))
|
|
12
|
+
throw new Error('GLB loader only accepts internally embedded blob resources');
|
|
13
|
+
return url;
|
|
14
|
+
});
|
|
15
|
+
const loader = new GLTFLoader(manager);
|
|
16
|
+
const loading = loader.parseAsync(bytes.buffer, '').then(result => {
|
|
17
|
+
if (resources.signal.aborted) {
|
|
18
|
+
disposeThreeWorld(result.scene);
|
|
19
|
+
resources.signal.throwIfAborted();
|
|
20
|
+
}
|
|
21
|
+
return result.scene;
|
|
22
|
+
});
|
|
23
|
+
return resources.track(loading);
|
|
24
|
+
}
|
|
25
|
+
export function loadSceneTexture(source, resources) {
|
|
26
|
+
resources.signal.throwIfAborted();
|
|
27
|
+
const prefix = 'data:image/png;base64,';
|
|
28
|
+
if (typeof source !== 'string' || !source.startsWith(prefix) || source.length > prefix.length + Math.ceil(5 * 1024 * 1024 / 3) * 4)
|
|
29
|
+
throw new Error('Use an approved embedded PNG texture of at most 5 MiB');
|
|
30
|
+
const decoded = atob(source.slice(prefix.length));
|
|
31
|
+
if (decoded.length > 5 * 1024 * 1024)
|
|
32
|
+
throw new Error('PNG texture exceeds its byte limit');
|
|
33
|
+
pngDimensions(Uint8Array.from(decoded, character => character.charCodeAt(0)));
|
|
34
|
+
return resources.track(new THREE.TextureLoader().loadAsync(source).then(texture => {
|
|
35
|
+
if (resources.signal.aborted) {
|
|
36
|
+
texture.dispose();
|
|
37
|
+
resources.signal.throwIfAborted();
|
|
38
|
+
}
|
|
39
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
40
|
+
return texture;
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { type Component } from 'svelte';
|
|
2
|
+
import { type MountOptions, type SceneController, type SceneProps, type SceneRequest } from '../runtime/index.js';
|
|
3
|
+
export declare function mountSvelteScene<P extends SceneProps>(component: Component<P>, target: HTMLElement, request: SceneRequest, defaults: P, options?: MountOptions): SceneController;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { mount, tick, unmount } from 'svelte';
|
|
2
|
+
import { mountScene, seededRandom } from '../runtime/index.js';
|
|
3
|
+
import { SCENE_CONTEXT } from './context.js';
|
|
4
|
+
export function mountSvelteScene(component, target, request, defaults, options) {
|
|
5
|
+
return mountScene({
|
|
6
|
+
id: request.sceneId,
|
|
7
|
+
defaults,
|
|
8
|
+
async create({ target: element, props, config, resources, objects }) {
|
|
9
|
+
const random = (key) => seededRandom(request.seed ?? 'scene-engine', key);
|
|
10
|
+
let frame = $state({
|
|
11
|
+
frame: 0,
|
|
12
|
+
time: 0,
|
|
13
|
+
progress: 0,
|
|
14
|
+
config,
|
|
15
|
+
random,
|
|
16
|
+
renderScale: options?.renderScale ?? 1,
|
|
17
|
+
capture: options?.capture ?? true,
|
|
18
|
+
});
|
|
19
|
+
const context = {
|
|
20
|
+
get frame() { return frame; },
|
|
21
|
+
resources,
|
|
22
|
+
objects,
|
|
23
|
+
// Saved AI components also use these fields directly on useScene().
|
|
24
|
+
get time() { return frame.time; },
|
|
25
|
+
get progress() { return frame.progress; },
|
|
26
|
+
config,
|
|
27
|
+
random,
|
|
28
|
+
};
|
|
29
|
+
const instance = mount(component, { target: element, props, context: new Map([[SCENE_CONTEXT, context]]) });
|
|
30
|
+
await tick();
|
|
31
|
+
return {
|
|
32
|
+
async update(next) {
|
|
33
|
+
frame = next;
|
|
34
|
+
await tick();
|
|
35
|
+
},
|
|
36
|
+
async dispose() {
|
|
37
|
+
await unmount(instance);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
}, target, request, options);
|
|
42
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
export interface OrbitPose {
|
|
3
|
+
target?: readonly [number, number, number];
|
|
4
|
+
radius: number;
|
|
5
|
+
azimuth: number;
|
|
6
|
+
elevation: number;
|
|
7
|
+
}
|
|
8
|
+
/** Assign an absolute camera pose on each frame; angles are radians. */
|
|
9
|
+
export declare function setCameraOrbit(camera: THREE.PerspectiveCamera, { target, radius, azimuth, elevation }: OrbitPose): void;
|
|
10
|
+
export interface StudioLightOptions {
|
|
11
|
+
intensity?: number;
|
|
12
|
+
keyColor?: THREE.ColorRepresentation;
|
|
13
|
+
rimColor?: THREE.ColorRepresentation;
|
|
14
|
+
}
|
|
15
|
+
export declare function createStudioLighting(world: THREE.Scene, { intensity, keyColor, rimColor }?: StudioLightOptions): {
|
|
16
|
+
ambient: THREE.HemisphereLight;
|
|
17
|
+
key: THREE.DirectionalLight;
|
|
18
|
+
fill: THREE.DirectionalLight;
|
|
19
|
+
rim: THREE.DirectionalLight;
|
|
20
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
/** Assign an absolute camera pose on each frame; angles are radians. */
|
|
3
|
+
export function setCameraOrbit(camera, { target = [0, 0, 0], radius, azimuth, elevation }) {
|
|
4
|
+
if (![...target, radius, azimuth, elevation].every(Number.isFinite) || radius <= 0)
|
|
5
|
+
throw new Error('Camera orbit needs finite values and a positive radius');
|
|
6
|
+
const horizontal = radius * Math.cos(elevation);
|
|
7
|
+
camera.position.set(target[0] + horizontal * Math.sin(azimuth), target[1] + radius * Math.sin(elevation), target[2] + horizontal * Math.cos(azimuth));
|
|
8
|
+
camera.lookAt(...target);
|
|
9
|
+
}
|
|
10
|
+
export function createStudioLighting(world, { intensity = 1, keyColor = '#fff1db', rimColor = '#91baff' } = {}) {
|
|
11
|
+
if (!Number.isFinite(intensity) || intensity < 0)
|
|
12
|
+
throw new Error('Lighting intensity must be finite and nonnegative');
|
|
13
|
+
const ambient = new THREE.HemisphereLight('#dce9ff', '#25212b', 0.8 * intensity);
|
|
14
|
+
const key = new THREE.DirectionalLight(keyColor, 3 * intensity);
|
|
15
|
+
const fill = new THREE.DirectionalLight('#d7e8ff', 1.1 * intensity);
|
|
16
|
+
const rim = new THREE.DirectionalLight(rimColor, 2 * intensity);
|
|
17
|
+
key.position.set(4, 5, 6);
|
|
18
|
+
fill.position.set(-4, 1, 3);
|
|
19
|
+
rim.position.set(1, 3, -5);
|
|
20
|
+
world.add(ambient, key, fill, rim);
|
|
21
|
+
return { ambient, key, fill, rim };
|
|
22
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { FrameContext } from '../runtime/index.js';
|
|
2
|
+
import { type SceneThreeContext, type SceneThreeSetup } from './three.js';
|
|
3
|
+
export declare function createThreeSession(setup: SceneThreeSetup, context: SceneThreeContext, onReady: () => void, releaseRenderer: () => void): {
|
|
4
|
+
update(frame: FrameContext): boolean;
|
|
5
|
+
dispose: () => void;
|
|
6
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { disposeThreeWorld } from './three.js';
|
|
2
|
+
export function createThreeSession(setup, context, onReady, releaseRenderer) {
|
|
3
|
+
let state = { phase: 'pending' };
|
|
4
|
+
let rendererCleanup = releaseRenderer;
|
|
5
|
+
function clearWorld() {
|
|
6
|
+
disposeThreeWorld(context.world);
|
|
7
|
+
context.world.clear();
|
|
8
|
+
context.world.background = null;
|
|
9
|
+
context.world.environment = null;
|
|
10
|
+
}
|
|
11
|
+
function dispose() {
|
|
12
|
+
const previous = state;
|
|
13
|
+
state = { phase: 'disposed' };
|
|
14
|
+
try {
|
|
15
|
+
if (previous.phase === 'ready')
|
|
16
|
+
previous.animation.dispose?.();
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
clearWorld();
|
|
20
|
+
const cleanup = rendererCleanup;
|
|
21
|
+
rendererCleanup = undefined;
|
|
22
|
+
cleanup?.();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const pending = Promise.resolve().then(() => {
|
|
26
|
+
if (state.phase === 'disposed')
|
|
27
|
+
return;
|
|
28
|
+
context.resources.signal.throwIfAborted();
|
|
29
|
+
return setup(context);
|
|
30
|
+
}).then(value => {
|
|
31
|
+
if (state.phase === 'disposed') {
|
|
32
|
+
try {
|
|
33
|
+
value?.dispose?.();
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
clearWorld();
|
|
37
|
+
}
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (!value || typeof value.update !== 'function')
|
|
41
|
+
throw new Error('SceneThree setup must return update(frame)');
|
|
42
|
+
state = { phase: 'ready', animation: value };
|
|
43
|
+
onReady();
|
|
44
|
+
}).catch(error => {
|
|
45
|
+
// Setup can add resources after an earlier unmount, then reject instead of returning them.
|
|
46
|
+
dispose();
|
|
47
|
+
throw error;
|
|
48
|
+
});
|
|
49
|
+
void pending.catch(() => undefined);
|
|
50
|
+
try {
|
|
51
|
+
context.resources.track(pending);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
dispose();
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
update(frame) {
|
|
59
|
+
if (state.phase !== 'ready')
|
|
60
|
+
return false;
|
|
61
|
+
state.animation.update(frame);
|
|
62
|
+
return true;
|
|
63
|
+
},
|
|
64
|
+
dispose,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import type { FrameContext, ResourceScope } from '../runtime/index.js';
|
|
3
|
+
export interface SceneThreeContext {
|
|
4
|
+
THREE: typeof THREE;
|
|
5
|
+
renderer: THREE.WebGLRenderer;
|
|
6
|
+
world: THREE.Scene;
|
|
7
|
+
camera: THREE.PerspectiveCamera;
|
|
8
|
+
resources: ResourceScope;
|
|
9
|
+
}
|
|
10
|
+
export interface SceneThreeAnimation {
|
|
11
|
+
update(frame: FrameContext): void;
|
|
12
|
+
dispose?(): void;
|
|
13
|
+
}
|
|
14
|
+
export type SceneThreeSetup = (context: SceneThreeContext) => SceneThreeAnimation | Promise<SceneThreeAnimation>;
|
|
15
|
+
export declare function disposeThreeWorld(world: THREE.Object3D): void;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
export function disposeThreeWorld(world) {
|
|
3
|
+
const geometries = new Set();
|
|
4
|
+
const materials = new Set();
|
|
5
|
+
const textures = new Set();
|
|
6
|
+
const images = new Set();
|
|
7
|
+
world.traverse(object => {
|
|
8
|
+
const mesh = object;
|
|
9
|
+
if (mesh.geometry)
|
|
10
|
+
geometries.add(mesh.geometry);
|
|
11
|
+
for (const material of Array.isArray(mesh.material) ? mesh.material : mesh.material ? [mesh.material] : []) {
|
|
12
|
+
materials.add(material);
|
|
13
|
+
for (const value of Object.values(material))
|
|
14
|
+
if (value instanceof THREE.Texture)
|
|
15
|
+
textures.add(value);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
if (world instanceof THREE.Scene) {
|
|
19
|
+
if (world.background instanceof THREE.Texture)
|
|
20
|
+
textures.add(world.background);
|
|
21
|
+
if (world.environment instanceof THREE.Texture)
|
|
22
|
+
textures.add(world.environment);
|
|
23
|
+
}
|
|
24
|
+
textures.forEach(texture => {
|
|
25
|
+
if (typeof ImageBitmap !== 'undefined' && texture.image instanceof ImageBitmap)
|
|
26
|
+
images.add(texture.image);
|
|
27
|
+
texture.dispose();
|
|
28
|
+
});
|
|
29
|
+
images.forEach(image => image.close());
|
|
30
|
+
materials.forEach(material => material.dispose());
|
|
31
|
+
geometries.forEach(geometry => geometry.dispose());
|
|
32
|
+
world.clear();
|
|
33
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
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>Machinal scenes quickstart</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<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>
|
|
17
|
+
</main>
|
|
18
|
+
<script type="module" src="/src/main.ts"></script>
|
|
19
|
+
</body>
|
|
20
|
+
</html>
|
|
@@ -0,0 +1,26 @@
|
|
|
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.0",
|
|
17
|
+
"svelte": "^5.15.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
|
21
|
+
"@types/node": "^24.0.0",
|
|
22
|
+
"svelte-check": "^4.7.6",
|
|
23
|
+
"typescript": "^5.9.3",
|
|
24
|
+
"vite": "^6.4.4"
|
|
25
|
+
}
|
|
26
|
+
}
|