@hraness/sponge 0.1.2 → 0.2.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/README.md +164 -17
- package/bin/sponge.js +9 -2
- package/dist/contracts/index.d.ts +164 -0
- package/dist/contracts/index.js +6 -0
- package/dist/contracts/validate.d.ts +14 -0
- package/dist/contracts/validate.js +187 -0
- package/dist/migrations/001.d.ts +3 -0
- package/dist/migrations/001.js +33 -0
- package/dist/migrations/002.d.ts +3 -0
- package/dist/migrations/002.js +6 -0
- package/dist/migrations/003.d.ts +3 -0
- package/dist/migrations/003.js +10 -0
- package/dist/migrations/004.d.ts +3 -0
- package/dist/migrations/004.js +9 -0
- package/dist/src/acquisition/capture.d.ts +29 -0
- package/dist/src/acquisition/capture.js +160 -0
- package/dist/src/acquisition/cleanup.d.ts +6 -0
- package/dist/src/acquisition/cleanup.js +20 -0
- package/dist/src/acquisition/errors.d.ts +3 -0
- package/dist/src/acquisition/errors.js +20 -0
- package/dist/src/acquisition/files.d.ts +4 -0
- package/dist/src/acquisition/files.js +49 -0
- package/dist/src/acquisition/index.d.ts +6 -0
- package/dist/src/acquisition/index.js +4 -0
- package/dist/src/acquisition/lock.d.ts +1 -0
- package/dist/src/acquisition/lock.js +2 -0
- package/dist/src/acquisition/normalize.d.ts +11 -0
- package/dist/src/acquisition/normalize.js +62 -0
- package/dist/src/acquisition/pins.d.ts +3 -0
- package/dist/src/acquisition/pins.js +9 -0
- package/dist/src/acquisition/process.d.ts +16 -0
- package/dist/src/acquisition/process.js +63 -0
- package/dist/src/acquisition/runtime.d.ts +54 -0
- package/dist/src/acquisition/runtime.js +218 -0
- package/dist/src/acquisition/verify-helper.d.ts +1 -0
- package/dist/src/acquisition/verify-helper.js +19 -0
- package/dist/src/app/article.d.ts +23 -0
- package/dist/src/app/article.js +82 -0
- package/dist/src/app/bundle-files.d.ts +8 -0
- package/dist/src/app/bundle-files.js +66 -0
- package/dist/src/app/markdown.d.ts +6 -0
- package/dist/src/app/markdown.js +90 -0
- package/dist/src/app/source.d.ts +15 -0
- package/dist/src/app/source.js +40 -0
- package/dist/src/cli/arguments.d.ts +9 -0
- package/dist/src/cli/arguments.js +106 -0
- package/dist/src/cli/config.d.ts +8 -0
- package/dist/src/cli/config.js +45 -0
- package/dist/src/cli/import.d.ts +4 -0
- package/dist/src/cli/import.js +3 -0
- package/dist/src/cli/indexing.d.ts +7 -0
- package/dist/src/cli/indexing.js +21 -0
- package/dist/src/cli/main.d.ts +1 -0
- package/dist/src/cli/main.js +333 -0
- package/dist/src/cli/portability.d.ts +9 -0
- package/dist/src/cli/portability.js +39 -0
- package/dist/src/cli/queue.d.ts +2 -0
- package/dist/src/cli/queue.js +23 -0
- package/dist/src/cli/terminal.d.ts +2 -0
- package/dist/src/cli/terminal.js +4 -0
- package/dist/src/cli/worker.d.ts +16 -0
- package/dist/src/cli/worker.js +62 -0
- package/dist/src/importers/apply.d.ts +29 -0
- package/dist/src/importers/apply.js +85 -0
- package/dist/src/importers/custody.d.ts +6 -0
- package/dist/src/importers/custody.js +35 -0
- package/dist/src/importers/inspect.d.ts +21 -0
- package/dist/src/importers/inspect.js +97 -0
- package/dist/src/importers/manifest.d.ts +22 -0
- package/dist/src/importers/manifest.js +43 -0
- package/dist/src/importers/verify-helper.d.ts +1 -0
- package/dist/src/importers/verify-helper.js +27 -0
- package/dist/src/indexing/image.d.ts +10 -0
- package/dist/src/indexing/image.js +34 -0
- package/dist/src/indexing/index.d.ts +53 -0
- package/dist/src/indexing/index.js +123 -0
- package/dist/src/indexing/jobs.d.ts +14 -0
- package/dist/src/indexing/jobs.js +49 -0
- package/dist/src/indexing/runner.d.ts +23 -0
- package/dist/src/indexing/runner.js +114 -0
- package/dist/src/inference/asr/bridge.d.ts +1 -0
- package/dist/src/inference/asr/bridge.js +16 -0
- package/dist/src/inference/asr/protocol.d.ts +23 -0
- package/dist/src/inference/asr/protocol.js +60 -0
- package/dist/src/inference/asr/runtime.d.ts +29 -0
- package/dist/src/inference/asr/runtime.js +103 -0
- package/dist/src/inference/asr/windows.d.ts +9 -0
- package/dist/src/inference/asr/windows.js +41 -0
- package/dist/src/inference/embedding/index.d.ts +47 -0
- package/dist/src/inference/embedding/index.js +122 -0
- package/dist/src/media/capture-helper.d.ts +1 -0
- package/dist/src/media/capture-helper.js +16 -0
- package/dist/src/media/capture.d.ts +32 -0
- package/dist/src/media/capture.js +193 -0
- package/dist/src/media/handler.d.ts +22 -0
- package/dist/src/media/handler.js +82 -0
- package/dist/src/media/transcribe.d.ts +29 -0
- package/dist/src/media/transcribe.js +93 -0
- package/dist/src/metadata/article.d.ts +5 -0
- package/dist/src/metadata/article.js +51 -0
- package/dist/src/models/artifacts.d.ts +36 -0
- package/dist/src/models/artifacts.js +274 -0
- package/dist/src/models/catalog.d.ts +35 -0
- package/dist/src/models/catalog.js +71 -0
- package/dist/src/models/download.d.ts +9 -0
- package/dist/src/models/download.js +147 -0
- package/dist/src/models/runtime.d.ts +24 -0
- package/dist/src/models/runtime.js +37 -0
- package/dist/src/pdf/capture-helper.d.ts +1 -0
- package/dist/src/pdf/capture-helper.js +12 -0
- package/dist/src/pdf/capture.d.ts +29 -0
- package/dist/src/pdf/capture.js +156 -0
- package/dist/src/pdf/errors.d.ts +2 -0
- package/dist/src/pdf/errors.js +13 -0
- package/dist/src/pdf/handler.d.ts +24 -0
- package/dist/src/pdf/handler.js +84 -0
- package/dist/src/pdf/index.d.ts +4 -0
- package/dist/src/pdf/index.js +4 -0
- package/dist/src/pdf/input.d.ts +2 -0
- package/dist/src/pdf/input.js +46 -0
- package/dist/src/pdf/normalize.d.ts +10 -0
- package/dist/src/pdf/normalize.js +64 -0
- package/dist/src/pdf/source-identity.d.ts +4 -0
- package/dist/src/pdf/source-identity.js +12 -0
- package/dist/src/pdf/source.d.ts +6 -0
- package/dist/src/pdf/source.js +22 -0
- package/dist/src/pdf/verify-helper.d.ts +1 -0
- package/dist/src/pdf/verify-helper.js +17 -0
- package/dist/src/portability/backup.d.ts +23 -0
- package/dist/src/portability/backup.js +138 -0
- package/dist/src/portability/database.d.ts +14 -0
- package/dist/src/portability/database.js +78 -0
- package/dist/src/portability/export.d.ts +8 -0
- package/dist/src/portability/export.js +36 -0
- package/dist/src/portability/files.d.ts +33 -0
- package/dist/src/portability/files.js +78 -0
- package/dist/src/portability/gc.d.ts +21 -0
- package/dist/src/portability/gc.js +66 -0
- package/dist/src/search/extension.d.ts +3 -0
- package/dist/src/search/extension.js +17 -0
- package/dist/src/search/hybrid.d.ts +39 -0
- package/dist/src/search/hybrid.js +118 -0
- package/dist/src/search/query.d.ts +12 -0
- package/dist/src/search/query.js +47 -0
- package/dist/src/search/vector.d.ts +21 -0
- package/dist/src/search/vector.js +21 -0
- package/dist/src/service/launch-agent.d.ts +47 -0
- package/dist/src/service/launch-agent.js +179 -0
- package/dist/src/service/lifetime.d.ts +7 -0
- package/dist/src/service/lifetime.js +73 -0
- package/dist/src/service/loop.d.ts +21 -0
- package/dist/src/service/loop.js +94 -0
- package/dist/src/store/files.d.ts +12 -0
- package/dist/src/store/files.js +101 -0
- package/dist/src/store/index.d.ts +118 -0
- package/dist/src/store/index.js +727 -0
- package/dist/src/store/types.d.ts +90 -0
- package/dist/src/store/types.js +8 -0
- package/dist/src/worker/basic.d.ts +10 -0
- package/dist/src/worker/basic.js +27 -0
- package/dist/src/worker/custody.d.ts +13 -0
- package/dist/src/worker/custody.js +185 -0
- package/dist/src/worker/engine.d.ts +28 -0
- package/dist/src/worker/engine.js +78 -0
- package/dist/src/worker/identity.d.ts +7 -0
- package/dist/src/worker/identity.js +27 -0
- package/dist/src/worker/journal.d.ts +5 -0
- package/dist/src/worker/journal.js +30 -0
- package/dist/src/worker/supervisor-child.d.ts +1 -0
- package/dist/src/worker/supervisor-child.js +33 -0
- package/dist/src/worker/supervisor.d.ts +3 -0
- package/dist/src/worker/supervisor.js +47 -0
- package/native/runtime-manifest.json +40 -0
- package/package.json +31 -5
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { runOwned } from '../../acquisition/process.js';
|
|
2
|
+
export declare const embeddingIdentity: {
|
|
3
|
+
readonly modelSha256: "92dcbea108899e5d6e30d919b0744f90d9967e80c67a4ab5503ac16d54f62eb0";
|
|
4
|
+
readonly librarySha256: "9526db9ee35e7eb4a5a58ac1e53330032f22a13890bd4ee01818fb380a57a362";
|
|
5
|
+
readonly runtime: "LiteRT-LM@0.18.0";
|
|
6
|
+
readonly dimensions: 768;
|
|
7
|
+
readonly normalization: "unit-l2";
|
|
8
|
+
readonly preprocessing: "sips-max512-png-litert-v1";
|
|
9
|
+
readonly prompt: "search-result-query-title-none-v1";
|
|
10
|
+
readonly protocol: 1;
|
|
11
|
+
readonly backend: "cpu-2threads-f32";
|
|
12
|
+
};
|
|
13
|
+
export type EmbeddingInput = {
|
|
14
|
+
kind: 'query' | 'document';
|
|
15
|
+
text: string;
|
|
16
|
+
} | {
|
|
17
|
+
kind: 'image';
|
|
18
|
+
png: Uint8Array;
|
|
19
|
+
};
|
|
20
|
+
export interface EmbeddingRuntime {
|
|
21
|
+
helper: string;
|
|
22
|
+
helperSha256: string;
|
|
23
|
+
model: string;
|
|
24
|
+
library: string;
|
|
25
|
+
}
|
|
26
|
+
export declare class EmbeddingError extends Error {
|
|
27
|
+
code: 'integrity' | 'unsupported' | 'limit-exceeded' | 'cancelled' | 'unavailable' | 'cleanup-required';
|
|
28
|
+
constructor(code: 'integrity' | 'unsupported' | 'limit-exceeded' | 'cancelled' | 'unavailable' | 'cleanup-required');
|
|
29
|
+
}
|
|
30
|
+
export declare function spaceFingerprint(identity: Record<string, string | number>): string;
|
|
31
|
+
export declare function embeddingFingerprint(helperSha256: string): string;
|
|
32
|
+
/** Versioned length-delimited bytes. No user content or image pathname enters argv. */
|
|
33
|
+
export declare function encodeEmbeddingRequest(inputs: readonly EmbeddingInput[]): {
|
|
34
|
+
bytes: Buffer;
|
|
35
|
+
inputHashes: string[];
|
|
36
|
+
};
|
|
37
|
+
export declare function parseEmbeddingOutput(stdout: string, count: number): number[][];
|
|
38
|
+
export declare function embedBatch(inputs: readonly EmbeddingInput[], options: {
|
|
39
|
+
runtime: EmbeddingRuntime;
|
|
40
|
+
scratchRoot: string;
|
|
41
|
+
signal?: AbortSignal;
|
|
42
|
+
runProcess?: typeof runOwned;
|
|
43
|
+
}): Promise<{
|
|
44
|
+
fingerprint: string;
|
|
45
|
+
inputHashes: string[];
|
|
46
|
+
vectors: number[][];
|
|
47
|
+
}>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { open, mkdtemp, chmod, writeFile, rm, realpath } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join, isAbsolute } from 'node:path';
|
|
5
|
+
import { runOwned } from '../../acquisition/process.js';
|
|
6
|
+
export const embeddingIdentity = { modelSha256: '92dcbea108899e5d6e30d919b0744f90d9967e80c67a4ab5503ac16d54f62eb0', librarySha256: '9526db9ee35e7eb4a5a58ac1e53330032f22a13890bd4ee01818fb380a57a362', runtime: 'LiteRT-LM@0.18.0', dimensions: 768, normalization: 'unit-l2', preprocessing: 'sips-max512-png-litert-v1', prompt: 'search-result-query-title-none-v1', protocol: 1, backend: 'cpu-2threads-f32' };
|
|
7
|
+
export class EmbeddingError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code) {
|
|
10
|
+
super(code);
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const fail = (code = 'integrity') => { throw new EmbeddingError(code); };
|
|
15
|
+
const hash = (b) => createHash('sha256').update(b).digest('hex');
|
|
16
|
+
export function spaceFingerprint(identity) { const keys = Object.keys(identity).sort(); if (!keys.length || keys.some(k => !['string', 'number'].includes(typeof identity[k]) || (typeof identity[k] === 'number' && !Number.isFinite(identity[k]))))
|
|
17
|
+
fail(); return hash(JSON.stringify(Object.fromEntries(keys.map(k => [k, identity[k]])))); }
|
|
18
|
+
export function embeddingFingerprint(helperSha256) { if (!/^[a-f0-9]{64}$/.test(helperSha256))
|
|
19
|
+
fail(); return spaceFingerprint({ ...embeddingIdentity, helperSha256 }); }
|
|
20
|
+
/** Versioned length-delimited bytes. No user content or image pathname enters argv. */
|
|
21
|
+
export function encodeEmbeddingRequest(inputs) {
|
|
22
|
+
if (!inputs.length || inputs.length > 16)
|
|
23
|
+
fail('limit-exceeded');
|
|
24
|
+
const parts = [Buffer.from('SPGEMB01'), Buffer.from([inputs.length])], inputHashes = [];
|
|
25
|
+
for (const input of inputs) {
|
|
26
|
+
let payload, kind;
|
|
27
|
+
if (input.kind === 'image') {
|
|
28
|
+
payload = Buffer.from(input.png);
|
|
29
|
+
kind = 2;
|
|
30
|
+
if (payload.length < 33 || payload.length > 2 * 1024 * 1024)
|
|
31
|
+
fail('limit-exceeded');
|
|
32
|
+
if (!payload.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) || payload.toString('ascii', 12, 16) !== 'IHDR' || payload.readUInt32BE(8) !== 13)
|
|
33
|
+
fail('unsupported');
|
|
34
|
+
const w = payload.readUInt32BE(16), h = payload.readUInt32BE(20);
|
|
35
|
+
if (!w || !h || w * h > 262144)
|
|
36
|
+
fail('limit-exceeded');
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
if (input.kind !== 'query' && input.kind !== 'document')
|
|
40
|
+
fail('unsupported');
|
|
41
|
+
if (typeof input.text !== 'string' || !input.text.trim() || input.text.includes('\0'))
|
|
42
|
+
fail();
|
|
43
|
+
payload = Buffer.from((input.kind === 'query' ? 'task: search result | query: ' : 'title: none | text: ') + input.text);
|
|
44
|
+
kind = 1;
|
|
45
|
+
if (payload.length > 8192)
|
|
46
|
+
fail('limit-exceeded');
|
|
47
|
+
}
|
|
48
|
+
const header = Buffer.alloc(5);
|
|
49
|
+
header[0] = kind;
|
|
50
|
+
header.writeUInt32BE(payload.length, 1);
|
|
51
|
+
parts.push(header, payload);
|
|
52
|
+
inputHashes.push(hash(Buffer.concat([header, payload])));
|
|
53
|
+
}
|
|
54
|
+
return { bytes: Buffer.concat(parts), inputHashes };
|
|
55
|
+
}
|
|
56
|
+
export function parseEmbeddingOutput(stdout, count) {
|
|
57
|
+
if (Buffer.byteLength(stdout) > 512 * 1024)
|
|
58
|
+
fail('limit-exceeded');
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(stdout);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
fail();
|
|
65
|
+
}
|
|
66
|
+
const p = parsed;
|
|
67
|
+
if (!p || p.protocol !== 1 || !Array.isArray(p.vectors) || p.vectors.length !== count)
|
|
68
|
+
fail();
|
|
69
|
+
return p.vectors.map((v) => { if (!Array.isArray(v) || v.length !== 768 || v.some(n => typeof n !== 'number' || !Number.isFinite(n)))
|
|
70
|
+
fail(); const vector = v; if (Math.abs(Math.hypot(...vector) - 1) > .001)
|
|
71
|
+
fail(); return vector; });
|
|
72
|
+
}
|
|
73
|
+
async function verify(path, expected) {
|
|
74
|
+
if (!isAbsolute(path) || !/^[a-f0-9]{64}$/.test(expected))
|
|
75
|
+
fail();
|
|
76
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
77
|
+
try {
|
|
78
|
+
const before = await file.stat();
|
|
79
|
+
if (!before.isFile())
|
|
80
|
+
fail();
|
|
81
|
+
const digest = createHash('sha256');
|
|
82
|
+
for await (const chunk of file.createReadStream({ autoClose: false }))
|
|
83
|
+
digest.update(chunk);
|
|
84
|
+
const after = await file.stat();
|
|
85
|
+
if (digest.digest('hex') !== expected || before.size !== after.size || before.mtimeMs !== after.mtimeMs)
|
|
86
|
+
fail();
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
await file.close();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export async function embedBatch(inputs, options) {
|
|
93
|
+
const request = encodeEmbeddingRequest(inputs), r = options.runtime, fingerprint = embeddingFingerprint(r.helperSha256);
|
|
94
|
+
if (options.signal?.aborted)
|
|
95
|
+
fail('cancelled');
|
|
96
|
+
if (process.platform !== 'darwin' || process.arch !== 'arm64')
|
|
97
|
+
fail('unsupported');
|
|
98
|
+
await Promise.all([verify(r.helper, r.helperSha256), verify(r.model, embeddingIdentity.modelSha256), verify(r.library, embeddingIdentity.librarySha256)]);
|
|
99
|
+
if (await realpath(r.library) !== join(await realpath(dirname(r.helper)), 'libCLiteRTLM_mac.dylib'))
|
|
100
|
+
fail();
|
|
101
|
+
const scratch = await mkdtemp(join(options.scratchRoot, 'embedding-'));
|
|
102
|
+
await chmod(scratch, 0o700);
|
|
103
|
+
let settled = true;
|
|
104
|
+
try {
|
|
105
|
+
const path = join(scratch, 'request.bin');
|
|
106
|
+
await writeFile(path, request.bytes, { mode: 0o600, flag: 'wx' });
|
|
107
|
+
settled = false;
|
|
108
|
+
const result = await (options.runProcess ?? runOwned)('/usr/bin/sandbox-exec', ['-p', '(version 1) (allow default) (deny network*)', r.helper, r.model, path], { env: { PATH: '/usr/bin:/bin', HOME: scratch, TMPDIR: scratch, LANG: 'C' }, signal: options.signal, deadlineMs: 120000, maxOutputBytes: 512 * 1024 });
|
|
109
|
+
settled = result.cleanup === 'settled';
|
|
110
|
+
if (!settled)
|
|
111
|
+
fail('cleanup-required');
|
|
112
|
+
if (result.stopped)
|
|
113
|
+
fail(result.stopped);
|
|
114
|
+
if (result.code !== 0)
|
|
115
|
+
fail('unavailable');
|
|
116
|
+
return { fingerprint, inputHashes: request.inputHashes, vectors: parseEmbeddingOutput(result.stdout, inputs.length) };
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
if (settled)
|
|
120
|
+
await rm(scratch, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Public Ghostget SDK bridge; its complete inherited group belongs to the caller. */
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { processIdentity } from '../worker/identity.js';
|
|
4
|
+
try {
|
|
5
|
+
const config = JSON.parse(await readFile(process.argv[2], 'utf8'));
|
|
6
|
+
const sdk = await import(import.meta.resolve('@hraness/ghostget/media-acquisition', `${config.runtimeRoot}/package.json`));
|
|
7
|
+
const identity = await processIdentity(process.pid);
|
|
8
|
+
if (!identity)
|
|
9
|
+
throw Error('identity');
|
|
10
|
+
const result = await sdk.acquireEphemeralMedia({ ...config.request, processGroup: 'inherit', supervisor: { requestId: config.requestId, processGroupId: process.pid, processStartIdentity: identity.started } });
|
|
11
|
+
process.stdout.write(JSON.stringify(result));
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
process.stdout.write(JSON.stringify({ ok: false, code: 'unavailable', cleanup: 'settled' }));
|
|
15
|
+
process.exitCode = 1;
|
|
16
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Asset, ErrorEnvelope } from '../../contracts/index.js';
|
|
2
|
+
import { type CaptureRuntime } from '../acquisition/runtime.js';
|
|
3
|
+
import { runOwned } from '../acquisition/process.js';
|
|
4
|
+
import type { CaptureConnection } from '../acquisition/index.js';
|
|
5
|
+
import type { AudioInput } from '../inference/asr/runtime.js';
|
|
6
|
+
import type { CaptionInput } from './transcribe.js';
|
|
7
|
+
export interface MediaCaptureRequest {
|
|
8
|
+
requestId: string;
|
|
9
|
+
sourceUrl: string;
|
|
10
|
+
runtime: CaptureRuntime;
|
|
11
|
+
stagingRoot: string;
|
|
12
|
+
connection?: CaptureConnection;
|
|
13
|
+
language?: string;
|
|
14
|
+
forceLocal?: boolean;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
runProcess?: typeof runOwned;
|
|
17
|
+
}
|
|
18
|
+
export interface CapturedMedia {
|
|
19
|
+
ok: true;
|
|
20
|
+
root: string;
|
|
21
|
+
audio?: AudioInput;
|
|
22
|
+
captions?: CaptionInput;
|
|
23
|
+
thumbnail?: Asset;
|
|
24
|
+
title?: string;
|
|
25
|
+
author?: string;
|
|
26
|
+
manifestSha256: string;
|
|
27
|
+
warnings: string[];
|
|
28
|
+
dispose: () => Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
export type MediaCaptureOutcome = CapturedMedia | ErrorEnvelope;
|
|
31
|
+
/** All ephemeral bytes remain below one capture owner; deletion requires a settled process tree. */
|
|
32
|
+
export declare function captureMedia(request: MediaCaptureRequest): Promise<MediaCaptureOutcome>;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open, mkdir, mkdtemp, writeFile, statfs, realpath } from 'node:fs/promises';
|
|
3
|
+
import { join, relative, isAbsolute } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { limits } from '../../contracts/index.js';
|
|
8
|
+
import { failure } from '../acquisition/errors.js';
|
|
9
|
+
import { inspectCaptureRuntime, CaptureSetupError } from '../acquisition/runtime.js';
|
|
10
|
+
import { privateRoot, physicalBytes, readConfined } from '../acquisition/files.js';
|
|
11
|
+
import { runOwned, isolatedEnvironment } from '../acquisition/process.js';
|
|
12
|
+
import { settleCaptureRoot, CaptureCleanupError } from '../acquisition/cleanup.js';
|
|
13
|
+
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
14
|
+
/** All ephemeral bytes remain below one capture owner; deletion requires a settled process tree. */
|
|
15
|
+
export async function captureMedia(request) {
|
|
16
|
+
const { requestId, signal } = request;
|
|
17
|
+
if (!/^[A-Za-z0-9-]{1,128}$/.test(requestId))
|
|
18
|
+
return failure('invalid-request', 'invalid-input');
|
|
19
|
+
let url;
|
|
20
|
+
try {
|
|
21
|
+
url = new URL(request.sourceUrl);
|
|
22
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || request.sourceUrl.length > 16384)
|
|
23
|
+
throw Error();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return failure(requestId, 'invalid-input');
|
|
27
|
+
}
|
|
28
|
+
url.hash = '';
|
|
29
|
+
if (request.connection?.kind === 'cdp')
|
|
30
|
+
return failure(requestId, 'unsupported');
|
|
31
|
+
if (signal?.aborted)
|
|
32
|
+
return failure(requestId, 'cancelled');
|
|
33
|
+
let runtime, base;
|
|
34
|
+
try {
|
|
35
|
+
runtime = await inspectCaptureRuntime(request.runtime.root, request.runtime.pins);
|
|
36
|
+
base = await privateRoot(request.stagingRoot);
|
|
37
|
+
const disk = await statfs(base);
|
|
38
|
+
if (disk.bavail * disk.bsize < limits.stagingBytes + limits.freeReserveBytes)
|
|
39
|
+
return failure(requestId, 'limit-exceeded');
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
return failure(requestId, error instanceof CaptureSetupError ? error.code : 'invalid-input');
|
|
43
|
+
}
|
|
44
|
+
let selectedState;
|
|
45
|
+
if (request.connection?.kind === 'account') {
|
|
46
|
+
if (!/^[A-Za-z0-9_.-]{1,128}$/.test(request.connection.accountId))
|
|
47
|
+
return failure(requestId, 'invalid-input');
|
|
48
|
+
try {
|
|
49
|
+
selectedState = await privateRoot(request.connection.stateHome);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return failure(requestId, 'repair-auth');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const root = await realpath(await mkdtemp(join(base, 'capture-'))), home = join(root, 'home');
|
|
56
|
+
let cleanup = 'settled', keep = false, checking = false, quota = false;
|
|
57
|
+
const controller = new AbortController(), abort = () => controller.abort();
|
|
58
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
59
|
+
if (signal?.aborted)
|
|
60
|
+
abort();
|
|
61
|
+
const monitor = setInterval(async () => { if (checking)
|
|
62
|
+
return; checking = true; try {
|
|
63
|
+
if (await physicalBytes(root) > limits.stagingBytes) {
|
|
64
|
+
quota = true;
|
|
65
|
+
controller.abort();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
quota = true;
|
|
70
|
+
controller.abort();
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
checking = false;
|
|
74
|
+
} }, 100);
|
|
75
|
+
try {
|
|
76
|
+
await mkdir(home, { mode: 0o700 });
|
|
77
|
+
await writeFile(join(root, 'ownership.json'), JSON.stringify({ schemaVersion: 1, requestId, state: 'acquiring' }), { mode: 0o600 });
|
|
78
|
+
const requestFile = join(root, 'request.json');
|
|
79
|
+
await writeFile(requestFile, JSON.stringify({ runtimeRoot: runtime.root, requestId, request: { url: request.sourceUrl, stagingParent: root, accountId: request.connection?.kind === 'account' ? request.connection.accountId : undefined, language: request.language ?? 'en', preferAudio: request.forceLocal === true, maxBytes: 512 * 1024 ** 2, maxDurationSeconds: 3600, timeoutMs: limits.captureDeadlineMs } }), { mode: 0o600, flag: 'wx' });
|
|
80
|
+
const entry = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? './capture-helper.ts' : './capture-helper.js', import.meta.url));
|
|
81
|
+
const environment = isolatedEnvironment(home, home, selectedState ?? join(home, 'state'));
|
|
82
|
+
environment.XDG_CACHE_HOME = home;
|
|
83
|
+
environment.XDG_CONFIG_HOME = home;
|
|
84
|
+
environment.XDG_STATE_HOME = home;
|
|
85
|
+
if (selectedState)
|
|
86
|
+
environment.HOME = homedir();
|
|
87
|
+
cleanup = 'pending';
|
|
88
|
+
const result = await (request.runProcess ?? runOwned)(runtime.bun, [entry, requestFile], { cwd: root, env: environment, signal: controller.signal, deadlineMs: limits.captureDeadlineMs, maxOutputBytes: limits.metadataBytes });
|
|
89
|
+
cleanup = result.cleanup;
|
|
90
|
+
if (cleanup === 'pending')
|
|
91
|
+
return failure(requestId, 'cleanup-required', 'cleanup');
|
|
92
|
+
if (quota)
|
|
93
|
+
return failure(requestId, 'limit-exceeded');
|
|
94
|
+
if (result.stopped)
|
|
95
|
+
return failure(requestId, result.stopped);
|
|
96
|
+
const parsed = JSON.parse(result.stdout);
|
|
97
|
+
if (parsed.ok !== true) {
|
|
98
|
+
if (parsed.code === 'cleanup-required')
|
|
99
|
+
cleanup = 'pending';
|
|
100
|
+
const mapping = { 'permission-or-provider-failure': 'grant-permission', timeout: 'unavailable', 'cleanup-required': 'cleanup-required', 'invalid-input': 'invalid-input', unsupported: 'unsupported', unavailable: 'unavailable', 'limit-exceeded': 'limit-exceeded', cancelled: 'cancelled', integrity: 'integrity' };
|
|
101
|
+
return failure(requestId, mapping[parsed.code] ?? 'unclassified');
|
|
102
|
+
}
|
|
103
|
+
const manifest = parsed.manifest, mediaRoot = parsed.receipt?.root;
|
|
104
|
+
if (result.code !== 0 || typeof mediaRoot !== 'string' || !mediaRoot.startsWith(root + '/media-') || await realpath(mediaRoot) !== mediaRoot || manifest?.schemaVersion !== 1 || manifest.kind !== 'ephemeral-media' || manifest.retainedSourceArchive !== false || manifest.source?.requestedUrlSha256 !== hash(url.href) || !Number.isFinite(manifest.source.durationSeconds) || manifest.source.durationSeconds <= 0 || manifest.source.durationSeconds > 3600 || !Array.isArray(manifest.files) || manifest.files.length > 3)
|
|
105
|
+
throw Error('integrity');
|
|
106
|
+
const captured = { ok: true, root: mediaRoot, manifestSha256: hash(JSON.stringify(manifest)), warnings: Array.isArray(manifest.warnings) ? manifest.warnings.map(() => 'partial') : [], dispose: async () => { if (await settleCaptureRoot(root, requestId) === 'pending')
|
|
107
|
+
throw new CaptureCleanupError(); } };
|
|
108
|
+
for (const field of ['title', 'author']) {
|
|
109
|
+
const value = manifest.source[field];
|
|
110
|
+
if (value !== undefined) {
|
|
111
|
+
if (typeof value !== 'string' || Buffer.byteLength(value) > 16384)
|
|
112
|
+
throw Error('integrity');
|
|
113
|
+
captured[field] = value;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const roles = new Set();
|
|
117
|
+
for (const file of manifest.files) {
|
|
118
|
+
if (!['audio', 'captions', 'thumbnail'].includes(file.role) || roles.has(file.role) || typeof file.path !== 'string' || !Number.isSafeInteger(file.bytes) || file.bytes < 1 || !/^[a-f0-9]{64}$/.test(file.sha256))
|
|
119
|
+
throw Error('integrity');
|
|
120
|
+
roles.add(file.role);
|
|
121
|
+
const relativePath = isAbsolute(file.path) ? relative(mediaRoot, file.path) : file.path;
|
|
122
|
+
const maximum = file.role === 'audio' ? 512 * 1024 ** 2 : file.role === 'captions' ? 4 * 1024 ** 2 : limits.imageBytes;
|
|
123
|
+
// Bounded confined byte verification before handing anything to native code.
|
|
124
|
+
let data = Buffer.alloc(0);
|
|
125
|
+
if (file.role === 'audio') {
|
|
126
|
+
if (!relativePath || relativePath.split('/').some((part) => !part || part === '.' || part === '..') || relativePath.includes('\\') || file.bytes > maximum)
|
|
127
|
+
throw Error('integrity');
|
|
128
|
+
const path = join(mediaRoot, relativePath);
|
|
129
|
+
if (await realpath(path) !== path)
|
|
130
|
+
throw Error('integrity');
|
|
131
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
132
|
+
try {
|
|
133
|
+
const stat = await handle.stat();
|
|
134
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size !== file.bytes)
|
|
135
|
+
throw Error('integrity');
|
|
136
|
+
const digest = createHash('sha256'), buffer = Buffer.alloc(65536);
|
|
137
|
+
let bytes = 0;
|
|
138
|
+
for (;;) {
|
|
139
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
|
|
140
|
+
if (!bytesRead)
|
|
141
|
+
break;
|
|
142
|
+
bytes += bytesRead;
|
|
143
|
+
if (bytes > file.bytes)
|
|
144
|
+
throw Error('limit-exceeded');
|
|
145
|
+
digest.update(buffer.subarray(0, bytesRead));
|
|
146
|
+
}
|
|
147
|
+
if (bytes !== file.bytes || digest.digest('hex') !== file.sha256)
|
|
148
|
+
throw Error('integrity');
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
await handle.close();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
data = await readConfined(mediaRoot, relativePath, maximum);
|
|
156
|
+
if (data.length !== file.bytes || hash(data) !== file.sha256)
|
|
157
|
+
throw Error('integrity');
|
|
158
|
+
}
|
|
159
|
+
if (file.role === 'audio')
|
|
160
|
+
captured.audio = { path: join(mediaRoot, relativePath), sha256: file.sha256, bytes: file.bytes, durationMs: manifest.source.durationSeconds * 1000 };
|
|
161
|
+
else if (file.role === 'captions') {
|
|
162
|
+
if (!manifest.caption || !['manual', 'automatic'].includes(manifest.caption.source))
|
|
163
|
+
throw Error('integrity');
|
|
164
|
+
captured.captions = { text: new TextDecoder('utf8', { fatal: true, ignoreBOM: true }).decode(data), sha256: file.sha256, source: manifest.caption.source, language: manifest.caption.language, durationMs: manifest.source.durationSeconds * 1000 };
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
const mediaType = data[0] === 0xff && data[1] === 0xd8 ? 'image/jpeg' : data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ? 'image/png' : data.toString('ascii', 0, 4) === 'RIFF' && data.toString('ascii', 8, 12) === 'WEBP' ? 'image/webp' : undefined;
|
|
168
|
+
if (!mediaType)
|
|
169
|
+
throw Error('integrity');
|
|
170
|
+
captured.thumbnail = { relativePath, bytes: file.bytes, sha256: file.sha256, mediaType };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!captured.audio && !captured.captions)
|
|
174
|
+
throw Error('integrity');
|
|
175
|
+
if (controller.signal.aborted)
|
|
176
|
+
return failure(requestId, quota ? 'limit-exceeded' : 'cancelled');
|
|
177
|
+
keep = true;
|
|
178
|
+
return captured;
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
if (cleanup === 'pending')
|
|
182
|
+
return failure(requestId, 'cleanup-required', 'cleanup');
|
|
183
|
+
return failure(requestId, error instanceof Error && error.message === 'limit-exceeded' ? 'limit-exceeded' : 'integrity', 'verification');
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
clearInterval(monitor);
|
|
187
|
+
while (checking)
|
|
188
|
+
await new Promise(r => setTimeout(r, 5));
|
|
189
|
+
signal?.removeEventListener('abort', abort);
|
|
190
|
+
if (!keep && cleanup === 'settled' && await settleCaptureRoot(root, requestId) === 'pending')
|
|
191
|
+
return failure(requestId, 'cleanup-required', 'cleanup');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ErrorEnvelope, JobCheckpoint } from '../../contracts/index.js';
|
|
2
|
+
import type { LibraryStore, AttemptToken } from '../store/index.js';
|
|
3
|
+
import type { AsrRuntime } from '../inference/asr/runtime.js';
|
|
4
|
+
import { type MediaCaptureRequest, type MediaCaptureOutcome } from './capture.js';
|
|
5
|
+
import { transcribeMedia } from './transcribe.js';
|
|
6
|
+
export interface MediaOptions extends Omit<MediaCaptureRequest, 'requestId' | 'sourceUrl' | 'stagingRoot'> {
|
|
7
|
+
asrRuntime?: AsrRuntime;
|
|
8
|
+
forceLocal?: boolean;
|
|
9
|
+
checkpoint?: (stage: JobCheckpoint['stage']) => Promise<void>;
|
|
10
|
+
capture?: (request: MediaCaptureRequest) => Promise<MediaCaptureOutcome>;
|
|
11
|
+
transcribe?: typeof transcribeMedia;
|
|
12
|
+
}
|
|
13
|
+
export type MediaOutcome = ({
|
|
14
|
+
ok: true;
|
|
15
|
+
itemId: string;
|
|
16
|
+
revisionId: string;
|
|
17
|
+
status: 'complete' | 'partial';
|
|
18
|
+
} | ErrorEnvelope) & {
|
|
19
|
+
cleanup: 'settled' | 'pending';
|
|
20
|
+
};
|
|
21
|
+
/** Only a transcript and thumbnail enter durable storage; acquisition owns all source media. */
|
|
22
|
+
export declare function ingestMedia(store: LibraryStore, token: AttemptToken, options: MediaOptions): Promise<MediaOutcome>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { failure } from '../acquisition/errors.js';
|
|
4
|
+
import { bundleBytes } from '../app/bundle-files.js';
|
|
5
|
+
import { runOwned } from '../acquisition/process.js';
|
|
6
|
+
import { captureMedia } from './capture.js';
|
|
7
|
+
import { transcribeMedia } from './transcribe.js';
|
|
8
|
+
/** Only a transcript and thumbnail enter durable storage; acquisition owns all source media. */
|
|
9
|
+
export async function ingestMedia(store, token, options) {
|
|
10
|
+
let captured, committed = false, cleanup = 'settled';
|
|
11
|
+
let outcome = failure(token.attemptId, 'unclassified');
|
|
12
|
+
try {
|
|
13
|
+
const source = store.sourceForJob(token.jobId);
|
|
14
|
+
if (!source || source.kind !== 'media')
|
|
15
|
+
throw Error('invalid-input');
|
|
16
|
+
const result = await (options.capture ?? captureMedia)({ ...options, stagingRoot: join(store.root, 'staging'), requestId: token.attemptId, sourceUrl: source.locator });
|
|
17
|
+
if (!result.ok) {
|
|
18
|
+
outcome = result;
|
|
19
|
+
cleanup = result.code === 'cleanup-required' ? 'pending' : 'settled';
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
captured = result;
|
|
23
|
+
await options.checkpoint?.('extracting');
|
|
24
|
+
const transcription = await (options.transcribe ?? transcribeMedia)({ requestId: token.attemptId, audio: result.audio, captions: result.captions, language: options.language, forceLocal: options.forceLocal }, { scratchRoot: result.root, runtime: options.asrRuntime, signal: options.signal, runProcess: options.runProcess ?? runOwned });
|
|
25
|
+
cleanup = transcription.cleanup;
|
|
26
|
+
if (!transcription.ok)
|
|
27
|
+
outcome = transcription;
|
|
28
|
+
else {
|
|
29
|
+
const transcriptBytes = Buffer.from(JSON.stringify(transcription.transcript)), transcriptHash = createHash('sha256').update(transcriptBytes).digest('hex');
|
|
30
|
+
const transcriptBlob = await store.putBlob(token, transcriptHash, transcriptBytes.length, (async function* () { yield transcriptBytes; })());
|
|
31
|
+
const assets = [{ ...transcriptBlob, mediaType: 'application/json', relationship: 'transcript' }];
|
|
32
|
+
if (result.thumbnail) {
|
|
33
|
+
const blob = await store.putBlob(token, result.thumbnail.sha256, result.thumbnail.bytes, bundleBytes(result.root, result.thumbnail));
|
|
34
|
+
assets.push({ ...blob, mediaType: result.thumbnail.mediaType, relationship: 'thumbnail' });
|
|
35
|
+
}
|
|
36
|
+
if (options.signal?.aborted) {
|
|
37
|
+
store.requestCancellation(token.jobId);
|
|
38
|
+
throw Error('cancelled');
|
|
39
|
+
}
|
|
40
|
+
const observedAt = new Date().toISOString(), metadata = [{ field: 'transcriptSource', value: transcription.transcript.source, source: 'media', observedAt }, { field: 'language', value: transcription.transcript.language, source: 'media', observedAt }, { field: 'durationMs', value: String(transcription.transcript.durationMs), source: 'media', observedAt }];
|
|
41
|
+
for (const field of ['title', 'author'])
|
|
42
|
+
if (result[field])
|
|
43
|
+
metadata.push({ field: field === 'author' ? 'authors' : field, value: field === 'author' ? [result[field]] : result[field], source: 'media', observedAt, selected: true });
|
|
44
|
+
const status = result.warnings.length ? 'partial' : 'complete';
|
|
45
|
+
await options.checkpoint?.('promoting');
|
|
46
|
+
const revision = store.commitRevision({ ...token, itemId: source.itemId, bundleHash: createHash('sha256').update(JSON.stringify(['media-transcript-v1', result.manifestSha256, transcriptHash])).digest('hex'), markdown: transcription.markdown, title: result.title, extractor: { name: 'sponge-media', version: '1' }, status, metadata, assets, warnings: result.warnings.length ? ['partial'] : [] });
|
|
47
|
+
committed = true;
|
|
48
|
+
outcome = { ok: true, itemId: source.itemId, revisionId: revision.revisionId, status };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
const raw = error?.code ?? (error instanceof Error ? error.message : '');
|
|
54
|
+
if (raw === 'cleanup-required')
|
|
55
|
+
cleanup = 'pending';
|
|
56
|
+
const code = ['cleanup-required', 'integrity', 'invalid-input', 'limit-exceeded', 'cancelled', 'owner-busy'].includes(raw) ? raw : 'unclassified';
|
|
57
|
+
outcome = failure(token.attemptId, code, 'storage');
|
|
58
|
+
}
|
|
59
|
+
// An uncertain native process may still be reading source bytes: preserve ownership.
|
|
60
|
+
if (captured && cleanup === 'settled')
|
|
61
|
+
try {
|
|
62
|
+
await captured.dispose();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
cleanup = 'pending';
|
|
66
|
+
}
|
|
67
|
+
if (!committed)
|
|
68
|
+
try {
|
|
69
|
+
store.failAttempt(token, outcome.ok ? 'unclassified' : outcome.code, outcome.ok ? {} : { retryable: outcome.retryable, retryAfterMs: outcome.retryAfterMs });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
cleanup = 'pending';
|
|
73
|
+
}
|
|
74
|
+
if (cleanup === 'settled')
|
|
75
|
+
try {
|
|
76
|
+
store.settleAttempt(token);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
cleanup = 'pending';
|
|
80
|
+
}
|
|
81
|
+
return cleanup === 'pending' ? { ...failure(token.attemptId, 'cleanup-required', 'cleanup'), cleanup } : { ...outcome, cleanup };
|
|
82
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ErrorEnvelope } from '../../contracts/index.js';
|
|
2
|
+
import { type AudioInput, type AsrOptions } from '../inference/asr/runtime.js';
|
|
3
|
+
import type { SourceTranscript } from '../inference/asr/protocol.js';
|
|
4
|
+
export interface CaptionInput {
|
|
5
|
+
text: string;
|
|
6
|
+
sha256: string;
|
|
7
|
+
source: 'manual' | 'automatic';
|
|
8
|
+
language: string;
|
|
9
|
+
durationMs: number;
|
|
10
|
+
}
|
|
11
|
+
export interface TranscriptionInput {
|
|
12
|
+
requestId: string;
|
|
13
|
+
captions?: CaptionInput;
|
|
14
|
+
audio?: AudioInput;
|
|
15
|
+
language?: string;
|
|
16
|
+
forceLocal?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export type TranscriptionOutcome = ({
|
|
19
|
+
ok: true;
|
|
20
|
+
transcript: SourceTranscript;
|
|
21
|
+
markdown: string;
|
|
22
|
+
} | ErrorEnvelope) & {
|
|
23
|
+
cleanup: 'settled' | 'pending';
|
|
24
|
+
};
|
|
25
|
+
/** Strict, bounded timed WebVTT. Rolling captions only deduplicate already-overlapping text. */
|
|
26
|
+
export declare function parseWebVtt(captions: CaptionInput, requestId: string): SourceTranscript;
|
|
27
|
+
export declare function transcriptMarkdown(transcript: SourceTranscript): string;
|
|
28
|
+
/** Acquisition owns media lifetime; transcription never deletes or mutates caller source files. */
|
|
29
|
+
export declare function transcribeMedia(input: TranscriptionInput, options: AsrOptions): Promise<TranscriptionOutcome>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { failure } from '../acquisition/errors.js';
|
|
3
|
+
import { transcribeAudio } from '../inference/asr/runtime.js';
|
|
4
|
+
const timestamp = (s) => { const m = /^(?:(\d{2,}):)?(\d{2}):(\d{2})\.(\d{3})$/.exec(s); if (!m || Number(m[2]) > 59 || Number(m[3]) > 59)
|
|
5
|
+
throw Error('integrity'); const n = ((Number(m[1] ?? 0) * 60 + Number(m[2])) * 60 + Number(m[3])) * 1000 + Number(m[4]); if (n > 86400000)
|
|
6
|
+
throw Error('limit-exceeded'); return n; };
|
|
7
|
+
function plain(text) { return text.replace(/<[^>]*>/g, '').replace(/&(?:amp|lt|gt|quot|apos|nbsp);/g, s => ({ '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ' ': ' ' })[s]).replace(/\s+/g, ' ').trim(); }
|
|
8
|
+
/** Strict, bounded timed WebVTT. Rolling captions only deduplicate already-overlapping text. */
|
|
9
|
+
export function parseWebVtt(captions, requestId) {
|
|
10
|
+
if (Buffer.byteLength(captions.text) > 4 * 1024 ** 2 || createHash('sha256').update(captions.text).digest('hex') !== captions.sha256 || !['manual', 'automatic'].includes(captions.source) || !/^[a-zA-Z]{2,8}(?:-[a-zA-Z0-9]{1,8})*$/.test(captions.language) || !Number.isFinite(captions.durationMs) || captions.durationMs <= 0 || captions.durationMs > 86400000)
|
|
11
|
+
throw Error('integrity');
|
|
12
|
+
const text = captions.text.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
|
|
13
|
+
if (!/^WEBVTT(?:[ \t][^\n]*)?\n/.test(text) || text.includes('\0'))
|
|
14
|
+
throw Error('integrity');
|
|
15
|
+
const segments = [];
|
|
16
|
+
let lastStart = 0;
|
|
17
|
+
let previousCue;
|
|
18
|
+
for (const block of text.split(/\n[ \t]*\n/).slice(1)) {
|
|
19
|
+
const lines = block.trim().split('\n');
|
|
20
|
+
if (!lines[0] || /^(NOTE(?:\s|$)|STYLE$|REGION$)/.test(lines[0]))
|
|
21
|
+
continue;
|
|
22
|
+
const index = lines[0].includes('-->') ? 0 : 1, line = lines[index];
|
|
23
|
+
if (!line)
|
|
24
|
+
throw Error('integrity');
|
|
25
|
+
const match = /^(\S+)\s+-->\s+(\S+)(?:\s+.*)?$/.exec(line);
|
|
26
|
+
if (!match)
|
|
27
|
+
throw Error('integrity');
|
|
28
|
+
const startMs = timestamp(match[1]), endMs = timestamp(match[2]);
|
|
29
|
+
if (startMs < lastStart || endMs <= startMs || endMs > captions.durationMs + 1000)
|
|
30
|
+
throw Error('integrity');
|
|
31
|
+
lastStart = startMs;
|
|
32
|
+
let cue = plain(lines.slice(index + 1).join(' '));
|
|
33
|
+
if (Buffer.byteLength(cue) > 16384)
|
|
34
|
+
throw Error('limit-exceeded');
|
|
35
|
+
if (!cue)
|
|
36
|
+
continue;
|
|
37
|
+
const previous = segments.at(-1), rawCue = cue;
|
|
38
|
+
const prior = previousCue;
|
|
39
|
+
previousCue = { text: rawCue, endMs };
|
|
40
|
+
if (previous && prior && startMs < prior.endMs) {
|
|
41
|
+
const a = prior.text.split(' '), b = cue.split(' ');
|
|
42
|
+
let overlap = 0;
|
|
43
|
+
for (let n = 1; n <= Math.min(a.length, b.length); n++)
|
|
44
|
+
if (a.slice(-n).join(' ') === b.slice(0, n).join(' '))
|
|
45
|
+
overlap = n;
|
|
46
|
+
cue = b.slice(overlap).join(' ');
|
|
47
|
+
if (!cue) {
|
|
48
|
+
previous.endMs = Math.max(previous.endMs, endMs);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
segments.push({ startMs, endMs, text: cue });
|
|
53
|
+
if (segments.length > 30000)
|
|
54
|
+
throw Error('limit-exceeded');
|
|
55
|
+
}
|
|
56
|
+
if (!segments.length)
|
|
57
|
+
throw Error('unsupported');
|
|
58
|
+
return { schemaVersion: 1, requestId, source: captions.source === 'manual' ? 'human-captions' : 'automatic-captions', language: captions.language, segments, cleanup: 'settled', sourceSha256: captions.sha256, timing: 'source-captions', durationMs: captions.durationMs };
|
|
59
|
+
}
|
|
60
|
+
const stamp = (ms) => { const seconds = Math.floor(ms / 1000); return [Math.floor(seconds / 3600), Math.floor(seconds / 60) % 60, seconds % 60].map(n => String(n).padStart(2, '0')).join(':'); };
|
|
61
|
+
export function transcriptMarkdown(transcript) {
|
|
62
|
+
const paragraphs = [];
|
|
63
|
+
for (const segment of transcript.segments) {
|
|
64
|
+
const prior = paragraphs.at(-1);
|
|
65
|
+
if (transcript.source === 'local-asr' && prior && segment.startMs - prior.endMs <= 2000 && segment.endMs - prior.startMs <= 15000 && !/[.!?]["')]*$/.test(prior.text)) {
|
|
66
|
+
prior.text += ' ' + segment.text;
|
|
67
|
+
prior.endMs = segment.endMs;
|
|
68
|
+
}
|
|
69
|
+
else
|
|
70
|
+
paragraphs.push({ ...segment });
|
|
71
|
+
}
|
|
72
|
+
return paragraphs.map(s => `[${stamp(s.startMs)}] ${s.text.replace(/[\\`*_{}\[\]()<>!#|]/g, '\\$&')}`).join('\n\n') + '\n';
|
|
73
|
+
}
|
|
74
|
+
/** Acquisition owns media lifetime; transcription never deletes or mutates caller source files. */
|
|
75
|
+
export async function transcribeMedia(input, options) {
|
|
76
|
+
const failed = (code) => ({ ...failure(input.requestId, code, 'inference'), cleanup: 'settled' });
|
|
77
|
+
if (options.signal?.aborted)
|
|
78
|
+
return failed('cancelled');
|
|
79
|
+
if (input.captions && !input.forceLocal) {
|
|
80
|
+
try {
|
|
81
|
+
const transcript = parseWebVtt(input.captions, input.requestId);
|
|
82
|
+
return { ok: true, transcript, markdown: transcriptMarkdown(transcript), cleanup: 'settled' };
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
if (!(e instanceof Error) || e.message !== 'unsupported' || !input.audio)
|
|
86
|
+
return failed(e instanceof Error && e.message === 'limit-exceeded' ? 'limit-exceeded' : 'integrity');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!input.audio)
|
|
90
|
+
return failed('unsupported');
|
|
91
|
+
const result = await transcribeAudio(input.requestId, input.audio, input.language, options);
|
|
92
|
+
return result.ok ? { ...result, markdown: transcriptMarkdown(result.transcript) } : result;
|
|
93
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { MetadataClaim, MetadataWarning } from '../../contracts/index.js';
|
|
2
|
+
/** Consume bounded observed Wordcell evidence without fetching linked JSON-LD contexts. */
|
|
3
|
+
export declare function articleMetadata(input: unknown, observedAt: string): MetadataClaim[];
|
|
4
|
+
export declare function selectedMetadata(claims: readonly MetadataClaim[], field: string): MetadataClaim | undefined;
|
|
5
|
+
export declare function articleMetadataWarnings(input: unknown): MetadataWarning[];
|