@osmo.inc/cli-linux-arm64 0.1.22 → 0.1.24
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/dist/studio.js +11834 -7989
- package/package.json +1 -1
- package/release.json +32 -32
- package/runtime/apps/native/tools/compile.mjs +20 -2
- package/runtime/packages/render-protocol/src/film-catalog.json +2543 -0
- package/runtime/packages/render-protocol/src/film-resources.mjs +91 -0
- package/runtime/packages/render-protocol/src/film-store.node.mjs +220 -0
- package/runtime/packages/scene/scripts/sync-film.mjs +8 -0
- package/runtime/packages/scene/scripts/validate-strict.mjs +14 -4
- package/runtime/packages/scene/scripts/verify-export-resolution.mjs +67 -0
- package/runtime/packages/scene/scripts/verify-film.mjs +66 -0
- package/runtime/packages/scene/src/effect-schemas.d.mts +246 -0
- package/runtime/packages/scene/src/effect-schemas.mjs +136 -0
- package/runtime/packages/scene/src/effects.ts +23 -6
- package/runtime/packages/scene/src/elements.ts +15 -3
- package/runtime/packages/scene/src/evaluate.ts +88 -12
- package/runtime/packages/scene/src/film-catalog.generated.ts +3 -0
- package/runtime/packages/scene/src/film.ts +39 -0
- package/runtime/packages/scene/src/index.ts +6 -2
- package/runtime/packages/scene/src/knob-schema.ts +5 -2
- package/runtime/packages/scene/src/metadata.d.mts +2 -1
- package/runtime/packages/scene/src/metadata.mjs +4 -4
- package/runtime/packages/scene/src/motion-blur.ts +30 -0
- package/src/starter/scene.tsx +1 -1
- package/studio/src/app.css +1 -1
- package/studio/src/app.js +236 -176
- package/studio/src/engine_bg.wasm +0 -0
- package/studio/src/preview-worker.js +33 -0
- package/third-party/braw/BlackmagicRawAPI.h +1137 -0
- package/third-party/braw/README.md +7 -0
- package/third-party/cargo/block-buffer@0.10.4/Cargo.toml +32 -0
- package/third-party/cargo/block-buffer@0.10.4/LICENSE-APACHE +201 -0
- package/third-party/cargo/block-buffer@0.10.4/LICENSE-MIT +25 -0
- package/third-party/cargo/cc@1.4.5/Cargo.toml +89 -0
- package/third-party/cargo/cc@1.4.5/LICENSE-APACHE +201 -0
- package/third-party/cargo/cc@1.4.5/LICENSE-MIT +25 -0
- package/third-party/cargo/cpufeatures@0.2.17/Cargo.toml +71 -0
- package/third-party/cargo/cpufeatures@0.2.17/LICENSE-APACHE +201 -0
- package/third-party/cargo/cpufeatures@0.2.17/LICENSE-MIT +25 -0
- package/third-party/cargo/crypto-common@0.1.7/Cargo.toml +62 -0
- package/third-party/cargo/crypto-common@0.1.7/LICENSE-APACHE +201 -0
- package/third-party/cargo/crypto-common@0.1.7/LICENSE-MIT +25 -0
- package/third-party/cargo/digest@0.10.7/Cargo.toml +70 -0
- package/third-party/cargo/digest@0.10.7/LICENSE-APACHE +201 -0
- package/third-party/cargo/digest@0.10.7/LICENSE-MIT +25 -0
- package/third-party/cargo/find-msvc-tools@0.1.12/Cargo.toml +49 -0
- package/third-party/cargo/find-msvc-tools@0.1.12/LICENSE-APACHE +201 -0
- package/third-party/cargo/find-msvc-tools@0.1.12/LICENSE-MIT +25 -0
- package/third-party/cargo/generic-array@0.14.7/Cargo.toml +75 -0
- package/third-party/cargo/generic-array@0.14.7/LICENSE +21 -0
- package/third-party/cargo/sha2@0.10.9/Cargo.toml +90 -0
- package/third-party/cargo/sha2@0.10.9/LICENSE-APACHE +201 -0
- package/third-party/cargo/sha2@0.10.9/LICENSE-MIT +27 -0
- package/third-party/cargo/shlex@2.0.1/Cargo.toml +51 -0
- package/third-party/cargo/shlex@2.0.1/LICENSE-APACHE +13 -0
- package/third-party/cargo/shlex@2.0.1/LICENSE-MIT +21 -0
- package/third-party/cargo/typenum@1.20.1/Cargo.toml +75 -0
- package/third-party/cargo/typenum@1.20.1/LICENSE +1 -0
- package/third-party/cargo/typenum@1.20.1/LICENSE-APACHE +201 -0
- package/third-party/cargo/typenum@1.20.1/LICENSE-MIT +21 -0
- package/third-party/film/camera-COLOUR-LICENSE +11 -0
- package/third-party/film/camera-CONFIG-LICENSE +26 -0
- package/third-party/film/camera-LICENSE +26 -0
- package/third-party/film/camera-metadata.json +344 -0
- package/third-party/film/provenance.json +7 -0
- package/third-party/inventory.json +140 -0
- package/vendor/osmo-lite +0 -0
- package/vendor/osmo-scene-0.1.0.tgz +0 -0
- package/vendor/platform.json +1 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Pinned data shared by the backend catalogue, CLI, and native compiler.
|
|
2
|
+
// Existing table URLs/checksums are immutable. Bump this version when adding tables.
|
|
3
|
+
import catalog from "./film-catalog.json" with { type: "json" };
|
|
4
|
+
export const FILM_RESOURCE_VERSION = "1.0.0";
|
|
5
|
+
export const FILM_TABLE_BYTES = 65 ** 3 * 12;
|
|
6
|
+
export const FILM_TABLE_BASE = "https://osmo.inc/api/35mm/tables";
|
|
7
|
+
export const filmStocks = catalog.items;
|
|
8
|
+
export const filmTables = [
|
|
9
|
+
...new Map(
|
|
10
|
+
filmStocks.flatMap((stock) =>
|
|
11
|
+
[stock, ...Object.values(stock.tables)].map((table) => [
|
|
12
|
+
table.hash,
|
|
13
|
+
{
|
|
14
|
+
hash: table.hash,
|
|
15
|
+
sha256: table.sha256,
|
|
16
|
+
bytes: FILM_TABLE_BYTES,
|
|
17
|
+
url: `${FILM_TABLE_BASE}/${table.hash}.bin`,
|
|
18
|
+
},
|
|
19
|
+
]),
|
|
20
|
+
),
|
|
21
|
+
).values(),
|
|
22
|
+
];
|
|
23
|
+
export function filmResource(name = "film", version = FILM_RESOURCE_VERSION) {
|
|
24
|
+
if (version !== FILM_RESOURCE_VERSION) return undefined;
|
|
25
|
+
// Numbers are append-only catalogue identities; labels can change.
|
|
26
|
+
const stocks =
|
|
27
|
+
name === "film"
|
|
28
|
+
? filmStocks
|
|
29
|
+
: filmStocks.filter((s) => name === `film-${s.number}`);
|
|
30
|
+
if (!stocks.length) return undefined;
|
|
31
|
+
const hashes = new Set(
|
|
32
|
+
stocks.flatMap((s) => [
|
|
33
|
+
s.hash,
|
|
34
|
+
...Object.values(s.tables).map((t) => t.hash),
|
|
35
|
+
]),
|
|
36
|
+
);
|
|
37
|
+
return {
|
|
38
|
+
id: `osmo/${name}`,
|
|
39
|
+
title: name === "film" ? "Film looks" : `Film look: ${stocks[0].label}`,
|
|
40
|
+
summary:
|
|
41
|
+
name === "film"
|
|
42
|
+
? "All film looks, installed once for every local project."
|
|
43
|
+
: `${stocks[0].label}, with all camera variants. Installed once for every local project.`,
|
|
44
|
+
use: /** @type {const} */ ("get"),
|
|
45
|
+
kind: /** @type {const} */ ("film"),
|
|
46
|
+
tags: [
|
|
47
|
+
"film",
|
|
48
|
+
"color",
|
|
49
|
+
"offline",
|
|
50
|
+
...new Set(stocks.map((s) => s.collection)),
|
|
51
|
+
],
|
|
52
|
+
version,
|
|
53
|
+
package: {
|
|
54
|
+
folder: name,
|
|
55
|
+
version,
|
|
56
|
+
primitive: "Film",
|
|
57
|
+
installScope: /** @type {const} */ ("global"),
|
|
58
|
+
compatibility: { scene: "0.1.0" },
|
|
59
|
+
files: filmTables
|
|
60
|
+
.filter((t) => hashes.has(t.hash))
|
|
61
|
+
.map((t) => ({ path: `${t.hash}.bin`, ...t })),
|
|
62
|
+
presets: stocks.map((s) => ({ id: s.id, title: s.label })),
|
|
63
|
+
defaultPreset: stocks[0].id,
|
|
64
|
+
previews: [],
|
|
65
|
+
requiresLogin: false,
|
|
66
|
+
notes: [
|
|
67
|
+
"Film data is shared by all projects and preserved across CLI updates. Missing tables also install automatically when used.",
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export const filmResourceItems = [
|
|
73
|
+
filmResource(),
|
|
74
|
+
...filmStocks.map((s) => filmResource(`film-${s.number}`)),
|
|
75
|
+
].map((detail) => {
|
|
76
|
+
const { package: _package, ...item } = detail;
|
|
77
|
+
return item;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/** Collect dependencies from evaluated frames, including compositions, text
|
|
81
|
+
* animation states and motion-blur samples. Never infer them from source text. */
|
|
82
|
+
export function collectFilmKeys(value, keys = new Set()) {
|
|
83
|
+
if (!value || typeof value !== "object") return keys;
|
|
84
|
+
for (const effect of value.effects ?? [])
|
|
85
|
+
if (typeof effect.film === "string") keys.add(effect.film);
|
|
86
|
+
for (const child of value.children ?? []) collectFilmKeys(child, keys);
|
|
87
|
+
for (const state of value.animate?.states ?? []) collectFilmKeys(state, keys);
|
|
88
|
+
for (const sample of value.motionSamples ?? []) collectFilmKeys(sample, keys);
|
|
89
|
+
if (value.scene) collectFilmKeys(value.scene, keys);
|
|
90
|
+
return keys;
|
|
91
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Node-only resource installer. Do not export this from the browser protocol entry.
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, writeFile, rename, rm, stat } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
7
|
+
import { filmTables } from "./film-resources.mjs";
|
|
8
|
+
|
|
9
|
+
export function filmDirectory(
|
|
10
|
+
env = process.env,
|
|
11
|
+
platform = process.platform,
|
|
12
|
+
home = os.homedir(),
|
|
13
|
+
) {
|
|
14
|
+
if (env.OSMO_FILM_TABLES) return path.resolve(env.OSMO_FILM_TABLES);
|
|
15
|
+
const base =
|
|
16
|
+
platform === "darwin"
|
|
17
|
+
? path.join(home, "Library/Application Support/Osmo")
|
|
18
|
+
: platform === "win32"
|
|
19
|
+
? path.join(
|
|
20
|
+
env.LOCALAPPDATA || path.join(home, "AppData/Local"),
|
|
21
|
+
"Osmo",
|
|
22
|
+
)
|
|
23
|
+
: path.join(
|
|
24
|
+
env.XDG_DATA_HOME || path.join(home, ".local/share"),
|
|
25
|
+
"osmo",
|
|
26
|
+
);
|
|
27
|
+
return path.join(base, "resources/film");
|
|
28
|
+
}
|
|
29
|
+
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
30
|
+
const valid = (bytes, table) =>
|
|
31
|
+
bytes?.length === table.bytes && digest(bytes) === table.sha256;
|
|
32
|
+
|
|
33
|
+
/** @typedef {{key:string, state:'checking'|'downloading'|'installed'|'failed', received:number, total:number, error?:string}} FilmProgress */
|
|
34
|
+
/** @param {{directory?:string, tables?:typeof filmTables, fetch?:typeof globalThis.fetch, onProgress?:(progress:FilmProgress)=>void}} options */
|
|
35
|
+
export function createFilmStore(options = {}) {
|
|
36
|
+
const directory = options.directory ?? filmDirectory();
|
|
37
|
+
const tables = new Map(
|
|
38
|
+
(options.tables ?? filmTables).map((t) => [`film:${t.hash}`, t]),
|
|
39
|
+
);
|
|
40
|
+
const pending = new Map();
|
|
41
|
+
/** @type {Map<string, FilmProgress>} */
|
|
42
|
+
const progress = new Map();
|
|
43
|
+
function tableFor(key) {
|
|
44
|
+
const table = tables.get(key);
|
|
45
|
+
if (!table) throw new Error(`Unknown film table: ${key}`);
|
|
46
|
+
return table;
|
|
47
|
+
}
|
|
48
|
+
function report(key, state, received = 0, error = undefined) {
|
|
49
|
+
const value = {
|
|
50
|
+
key,
|
|
51
|
+
state,
|
|
52
|
+
received,
|
|
53
|
+
total: tableFor(key).bytes,
|
|
54
|
+
...(error ? { error } : {}),
|
|
55
|
+
};
|
|
56
|
+
progress.set(key, value);
|
|
57
|
+
options.onProgress?.(value);
|
|
58
|
+
}
|
|
59
|
+
async function cached(table) {
|
|
60
|
+
const file = path.join(directory, `${table.hash}.bin`);
|
|
61
|
+
try {
|
|
62
|
+
if ((await stat(file)).size !== table.bytes) return null;
|
|
63
|
+
const bytes = await readFile(file);
|
|
64
|
+
return valid(bytes, table) ? bytes : null;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error.code === "ENOENT") return null;
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function install(key) {
|
|
71
|
+
const table = tableFor(key),
|
|
72
|
+
file = path.join(directory, `${table.hash}.bin`);
|
|
73
|
+
report(key, "checking");
|
|
74
|
+
const previous = await cached(table);
|
|
75
|
+
if (previous) {
|
|
76
|
+
report(key, "installed", table.bytes);
|
|
77
|
+
return previous;
|
|
78
|
+
}
|
|
79
|
+
await mkdir(directory, { recursive: true });
|
|
80
|
+
// Locks coalesce downloads across CLI commands, native compilers, and Studio.
|
|
81
|
+
// A crashed writer's lock expires after its bounded network deadline.
|
|
82
|
+
const lock = `${file}.lock`,
|
|
83
|
+
started = Date.now();
|
|
84
|
+
let held = false;
|
|
85
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
86
|
+
try {
|
|
87
|
+
while (!held) {
|
|
88
|
+
try {
|
|
89
|
+
await mkdir(lock);
|
|
90
|
+
held = true;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error.code !== "EEXIST") throw error;
|
|
93
|
+
const bytes = await cached(table);
|
|
94
|
+
if (bytes) {
|
|
95
|
+
report(key, "installed", table.bytes);
|
|
96
|
+
return bytes;
|
|
97
|
+
}
|
|
98
|
+
const info = await stat(lock).catch(() => null);
|
|
99
|
+
if (info && Date.now() - info.mtimeMs > 180000) {
|
|
100
|
+
await rm(lock, { recursive: true, force: true });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (Date.now() - started > 210000)
|
|
104
|
+
throw new Error(
|
|
105
|
+
"Another film installation is still running. Retry shortly.",
|
|
106
|
+
);
|
|
107
|
+
report(key, "downloading");
|
|
108
|
+
await delay(200);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const bytes = await cached(table);
|
|
112
|
+
if (bytes) {
|
|
113
|
+
report(key, "installed", table.bytes);
|
|
114
|
+
return bytes;
|
|
115
|
+
}
|
|
116
|
+
report(key, "downloading");
|
|
117
|
+
const response = await (options.fetch ?? globalThis.fetch)(table.url, {
|
|
118
|
+
signal: AbortSignal.timeout(120000),
|
|
119
|
+
redirect: "error",
|
|
120
|
+
});
|
|
121
|
+
if (!response.ok || !response.body) {
|
|
122
|
+
await response.body?.cancel();
|
|
123
|
+
throw new Error(`HTTP ${response.status}`);
|
|
124
|
+
}
|
|
125
|
+
const reader = response.body.getReader(),
|
|
126
|
+
data = Buffer.alloc(table.bytes);
|
|
127
|
+
let received = 0;
|
|
128
|
+
try {
|
|
129
|
+
for (;;) {
|
|
130
|
+
const { done, value } = await reader.read();
|
|
131
|
+
if (done) break;
|
|
132
|
+
if (received + value.length > data.length)
|
|
133
|
+
throw new Error("Film table exceeds its declared size");
|
|
134
|
+
data.set(value, received);
|
|
135
|
+
received += value.length;
|
|
136
|
+
report(key, "downloading", received);
|
|
137
|
+
}
|
|
138
|
+
} finally {
|
|
139
|
+
await reader.cancel().catch(() => {});
|
|
140
|
+
}
|
|
141
|
+
if (received !== data.length || !valid(data, table))
|
|
142
|
+
throw new Error("Film table checksum or size mismatch");
|
|
143
|
+
await writeFile(temporary, data, { flag: "wx" });
|
|
144
|
+
await rename(temporary, file);
|
|
145
|
+
report(key, "installed", table.bytes);
|
|
146
|
+
return data;
|
|
147
|
+
} finally {
|
|
148
|
+
await rm(temporary, { force: true });
|
|
149
|
+
if (held) await rm(lock, { recursive: true, force: true });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function ensure(key) {
|
|
153
|
+
tableFor(key); // Reject unknown paths before touching the filesystem/network.
|
|
154
|
+
if (pending.has(key)) return pending.get(key);
|
|
155
|
+
const task = install(key)
|
|
156
|
+
.catch((cause) => {
|
|
157
|
+
const error = new Error(
|
|
158
|
+
`Film resource unavailable: ${key}. ${cause.message}. Check your connection and retry, or run osmo resources get film while online.`,
|
|
159
|
+
);
|
|
160
|
+
report(key, "failed", 0, error.message);
|
|
161
|
+
throw error;
|
|
162
|
+
})
|
|
163
|
+
.finally(() => pending.delete(key));
|
|
164
|
+
pending.set(key, task);
|
|
165
|
+
return task;
|
|
166
|
+
}
|
|
167
|
+
async function status(keys) {
|
|
168
|
+
const result = [];
|
|
169
|
+
// Bound memory even when inspecting the entire 1.16 GiB library.
|
|
170
|
+
for (const key of new Set(keys)) {
|
|
171
|
+
const table = tableFor(key);
|
|
172
|
+
if (pending.has(key)) result.push(progress.get(key));
|
|
173
|
+
else if (await cached(table))
|
|
174
|
+
result.push({
|
|
175
|
+
key,
|
|
176
|
+
state: "installed",
|
|
177
|
+
received: table.bytes,
|
|
178
|
+
total: table.bytes,
|
|
179
|
+
});
|
|
180
|
+
else
|
|
181
|
+
result.push(
|
|
182
|
+
progress.get(key)?.state === "failed"
|
|
183
|
+
? progress.get(key)
|
|
184
|
+
: { key, state: "missing", received: 0, total: table.bytes },
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
async function ensureAll(keys, onDone = (_done, _total) => {}) {
|
|
190
|
+
const unique = [...new Set(keys)];
|
|
191
|
+
unique.forEach(tableFor);
|
|
192
|
+
const queue = [...unique];
|
|
193
|
+
let done = 0,
|
|
194
|
+
failed = false;
|
|
195
|
+
// Settle active workers before returning an error; never leave background writes.
|
|
196
|
+
const results = await Promise.allSettled(
|
|
197
|
+
Array.from({ length: Math.min(4, queue.length) }, async () => {
|
|
198
|
+
while (queue.length && !failed) {
|
|
199
|
+
try {
|
|
200
|
+
await ensure(queue.shift());
|
|
201
|
+
onDone(++done, unique.length);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
failed = true;
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
const failure = results.find((result) => result.status === "rejected");
|
|
210
|
+
if (failure?.status === "rejected") throw failure.reason;
|
|
211
|
+
return { directory, tables: unique.length };
|
|
212
|
+
}
|
|
213
|
+
return { directory, ensure, ensureAll, status };
|
|
214
|
+
}
|
|
215
|
+
// One instance per process; native compiler processes share its on-disk locks.
|
|
216
|
+
let shared;
|
|
217
|
+
export function filmStore() {
|
|
218
|
+
shared ??= createFilmStore();
|
|
219
|
+
return shared;
|
|
220
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Keep the standalone authoring package independent of private workspace packages.
|
|
2
|
+
// Its generated catalog is checked against the engine's canonical catalog in tests.
|
|
3
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
const root = new URL('../', import.meta.url);
|
|
5
|
+
const catalog = JSON.parse(await readFile(new URL('../render-protocol/src/film-catalog.json', root), 'utf8'));
|
|
6
|
+
const cameras = await readFile(new URL('../render-protocol/src/film-cameras.ts', root), 'utf8');
|
|
7
|
+
const names = JSON.parse(cameras.slice(cameras.indexOf('['), cameras.lastIndexOf(']') + 1).replace(/,\s*]/g, ']'));
|
|
8
|
+
await writeFile(new URL('src/film-catalog.generated.ts', root), '// Generated by scripts/sync-film.mjs; do not edit.\nexport const FILM_STOCKS = ' + JSON.stringify(catalog.items) + ';\nexport const FILM_CAMERAS = ' + JSON.stringify(names) + ' as const;\n');
|
|
@@ -35,7 +35,7 @@ export function validate(file, mode, options = {}) {
|
|
|
35
35
|
const p = ast.getLineAndCharacterOfPosition(d.start);
|
|
36
36
|
errors.push(`${p.line + 1}:${p.character + 1} ${ts.flattenDiagnosticMessageText(d.messageText, '\n')}`);
|
|
37
37
|
}
|
|
38
|
-
const allowed = new Set(['Scene', 'Camera', 'Group', 'Rect', 'Path', 'Text', 'Image', 'Video', 'Audio', 'Model', 'Surface', 'Fragment', 'defineMotion', 'defineKnobs', 'Shader', 'sampleMotion', 'describeMotion', 'blur', 'grain']);
|
|
38
|
+
const allowed = new Set(['Scene', 'Camera', 'Group', 'Rect', 'Path', 'Text', 'Image', 'Video', 'Audio', 'Model', 'Surface', 'Fragment', 'defineMotion', 'defineKnobs', 'Shader', 'sampleMotion', 'describeMotion', 'blur', 'grain', 'film']);
|
|
39
39
|
allowed.add('defineEffect');
|
|
40
40
|
const importedGroups = new Set(), sourceIds = new Set(), effects = new Set();
|
|
41
41
|
const tracks = new Map();
|
|
@@ -72,11 +72,16 @@ export function validate(file, mode, options = {}) {
|
|
|
72
72
|
if (refs && ts.isIdentifier(n) && knobSets.has(n.text)) return;
|
|
73
73
|
if (refs && ts.isPropertyAccessExpression(n) && ts.isIdentifier(n.expression) && tracks.get(n.expression.text)?.has(n.name.text))
|
|
74
74
|
return;
|
|
75
|
-
if (refs && ts.isCallExpression(n) && ts.isIdentifier(n.expression) && (['blur', 'grain'].includes(n.expression.text)||effects.has(n.expression.text)) && n.arguments.length <=
|
|
75
|
+
if (refs && ts.isCallExpression(n) && ts.isIdentifier(n.expression) && (['blur', 'grain', 'film'].includes(n.expression.text)||effects.has(n.expression.text)) && n.arguments.length <= 2) {
|
|
76
76
|
if(n.arguments[0])value(n.arguments[0]);
|
|
77
|
+
const options = n.arguments[1];
|
|
78
|
+
if (options && (!ts.isObjectLiteralExpression(options) || options.properties.some(p =>
|
|
79
|
+
!ts.isPropertyAssignment(p) || ts.isComputedPropertyName(p.name) || p.name.text !== "enabled" ||
|
|
80
|
+
![ts.SyntaxKind.TrueKeyword, ts.SyntaxKind.FalseKeyword].includes(p.initializer.kind))))
|
|
81
|
+
fail(options, "Effect options must be a literal { enabled: boolean }");
|
|
77
82
|
return;
|
|
78
83
|
}
|
|
79
|
-
fail(n, 'Strict accepts literals, literal collections, declared motion/knob references, declared effects, blur and
|
|
84
|
+
fail(n, 'Strict accepts literals, literal collections, declared motion/knob references, declared effects, blur, grain and film only');
|
|
80
85
|
}
|
|
81
86
|
for (const st of ast.statements) {
|
|
82
87
|
if (ts.isImportDeclaration(st)) {
|
|
@@ -156,7 +161,7 @@ export function validate(file, mode, options = {}) {
|
|
|
156
161
|
const lane = v && ts.isNumericLiteral(v) ? Number(v.text) : NaN;
|
|
157
162
|
if (!Number.isInteger(lane) || lane < 0 || lane > 99) fail(track, 'track must be a literal integer from 0 to 99');
|
|
158
163
|
}
|
|
159
|
-
if ((!allowed.has(tag) && !importedGroups.has(tag)) || ['defineMotion', 'defineKnobs', 'defineEffect', 'sampleMotion', 'describeMotion', 'blur', 'grain'].includes(tag))
|
|
164
|
+
if ((!allowed.has(tag) && !importedGroups.has(tag)) || ['defineMotion', 'defineKnobs', 'defineEffect', 'sampleMotion', 'describeMotion', 'blur', 'grain', 'film'].includes(tag))
|
|
160
165
|
fail(el, 'Use public visual primitives only');
|
|
161
166
|
if (importedGroups.has(tag) && ts.isJsxElement(n) && n.children.some(c => !ts.isJsxText(c) || c.text.trim()))
|
|
162
167
|
fail(n, 'Animation instances cannot replace children; edit the original Group file');
|
|
@@ -194,6 +199,11 @@ export function validate(file, mode, options = {}) {
|
|
|
194
199
|
else if (sourceIds.has(id.text)) fail(a, 'IDs must be unique within their source file');
|
|
195
200
|
else sourceIds.add(id.text);
|
|
196
201
|
}
|
|
202
|
+
if (a.name.text === 'originalSrc') {
|
|
203
|
+
const src = a.initializer && (ts.isJsxExpression(a.initializer) ? a.initializer.expression : a.initializer);
|
|
204
|
+
if (tag !== 'Video' || !src || !ts.isStringLiteral(src) || !src.text.trim() || /^[a-z]+:/i.test(src.text))
|
|
205
|
+
fail(a, 'Video.originalSrc must be a static local path');
|
|
206
|
+
}
|
|
197
207
|
if (tag === 'Shader' && a.name.text === 'src') {
|
|
198
208
|
const src = a.initializer && (ts.isJsxExpression(a.initializer) ? a.initializer.expression : a.initializer);
|
|
199
209
|
if (!src || !ts.isStringLiteral(src) || !src.text.trim() || /^[a-z]+:/i.test(src.text) || path.isAbsolute(src.text) || !/\.wgsl$/i.test(src.text))
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Real browser WebGPU regression checks for export-layer resolution.
|
|
2
|
+
// Requires built engine WASM, Chrome, FFmpeg and tools/engine-lab dependencies.
|
|
3
|
+
// node packages/scene/scripts/verify-export-resolution.mjs
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import {createServer} from 'node:http';
|
|
6
|
+
import {execFileSync} from 'node:child_process';
|
|
7
|
+
import {createRequire} from 'node:module';
|
|
8
|
+
import {readFile} from 'node:fs/promises';
|
|
9
|
+
import {fileURLToPath} from 'node:url';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
const repo=fileURLToPath(new URL('../../../',import.meta.url));
|
|
12
|
+
const require=createRequire(path.join(repo,'tools/engine-lab/package.json'));
|
|
13
|
+
const {chromium}=require('playwright-core');
|
|
14
|
+
const stage=`import init,{create_engine,SceneEncoder} from '/engine.js';
|
|
15
|
+
await init();const canvas=document.querySelector('canvas');
|
|
16
|
+
const engine=await create_engine(canvas),encoder=new SceneEncoder();
|
|
17
|
+
engine.set_mode(0);engine.set_effect(0);engine.set_orbit(0,0);engine.set_dither(0);
|
|
18
|
+
engine.set_render_scale(1);engine.set_transparent(true);engine.set_exporting(true);
|
|
19
|
+
window.draw=async(meta,scene)=>{
|
|
20
|
+
canvas.width=meta.width;canvas.height=meta.height;engine.resize(meta.width,meta.height);
|
|
21
|
+
engine.update_scene(encoder.encode(JSON.stringify(meta),JSON.stringify(scene),engine));
|
|
22
|
+
engine.render(0);await engine.submitted_work_done();return canvas.toDataURL();
|
|
23
|
+
};window.ready=true;`;
|
|
24
|
+
const server=createServer(async(req,res)=>{try{
|
|
25
|
+
const name=new URL(req.url,'http://localhost').pathname;
|
|
26
|
+
if(name==='/'){res.setHeader('content-type','text/html');res.end('<canvas></canvas><script type="module" src="/stage.js"></script>');return;}
|
|
27
|
+
if(name==='/stage.js'){res.setHeader('content-type','text/javascript');res.end(stage);return;}
|
|
28
|
+
if(!['/engine.js','/engine_bg.wasm'].includes(name)){res.writeHead(404);res.end();return;}
|
|
29
|
+
res.setHeader('content-type',name.endsWith('.wasm')?'application/wasm':'text/javascript');
|
|
30
|
+
res.end(await readFile(path.join(repo,'packages/render-engine/pkg',name.slice(1))));
|
|
31
|
+
}catch(e){res.writeHead(500);res.end(String(e));}});
|
|
32
|
+
const transform=scale=>({position:[0,0],scale:[scale,scale],rotation:0,origin:[0,0],opacity:1});
|
|
33
|
+
const group=(w,h,scale,children)=>({type:'group',width:w,height:h,clip:true,transform:transform(scale),children});
|
|
34
|
+
const artwork=scale=>[
|
|
35
|
+
{type:'path',d:'M16 30C16 2 96 2 96 30C96 58 16 58 16 30Z M30 30C30 44 82 44 82 30C82 16 30 16 30 30Z',fill:'#ffffff',fillRule:'evenodd',transform:transform(scale)},
|
|
36
|
+
{type:'text',text:'/osmo',font:'Inter',fontSize:18,color:'#ffffff',align:'left',lineHeight:1.2,letterSpacing:0,width:120,height:32,transform:{...transform(scale),position:[8*scale,60*scale]}},
|
|
37
|
+
];
|
|
38
|
+
const fixture=(scale,reference)=>{
|
|
39
|
+
const authored=reference?scale:1,innerScale=reference?1:scale/2;
|
|
40
|
+
const inner=group(128*authored,96*authored,innerScale,[
|
|
41
|
+
{type:'rect',width:120*authored,height:90*authored,radius:4*authored,fill:'#ffffff',transform:transform(1)},...artwork(authored)]);
|
|
42
|
+
inner.mask={index:0,mode:'alpha',invert:false};
|
|
43
|
+
const outer=group(128*(reference?authored:innerScale),96*(reference?authored:innerScale),reference?1:2,[inner]);
|
|
44
|
+
outer.transform.opacity=0.75;return [outer];
|
|
45
|
+
};
|
|
46
|
+
let browser;
|
|
47
|
+
try{
|
|
48
|
+
await new Promise((ok,fail)=>{server.once('error',fail);server.listen(0,'127.0.0.1',ok);});
|
|
49
|
+
browser=await chromium.launch({channel:'chrome',headless:true});
|
|
50
|
+
const page=await browser.newPage();await page.goto(`http://127.0.0.1:${server.address().port}/`);
|
|
51
|
+
await page.waitForFunction(()=>window.ready);
|
|
52
|
+
async function draw(width,height,children){
|
|
53
|
+
const meta={version:1,source:'resolution.tsx',width,height,fps:24,totalFrames:1,duration:1/24};
|
|
54
|
+
const uri=await page.evaluate(([m,s])=>window.draw(m,s),[meta,{time:0,background:'transparent',children}]);
|
|
55
|
+
return execFileSync('ffmpeg',['-v','error','-i','pipe:0','-f','rawvideo','-pix_fmt','rgba','-'],{input:Buffer.from(uri.split(',')[1],'base64'),maxBuffer:32*1024*1024});
|
|
56
|
+
}
|
|
57
|
+
const compare=(a,b,label)=>{
|
|
58
|
+
let sum=0,count=0;for(let i=3;i<a.length;i+=4){if(a[i]||b[i]){sum+=Math.abs(a[i]-b[i]);count++;}}
|
|
59
|
+
assert.ok(count>500,'fixture paints visible content');const mean=sum/count;
|
|
60
|
+
assert.ok(mean<1.5,`${label}: mean coverage error ${mean}`);console.log(JSON.stringify({label,mean}));
|
|
61
|
+
};
|
|
62
|
+
for(const scale of [2,3,5.04])compare(await draw(768,512,fixture(scale,false)),await draw(768,512,fixture(scale,true)),`nested masked text/vector at ${scale}x`);
|
|
63
|
+
const bars=Array.from({length:375},(_,i)=>({type:'rect',width:1,height:48,radius:0,fill:'#ffffff',transform:{...transform(1),position:[4200+4*i,8]}}));
|
|
64
|
+
const reference=await draw(5760,64,bars);
|
|
65
|
+
compare(reference,await draw(5760,64,[group(5760,64,1,bars)]),'6K single-pixel detail');
|
|
66
|
+
compare(reference,await draw(5760,64,[{type:'composition',width:5760,height:64,transform:transform(1),scene:{time:0,background:'transparent',children:[group(5760,64,1,bars)]}}]),'6K nested scene inherits export quality');
|
|
67
|
+
}finally{await browser?.close();await new Promise(ok=>server.close(ok));}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Real browser WebGPU / native Metal parity for the public TSX film() effect.
|
|
2
|
+
// Requires Chrome, FFmpeg, the built native renderer/WASM, and verified LUTs.
|
|
3
|
+
// node packages/scene/scripts/verify-film.mjs /tmp/film-parity
|
|
4
|
+
import {createServer} from 'node:http';
|
|
5
|
+
import {createRequire} from 'node:module';
|
|
6
|
+
import {execFileSync} from 'node:child_process';
|
|
7
|
+
import {mkdir,readFile,writeFile} from 'node:fs/promises';
|
|
8
|
+
import {fileURLToPath} from 'node:url';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
const repo=fileURLToPath(new URL('../../../',import.meta.url));
|
|
12
|
+
const output=path.resolve(process.argv[2]??'/tmp/osmo-film-parity');await mkdir(output,{recursive:true});
|
|
13
|
+
const {chromium}=createRequire(path.join(repo,'tools/engine-lab/package.json'))('playwright-core');
|
|
14
|
+
const native=process.env.OSMO_NATIVE_BIN??path.join(repo,'apps/native/target/debug/osmo-lite');
|
|
15
|
+
const frames=[0,2,5],results=[];
|
|
16
|
+
const cameras=['Studio (automatic)','BMDFilm Gen 5 | BMD Wide Gamut','Arri LogC4 | Arri Wide Gamut 4','Sony S-Log3 | S-Gamut3.Cine'];
|
|
17
|
+
const stage=`import init,{create_engine,SceneEncoder} from '/engine.js';
|
|
18
|
+
await init();const canvas=document.querySelector('canvas');canvas.width=128;canvas.height=72;
|
|
19
|
+
const engine=await create_engine(canvas),encoder=new SceneEncoder();
|
|
20
|
+
engine.set_mode(0);engine.set_effect(0);engine.set_orbit(0,0);engine.set_dither(0);engine.set_render_scale(1);engine.set_transparent(true);
|
|
21
|
+
window.draw=async(meta,scene)=>{
|
|
22
|
+
const keys=new Set();const visit=n=>{for(const fx of n.effects??[])if(fx.film)keys.add(fx.film);for(const child of n.children??[])visit(child);if(n.scene)visit(n.scene);};visit(scene);
|
|
23
|
+
for(const key of keys)if(!engine.has_film_table(key)){const r=await fetch('/tables/'+key.slice(5)+'.bin');if(!r.ok)throw Error('Missing film');if(!engine.add_image_encoded(key,new Uint8Array(await r.arrayBuffer())))throw Error('Invalid film');}
|
|
24
|
+
engine.update_scene(encoder.encode(JSON.stringify(meta),JSON.stringify(scene),engine));engine.render(scene.time);await engine.submitted_work_done();return canvas.toDataURL();
|
|
25
|
+
};window.ready=true;`;
|
|
26
|
+
const server=createServer(async(req,res)=>{try{
|
|
27
|
+
const name=new URL(req.url,'http://localhost').pathname;
|
|
28
|
+
if(name==='/'){res.setHeader('content-type','text/html');res.end('<canvas></canvas><script type="module" src="/stage.js"></script>');return;}
|
|
29
|
+
if(name==='/stage.js'){res.setHeader('content-type','text/javascript');res.end(stage);return;}
|
|
30
|
+
let file;
|
|
31
|
+
if(['/engine.js','/engine_bg.wasm'].includes(name)){file=path.join(repo,'packages/render-engine/pkg',name.slice(1));res.setHeader('content-type',name.endsWith('.wasm')?'application/wasm':'text/javascript');}
|
|
32
|
+
else if(/^\/tables\/[a-f0-9]+(?:-logc4|-slog3cine)?\.bin$/.test(name))file=path.join(process.env.OSMO_FILM_TABLES??path.join(repo,'packages/render-engine/assets/film'),path.basename(name));
|
|
33
|
+
else {res.writeHead(404);res.end();return;}
|
|
34
|
+
res.end(await readFile(file));
|
|
35
|
+
}catch(e){res.writeHead(500);res.end(String(e));}});
|
|
36
|
+
let browser;
|
|
37
|
+
try {
|
|
38
|
+
await new Promise((ok,fail)=>{server.once('error',fail);server.listen(0,'127.0.0.1',ok);});
|
|
39
|
+
browser=await chromium.launch({channel:'chrome',headless:true});
|
|
40
|
+
const page=await browser.newPage();await page.goto(`http://127.0.0.1:${server.address().port}/`);await page.waitForFunction(()=>window.ready);
|
|
41
|
+
for(const [index,cameraProfile] of cameras.entries()) {
|
|
42
|
+
const source=path.join(output,`scene-${index}.tsx`),snapshot=path.join(output,`scene-${index}.jsonl`);
|
|
43
|
+
const sourceText=`import {Scene,Group,Rect,film} from '@osmo/scene';
|
|
44
|
+
export default <Scene width={128} height={72} fps={24} duration={0.25} background="#28392d">
|
|
45
|
+
<Group width={128} height={72} effects={[film({stock:'500t',cameraProfile:${JSON.stringify(cameraProfile)},seed:42,grain:0.5,exposure:0.2})]}>
|
|
46
|
+
<Rect width={128} height={72} fill="#707c80"/><Rect position={[8,8]} width={32} height={56} fill="#b86a43"/><Rect position={[48,8]} width={32} height={56} fill="#46687d"/><Rect position={[88,8]} width={32} height={56} fill="#ece8da"/>
|
|
47
|
+
</Group></Scene>;`;
|
|
48
|
+
await writeFile(source,sourceText);
|
|
49
|
+
// The compiler deliberately refuses overwriting snapshots.
|
|
50
|
+
const {rm}=await import('node:fs/promises');await rm(snapshot,{force:true});
|
|
51
|
+
execFileSync(process.execPath,[path.join(repo,'apps/native/tools/compile.mjs'),source,snapshot],{stdio:'pipe'});
|
|
52
|
+
for(const frame of frames) await rm(path.join(output,`native-${index}-${frame}.png`),{force:true});
|
|
53
|
+
execFileSync(native,['--scene-snapshot',snapshot,'--snapshot',path.join(output,`native-${index}.png`),'--frame',frames.join(',')],{env:{...process.env,OSMO_TEST_NO_DITHER:'1'},stdio:'pipe'});
|
|
54
|
+
const [meta,...scenes]=(await readFile(snapshot,'utf8')).trim().split('\n').map(JSON.parse);
|
|
55
|
+
for(const frame of frames) {
|
|
56
|
+
const uri=await page.evaluate(([meta,scene])=>window.draw(meta,scene),[meta,scenes[frame]]);
|
|
57
|
+
const file=path.join(output,`browser-${index}-${frame}.png`);await writeFile(file,Buffer.from(uri.split(',')[1],'base64'));
|
|
58
|
+
const pixels=f=>execFileSync(process.env.OSMO_FFMPEG??'ffmpeg',['-v','error','-i',f,'-f','rawvideo','-pix_fmt','rgba','-']);
|
|
59
|
+
const a=pixels(path.join(output,`native-${index}-${frame}.png`)),b=pixels(file);assert.equal(a.length,b.length);
|
|
60
|
+
let max=0,sum=0,different=0;for(let i=0;i<a.length;i++){const d=Math.abs(a[i]-b[i]);max=Math.max(max,d);sum+=d;if(d)different++;}
|
|
61
|
+
const result={cameraProfile,frame,max,mean:sum/a.length,different,total:a.length};results.push(result);
|
|
62
|
+
assert.ok(max<=2,JSON.stringify(result));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
await writeFile(path.join(output,'results.json'),JSON.stringify(results,null,2)+'\n');console.log(JSON.stringify(results));
|
|
66
|
+
} finally {await browser?.close();await new Promise(ok=>server.close(ok));}
|