@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,76 @@
|
|
|
1
|
+
const COMMAND = 'scene-engine:command';
|
|
2
|
+
const RESPONSE = 'scene-engine:response';
|
|
3
|
+
export function exposeScene(controller, view = window) {
|
|
4
|
+
view.__scene = controller;
|
|
5
|
+
const receive = async (event) => {
|
|
6
|
+
const command = event.data;
|
|
7
|
+
if (event.source !== view.parent || event.origin !== view.location.origin)
|
|
8
|
+
return;
|
|
9
|
+
if (command?.type !== COMMAND || typeof command.id !== 'string')
|
|
10
|
+
return;
|
|
11
|
+
try {
|
|
12
|
+
if (command.method === 'seek')
|
|
13
|
+
await controller.seek(command.frame);
|
|
14
|
+
else if (command.method === 'ready')
|
|
15
|
+
await controller.ready();
|
|
16
|
+
else
|
|
17
|
+
throw new Error('Unknown scene command');
|
|
18
|
+
view.parent.postMessage({ type: RESPONSE, id: command.id, manifest: controller.manifest }, event.origin);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
view.parent.postMessage({ type: RESPONSE, id: command.id, error: error instanceof Error ? error.message : String(error) }, event.origin);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
view.addEventListener('message', receive);
|
|
25
|
+
return () => {
|
|
26
|
+
view.removeEventListener('message', receive);
|
|
27
|
+
if (view.__scene === controller)
|
|
28
|
+
delete view.__scene;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function connectScene(iframe, timeoutMs = 30_000) {
|
|
32
|
+
const view = iframe.ownerDocument.defaultView;
|
|
33
|
+
const origin = new URL(iframe.src, view.location.href).origin;
|
|
34
|
+
const pending = new Map();
|
|
35
|
+
let disposed = false;
|
|
36
|
+
const receive = (event) => {
|
|
37
|
+
if (event.source !== iframe.contentWindow || event.origin !== origin || event.data?.type !== RESPONSE)
|
|
38
|
+
return;
|
|
39
|
+
const entry = pending.get(event.data.id);
|
|
40
|
+
if (!entry)
|
|
41
|
+
return;
|
|
42
|
+
clearTimeout(entry.timer);
|
|
43
|
+
pending.delete(event.data.id);
|
|
44
|
+
if (event.data.error)
|
|
45
|
+
entry.reject(new Error(event.data.error));
|
|
46
|
+
else
|
|
47
|
+
entry.resolve(event.data.manifest);
|
|
48
|
+
};
|
|
49
|
+
const send = (method, frame) => {
|
|
50
|
+
if (disposed)
|
|
51
|
+
return Promise.reject(new Error('Scene connection disposed'));
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const id = crypto.randomUUID();
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
pending.delete(id);
|
|
56
|
+
reject(new Error(`Scene ${method} timed out after ${timeoutMs}ms`));
|
|
57
|
+
}, timeoutMs);
|
|
58
|
+
pending.set(id, { resolve, reject, timer });
|
|
59
|
+
iframe.contentWindow.postMessage({ type: COMMAND, id, method, frame }, origin);
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
view.addEventListener('message', receive);
|
|
63
|
+
return {
|
|
64
|
+
ready: () => send('ready'),
|
|
65
|
+
seek: (frame) => send('seek', frame),
|
|
66
|
+
dispose() {
|
|
67
|
+
disposed = true;
|
|
68
|
+
view.removeEventListener('message', receive);
|
|
69
|
+
for (const entry of pending.values()) {
|
|
70
|
+
clearTimeout(entry.timer);
|
|
71
|
+
entry.reject(new Error('Scene connection disposed'));
|
|
72
|
+
}
|
|
73
|
+
pending.clear();
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type Easing = (progress: number) => number;
|
|
2
|
+
export declare const easing: {
|
|
3
|
+
linear: (p: number) => number;
|
|
4
|
+
inOutCubic: (p: number) => number;
|
|
5
|
+
outQuint: (p: number) => number;
|
|
6
|
+
smooth: (p: number) => number;
|
|
7
|
+
};
|
|
8
|
+
export interface Cue {
|
|
9
|
+
id: string;
|
|
10
|
+
start: number;
|
|
11
|
+
duration: number;
|
|
12
|
+
}
|
|
13
|
+
export interface CueState {
|
|
14
|
+
start: number;
|
|
15
|
+
end: number;
|
|
16
|
+
progress: number;
|
|
17
|
+
active: boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare function progressAt(time: number, start: number, duration: number, curve?: Easing): number;
|
|
20
|
+
export declare function stagger(index: number, interval: number, start?: number): number;
|
|
21
|
+
/** Times are scene-local seconds; evaluation never depends on previous seeks. */
|
|
22
|
+
export declare function createChoreography(cues: readonly Cue[]): Readonly<{
|
|
23
|
+
marker(id: string, edge?: "start" | "end"): number;
|
|
24
|
+
at(id: string, time: number, curve?: Easing): CueState;
|
|
25
|
+
}>;
|
|
26
|
+
export interface SpringOptions {
|
|
27
|
+
stiffness?: number;
|
|
28
|
+
damping?: number;
|
|
29
|
+
mass?: number;
|
|
30
|
+
}
|
|
31
|
+
/** Analytic unit-step response: safe for backward/out-of-order frame evaluation. */
|
|
32
|
+
export declare function springAt(time: number, { stiffness, damping, mass }?: SpringOptions): number;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { clamp } from './math.js';
|
|
2
|
+
export const easing = {
|
|
3
|
+
linear: (p) => clamp(p),
|
|
4
|
+
inOutCubic: (p) => { const t = clamp(p); return t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2; },
|
|
5
|
+
outQuint: (p) => 1 - (1 - clamp(p)) ** 5,
|
|
6
|
+
smooth: (p) => { const t = clamp(p); return t * t * (3 - 2 * t); },
|
|
7
|
+
};
|
|
8
|
+
function finite(value, name) {
|
|
9
|
+
if (!Number.isFinite(value))
|
|
10
|
+
throw new Error(`${name} must be finite`);
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
export function progressAt(time, start, duration, curve = easing.linear) {
|
|
14
|
+
finite(time, 'time');
|
|
15
|
+
finite(start, 'start');
|
|
16
|
+
finite(duration, 'duration');
|
|
17
|
+
if (duration < 0)
|
|
18
|
+
throw new Error('duration must not be negative');
|
|
19
|
+
return curve(duration === 0 ? Number(time >= start) : clamp((time - start) / duration));
|
|
20
|
+
}
|
|
21
|
+
export function stagger(index, interval, start = 0) {
|
|
22
|
+
if (!Number.isSafeInteger(index) || index < 0)
|
|
23
|
+
throw new Error('index must be a nonnegative integer');
|
|
24
|
+
finite(interval, 'interval');
|
|
25
|
+
finite(start, 'start');
|
|
26
|
+
if (interval < 0)
|
|
27
|
+
throw new Error('interval must not be negative');
|
|
28
|
+
return finite(start + index * interval, 'stagger result');
|
|
29
|
+
}
|
|
30
|
+
/** Times are scene-local seconds; evaluation never depends on previous seeks. */
|
|
31
|
+
export function createChoreography(cues) {
|
|
32
|
+
const events = new Map();
|
|
33
|
+
for (const cue of cues) {
|
|
34
|
+
if (!cue.id.trim() || events.has(cue.id))
|
|
35
|
+
throw new Error('Cue IDs must be nonempty and unique');
|
|
36
|
+
finite(cue.start, 'start');
|
|
37
|
+
finite(cue.duration, 'duration');
|
|
38
|
+
if (cue.start < 0 || cue.duration < 0)
|
|
39
|
+
throw new Error('Cue times must not be negative');
|
|
40
|
+
finite(cue.start + cue.duration, 'cue end');
|
|
41
|
+
events.set(cue.id, Object.freeze({ ...cue }));
|
|
42
|
+
}
|
|
43
|
+
const get = (id) => {
|
|
44
|
+
const cue = events.get(id);
|
|
45
|
+
if (!cue)
|
|
46
|
+
throw new Error(`Unknown cue: ${id}`);
|
|
47
|
+
return cue;
|
|
48
|
+
};
|
|
49
|
+
return Object.freeze({
|
|
50
|
+
marker(id, edge = 'start') {
|
|
51
|
+
const cue = get(id);
|
|
52
|
+
return cue.start + (edge === 'end' ? cue.duration : 0);
|
|
53
|
+
},
|
|
54
|
+
at(id, time, curve = easing.linear) {
|
|
55
|
+
const cue = get(id);
|
|
56
|
+
return { start: cue.start, end: cue.start + cue.duration, progress: progressAt(time, cue.start, cue.duration, curve), active: time >= cue.start && time < cue.start + cue.duration };
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/** Analytic unit-step response: safe for backward/out-of-order frame evaluation. */
|
|
61
|
+
export function springAt(time, { stiffness = 170, damping = 26, mass = 1 } = {}) {
|
|
62
|
+
finite(time, 'time');
|
|
63
|
+
for (const [name, value] of Object.entries({ stiffness, damping, mass })) {
|
|
64
|
+
if (finite(value, name) <= 0)
|
|
65
|
+
throw new Error(`${name} must be positive`);
|
|
66
|
+
}
|
|
67
|
+
if (time <= 0)
|
|
68
|
+
return 0;
|
|
69
|
+
const omega = Math.sqrt(stiffness / mass);
|
|
70
|
+
const zeta = damping / (2 * Math.sqrt(stiffness * mass));
|
|
71
|
+
if (Math.abs(zeta - 1) < 1e-6)
|
|
72
|
+
return 1 - Math.exp(-omega * time) * (1 + omega * time);
|
|
73
|
+
if (zeta < 1) {
|
|
74
|
+
const wd = omega * Math.sqrt(1 - zeta * zeta);
|
|
75
|
+
return 1 - Math.exp(-zeta * omega * time) * (Math.cos(wd * time) + zeta * omega / wd * Math.sin(wd * time));
|
|
76
|
+
}
|
|
77
|
+
const root = Math.sqrt(zeta * zeta - 1);
|
|
78
|
+
const r1 = -omega / (zeta + root);
|
|
79
|
+
const r2 = -omega * (zeta + root);
|
|
80
|
+
return 1 - (r2 * Math.exp(r1 * time) - r1 * Math.exp(r2 * time)) / (r2 - r1);
|
|
81
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type SceneController, type SceneDefinition, type SceneProps, type SceneRenderOptions, type SceneRequest } from './types.js';
|
|
2
|
+
export interface MountOptions extends SceneRenderOptions {
|
|
3
|
+
settle?: (target: HTMLElement) => Promise<void>;
|
|
4
|
+
}
|
|
5
|
+
export declare function mountScene<P extends SceneProps>(definition: SceneDefinition<P>, target: HTMLElement, request: SceneRequest, options?: MountOptions): SceneController;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { seededRandom } from './math.js';
|
|
2
|
+
import { abortable, createResourceScope, seekCssAnimations, waitForVisualResources } from './resources.js';
|
|
3
|
+
import { PROTOCOL_VERSION } from './types.js';
|
|
4
|
+
import { validateFrame, validateRequest } from './validation.js';
|
|
5
|
+
import { presentSceneVideos, seekSceneVideos } from './media.js';
|
|
6
|
+
import { createObjectRegistry } from './objects.js';
|
|
7
|
+
export function mountScene(definition, target, request, options = {}) {
|
|
8
|
+
validateRequest(request);
|
|
9
|
+
if (request.sceneId !== definition.id)
|
|
10
|
+
throw new Error(`Unknown scene: ${request.sceneId}`);
|
|
11
|
+
const config = Object.freeze({ ...request.config });
|
|
12
|
+
const props = structuredClone({ ...definition.defaults, ...request.props });
|
|
13
|
+
const seed = request.seed ?? 'scene-engine';
|
|
14
|
+
const manifest = { version: PROTOCOL_VERSION, sceneId: definition.id, props, seed, config };
|
|
15
|
+
const abort = new AbortController();
|
|
16
|
+
const resources = createResourceScope(abort.signal);
|
|
17
|
+
const objects = createObjectRegistry();
|
|
18
|
+
let currentFrame = frameContext(0, config, seed, options);
|
|
19
|
+
const instance = Promise.resolve().then(() => definition.create({ target, props, config, resources, objects }));
|
|
20
|
+
let queue = Promise.resolve();
|
|
21
|
+
let disposed = false;
|
|
22
|
+
let disposal;
|
|
23
|
+
const settle = async (time) => {
|
|
24
|
+
await resources.wait();
|
|
25
|
+
await seekSceneVideos(target, time, abort.signal);
|
|
26
|
+
await abortable((options.settle ?? waitForVisualResources)(target), abort.signal);
|
|
27
|
+
await resources.wait();
|
|
28
|
+
};
|
|
29
|
+
const initialReady = instance.then(async (created) => {
|
|
30
|
+
await abortable(Promise.resolve(created.update(currentFrame)), abort.signal);
|
|
31
|
+
seekCssAnimations(target, 0);
|
|
32
|
+
await settle(0);
|
|
33
|
+
});
|
|
34
|
+
void initialReady.catch(() => undefined);
|
|
35
|
+
return {
|
|
36
|
+
version: PROTOCOL_VERSION,
|
|
37
|
+
manifest,
|
|
38
|
+
async ready() {
|
|
39
|
+
assertActive(disposed);
|
|
40
|
+
await initialReady;
|
|
41
|
+
},
|
|
42
|
+
seek(frame, seekOptions) {
|
|
43
|
+
assertActive(disposed);
|
|
44
|
+
validateFrame(frame, config);
|
|
45
|
+
const operation = queue.then(async () => {
|
|
46
|
+
assertActive(disposed);
|
|
47
|
+
await initialReady;
|
|
48
|
+
const created = await instance;
|
|
49
|
+
currentFrame = frameContext(frame, config, seed, { ...options, ...seekOptions });
|
|
50
|
+
await abortable(Promise.resolve(created.update(currentFrame)), abort.signal);
|
|
51
|
+
seekCssAnimations(target, frame / config.fps);
|
|
52
|
+
if (seekOptions?.realtime) {
|
|
53
|
+
presentSceneVideos(target, frame / config.fps, seekOptions.playbackRate ?? 1);
|
|
54
|
+
}
|
|
55
|
+
else
|
|
56
|
+
await settle(frame / config.fps);
|
|
57
|
+
assertActive(disposed);
|
|
58
|
+
});
|
|
59
|
+
queue = operation.catch(() => undefined);
|
|
60
|
+
return operation;
|
|
61
|
+
},
|
|
62
|
+
dispose() {
|
|
63
|
+
if (disposal)
|
|
64
|
+
return disposal;
|
|
65
|
+
disposed = true;
|
|
66
|
+
abort.abort(new Error('Scene disposed'));
|
|
67
|
+
disposal = queue.then(async () => {
|
|
68
|
+
const created = await instance;
|
|
69
|
+
await created.dispose();
|
|
70
|
+
}).finally(() => objects.clear());
|
|
71
|
+
return disposal;
|
|
72
|
+
},
|
|
73
|
+
snapshotObjects() {
|
|
74
|
+
assertActive(disposed);
|
|
75
|
+
return objects.snapshot(currentFrame);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function assertActive(disposed) {
|
|
80
|
+
if (disposed)
|
|
81
|
+
throw new Error('Scene disposed');
|
|
82
|
+
}
|
|
83
|
+
function frameContext(frame, config, seed, options) {
|
|
84
|
+
return {
|
|
85
|
+
frame,
|
|
86
|
+
time: frame / config.fps,
|
|
87
|
+
progress: config.durationInFrames === 1 ? 0 : frame / (config.durationInFrames - 1),
|
|
88
|
+
config,
|
|
89
|
+
renderScale: options.renderScale && Number.isFinite(options.renderScale) && options.renderScale > 0 ? Math.min(1, options.renderScale) : 1,
|
|
90
|
+
capture: options.capture ?? true,
|
|
91
|
+
random: (key) => seededRandom(seed, key),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface Point {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
}
|
|
5
|
+
export interface Bounds extends Point {
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
}
|
|
9
|
+
export interface Pose2D extends Point {
|
|
10
|
+
rotation: number;
|
|
11
|
+
scaleX: number;
|
|
12
|
+
scaleY: number;
|
|
13
|
+
}
|
|
14
|
+
export type Anchor = 'center' | 'top' | 'right' | 'bottom' | 'left';
|
|
15
|
+
export declare function anchorPoint(bounds: Bounds, anchor?: Anchor): Point;
|
|
16
|
+
/** Rotation is in radians, around the subject's local origin. */
|
|
17
|
+
export declare function transformPoint(point: Point, pose: Pose2D): Point;
|
|
18
|
+
export declare function bezierPoint(points: readonly [Point, Point, Point, Point], progress: number): Point;
|
|
19
|
+
export declare function connectorPath(from: Point, to: Point, bend?: number): string;
|
|
20
|
+
export declare function interpolatePose(from: Pose2D, to: Pose2D, progress: number): Pose2D;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { clamp } from './math.js';
|
|
2
|
+
export function anchorPoint(bounds, anchor = 'center') {
|
|
3
|
+
const x = bounds.x + bounds.width / 2;
|
|
4
|
+
const y = bounds.y + bounds.height / 2;
|
|
5
|
+
switch (anchor) {
|
|
6
|
+
case 'top': return { x, y: bounds.y };
|
|
7
|
+
case 'right': return { x: bounds.x + bounds.width, y };
|
|
8
|
+
case 'bottom': return { x, y: bounds.y + bounds.height };
|
|
9
|
+
case 'left': return { x: bounds.x, y };
|
|
10
|
+
default: return { x, y };
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Rotation is in radians, around the subject's local origin. */
|
|
14
|
+
export function transformPoint(point, pose) {
|
|
15
|
+
const x = point.x * pose.scaleX;
|
|
16
|
+
const y = point.y * pose.scaleY;
|
|
17
|
+
const cos = Math.cos(pose.rotation), sin = Math.sin(pose.rotation);
|
|
18
|
+
return { x: pose.x + x * cos - y * sin, y: pose.y + x * sin + y * cos };
|
|
19
|
+
}
|
|
20
|
+
export function bezierPoint(points, progress) {
|
|
21
|
+
const t = clamp(progress), u = 1 - t;
|
|
22
|
+
const [a, b, c, d] = points;
|
|
23
|
+
const sample = (key) => u ** 3 * a[key] + 3 * u * u * t * b[key] + 3 * u * t * t * c[key] + t ** 3 * d[key];
|
|
24
|
+
return { x: sample('x'), y: sample('y') };
|
|
25
|
+
}
|
|
26
|
+
export function connectorPath(from, to, bend = 0.5) {
|
|
27
|
+
const middle = from.x + (to.x - from.x) * clamp(bend);
|
|
28
|
+
return `M ${from.x} ${from.y} C ${middle} ${from.y}, ${middle} ${to.y}, ${to.x} ${to.y}`;
|
|
29
|
+
}
|
|
30
|
+
export function interpolatePose(from, to, progress) {
|
|
31
|
+
const t = clamp(progress);
|
|
32
|
+
const mix = (a, b) => a + (b - a) * t;
|
|
33
|
+
return { x: mix(from.x, to.x), y: mix(from.y, to.y), rotation: mix(from.rotation, to.rotation), scaleX: mix(from.scaleX, to.scaleX), scaleY: mix(from.scaleY, to.scaleY) };
|
|
34
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export * from './validation.js';
|
|
3
|
+
export * from './math.js';
|
|
4
|
+
export * from './resources.js';
|
|
5
|
+
export * from './controller.js';
|
|
6
|
+
export * from './bridge.js';
|
|
7
|
+
export * from './artifact.js';
|
|
8
|
+
export * from './video.js';
|
|
9
|
+
export * from './choreography.js';
|
|
10
|
+
export * from './geometry.js';
|
|
11
|
+
export * from './parameters.js';
|
|
12
|
+
export * from './models.js';
|
|
13
|
+
export * from './objects.js';
|
|
14
|
+
export * from './layout.js';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export * from './validation.js';
|
|
3
|
+
export * from './math.js';
|
|
4
|
+
export * from './resources.js';
|
|
5
|
+
export * from './controller.js';
|
|
6
|
+
export * from './bridge.js';
|
|
7
|
+
export * from './artifact.js';
|
|
8
|
+
export * from './video.js';
|
|
9
|
+
export * from './choreography.js';
|
|
10
|
+
export * from './geometry.js';
|
|
11
|
+
export * from './parameters.js';
|
|
12
|
+
export * from './models.js';
|
|
13
|
+
export * from './objects.js';
|
|
14
|
+
export * from './layout.js';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type Anchor, type Bounds, type Point } from './geometry.js';
|
|
2
|
+
export interface LabelPlacement {
|
|
3
|
+
bounds: Bounds;
|
|
4
|
+
side: Anchor;
|
|
5
|
+
anchor: Point;
|
|
6
|
+
fits: boolean;
|
|
7
|
+
collisions: number;
|
|
8
|
+
}
|
|
9
|
+
/** Deterministic candidate placement, not a DOM/physics solver or caption reflow. */
|
|
10
|
+
export declare function placeAnchoredLabel(subject: Bounds, size: {
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
}, stage: Bounds, options?: {
|
|
14
|
+
sides?: readonly Anchor[];
|
|
15
|
+
gap?: number;
|
|
16
|
+
obstacles?: readonly Bounds[];
|
|
17
|
+
}): LabelPlacement;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { anchorPoint } from './geometry.js';
|
|
2
|
+
function intersects(a, b) {
|
|
3
|
+
return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
|
|
4
|
+
}
|
|
5
|
+
function validateBounds(bounds) {
|
|
6
|
+
if (![bounds.x, bounds.y, bounds.width, bounds.height].every(Number.isFinite) || bounds.width < 0 || bounds.height < 0)
|
|
7
|
+
throw new Error('Layout needs finite nonnegative bounds');
|
|
8
|
+
}
|
|
9
|
+
/** Deterministic candidate placement, not a DOM/physics solver or caption reflow. */
|
|
10
|
+
export function placeAnchoredLabel(subject, size, stage, options = {}) {
|
|
11
|
+
validateBounds(subject);
|
|
12
|
+
validateBounds(stage);
|
|
13
|
+
validateBounds({ x: 0, y: 0, width: size.width, height: size.height });
|
|
14
|
+
const gap = options.gap ?? 16;
|
|
15
|
+
const sides = options.sides ?? ['right', 'left', 'top', 'bottom'];
|
|
16
|
+
const obstacles = options.obstacles ?? [];
|
|
17
|
+
if (!Number.isFinite(gap) || gap < 0 || !sides.length || sides.length > 5 || obstacles.length > 64)
|
|
18
|
+
throw new Error('Invalid anchored-layout limits');
|
|
19
|
+
if (sides.some(side => !['center', 'top', 'right', 'bottom', 'left'].includes(side)))
|
|
20
|
+
throw new Error('Invalid anchored-layout side');
|
|
21
|
+
obstacles.forEach(validateBounds);
|
|
22
|
+
let best;
|
|
23
|
+
let bestScore = Infinity;
|
|
24
|
+
for (const side of sides) {
|
|
25
|
+
const anchor = anchorPoint(subject, side);
|
|
26
|
+
let x = anchor.x - size.width / 2, y = anchor.y - size.height / 2;
|
|
27
|
+
if (side === 'right')
|
|
28
|
+
x = anchor.x + gap;
|
|
29
|
+
if (side === 'left')
|
|
30
|
+
x = anchor.x - gap - size.width;
|
|
31
|
+
if (side === 'top')
|
|
32
|
+
y = anchor.y - gap - size.height;
|
|
33
|
+
if (side === 'bottom')
|
|
34
|
+
y = anchor.y + gap;
|
|
35
|
+
const bounds = { x, y, width: size.width, height: size.height };
|
|
36
|
+
const inside = x >= stage.x && y >= stage.y && x + size.width <= stage.x + stage.width && y + size.height <= stage.y + stage.height;
|
|
37
|
+
const collisions = obstacles.filter(obstacle => intersects(bounds, obstacle)).length + Number(side !== 'center' && intersects(bounds, subject));
|
|
38
|
+
const candidate = { bounds, side, anchor, fits: inside && collisions === 0, collisions };
|
|
39
|
+
const score = collisions + (inside ? 0 : 1000);
|
|
40
|
+
if (score < bestScore) {
|
|
41
|
+
best = candidate;
|
|
42
|
+
bestScore = score;
|
|
43
|
+
}
|
|
44
|
+
if (candidate.fits)
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
return best;
|
|
48
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function clamp(value: number, min?: number, max?: number): number;
|
|
2
|
+
export declare function interpolate(value: number, input: readonly [number, number], output: readonly [number, number]): number;
|
|
3
|
+
export declare function easeOutCubic(progress: number): number;
|
|
4
|
+
export declare function seededRandom(seed: string, key: string): number;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function clamp(value, min = 0, max = 1) {
|
|
2
|
+
return Math.min(max, Math.max(min, value));
|
|
3
|
+
}
|
|
4
|
+
export function interpolate(value, input, output) {
|
|
5
|
+
if (input[1] <= input[0])
|
|
6
|
+
throw new Error('Interpolation input must be increasing');
|
|
7
|
+
const progress = clamp((value - input[0]) / (input[1] - input[0]));
|
|
8
|
+
return output[0] + (output[1] - output[0]) * progress;
|
|
9
|
+
}
|
|
10
|
+
export function easeOutCubic(progress) {
|
|
11
|
+
return 1 - (1 - clamp(progress)) ** 3;
|
|
12
|
+
}
|
|
13
|
+
export function seededRandom(seed, key) {
|
|
14
|
+
let hash = 2166136261;
|
|
15
|
+
const input = `${seed}\0${key}`;
|
|
16
|
+
for (let index = 0; index < input.length; index++) {
|
|
17
|
+
hash = Math.imul(hash ^ input.charCodeAt(index), 16777619);
|
|
18
|
+
}
|
|
19
|
+
hash ^= hash >>> 16;
|
|
20
|
+
hash = Math.imul(hash, 0x85ebca6b);
|
|
21
|
+
hash ^= hash >>> 13;
|
|
22
|
+
return (hash >>> 0) / 4294967296;
|
|
23
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const MEDIA_TIMEOUT_MS = 10000;
|
|
2
|
+
const PLAYBACK_DRIFT_SECONDS = 0.15;
|
|
3
|
+
const pendingPlayback = new WeakSet();
|
|
4
|
+
export function presentSceneVideos(target, time, playbackRate) {
|
|
5
|
+
for (const video of target.querySelectorAll('video')) {
|
|
6
|
+
if ((!video.src && !video.currentSrc) || video.readyState < 2)
|
|
7
|
+
continue;
|
|
8
|
+
video.muted = true;
|
|
9
|
+
const desired = Number(video.dataset.sceneVideoTime ?? time);
|
|
10
|
+
const rate = Number(video.dataset.sceneVideoRate ?? 1) * playbackRate;
|
|
11
|
+
if (!Number.isFinite(desired) || !Number.isFinite(rate))
|
|
12
|
+
continue;
|
|
13
|
+
const position = Math.max(0, Math.min(video.duration - 0.000001, desired));
|
|
14
|
+
const tolerance = rate > 0 ? PLAYBACK_DRIFT_SECONDS : 0.000001;
|
|
15
|
+
if (!video.seeking && Math.abs(video.currentTime - position) > tolerance)
|
|
16
|
+
video.currentTime = position;
|
|
17
|
+
if (rate <= 0 || rate > 16) {
|
|
18
|
+
video.pause();
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (video.playbackRate !== rate)
|
|
22
|
+
video.playbackRate = rate;
|
|
23
|
+
if (video.paused && !pendingPlayback.has(video)) {
|
|
24
|
+
pendingPlayback.add(video);
|
|
25
|
+
void video.play().catch(() => { }).finally(() => pendingPlayback.delete(video));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function waitForMedia(video, event, ready, signal) {
|
|
30
|
+
signal.throwIfAborted();
|
|
31
|
+
if (video.error)
|
|
32
|
+
return Promise.reject(new Error(`Scene video could not decode: ${video.error.message}`));
|
|
33
|
+
if (ready())
|
|
34
|
+
return Promise.resolve();
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const cleanup = () => {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
video.removeEventListener(event, complete);
|
|
39
|
+
video.removeEventListener('error', failed);
|
|
40
|
+
signal.removeEventListener('abort', aborted);
|
|
41
|
+
};
|
|
42
|
+
const complete = () => { if (ready()) {
|
|
43
|
+
cleanup();
|
|
44
|
+
resolve();
|
|
45
|
+
} };
|
|
46
|
+
const failed = () => { cleanup(); reject(new Error('Scene video could not decode. Choose H.264 MP4 or VP8/VP9 WebM.')); };
|
|
47
|
+
const aborted = () => { cleanup(); reject(signal.reason); };
|
|
48
|
+
const timer = setTimeout(() => { cleanup(); reject(new Error('Scene video frame timed out.')); }, MEDIA_TIMEOUT_MS);
|
|
49
|
+
video.addEventListener(event, complete);
|
|
50
|
+
video.addEventListener('error', failed);
|
|
51
|
+
signal.addEventListener('abort', aborted, { once: true });
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
export async function seekSceneVideos(target, time, signal) {
|
|
55
|
+
await Promise.all([...target.querySelectorAll('video')].map(async (video) => {
|
|
56
|
+
if (!video.src && !video.currentSrc)
|
|
57
|
+
return;
|
|
58
|
+
video.pause();
|
|
59
|
+
video.muted = true;
|
|
60
|
+
await waitForMedia(video, 'loadeddata', () => video.readyState >= 2, signal);
|
|
61
|
+
const desired = Number(video.dataset.sceneVideoTime ?? time);
|
|
62
|
+
if (!Number.isFinite(desired))
|
|
63
|
+
throw new Error('Scene video time must be finite');
|
|
64
|
+
const position = Math.max(0, Math.min(video.duration - 0.000001, desired));
|
|
65
|
+
if (Math.abs(video.currentTime - position) > 0.000001)
|
|
66
|
+
video.currentTime = position;
|
|
67
|
+
await waitForMedia(video, 'seeked', () => !video.seeking && video.readyState >= 2, signal);
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare const MODEL_DATA_PREFIX = "data:model/gltf-binary;base64,";
|
|
2
|
+
export declare const MAX_MODEL_BYTES: number;
|
|
3
|
+
export declare const MAX_MODEL_PIXELS = 4000000;
|
|
4
|
+
export declare function decodeModelData(source: string): Uint8Array;
|
|
5
|
+
export declare function pngDimensions(bytes: Uint8Array): {
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
};
|
|
9
|
+
export declare function validateGLB(bytes: Uint8Array): {
|
|
10
|
+
nodes: number;
|
|
11
|
+
meshes: number;
|
|
12
|
+
textures: number;
|
|
13
|
+
};
|
|
14
|
+
export declare function validateSceneModels(models: unknown): void;
|