@maravilla-labs/platform 0.2.5 → 0.3.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/dist/config.d.ts +12 -1
- package/dist/config.js.map +1 -1
- package/dist/events.d.ts +45 -2
- package/dist/events.js +5 -1
- package/dist/events.js.map +1 -1
- package/dist/index.d.ts +89 -1
- package/dist/index.js +169 -1
- package/dist/index.js.map +1 -1
- package/dist/transforms-KzpoYW43.d.ts +156 -0
- package/package.json +8 -3
- package/src/config.ts +10 -0
- package/src/events.ts +52 -0
- package/src/index.ts +1 -0
- package/src/remote-client.ts +139 -1
- package/src/ren.ts +6 -0
- package/src/transforms.ts +251 -0
- package/src/types.ts +99 -0
- package/tests/derive-key.test.ts +68 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Media transforms — async ffmpeg / image / OCR jobs.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the Rust `platform.media.transforms` surface. All mutating
|
|
5
|
+
* methods (transcode/thumbnail/resize/ocr) return a {@link JobHandle}
|
|
6
|
+
* whose `outputKey` is content-addressed via {@link keyFor} and known
|
|
7
|
+
* up front — clients can render placeholder UI for the derived asset
|
|
8
|
+
* before the worker even starts the job.
|
|
9
|
+
*
|
|
10
|
+
* The runtime injects the live JS object as `platform.media.transforms`
|
|
11
|
+
* (see `crates/runtime/src/ops/platform/js_bindings/platform_js.rs`).
|
|
12
|
+
* The static helper {@link keyFor} is exported separately and runs
|
|
13
|
+
* client-side — it must produce byte-for-byte identical output to the
|
|
14
|
+
* Rust `derive_key`. The shared golden-vector fixture
|
|
15
|
+
* `crates/platform/tests/derive_key_vectors.json` is consumed from both
|
|
16
|
+
* the Rust integration test (`crates/platform/tests/derive_key_golden.rs`)
|
|
17
|
+
* and the TS test (`packages/platform/tests/derive-key.test.ts`) to keep
|
|
18
|
+
* the two in lockstep.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// Isomorphic SHA-256 via @noble/hashes — runs in Node, Deno, and every
|
|
22
|
+
// browser without a bundler-specific shim. `node:crypto` would pull
|
|
23
|
+
// Node built-ins into browser bundles and break Vite builds that
|
|
24
|
+
// consume this package client-side.
|
|
25
|
+
import { sha256 } from '@noble/hashes/sha2';
|
|
26
|
+
|
|
27
|
+
// ── Types — mirror `crates/platform/src/media/transforms/types.rs` ──────
|
|
28
|
+
|
|
29
|
+
/** Video container targets supported by v1. */
|
|
30
|
+
export type VideoFormat = 'mp4' | 'webm';
|
|
31
|
+
|
|
32
|
+
/** Image output formats supported by v1. */
|
|
33
|
+
export type ImageFormat = 'jpg' | 'png' | 'webp';
|
|
34
|
+
|
|
35
|
+
/** Lifecycle state of a transform job. */
|
|
36
|
+
export type JobStatus = 'pending' | 'running' | 'complete' | 'failed';
|
|
37
|
+
|
|
38
|
+
/** Options for `transforms.transcode`. */
|
|
39
|
+
export interface TranscodeOpts {
|
|
40
|
+
format: VideoFormat;
|
|
41
|
+
codec?: string;
|
|
42
|
+
max_width?: number;
|
|
43
|
+
max_height?: number;
|
|
44
|
+
audio_codec?: string;
|
|
45
|
+
bitrate_kbps?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Options for `transforms.thumbnail` — extract a single video frame. */
|
|
49
|
+
export interface ThumbnailOpts {
|
|
50
|
+
/** Offset into the source. Accepts `"00:00:01"`, `"1s"`, or a plain number-as-string. */
|
|
51
|
+
at: string;
|
|
52
|
+
width?: number;
|
|
53
|
+
height?: number;
|
|
54
|
+
/** Defaults to `"jpg"` server-side when omitted. */
|
|
55
|
+
format?: ImageFormat;
|
|
56
|
+
quality?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Options for `transforms.resize`. */
|
|
60
|
+
export interface ResizeOpts {
|
|
61
|
+
width?: number;
|
|
62
|
+
height?: number;
|
|
63
|
+
format: ImageFormat;
|
|
64
|
+
quality?: number;
|
|
65
|
+
strip_metadata?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Options for `transforms.ocr`. */
|
|
69
|
+
export interface OcrOpts {
|
|
70
|
+
/** Tesseract language code(s). Defaults to `"eng"` server-side when omitted. */
|
|
71
|
+
lang?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Tagged union of all transform requests — matches the Rust `TransformSpec`. */
|
|
75
|
+
export type TransformSpec =
|
|
76
|
+
| ({ kind: 'transcode' } & TranscodeOpts)
|
|
77
|
+
| ({ kind: 'thumbnail' } & ThumbnailOpts)
|
|
78
|
+
| ({ kind: 'resize' } & ResizeOpts)
|
|
79
|
+
| ({ kind: 'ocr' } & OcrOpts);
|
|
80
|
+
|
|
81
|
+
/** Probe output. All fields optional — ffprobe doesn't fill every one for every input. */
|
|
82
|
+
export interface MediaInfo {
|
|
83
|
+
duration_secs?: number;
|
|
84
|
+
width?: number;
|
|
85
|
+
height?: number;
|
|
86
|
+
video_codec?: string;
|
|
87
|
+
audio_codec?: string;
|
|
88
|
+
bitrate_bps?: number;
|
|
89
|
+
container?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Returned by every mutating transforms method. */
|
|
93
|
+
export interface JobHandle {
|
|
94
|
+
id: string;
|
|
95
|
+
src_key: string;
|
|
96
|
+
output_key: string;
|
|
97
|
+
status: JobStatus;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Returned by `transforms.job(id)`. */
|
|
101
|
+
export interface JobStatusResponse {
|
|
102
|
+
id: string;
|
|
103
|
+
status: JobStatus;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Service interface (matches the JS object the runtime injects) ───────
|
|
107
|
+
|
|
108
|
+
export interface TransformsService {
|
|
109
|
+
transcode(srcKey: string, opts: TranscodeOpts): Promise<JobHandle>;
|
|
110
|
+
thumbnail(srcKey: string, opts: ThumbnailOpts): Promise<JobHandle>;
|
|
111
|
+
resize(srcKey: string, opts: ResizeOpts): Promise<JobHandle>;
|
|
112
|
+
probe(srcKey: string): Promise<MediaInfo>;
|
|
113
|
+
ocr(srcKey: string, opts?: OcrOpts | null): Promise<JobHandle>;
|
|
114
|
+
job(id: string): Promise<JobStatusResponse>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── derive_key mirror ───────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/** Sort object keys recursively so the canonical JSON is stable. */
|
|
120
|
+
function sortKeys(value: unknown): unknown {
|
|
121
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
122
|
+
if (value && typeof value === 'object') {
|
|
123
|
+
const out: Record<string, unknown> = {};
|
|
124
|
+
for (const k of Object.keys(value as Record<string, unknown>).sort()) {
|
|
125
|
+
out[k] = sortKeys((value as Record<string, unknown>)[k]);
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Canonical JSON encoding — matches `serde_json::to_string` over a
|
|
134
|
+
* recursively key-sorted object. Numbers, booleans, strings, null, and
|
|
135
|
+
* nested objects/arrays only; no `undefined`, no functions.
|
|
136
|
+
*/
|
|
137
|
+
function canonicalJson(value: unknown): string {
|
|
138
|
+
return JSON.stringify(sortKeys(value));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Hex-encoded SHA-256 of the input bytes, truncated to 16 chars (8 bytes). */
|
|
142
|
+
function shortHexSha256(input: string): string {
|
|
143
|
+
const bytes = new TextEncoder().encode(input);
|
|
144
|
+
const hash = sha256(bytes);
|
|
145
|
+
return Array.from(hash.slice(0, 8))
|
|
146
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
147
|
+
.join('');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Output extension for a spec — drives the `.<ext>` suffix on the
|
|
152
|
+
* derived key. Matches `TransformSpec::output_extension` in Rust.
|
|
153
|
+
*/
|
|
154
|
+
function outputExtension(spec: TransformSpec): string {
|
|
155
|
+
switch (spec.kind) {
|
|
156
|
+
case 'transcode':
|
|
157
|
+
return spec.format; // 'mp4' | 'webm'
|
|
158
|
+
case 'thumbnail':
|
|
159
|
+
case 'resize': {
|
|
160
|
+
// thumbnail's `format` is optional in TS (defaults to jpg server-side);
|
|
161
|
+
// mirror that default here so keyFor matches the canonical Rust form.
|
|
162
|
+
const fmt = (spec as { format?: ImageFormat }).format ?? 'jpg';
|
|
163
|
+
return fmt;
|
|
164
|
+
}
|
|
165
|
+
case 'ocr':
|
|
166
|
+
return 'txt';
|
|
167
|
+
default: {
|
|
168
|
+
const _exhaust: never = spec;
|
|
169
|
+
throw new Error(`unknown transform kind: ${(_exhaust as { kind: string }).kind}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Compute the canonical derived-asset key for `(srcKey, spec)`.
|
|
176
|
+
*
|
|
177
|
+
* Shape: `__derived/<srcHash>/<variantHash>.<ext>` where each hash is
|
|
178
|
+
* the first 16 hex chars (8 bytes / 64 bits) of `SHA-256(...)`.
|
|
179
|
+
*
|
|
180
|
+
* **MUST** match Rust `platform::media::transforms::derive_key` byte-for-byte.
|
|
181
|
+
* Cross-language golden vectors live at
|
|
182
|
+
* `crates/platform/tests/derive_key_vectors.json`.
|
|
183
|
+
*/
|
|
184
|
+
export function keyFor(srcKey: string, spec: TransformSpec): string {
|
|
185
|
+
const srcHash = shortHexSha256(srcKey);
|
|
186
|
+
// Re-encode so the canonical JSON includes the `kind` discriminator at
|
|
187
|
+
// a sorted position alongside the inlined opts fields — matches what
|
|
188
|
+
// Rust's `serde_json::to_value(&spec)` produces for the tagged enum.
|
|
189
|
+
const variantHash = shortHexSha256(canonicalJson(spec));
|
|
190
|
+
const ext = outputExtension(spec);
|
|
191
|
+
return `__derived/${srcHash}/${variantHash}.${ext}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Convenience namespace mirroring the Rust `transforms::keyFor` re-export. */
|
|
195
|
+
export const transforms = {
|
|
196
|
+
keyFor,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// ── Declarative `transforms` config (consumed by the adapter compiler) ──
|
|
200
|
+
//
|
|
201
|
+
// Mirrors the build-time `TransformsConfig` in `@maravilla-labs/adapter-core`,
|
|
202
|
+
// duplicated here so user code that writes `defineConfig({ transforms: {...} })`
|
|
203
|
+
// gets full IDE completion without depending on adapter-core at runtime.
|
|
204
|
+
//
|
|
205
|
+
// The two type definitions are structurally identical — adapter-core's
|
|
206
|
+
// `compileTransforms` consumes either shape interchangeably (it only walks the
|
|
207
|
+
// JSON-shaped values, not the type-level identity).
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Per-pattern transforms declaration used inside the `transforms` block of
|
|
211
|
+
* `maravilla.config.ts`. Each field accepts a single opts object or an array.
|
|
212
|
+
*
|
|
213
|
+
* `variants` is sugar for `resize` arrays — convenient for the common
|
|
214
|
+
* "image → multiple resized renditions" case.
|
|
215
|
+
*/
|
|
216
|
+
export interface TransformsPatternSpec {
|
|
217
|
+
transcode?: TranscodeOpts | TranscodeOpts[];
|
|
218
|
+
thumbnail?: ThumbnailOpts | ThumbnailOpts[];
|
|
219
|
+
resize?: ResizeOpts | ResizeOpts[];
|
|
220
|
+
/** Sugar for `resize` arrays — same shape, same semantics. */
|
|
221
|
+
variants?: ResizeOpts[];
|
|
222
|
+
ocr?: OcrOpts | OcrOpts[];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Top-level `transforms` block. Keys are glob path patterns matched against
|
|
227
|
+
* the storage key of every uploaded object. The adapter compiles each entry
|
|
228
|
+
* into a synthetic `storage.put` event handler that fans out the declared
|
|
229
|
+
* transforms via `Promise.all`.
|
|
230
|
+
*
|
|
231
|
+
* @example
|
|
232
|
+
* ```ts
|
|
233
|
+
* import { defineConfig } from '@maravilla-labs/platform/config';
|
|
234
|
+
*
|
|
235
|
+
* export default defineConfig({
|
|
236
|
+
* transforms: {
|
|
237
|
+
* 'uploads/videos/**': {
|
|
238
|
+
* transcode: [{ format: 'mp4' }, { format: 'webm' }],
|
|
239
|
+
* thumbnail: { at: '1s', width: 640, format: 'jpg' },
|
|
240
|
+
* },
|
|
241
|
+
* 'uploads/photos/**': {
|
|
242
|
+
* variants: [
|
|
243
|
+
* { width: 1600, format: 'webp', quality: 85 },
|
|
244
|
+
* { width: 400, format: 'webp', quality: 80 },
|
|
245
|
+
* ],
|
|
246
|
+
* },
|
|
247
|
+
* },
|
|
248
|
+
* });
|
|
249
|
+
* ```
|
|
250
|
+
*/
|
|
251
|
+
export type TransformsConfig = Record<string, TransformsPatternSpec>;
|
package/src/types.ts
CHANGED
|
@@ -687,6 +687,26 @@ export interface Storage {
|
|
|
687
687
|
lastModified: string;
|
|
688
688
|
metadata?: any;
|
|
689
689
|
} | null>;
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Notify the platform that a presigned-PUT upload completed at the given
|
|
693
|
+
* key. The bytes never crossed the server, so no `storage.put` event would
|
|
694
|
+
* fire automatically — call this from your form action right after the
|
|
695
|
+
* client's direct PUT succeeds. The platform looks up the object's
|
|
696
|
+
* metadata and publishes the matching `storage.put` event so any registered
|
|
697
|
+
* event handlers (including the synthesized handlers from a `transforms`
|
|
698
|
+
* config block) fire as if the upload had streamed through the server.
|
|
699
|
+
*
|
|
700
|
+
* Idempotent — safe to call more than once with the same key (the server
|
|
701
|
+
* dedupes by emitting at most one event per (tenant, key, mtime) tuple).
|
|
702
|
+
*
|
|
703
|
+
* @example
|
|
704
|
+
* ```ts
|
|
705
|
+
* await fetch(presignedUrl, { method: 'PUT', body: blob });
|
|
706
|
+
* await platform.env.STORAGE.confirm(key);
|
|
707
|
+
* ```
|
|
708
|
+
*/
|
|
709
|
+
confirm(key: string): Promise<void>;
|
|
690
710
|
}
|
|
691
711
|
|
|
692
712
|
/**
|
|
@@ -1346,6 +1366,83 @@ export interface PushService {
|
|
|
1346
1366
|
rotateVapidKeys(): Promise<PublicPushConfig>;
|
|
1347
1367
|
}
|
|
1348
1368
|
|
|
1369
|
+
/** State of a workflow run. */
|
|
1370
|
+
export type WorkflowRunStatus =
|
|
1371
|
+
| 'queued'
|
|
1372
|
+
| 'running'
|
|
1373
|
+
| 'sleeping'
|
|
1374
|
+
| 'waiting_event'
|
|
1375
|
+
| 'completed'
|
|
1376
|
+
| 'failed'
|
|
1377
|
+
| 'cancelled';
|
|
1378
|
+
|
|
1379
|
+
/** Kind of a recorded step. */
|
|
1380
|
+
export type WorkflowStepKind = 'run' | 'sleep' | 'wait_event' | 'ai' | 'mcp' | 'invoke';
|
|
1381
|
+
|
|
1382
|
+
/** A durable workflow run as stored in the ledger. */
|
|
1383
|
+
export interface WorkflowRun {
|
|
1384
|
+
runId: string;
|
|
1385
|
+
workflowId: string;
|
|
1386
|
+
projectId?: string;
|
|
1387
|
+
deploymentId: string;
|
|
1388
|
+
input: unknown;
|
|
1389
|
+
status: WorkflowRunStatus;
|
|
1390
|
+
attempt: number;
|
|
1391
|
+
createdAt: number;
|
|
1392
|
+
updatedAt: number;
|
|
1393
|
+
completedAt?: number;
|
|
1394
|
+
output?: unknown;
|
|
1395
|
+
error?: unknown;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
/** A single step in a run's history. */
|
|
1399
|
+
export interface WorkflowStepRecord {
|
|
1400
|
+
name: string;
|
|
1401
|
+
kind: WorkflowStepKind;
|
|
1402
|
+
status: 'pending' | 'completed' | 'failed';
|
|
1403
|
+
output?: unknown;
|
|
1404
|
+
error?: unknown;
|
|
1405
|
+
startedAt: number;
|
|
1406
|
+
completedAt?: number;
|
|
1407
|
+
wakeAt?: number;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* Handle returned by `platform.workflows.start()` — poll status, await
|
|
1412
|
+
* the final result, cancel, or inspect step history.
|
|
1413
|
+
*/
|
|
1414
|
+
export interface WorkflowHandle {
|
|
1415
|
+
/** The run's unique id. */
|
|
1416
|
+
readonly runId: string;
|
|
1417
|
+
/** Current run record, or `null` if the run does not exist. */
|
|
1418
|
+
status(): Promise<WorkflowRun | null>;
|
|
1419
|
+
/** Full step history — useful for debugging. */
|
|
1420
|
+
history(): Promise<WorkflowStepRecord[]>;
|
|
1421
|
+
/**
|
|
1422
|
+
* Wait for the run to reach a terminal state and return its output.
|
|
1423
|
+
* Throws if the run ended `failed` or `cancelled`; the thrown error
|
|
1424
|
+
* carries the run record on the `run` property.
|
|
1425
|
+
*/
|
|
1426
|
+
result(options?: { timeoutMs?: number }): Promise<unknown>;
|
|
1427
|
+
/** Best-effort cancellation. Returns `true` if the run transitioned. */
|
|
1428
|
+
cancel(): Promise<boolean>;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/** Durable multi-step workflow service. */
|
|
1432
|
+
export interface Workflows {
|
|
1433
|
+
/** Start a new run of the workflow with the given input. */
|
|
1434
|
+
start(workflowId: string, input?: unknown): Promise<WorkflowHandle>;
|
|
1435
|
+
/** Get a handle to an existing run without starting one. */
|
|
1436
|
+
handle(runId: string): WorkflowHandle;
|
|
1437
|
+
/**
|
|
1438
|
+
* Signal an event to any runs paused on `step.waitForEvent`. Returns
|
|
1439
|
+
* the number of runs resolved. Matching rules: `eventType` must equal
|
|
1440
|
+
* the waiter's filter `type`; each key in the filter's `match` must
|
|
1441
|
+
* be present in `payload` with an equal value.
|
|
1442
|
+
*/
|
|
1443
|
+
sendEvent(eventType: string, payload?: unknown): Promise<number>;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1349
1446
|
export interface Platform {
|
|
1350
1447
|
/** Environment containing all available platform services */
|
|
1351
1448
|
env: PlatformEnv;
|
|
@@ -1364,4 +1461,6 @@ export interface Platform {
|
|
|
1364
1461
|
* fallback that doesn't proxy to delivery.
|
|
1365
1462
|
*/
|
|
1366
1463
|
push?: PushService;
|
|
1464
|
+
/** Durable multi-step workflows — `start / handle / sendEvent`. */
|
|
1465
|
+
workflows: Workflows;
|
|
1367
1466
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-language golden vectors for `transforms.keyFor`.
|
|
3
|
+
*
|
|
4
|
+
* Loads `crates/platform/tests/derive_key_vectors.json` — the same
|
|
5
|
+
* fixture the Rust `derive_key` integration test consumes — and asserts
|
|
6
|
+
* that the TypeScript mirror produces byte-identical output for every
|
|
7
|
+
* vector. If this fails, either:
|
|
8
|
+
* - the JS algorithm has drifted from Rust → fix `transforms.ts`;
|
|
9
|
+
* - the Rust algorithm changed deliberately → regenerate the fixture
|
|
10
|
+
* via `cargo run --example gen_derive_key_vectors -p platform > …`
|
|
11
|
+
* and update the JS impl in lockstep.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { describe, expect, it } from 'vitest';
|
|
18
|
+
|
|
19
|
+
import { keyFor, type TransformSpec } from '../src/transforms.js';
|
|
20
|
+
|
|
21
|
+
interface Vector {
|
|
22
|
+
name: string;
|
|
23
|
+
src: string;
|
|
24
|
+
spec: Record<string, unknown>;
|
|
25
|
+
expected_key: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const fixturePath = join(
|
|
29
|
+
__dirname,
|
|
30
|
+
'..',
|
|
31
|
+
'..',
|
|
32
|
+
'..',
|
|
33
|
+
'crates',
|
|
34
|
+
'platform',
|
|
35
|
+
'tests',
|
|
36
|
+
'derive_key_vectors.json',
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
function loadVectors(): Vector[] {
|
|
40
|
+
const raw = readFileSync(fixturePath, 'utf8');
|
|
41
|
+
const parsed = JSON.parse(raw) as Vector[];
|
|
42
|
+
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
43
|
+
throw new Error(`expected non-empty vector array in ${fixturePath}`);
|
|
44
|
+
}
|
|
45
|
+
return parsed;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('transforms.keyFor (golden vectors shared with Rust)', () => {
|
|
49
|
+
const vectors = loadVectors();
|
|
50
|
+
|
|
51
|
+
it('loads at least 8 vectors from the shared fixture', () => {
|
|
52
|
+
expect(vectors.length).toBeGreaterThanOrEqual(8);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('covers every TransformSpec variant', () => {
|
|
56
|
+
const kinds = new Set(vectors.map((v) => v.spec['kind'] as string));
|
|
57
|
+
for (const required of ['transcode', 'thumbnail', 'resize', 'ocr']) {
|
|
58
|
+
expect(kinds.has(required), `missing ${required}`).toBe(true);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
for (const v of vectors) {
|
|
63
|
+
it(`matches Rust derive_key for ${v.name}`, () => {
|
|
64
|
+
const got = keyFor(v.src, v.spec as unknown as TransformSpec);
|
|
65
|
+
expect(got).toBe(v.expected_key);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
});
|