@wasmedia/wenblend 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.
Files changed (64) hide show
  1. package/LICENSE +367 -0
  2. package/LICENSE-GPL-3.0 +674 -0
  3. package/README.md +98 -0
  4. package/dist/cli.d.ts +27 -0
  5. package/dist/cli.d.ts.map +1 -0
  6. package/dist/cli.js +167 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/filesystem.d.ts +25 -0
  9. package/dist/filesystem.d.ts.map +1 -0
  10. package/dist/filesystem.js +75 -0
  11. package/dist/filesystem.js.map +1 -0
  12. package/dist/handler.d.ts +62 -0
  13. package/dist/handler.d.ts.map +1 -0
  14. package/dist/handler.js +91 -0
  15. package/dist/handler.js.map +1 -0
  16. package/dist/index.d.ts +17 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +17 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/loader.d.ts +74 -0
  21. package/dist/loader.d.ts.map +1 -0
  22. package/dist/loader.js +181 -0
  23. package/dist/loader.js.map +1 -0
  24. package/dist/operations.d.ts +45 -0
  25. package/dist/operations.d.ts.map +1 -0
  26. package/dist/operations.js +94 -0
  27. package/dist/operations.js.map +1 -0
  28. package/dist/staging.d.ts +37 -0
  29. package/dist/staging.d.ts.map +1 -0
  30. package/dist/staging.js +157 -0
  31. package/dist/staging.js.map +1 -0
  32. package/dist/tiff-header.d.ts +16 -0
  33. package/dist/tiff-header.d.ts.map +1 -0
  34. package/dist/tiff-header.js +97 -0
  35. package/dist/tiff-header.js.map +1 -0
  36. package/dist/types.d.ts +128 -0
  37. package/dist/types.d.ts.map +1 -0
  38. package/dist/types.js +47 -0
  39. package/dist/types.js.map +1 -0
  40. package/dist/wenblend.js +2 -0
  41. package/dist/wenblend.wasm +0 -0
  42. package/dist/wenfuse.js +2 -0
  43. package/dist/wenfuse.wasm +0 -0
  44. package/dist/wopfs.d.ts +37 -0
  45. package/dist/wopfs.d.ts.map +1 -0
  46. package/dist/wopfs.js +38 -0
  47. package/dist/wopfs.js.map +1 -0
  48. package/dist/worker.d.ts +16 -0
  49. package/dist/worker.d.ts.map +1 -0
  50. package/dist/worker.js +29 -0
  51. package/dist/worker.js.map +1 -0
  52. package/package.json +67 -0
  53. package/src/cli.ts +201 -0
  54. package/src/filesystem.ts +85 -0
  55. package/src/handler.ts +112 -0
  56. package/src/index.ts +41 -0
  57. package/src/loader.ts +223 -0
  58. package/src/operations.ts +147 -0
  59. package/src/staging.ts +173 -0
  60. package/src/tiff-header.ts +94 -0
  61. package/src/types.ts +153 -0
  62. package/src/wopfs.ts +70 -0
  63. package/src/worker.ts +31 -0
  64. package/types/wasm.d.ts +12 -0
@@ -0,0 +1,85 @@
1
+ /**
2
+ * MEMFS staging for N-input invocations.
3
+ *
4
+ * Enblend is N inputs to one output, so every call stages a set of files
5
+ * into a per-invocation directory and sweeps the whole directory
6
+ * afterwards. Nothing persists between calls.
7
+ */
8
+
9
+ import type { WasmModule, InputImage } from "./types.js";
10
+
11
+ let counter = 0;
12
+
13
+ export function toBytes(data: Uint8Array | ArrayBuffer): Uint8Array {
14
+ return data instanceof ArrayBuffer ? new Uint8Array(data) : data;
15
+ }
16
+
17
+ /** A fresh, unique work directory path. Unique naming ensures no collisions across invocations. */
18
+ export function nextWorkDirName(): string {
19
+ return `/work-${Date.now().toString(36)}-${(counter++).toString(36)}`;
20
+ }
21
+
22
+ /** Create a unique work directory. */
23
+ export function createWorkDir(module: WasmModule): string {
24
+ const dir = nextWorkDirName();
25
+ module.FS.mkdir(dir);
26
+ return dir;
27
+ }
28
+
29
+ /**
30
+ * Write each input into the work directory under its caller-chosen name.
31
+ *
32
+ * Returns the set of input names so `collectOutputs` can tell the tool's
33
+ * output from what we put there.
34
+ */
35
+ export function stageInputs(
36
+ module: WasmModule,
37
+ workDir: string,
38
+ inputs: InputImage[],
39
+ ): Set<string> {
40
+ const names = new Set<string>();
41
+ for (const { name, data } of inputs) {
42
+ module.FS.writeFile(`${workDir}/${name}`, toBytes(data));
43
+ names.add(name);
44
+ }
45
+ return names;
46
+ }
47
+
48
+ /** Read back everything in the work directory that was not staged as an input. */
49
+ export function collectOutputs(
50
+ module: WasmModule,
51
+ workDir: string,
52
+ inputNames: Set<string>,
53
+ ): Map<string, Uint8Array> {
54
+ const outputs = new Map<string, Uint8Array>();
55
+ for (const entry of module.FS.readdir(workDir)) {
56
+ if (entry === "." || entry === "..") continue;
57
+ if (inputNames.has(entry)) continue;
58
+ outputs.set(entry, module.FS.readFile(`${workDir}/${entry}`));
59
+ }
60
+ return outputs;
61
+ }
62
+
63
+ /** Remove every file in the work directory, then the directory itself. */
64
+ export function cleanWorkDir(module: WasmModule, workDir: string): void {
65
+ for (const entry of module.FS.readdir(workDir)) {
66
+ if (entry === "." || entry === "..") continue;
67
+ const path = `${workDir}/${entry}`;
68
+ try {
69
+ module.FS.unlink(path);
70
+ } catch {
71
+ // Entry might be a directory. Attempt rmdir as fallback; continue
72
+ // either way to ensure the whole directory is swept as defense-in-depth.
73
+ try {
74
+ module.FS.rmdir(path);
75
+ } catch {
76
+ // Best effort: one bad entry must not abort the sweep.
77
+ }
78
+ }
79
+ }
80
+ try {
81
+ module.FS.rmdir(workDir);
82
+ } catch {
83
+ // Best effort: work directory cleanup failure does not abort.
84
+ }
85
+ }
package/src/handler.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The worker's message handler and its scope test, with NO import-time
3
+ * side effects.
4
+ *
5
+ * This module is what the package's main entry (`src/index.ts`) re-exports.
6
+ * It must never register anything when imported: a consumer who writes
7
+ * their own Web Worker and does `import { blend } from "@wasmedia/wenblend"`
8
+ * inside it would otherwise get our `message` listener attached to THEIR
9
+ * worker, and any of their own messages carrying an `images` array would
10
+ * run a blend or fuse they never asked for. Registration lives only in
11
+ * `src/worker.ts`, the package's dedicated `./worker` entry.
12
+ */
13
+
14
+ import type { BlendOptions, FuseOptions, FileInput, OutputDir, WrittenFile } from "./types.js";
15
+ import { runOperation, type ImageInput } from "./operations.js";
16
+ import { isFileInput } from "./staging.js";
17
+
18
+ export interface WorkerRequest {
19
+ id: string;
20
+ op: "blend" | "fuse";
21
+ images: ImageInput[] | FileInput[];
22
+ options?: (BlendOptions | FuseOptions) & { outputDir?: OutputDir };
23
+ /** Where file mode loads @wasmedia/wopfs from; see LoaderOptions.wopfsPath. */
24
+ wopfsPath?: string;
25
+ }
26
+
27
+ export interface WorkerResponse {
28
+ id: string;
29
+ ok: boolean;
30
+ result?: Uint8Array | WrittenFile;
31
+ error?: { name: string; message: string; stderr?: string; exitCode?: number };
32
+ }
33
+
34
+ const OPS = { blend: "enblend", fuse: "enfuse" } as const;
35
+
36
+ /**
37
+ * Handle one request. Never throws and never rejects: worker.ts posts
38
+ * whatever this resolves to, so a throw would leave the caller waiting for a
39
+ * reply that never comes. Messages arrive untyped at runtime, so nothing
40
+ * about `request` is trusted — not its shape, not its `op`.
41
+ */
42
+ export async function handleMessage(request: WorkerRequest): Promise<WorkerResponse> {
43
+ // Echo the caller's id unchanged, whatever its runtime type: it exists only
44
+ // so the caller can correlate the reply. Plain-JS callers may use numbers.
45
+ const id = typeof request === "object" && request !== null ? request.id : "";
46
+ try {
47
+ if (typeof request !== "object" || request === null) {
48
+ return { id, ok: false, error: { name: "Error", message: "invalid request" } };
49
+ }
50
+ // Route only the two ops we know; anything else is rejected rather than
51
+ // quietly run as one of the tools. Only a string can name one, so a
52
+ // non-string op (which could carry a throwing toString) never reaches
53
+ // the lookup.
54
+ const opName = request.op;
55
+ const op = typeof opName === "string" && Object.hasOwn(OPS, opName) ? OPS[opName] : undefined;
56
+ if (!op) {
57
+ const shown = typeof opName === "string" ? `"${opName}"` : typeof opName;
58
+ return { id, ok: false, error: { name: "Error", message: `unknown op ${shown}` } };
59
+ }
60
+ if (request.wopfsPath !== undefined && typeof request.wopfsPath !== "string") {
61
+ return { id, ok: false, error: { name: "Error", message: "wopfsPath must be a string" } };
62
+ }
63
+ // Check if file mode is requested: all images are FileInputs
64
+ const imagesArray = Array.isArray(request.images) ? request.images : [];
65
+ const isFileMode = imagesArray.length > 0 && imagesArray.every(isFileInput);
66
+ if (isFileMode && !request.options?.outputDir) {
67
+ return { id, ok: false, error: { name: "Error", message: "file mode requires options.outputDir" } };
68
+ }
69
+ const result = await runOperation(op, request.images, request.options, { wopfsPath: request.wopfsPath });
70
+ return { id, ok: true, result };
71
+ } catch (e: unknown) {
72
+ const { stderr, exitCode } = (e ?? {}) as { stderr?: unknown; exitCode?: unknown };
73
+ return {
74
+ id,
75
+ ok: false,
76
+ error: {
77
+ name: e instanceof Error ? e.name : "Error",
78
+ message: e instanceof Error ? e.message : String(e),
79
+ stderr: typeof stderr === "string" ? stderr : undefined,
80
+ // Error instances do not survive postMessage as themselves, so the
81
+ // exit code has to travel as plain data or the caller loses it.
82
+ exitCode: typeof exitCode === "number" ? exitCode : undefined,
83
+ },
84
+ };
85
+ }
86
+ }
87
+
88
+ /**
89
+ * True only inside an actual dedicated/shared/service worker.
90
+ *
91
+ * `typeof self !== "undefined"` is NOT a worker test: on a browser main
92
+ * thread `self === window`, and `window.postMessage` is a perfectly
93
+ * ordinary function, so a guard built on those two checks alone is true
94
+ * on every web page, not just inside a worker. Importing the dedicated
95
+ * `./worker` entry (`src/worker.ts`) would then attach a `message`
96
+ * listener to `window` on any ordinary page and treat arbitrary
97
+ * postMessage traffic (other scripts, extensions, iframes) as a
98
+ * WorkerRequest.
99
+ *
100
+ * `WorkerGlobalScope` only exists as a global inside a worker context,
101
+ * and `self` is an instance of it there. Both `typeof` checks are
102
+ * ordered so the `instanceof` is only reached once both globals are
103
+ * confirmed to exist, so this can never throw — including in plain
104
+ * Node, which has neither global.
105
+ */
106
+ export function isWorkerScope(): boolean {
107
+ return (
108
+ typeof WorkerGlobalScope !== "undefined" &&
109
+ typeof self !== "undefined" &&
110
+ self instanceof WorkerGlobalScope
111
+ );
112
+ }
package/src/index.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * wenblend — Enblend and Enfuse compiled to WebAssembly.
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * import { blend } from "@wasmedia/wenblend";
7
+ *
8
+ * // Inputs must be RGBA TIFFs carrying correct XPosition/YPosition tags.
9
+ * const panorama = await blend([left, right]);
10
+ * ```
11
+ */
12
+
13
+ export { blend, fuse, normalizeInputs, runOperation, type ImageInput } from "./operations.js";
14
+ export { enblend, enfuse, runTool } from "./cli.js";
15
+ export {
16
+ createInstance,
17
+ loadFactory,
18
+ type Tool,
19
+ type ModuleFactory,
20
+ type InstanceHooks,
21
+ } from "./loader.js";
22
+ export { handleMessage, isWorkerScope, type WorkerRequest, type WorkerResponse } from "./handler.js";
23
+ export {
24
+ EnblendError,
25
+ OutOfMemoryError,
26
+ type WasmModule,
27
+ type EmscriptenFS,
28
+ type FSStat,
29
+ type CLIResult,
30
+ type FileInput,
31
+ type OutputDir,
32
+ type WrittenFile,
33
+ type CLIFileResult,
34
+ type FileModeOptions,
35
+ type InputImage,
36
+ type BlendOptions,
37
+ type FuseOptions,
38
+ type CommonOptions,
39
+ type LoaderOptions,
40
+ type LoadPhase,
41
+ } from "./types.js";
package/src/loader.ts ADDED
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Per-tool wasm module loading.
3
+ *
4
+ * Two facts drive this file:
5
+ *
6
+ * 1. Each tool is its own ~3 MB module. A page that only blends must
7
+ * not pay for the fuse module, so the import is per-tool and lazy.
8
+ * 2. Both tools keep state in C++ globals and neither resets it, so a
9
+ * module INSTANCE is never reused across invocations.
10
+ *
11
+ * So two things are cached per tool and one is not:
12
+ *
13
+ * - the JS glue's factory (the dynamic import), and
14
+ * - the COMPILED `WebAssembly.Module`, fetched and compiled once and
15
+ * handed to every later factory call through Emscripten's
16
+ * `instantiateWasm` hook, so a new instance costs an instantiate,
17
+ * not a re-download and re-compile of ~3 MB;
18
+ * - never the instance itself.
19
+ *
20
+ * Without the hook, each factory call runs the glue's own createWasm,
21
+ * which fetches and compiles the .wasm from scratch every time.
22
+ */
23
+
24
+ import type { WasmModule, LoaderOptions } from "./types.js";
25
+
26
+ export type Tool = "enblend" | "enfuse";
27
+
28
+ /**
29
+ * Output hooks, bound at construction.
30
+ *
31
+ * Emscripten reads `Module["print"]`/`Module["printErr"]` exactly once,
32
+ * inside the factory body, and binds them into module-local variables
33
+ * before the factory's promise resolves. Assigning `module.print` after
34
+ * `await factory(...)` returns is inert. Callers that want to capture
35
+ * output must build their closures first and pass them into
36
+ * `createInstance`.
37
+ */
38
+ export type InstanceHooks = Pick<WasmModule, "print" | "printErr">;
39
+
40
+ /** A tool's Emscripten module factory: the default export of its `.js` glue. */
41
+ export type ModuleFactory = (config?: Record<string, unknown>) => Promise<WasmModule>;
42
+ type FactoryLoader = (tool: Tool, options?: LoaderOptions) => Promise<ModuleFactory>;
43
+
44
+ const MODULE_FILE: Record<Tool, string> = {
45
+ enblend: "wenblend.js",
46
+ enfuse: "wenfuse.js",
47
+ };
48
+
49
+ const WASM_FILE: Record<Tool, string> = {
50
+ enblend: "wenblend.wasm",
51
+ enfuse: "wenfuse.wasm",
52
+ };
53
+
54
+ // Both caches are keyed on the tool AND the absolute glue URL, so a second
55
+ // call with a different `wasmPath` loads that module instead of silently
56
+ // reusing the first. Relative and absolute spellings of one location resolve
57
+ // to the same key.
58
+ const factories = new Map<string, Promise<ModuleFactory>>();
59
+ const compiled = new Map<string, Promise<WebAssembly.Module>>();
60
+
61
+ function glueUrl(tool: Tool, options?: LoaderOptions): string {
62
+ return options?.wasmPath
63
+ ?? new URL(`../dist/${MODULE_FILE[tool]}`, import.meta.url).href;
64
+ }
65
+
66
+ /**
67
+ * The glue's absolute URL. `new URL` needs an absolute base, but `wasmPath`
68
+ * may be relative (what bundlers hand out, e.g. Vite's `?url`), so it is
69
+ * resolved against this module — the same base `import()` uses for the glue.
70
+ */
71
+ function resolvedGlueUrl(tool: Tool, options?: LoaderOptions): URL {
72
+ return new URL(glueUrl(tool, options), import.meta.url);
73
+ }
74
+
75
+ function cacheKey(tool: Tool, options?: LoaderOptions): string {
76
+ return `${tool} ${resolvedGlueUrl(tool, options).href}`;
77
+ }
78
+
79
+ async function importFactory(tool: Tool, options?: LoaderOptions): Promise<ModuleFactory> {
80
+ const mod = await import(/* webpackIgnore: true */ glueUrl(tool, options));
81
+ return (mod.default ?? mod) as ModuleFactory;
82
+ }
83
+
84
+ /**
85
+ * Fetch and compile a tool's .wasm.
86
+ *
87
+ * The URL is the one the glue itself would use: its `locateFile` default
88
+ * resolves the .wasm name against the glue's own directory. `file:` URLs
89
+ * (Node) are read from disk, because Node's fetch does not do `file:`;
90
+ * everything else is fetched, streaming-compiled where the server's MIME
91
+ * type allows and compiled from bytes where it does not — the same
92
+ * fallback the glue performs.
93
+ */
94
+ async function compileWasm(tool: Tool, options?: LoaderOptions): Promise<WebAssembly.Module> {
95
+ const url = new URL(WASM_FILE[tool], resolvedGlueUrl(tool, options));
96
+ if (url.protocol === "file:") {
97
+ // A variable specifier keeps bundlers from trying to resolve a Node
98
+ // builtin into browser code; this branch only runs under Node.
99
+ const fsSpec = "node:fs/promises";
100
+ const urlSpec = "node:url";
101
+ const { readFile } = await import(/* webpackIgnore: true */ /* @vite-ignore */ fsSpec);
102
+ const { fileURLToPath } = await import(/* webpackIgnore: true */ /* @vite-ignore */ urlSpec);
103
+ return WebAssembly.compile(await readFile(fileURLToPath(url)));
104
+ }
105
+ const response = fetch(url.href, { credentials: "same-origin" });
106
+ if (typeof WebAssembly.compileStreaming === "function") {
107
+ try {
108
+ return await WebAssembly.compileStreaming(response);
109
+ } catch {
110
+ // Wrong MIME type, most often. Fall through to compiling bytes.
111
+ }
112
+ }
113
+ const res = await response;
114
+ if (!res.ok) throw new Error(`failed to fetch ${url.href}: HTTP ${res.status}`);
115
+ return WebAssembly.compile(await res.arrayBuffer());
116
+ }
117
+
118
+ let compileWasmImpl: (tool: Tool, options?: LoaderOptions) => Promise<WebAssembly.Module> = compileWasm;
119
+
120
+ /**
121
+ * The compiled module for a tool, compiled at most once per resolved URL.
122
+ *
123
+ * A promise is cached, so concurrent first calls share one compile. A
124
+ * failed compile is evicted, so a transient network error is retried on
125
+ * the next call rather than poisoning the cache for the page's lifetime.
126
+ */
127
+ async function loadCompiled(tool: Tool, options?: LoaderOptions): Promise<WebAssembly.Module> {
128
+ const key = cacheKey(tool, options);
129
+ let existing = compiled.get(key);
130
+ if (!existing) {
131
+ existing = compileWasmImpl(tool, options).catch((err) => {
132
+ compiled.delete(key);
133
+ throw err;
134
+ });
135
+ compiled.set(key, existing);
136
+ }
137
+ return existing;
138
+ }
139
+
140
+ let loadFactoryImpl: FactoryLoader = importFactory;
141
+
142
+ /** Test seam: replace the dynamic import. */
143
+ export function __setFactoryLoader(fn: FactoryLoader): void {
144
+ loadFactoryImpl = fn;
145
+ }
146
+
147
+ /** Test seam: replace the wasm fetch-and-compile step. */
148
+ export function __setWasmCompiler(
149
+ fn: (tool: Tool, options?: LoaderOptions) => Promise<WebAssembly.Module>,
150
+ ): void {
151
+ compileWasmImpl = fn;
152
+ }
153
+
154
+ /** Test seam: drop cached factories and compiled modules. */
155
+ export function resetFactoryCache(): void {
156
+ factories.clear();
157
+ compiled.clear();
158
+ loadFactoryImpl = importFactory;
159
+ compileWasmImpl = compileWasm;
160
+ }
161
+
162
+ /**
163
+ * Import a tool's JS glue and return its module factory. Cached per tool and resolved glue URL.
164
+ *
165
+ * Calling the returned factory directly compiles the .wasm afresh on every
166
+ * call; `createInstance` is what supplies the cached compiled module.
167
+ *
168
+ * `async` so that a `wasmPath` that is not a valid URL rejects rather than
169
+ * throwing synchronously. The cache lookup still happens before the first
170
+ * `await`, so concurrent first calls share one import.
171
+ */
172
+ export async function loadFactory(tool: Tool, options?: LoaderOptions): Promise<ModuleFactory> {
173
+ const key = cacheKey(tool, options);
174
+ let existing = factories.get(key);
175
+ if (!existing) {
176
+ existing = loadFactoryImpl(tool, options).catch((err) => {
177
+ factories.delete(key);
178
+ throw err;
179
+ });
180
+ factories.set(key, existing);
181
+ }
182
+ return existing;
183
+ }
184
+
185
+ /**
186
+ * Build a fresh module instance.
187
+ *
188
+ * Never cache the result. See the file header.
189
+ *
190
+ * `hooks` is passed straight into the factory's config object, since that
191
+ * is the only point at which `print`/`printErr` can take effect (see
192
+ * `InstanceHooks`).
193
+ *
194
+ * The compiled module reaches the glue through `instantiateWasm`: the glue
195
+ * calls it with its import object in place of its own fetch-and-compile,
196
+ * and waits for `receiveInstance`. That wait has no rejection path — the
197
+ * glue wraps the hook in a promise whose `reject` it never calls — so a
198
+ * compile or instantiate failure is raced against the factory here, or
199
+ * the call would hang forever instead of rejecting.
200
+ */
201
+ export async function createInstance(
202
+ tool: Tool,
203
+ options?: LoaderOptions,
204
+ hooks?: Partial<InstanceHooks>,
205
+ ): Promise<WasmModule> {
206
+ options?.onProgress?.("loading");
207
+ const factory = await loadFactory(tool, options);
208
+ options?.onProgress?.("initializing");
209
+ let fail!: (err: unknown) => void;
210
+ const failed = new Promise<never>((_, reject) => { fail = reject; });
211
+ const instantiateWasm = (
212
+ imports: WebAssembly.Imports,
213
+ receiveInstance: (instance: WebAssembly.Instance, module: WebAssembly.Module) => void,
214
+ ): Record<string, never> => {
215
+ loadCompiled(tool, options)
216
+ .then(async (module) => receiveInstance(await WebAssembly.instantiate(module, imports), module))
217
+ .catch(fail);
218
+ return {};
219
+ };
220
+ const instance = await Promise.race([factory({ ...hooks, instantiateWasm }), failed]);
221
+ options?.onProgress?.("ready");
222
+ return instance;
223
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * High-level functional API.
3
+ *
4
+ * Enblend is invocation-oriented — N inputs plus flags produce one
5
+ * output — so this layer is a thin argv builder rather than the
6
+ * chainable object the single-image siblings expose.
7
+ */
8
+
9
+ import type { InputImage, BlendOptions, FuseOptions, LoaderOptions, FileInput, OutputDir, WrittenFile } from "./types.js";
10
+ import { EnblendError } from "./types.js";
11
+ import { enblend, enfuse } from "./cli.js";
12
+ import type { Tool } from "./loader.js";
13
+ import { isFileInput } from "./staging.js";
14
+
15
+ export type ImageInput = InputImage | Uint8Array | ArrayBuffer;
16
+
17
+ const DEFAULT_OUTPUT = "output.tif";
18
+
19
+ /**
20
+ * Give every input a name, auto-naming bare buffers positionally.
21
+ *
22
+ * Throws on a name collision rather than silently renaming: a caller who
23
+ * supplies an explicit name may reference it again in `options.args`
24
+ * (an `--exclude somefile.tif` flag, say), so renaming around a clash
25
+ * could silently break a working invocation in a new way. Two inputs
26
+ * sharing a name would otherwise stage to the same MEMFS path — the
27
+ * second overwriting the first — and the tool would run to completion
28
+ * on the wrong input set with no error, which is exactly the kind of
29
+ * silent corruption this port cannot afford another instance of.
30
+ */
31
+ export function normalizeInputs(images: ImageInput[]): InputImage[];
32
+ export function normalizeInputs(images: FileInput[]): FileInput[];
33
+ export function normalizeInputs(images: ImageInput[] | FileInput[]): InputImage[] | FileInput[] {
34
+ const seen = new Set<string>();
35
+ return (images as Array<ImageInput | FileInput>).map((image, i) => {
36
+ const input = image instanceof Uint8Array || image instanceof ArrayBuffer
37
+ ? { name: `in${i}.tif`, data: image }
38
+ : image;
39
+ if (seen.has(input.name)) {
40
+ throw new Error(`normalizeInputs: duplicate input name "${input.name}"`);
41
+ }
42
+ seen.add(input.name);
43
+ return input;
44
+ }) as InputImage[] | FileInput[];
45
+ }
46
+
47
+ // Short options that take an argument in enblend or enfuse ("v" and "w" take
48
+ // an optional one). getopt treats the rest of a cluster after any of these as
49
+ // that argument, so scanning a cluster for "o" stops at the first of them.
50
+ const SHORT_WITH_ARGUMENT = new Set(["b", "d", "f", "l", "m", "o", "v", "w"]);
51
+
52
+ /** Whether getopt_long would read `arg` as the output option, as enblend and enfuse define it. */
53
+ function isOutputOption(arg: string): boolean {
54
+ if (arg.startsWith("--")) {
55
+ // Any unambiguous prefix of a long option is accepted, so "--out" is
56
+ // "--output". "--o" is ambiguous in enblend (it has --optimize too) but
57
+ // means --output in enfuse; rejecting it costs nothing either way.
58
+ const name = arg.slice(2).split("=", 1)[0];
59
+ return name.startsWith("o") && "output".startsWith(name);
60
+ }
61
+ if (!arg.startsWith("-")) return false;
62
+ for (const c of arg.slice(1)) {
63
+ if (c === "o") return true;
64
+ if (SHORT_WITH_ARGUMENT.has(c)) return false;
65
+ }
66
+ return false;
67
+ }
68
+
69
+ /**
70
+ * Reject output settings that could only end in a confusing "produced no
71
+ * output file" after a full run. The tool writes into its per-invocation
72
+ * work directory, and the output is collected from there by name, so it
73
+ * must be a plain file name that no input shares, and set only once.
74
+ */
75
+ function validateOutput(output: string, args: readonly string[], inputs: Array<{ name: string }>): void {
76
+ if (output === "" || output === "." || output === ".." || /[/\\]/.test(output)) {
77
+ throw new Error(`options.output must be a plain file name, got "${output}"`);
78
+ }
79
+ const setsOutput = args.find(isOutputOption);
80
+ if (setsOutput !== undefined) {
81
+ throw new Error(`set the output file with options.output, not "${setsOutput}" in args`);
82
+ }
83
+ if (inputs.some((i) => i.name === output)) {
84
+ throw new Error(`input name "${output}" collides with the output file name`);
85
+ }
86
+ }
87
+
88
+ export async function runOperation(
89
+ tool: Tool,
90
+ images: ImageInput[] | FileInput[],
91
+ options: (BlendOptions | FuseOptions) & { outputDir?: OutputDir } = {},
92
+ loaderOptions?: LoaderOptions,
93
+ ): Promise<Uint8Array | WrittenFile> {
94
+ if (images.length === 0) {
95
+ throw new Error(`${tool} requires at least one input image`);
96
+ }
97
+
98
+ const files = (images as unknown[]).every(isFileInput);
99
+ const inputs = normalizeInputs(images as never) as Array<InputImage | FileInput>;
100
+ const output = options.output ?? DEFAULT_OUTPUT;
101
+ validateOutput(output, options.args ?? [], inputs);
102
+ const argv = ["-o", output, ...(options.args ?? []), ...inputs.map((i) => i.name)];
103
+
104
+ const invoke = tool === "enblend" ? enblend : enfuse;
105
+ const result = files
106
+ ? await invoke(argv, inputs as FileInput[], { ...loaderOptions, outputDir: options.outputDir! })
107
+ : await invoke(argv, inputs as InputImage[], loaderOptions);
108
+
109
+ if (result.exitCode !== 0) {
110
+ throw new EnblendError(
111
+ `${tool} exited with code ${result.exitCode}${result.stderr ? `: ${result.stderr}` : ""}`,
112
+ result.exitCode,
113
+ result.stderr,
114
+ );
115
+ }
116
+
117
+ const produced = "written" in result ? result.written.get(output) : result.outputFiles.get(output);
118
+ if (!produced) {
119
+ throw new EnblendError(
120
+ `${tool} exited cleanly but produced no output file "${output}"`,
121
+ 0,
122
+ result.stderr,
123
+ );
124
+ }
125
+ return produced;
126
+ }
127
+
128
+ /**
129
+ * Blend overlapping images into one.
130
+ *
131
+ * Enblend positions images by their TIFF XPosition/YPosition tags, not
132
+ * by any argument. Inputs without correct offsets — two unaligned
133
+ * JPEGs, say — produce a valid file containing a useless image. Use
134
+ * remapped output from a stitcher.
135
+ */
136
+ export function blend(images: ImageInput[], options?: BlendOptions, loaderOptions?: LoaderOptions): Promise<Uint8Array>;
137
+ export function blend(images: FileInput[], options: BlendOptions & { outputDir: OutputDir }, loaderOptions?: LoaderOptions): Promise<WrittenFile>;
138
+ export function blend(images: ImageInput[] | FileInput[], options?: BlendOptions & { outputDir?: OutputDir }, loaderOptions?: LoaderOptions) {
139
+ return runOperation("enblend", images, options, loaderOptions);
140
+ }
141
+
142
+ /** Fuse a bracketed exposure series into one image. */
143
+ export function fuse(images: ImageInput[], options?: FuseOptions, loaderOptions?: LoaderOptions): Promise<Uint8Array>;
144
+ export function fuse(images: FileInput[], options: FuseOptions & { outputDir: OutputDir }, loaderOptions?: LoaderOptions): Promise<WrittenFile>;
145
+ export function fuse(images: ImageInput[] | FileInput[], options?: FuseOptions & { outputDir?: OutputDir }, loaderOptions?: LoaderOptions) {
146
+ return runOperation("enfuse", images, options, loaderOptions);
147
+ }