n8n-nodes-blurwerk 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.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # n8n-nodes-blurwerk
2
+
3
+ An [n8n](https://n8n.io) node for [blurwerk](https://blurwerk.de/?ref=n8n):
4
+ anonymize video inside a workflow. Every face in every frame is mosaiced,
5
+ voices can be disguised or muted, and location metadata is removed. You get the
6
+ same file back in the same format. Processing happens on blurwerk's own
7
+ hardware in Germany; the video is never sent to a third-party AI service.
8
+
9
+ ## Install
10
+
11
+ In n8n: **Settings → Community Nodes → Install**, and enter
12
+ `n8n-nodes-blurwerk`.
13
+
14
+ ## Credentials
15
+
16
+ blurwerk sells prepaid credit in whole units of €5. Buy it once at
17
+ [blurwerk.de/credit](https://blurwerk.de/credit?ref=n8n). The page shows an API
18
+ token (`bw_…`) **once**. Paste it into a new **blurwerk API** credential.
19
+
20
+ ## Operations
21
+
22
+ | Operation | What it does |
23
+ |---|---|
24
+ | **Anonymize Video** | Takes a video from a binary field and prices it from its own header, read locally (nothing is uploaded to get a price). Then it pays with credit, uploads the file and, by default, waits and outputs the anonymized video as binary data. |
25
+ | **Get Result** | Returns the state of an order and, once it is done, the video. Use it after an **Anonymize Video** run that did not wait. |
26
+ | **Get Balance** | Returns the remaining credit. |
27
+ | **Report Problem** | Reports that a delivered result is not usable. It is refunded, and the result is deleted. |
28
+
29
+ **Anonymize Video** requires two declarations, both mandatory: that work may
30
+ start at once (§ 356(4) BGB, which ends the 14-day withdrawal right), and that
31
+ the result will be checked before it is published.
32
+
33
+ **Options:**
34
+ - **Audio:** keep, disguise the voice, or mute.
35
+ - **Sensitivity:** how sure the detectors must be before a face is covered.
36
+ - **Mosaic size**
37
+ - **E-mail:** an address to send the download link to.
38
+ - **Language**
39
+
40
+ **File Details** are only needed if the node can't read the file's header
41
+ (MP4, MOV, MKV, WebM, AVI, TS/M2TS, MPEG-PS and FLV all work). blurwerk
42
+ measures the file again when it arrives. If it turns out cheaper, you're
43
+ charged the lower price; if it turns out dearer, the order is refused and
44
+ refunded.
45
+
46
+ ## Timing
47
+
48
+ Processing takes about twice the video's length at 1080p and about eleven
49
+ times at 4K, because every frame is checked at full resolution. For long
50
+ videos, or on n8n Cloud plans with short execution limits, turn off **Wait for
51
+ Result**. Then fetch the video later with **Get Result**, for example on a
52
+ schedule.
53
+
54
+ ## Privacy
55
+
56
+ - The filename never leaves your n8n instance; only the extension is sent.
57
+ - blurwerk deletes the uploaded copy as soon as the job finishes, and deletes
58
+ the result when its download link expires.
59
+ - See [blurwerk.de/datenschutz](https://blurwerk.de/datenschutz).
60
+
61
+ ## Development
62
+
63
+ ```sh
64
+ npm install --ignore-scripts # n8n-workflow's native optional deps are not needed
65
+ npm test # builds, then runs a whole order against a fake server
66
+ ```
67
+
68
+ The header parser in `nodes/Blurwerk/vendor/` is copied from the website's
69
+ `web/static` at build time. The repository's test suite fails if the two
70
+ differ.
71
+
72
+ ## License
73
+
74
+ MIT
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BlurwerkApi = void 0;
4
+ class BlurwerkApi {
5
+ name = 'blurwerkApi';
6
+ displayName = 'blurwerk API';
7
+ documentationUrl = 'https://blurwerk.de/docs';
8
+ properties = [
9
+ {
10
+ displayName: 'API Token',
11
+ name: 'token',
12
+ type: 'string',
13
+ typeOptions: { password: true },
14
+ default: '',
15
+ placeholder: 'bw_...',
16
+ description: 'A prepaid credit token. Buy credit once at https://blurwerk.de/credit — the token is shown once, store it here.',
17
+ },
18
+ {
19
+ displayName: 'Base URL',
20
+ name: 'baseUrl',
21
+ type: 'string',
22
+ default: 'https://blurwerk.de',
23
+ },
24
+ ];
25
+ authenticate = {
26
+ type: 'generic',
27
+ properties: {
28
+ headers: { Authorization: '=Bearer {{$credentials.token}}' },
29
+ },
30
+ };
31
+ test = {
32
+ request: {
33
+ baseURL: '={{$credentials.baseUrl}}',
34
+ url: '/api/credits/balance',
35
+ },
36
+ };
37
+ }
38
+ exports.BlurwerkApi = BlurwerkApi;
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Blurwerk = void 0;
4
+ const n8n_workflow_1 = require("n8n-workflow");
5
+ const client_1 = require("./client");
6
+ const properties_1 = require("./properties");
7
+ // n8n's own request helper, so the instance's proxy and TLS settings apply.
8
+ function httpFor(ctx) {
9
+ return async (req) => {
10
+ const options = {
11
+ method: req.method,
12
+ url: req.url,
13
+ headers: req.headers,
14
+ returnFullResponse: true,
15
+ ignoreHttpStatusErrors: true,
16
+ json: !req.raw && !req.binary,
17
+ };
18
+ if (req.json !== undefined)
19
+ options.body = req.json;
20
+ if (req.raw)
21
+ options.body = req.raw;
22
+ if (req.binary)
23
+ options.encoding = 'arraybuffer';
24
+ const res = (await ctx.helpers.httpRequest(options));
25
+ return { status: res.statusCode, headers: res.headers || {}, body: res.body };
26
+ };
27
+ }
28
+ // A finished job becomes an item carrying the video, when there is one.
29
+ async function finish(ctx, api, jobId, s, i, sourceName) {
30
+ const job = (s.job || {});
31
+ const json = {
32
+ jobId, state: job.state, expiresIn: s.expires_in, expired: !!s.expired,
33
+ voice: job.voice, error: job.error,
34
+ };
35
+ const item = { json, pairedItem: i };
36
+ const url = s.download_url;
37
+ if (url && ctx.getNodeParameter('download', i, true)) {
38
+ const video = await api.download(url);
39
+ const ext = client_1.BlurwerkClient.extension(new URL(url).pathname);
40
+ const name = `${(sourceName || jobId).replace(/\.[^.]+$/, '')}_anonymized${ext}`;
41
+ item.binary = { data: await ctx.helpers.prepareBinaryData(video, name) };
42
+ }
43
+ else if (url) {
44
+ json.downloadUrl = url;
45
+ }
46
+ return item;
47
+ }
48
+ async function anonymize(ctx, api, i) {
49
+ const prop = ctx.getNodeParameter('binaryPropertyName', i);
50
+ const binary = ctx.helpers.assertBinaryData(i, prop);
51
+ const file = await ctx.helpers.getBinaryDataBuffer(i, prop);
52
+ const opts = ctx.getNodeParameter('options', i, {});
53
+ const details = ctx.getNodeParameter('fileDetails', i, {});
54
+ const options = {
55
+ audio: opts.audio || 'keep',
56
+ faces: true,
57
+ sensitivity: opts.sensitivity || 'medium',
58
+ coverage: opts.coverage || 'medium',
59
+ lang: opts.lang || 'en',
60
+ };
61
+ const placed = await api.order({
62
+ file,
63
+ fileName: binary.fileName || 'video.mp4',
64
+ options,
65
+ consentWithdrawal: ctx.getNodeParameter('consentWithdrawal', i),
66
+ consentCheck: ctx.getNodeParameter('consentCheck', i),
67
+ email: opts.email || '',
68
+ stated: {
69
+ duration: details.duration,
70
+ width: details.width,
71
+ height: details.height,
72
+ fps: details.fps,
73
+ },
74
+ });
75
+ const wait = ctx.getNodeParameter('wait', i);
76
+ if (!wait)
77
+ return { json: { ...placed, state: 'uploaded' }, pairedItem: i };
78
+ const done = await api.waitFor(placed.jobId, ctx.getNodeParameter('maxWait', i) * 60_000, ctx.getNodeParameter('pollEvery', i) * 1000);
79
+ if (!done) {
80
+ return { json: { ...placed, state: 'processing',
81
+ note: 'Still processing. Fetch it later with the "Get Result" operation.' }, pairedItem: i };
82
+ }
83
+ const item = await finish(ctx, api, placed.jobId, done, i, binary.fileName);
84
+ item.json = { ...placed, ...item.json };
85
+ return item;
86
+ }
87
+ class Blurwerk {
88
+ description = {
89
+ displayName: 'blurwerk',
90
+ name: 'blurwerk',
91
+ icon: 'file:blurwerk.svg',
92
+ group: ['transform'],
93
+ version: 1,
94
+ subtitle: '={{$parameter["operation"]}}',
95
+ description: 'Anonymize video: every face mosaiced, voices disguised or muted, metadata removed. Processed in Germany.',
96
+ defaults: { name: 'blurwerk' },
97
+ inputs: ['main'],
98
+ outputs: ['main'],
99
+ usableAsTool: true,
100
+ credentials: [{ name: 'blurwerkApi', required: true }],
101
+ properties: properties_1.properties,
102
+ };
103
+ async execute() {
104
+ const items = this.getInputData();
105
+ const out = [];
106
+ const creds = await this.getCredentials('blurwerkApi');
107
+ const api = new client_1.BlurwerkClient(httpFor(this), String(creds.baseUrl || 'https://blurwerk.de'), String(creds.token || ''));
108
+ for (let i = 0; i < items.length; i++) {
109
+ try {
110
+ const op = this.getNodeParameter('operation', i);
111
+ if (op === 'balance') {
112
+ out.push({ json: await api.balance(), pairedItem: i });
113
+ }
114
+ else if (op === 'status') {
115
+ const jobId = this.getNodeParameter('jobId', i);
116
+ out.push(await finish(this, api, jobId, await api.status(jobId), i));
117
+ }
118
+ else if (op === 'problem') {
119
+ out.push({
120
+ json: await api.reportProblem(this.getNodeParameter('jobId', i), this.getNodeParameter('reason', i), this.getNodeParameter('email', i)),
121
+ pairedItem: i,
122
+ });
123
+ }
124
+ else {
125
+ out.push(await anonymize(this, api, i));
126
+ }
127
+ }
128
+ catch (error) {
129
+ if (this.continueOnFail()) {
130
+ out.push({ json: { error: error.message }, pairedItem: i });
131
+ continue;
132
+ }
133
+ if (error instanceof client_1.BlurwerkError && error.status) {
134
+ throw new n8n_workflow_1.NodeApiError(this.getNode(), { message: error.message }, { httpCode: String(error.status), itemIndex: i });
135
+ }
136
+ throw new n8n_workflow_1.NodeOperationError(this.getNode(), error, { itemIndex: i });
137
+ }
138
+ }
139
+ return [out];
140
+ }
141
+ }
142
+ exports.Blurwerk = Blurwerk;
@@ -0,0 +1,11 @@
1
+ {
2
+ "node": "n8n-nodes-blurwerk.blurwerk",
3
+ "nodeVersion": "1.0",
4
+ "codexVersion": "1.0",
5
+ "categories": ["Data & Storage", "Utility"],
6
+ "resources": {
7
+ "credentialDocumentation": [{ "url": "https://blurwerk.de/docs" }],
8
+ "primaryDocumentation": [{ "url": "https://blurwerk.de/docs" }]
9
+ },
10
+ "alias": ["blur", "face", "anonymize", "gdpr", "dsgvo", "pixelate", "redact", "privacy", "video"]
11
+ }
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
2
+ <rect width="32" height="32" rx="8" fill="#12705a"/>
3
+ <rect x="8" y="8" width="7" height="7" fill="#5fe0b4"/><rect x="17" y="8" width="7" height="7" fill="#2fa07c"/>
4
+ <rect x="8" y="17" width="7" height="7" fill="#2fa07c"/><rect x="17" y="17" width="7" height="7" fill="#8ff0cf"/>
5
+ </svg>
@@ -0,0 +1,175 @@
1
+ "use strict";
2
+ /* The whole order, as the API guide (docs/API.md) describes it, with no n8n in it.
3
+
4
+ Kept apart from the node so it can be driven against a fake server in a
5
+ plain `node --test` run: the node only turns n8n parameters into a call
6
+ here and the result back into items.
7
+
8
+ The file is priced from its own header, read locally by the same parser the
9
+ website uses (vendor/probe.js, copied from web/static — a test fails if the
10
+ copies drift). Nothing is uploaded to price a job, and the server measures
11
+ the file again on arrival: cheaper refunds the difference to the token,
12
+ dearer is refused and refunded whole. */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.BlurwerkClient = exports.BlurwerkError = void 0;
15
+ class BlurwerkError extends Error {
16
+ status;
17
+ constructor(message, status = 0) {
18
+ super(message);
19
+ this.status = status;
20
+ }
21
+ }
22
+ exports.BlurwerkError = BlurwerkError;
23
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
24
+ const PROBE = require('./vendor/probe.js');
25
+ class BlurwerkClient {
26
+ http;
27
+ base;
28
+ token;
29
+ sleep;
30
+ constructor(http, base, token = '', sleep = (ms) => new Promise((r) => setTimeout(r, ms))) {
31
+ this.http = http;
32
+ this.base = base;
33
+ this.token = token;
34
+ this.sleep = sleep;
35
+ this.base = base.replace(/\/+$/, '');
36
+ }
37
+ async call(method, path, json, auth = false) {
38
+ const headers = { Accept: 'application/json' };
39
+ if (auth)
40
+ headers.Authorization = `Bearer ${this.token}`;
41
+ const res = await this.http({ method, url: this.base + path, headers, json });
42
+ if (res.status >= 400) {
43
+ const said = res.body && (res.body.error || res.body.detail);
44
+ throw new BlurwerkError(`${method} ${path}: ${res.status} ${said || ''}`.trim(), res.status);
45
+ }
46
+ return res.body;
47
+ }
48
+ /** Duration, size and framerate from the file's own header, or null. */
49
+ static async probe(file) {
50
+ // A cast, not a copy: Node's Blob takes the Buffer as-is, and copying a
51
+ // multi-gigabyte video to satisfy a type would double the memory.
52
+ const blob = new Blob([file]);
53
+ try {
54
+ return await PROBE.read(blob);
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ static extension(name) {
61
+ const m = /\.[A-Za-z0-9]{1,5}$/.exec(name || '');
62
+ return m ? m[0].toLowerCase() : '.mp4';
63
+ }
64
+ async quote(meta, options, ext, size, stated) {
65
+ // The extension only — a filename is the customer's word for their own
66
+ // footage and is never sent (docs/API.md § 2).
67
+ return this.call('POST', '/api/quote', {
68
+ ext,
69
+ duration_s: meta.duration,
70
+ width: meta.width,
71
+ height: meta.height,
72
+ fps: meta.fps,
73
+ size_bytes: size,
74
+ stated,
75
+ ...options,
76
+ plates: false,
77
+ });
78
+ }
79
+ async balance() {
80
+ return this.call('GET', '/api/credits/balance', undefined, true);
81
+ }
82
+ async status(jobId) {
83
+ return this.call('GET', `/api/job/${encodeURIComponent(jobId)}`);
84
+ }
85
+ async reportProblem(jobId, reason, email) {
86
+ return this.call('POST', `/api/job/${encodeURIComponent(jobId)}/problem`, { reason, email });
87
+ }
88
+ /** Measure, quote, pay with credit and upload. Returns the paid job. */
89
+ async order(input) {
90
+ if (!input.consentWithdrawal || !input.consentCheck) {
91
+ throw new BlurwerkError('Both declarations are required: that work may start at once (§ 356(4) BGB) ' +
92
+ 'and that the result will be checked before it is published.');
93
+ }
94
+ if (!this.token)
95
+ throw new BlurwerkError('No API token: buy credit at https://blurwerk.de/credit');
96
+ const read = await BlurwerkClient.probe(input.file);
97
+ const stated = input.stated || {};
98
+ const meta = read || {
99
+ duration: Number(stated.duration),
100
+ width: Number(stated.width),
101
+ height: Number(stated.height),
102
+ fps: Number(stated.fps) || 25,
103
+ };
104
+ if (!(meta.duration > 0 && meta.width > 0 && meta.height > 0)) {
105
+ throw new BlurwerkError('Could not read duration and size from this file. Set them under ' +
106
+ '"File Details" — the server measures the file again and refunds any difference.');
107
+ }
108
+ const q = await this.quote(meta, input.options, BlurwerkClient.extension(input.fileName), input.file.length, !read);
109
+ const jobId = q.job.id;
110
+ const paid = await this.call('POST', `/api/job/${jobId}/checkout`, {
111
+ provider: 'credits',
112
+ withdrawal_consent: true,
113
+ check_before_publish: true,
114
+ lang: input.options.lang,
115
+ email: input.email || '',
116
+ }, true);
117
+ await this.upload(jobId, input.file);
118
+ return { jobId, quote: q.job.quote, charged: paid.charged, balance: paid.balance, measured: !!read };
119
+ }
120
+ /** Multipart, straight to object storage (docs/API.md § 4). */
121
+ async upload(jobId, file) {
122
+ const start = await this.call('POST', `/api/job/${jobId}/upload/start`);
123
+ const size = start.part_size;
124
+ const count = Math.max(1, Math.ceil(file.length / size));
125
+ if (count > start.max_parts)
126
+ throw new BlurwerkError('file is too large for one order');
127
+ const etags = [];
128
+ for (let first = 1; first <= count; first += 10) {
129
+ const numbers = Array.from({ length: Math.min(10, count - first + 1) }, (_, i) => first + i);
130
+ const signed = await this.call('POST', `/api/job/${jobId}/upload/sign`, {
131
+ upload_id: start.upload_id,
132
+ parts: numbers,
133
+ });
134
+ for (const n of numbers) {
135
+ const chunk = file.subarray((n - 1) * size, n * size);
136
+ etags.push({ PartNumber: n, ETag: await this.putPart(signed.parts[String(n)], chunk) });
137
+ }
138
+ }
139
+ return this.call('POST', `/api/job/${jobId}/upload/complete`, {
140
+ upload_id: start.upload_id,
141
+ parts: etags,
142
+ });
143
+ }
144
+ async putPart(url, chunk, tries = 3) {
145
+ for (let attempt = 1;; attempt++) {
146
+ const res = await this.http({ method: 'PUT', url, raw: chunk });
147
+ const etag = res.headers.etag || res.headers.ETag;
148
+ if (res.status < 300 && etag)
149
+ return etag;
150
+ if (attempt >= tries)
151
+ throw new BlurwerkError(`part upload failed (${res.status})`, res.status);
152
+ await this.sleep(1000 * attempt);
153
+ }
154
+ }
155
+ /** Poll until done or failed; null if `maxWaitMs` passes first. */
156
+ async waitFor(jobId, maxWaitMs, everyMs) {
157
+ const until = Date.now() + maxWaitMs;
158
+ for (;;) {
159
+ const s = await this.status(jobId);
160
+ const state = s.job && s.job.state;
161
+ if (state === 'done' || state === 'failed' || s.expired)
162
+ return s;
163
+ if (Date.now() + everyMs > until)
164
+ return null;
165
+ await this.sleep(everyMs);
166
+ }
167
+ }
168
+ async download(url) {
169
+ const res = await this.http({ method: 'GET', url, binary: true });
170
+ if (res.status >= 400)
171
+ throw new BlurwerkError(`download failed (${res.status})`, res.status);
172
+ return Buffer.from(res.body);
173
+ }
174
+ }
175
+ exports.BlurwerkClient = BlurwerkClient;
@@ -0,0 +1,185 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.properties = void 0;
4
+ const on = (...operation) => ({ displayOptions: { show: { operation } } });
5
+ exports.properties = [
6
+ {
7
+ displayName: 'Operation',
8
+ name: 'operation',
9
+ type: 'options',
10
+ noDataExpression: true,
11
+ default: 'anonymize',
12
+ options: [
13
+ { name: 'Anonymize Video', value: 'anonymize', action: 'Anonymize a video',
14
+ description: 'Mosaic every face, optionally disguise or mute voices, and return the file' },
15
+ { name: 'Get Result', value: 'status', action: 'Get the result of a job',
16
+ description: 'State of an order, and the anonymized video once it is done' },
17
+ { name: 'Get Balance', value: 'balance', action: 'Get the credit balance' },
18
+ { name: 'Report Problem', value: 'problem', action: 'Report a problem and get a refund',
19
+ description: 'A delivered result that is not usable is refunded and deleted' },
20
+ ],
21
+ },
22
+ // --- anonymize -----------------------------------------------------------
23
+ {
24
+ displayName: 'Input Binary Field',
25
+ name: 'binaryPropertyName',
26
+ type: 'string',
27
+ default: 'data',
28
+ required: true,
29
+ hint: 'The video to anonymize. Its filename is never sent — only the extension.',
30
+ ...on('anonymize'),
31
+ },
32
+ {
33
+ displayName: 'Work May Start Immediately',
34
+ name: 'consentWithdrawal',
35
+ type: 'boolean',
36
+ default: false,
37
+ required: true,
38
+ description: 'Whether the person you act for asked for work to begin at once and accepts losing the 14-day right of withdrawal (§ 356(4) BGB). Required.',
39
+ ...on('anonymize'),
40
+ },
41
+ {
42
+ displayName: 'Result Will Be Checked Before Publishing',
43
+ name: 'consentCheck',
44
+ type: 'boolean',
45
+ default: false,
46
+ required: true,
47
+ description: 'Whether the result will be looked at before it is published. Detection is automatic and no rate is guaranteed; an unusable result is refunded. Required.',
48
+ ...on('anonymize'),
49
+ },
50
+ {
51
+ displayName: 'Wait for Result',
52
+ name: 'wait',
53
+ type: 'boolean',
54
+ default: true,
55
+ description: 'Whether to wait until the video is done and output it. Processing takes about twice the video length at 1080p and about eleven times at 4K.',
56
+ ...on('anonymize'),
57
+ },
58
+ {
59
+ displayName: 'Max Wait (Minutes)',
60
+ name: 'maxWait',
61
+ type: 'number',
62
+ default: 120,
63
+ typeOptions: { minValue: 1 },
64
+ displayOptions: { show: { operation: ['anonymize'], wait: [true] } },
65
+ },
66
+ {
67
+ displayName: 'Poll Every (Seconds)',
68
+ name: 'pollEvery',
69
+ type: 'number',
70
+ default: 30,
71
+ typeOptions: { minValue: 5 },
72
+ displayOptions: { show: { operation: ['anonymize'], wait: [true] } },
73
+ },
74
+ {
75
+ displayName: 'Options',
76
+ name: 'options',
77
+ type: 'collection',
78
+ placeholder: 'Add Option',
79
+ default: {},
80
+ ...on('anonymize'),
81
+ options: [
82
+ {
83
+ displayName: 'Audio',
84
+ name: 'audio',
85
+ type: 'options',
86
+ default: 'keep',
87
+ options: [
88
+ { name: 'Keep Original', value: 'keep', description: 'Speakers stay recognisable. Free.' },
89
+ { name: 'Disguise Voice', value: 'robot',
90
+ description: 'Pitch-shifted: words stay clear, the speaker does not. Can add units.' },
91
+ { name: 'Mute', value: 'mute', description: 'Removes the audio track. Free.' },
92
+ ],
93
+ },
94
+ {
95
+ displayName: 'Sensitivity',
96
+ name: 'sensitivity',
97
+ type: 'options',
98
+ default: 'medium',
99
+ options: [
100
+ { name: 'Confirmed Only', value: 'low', description: 'Two of three detectors must agree' },
101
+ { name: 'Balanced', value: 'medium', description: 'Two agree, or one is sure' },
102
+ { name: 'Everything', value: 'high', description: 'Any detector that sees a face' },
103
+ ],
104
+ },
105
+ {
106
+ displayName: 'Mosaic Size',
107
+ name: 'coverage',
108
+ type: 'options',
109
+ default: 'medium',
110
+ options: [
111
+ { name: 'Tight', value: 'small' },
112
+ { name: 'Normal', value: 'medium' },
113
+ { name: 'Generous', value: 'large' },
114
+ ],
115
+ },
116
+ {
117
+ displayName: 'E-Mail for the Download Link',
118
+ name: 'email',
119
+ type: 'string',
120
+ placeholder: 'name@email.com',
121
+ default: '',
122
+ },
123
+ {
124
+ displayName: 'Language',
125
+ name: 'lang',
126
+ type: 'options',
127
+ default: 'en',
128
+ options: [
129
+ { name: 'English', value: 'en' },
130
+ { name: 'German', value: 'de' },
131
+ ],
132
+ },
133
+ ],
134
+ },
135
+ {
136
+ displayName: 'File Details',
137
+ name: 'fileDetails',
138
+ type: 'collection',
139
+ placeholder: 'Add Detail',
140
+ default: {},
141
+ description: "Only needed when the file's header cannot be read. The server measures the file again: cheaper is refunded, dearer is refused and refunded.",
142
+ ...on('anonymize'),
143
+ options: [
144
+ { displayName: 'Duration (Seconds)', name: 'duration', type: 'number', default: 0 },
145
+ { displayName: 'Width (Px)', name: 'width', type: 'number', default: 0 },
146
+ { displayName: 'Height (Px)', name: 'height', type: 'number', default: 0 },
147
+ { displayName: 'Frames per Second', name: 'fps', type: 'number', default: 25 },
148
+ ],
149
+ },
150
+ // --- result / problem ----------------------------------------------------
151
+ {
152
+ displayName: 'Job ID',
153
+ name: 'jobId',
154
+ type: 'string',
155
+ default: '',
156
+ required: true,
157
+ ...on('status', 'problem'),
158
+ },
159
+ {
160
+ displayName: 'Download Video',
161
+ name: 'download',
162
+ type: 'boolean',
163
+ default: true,
164
+ description: 'Whether to output the finished video as binary data, rather than only its link',
165
+ displayOptions: { show: { operation: ['status', 'anonymize'] } },
166
+ },
167
+ {
168
+ displayName: 'Reason',
169
+ name: 'reason',
170
+ type: 'string',
171
+ typeOptions: { rows: 3 },
172
+ default: '',
173
+ required: true,
174
+ ...on('problem'),
175
+ },
176
+ {
177
+ displayName: 'E-Mail on the Order',
178
+ name: 'email',
179
+ type: 'string',
180
+ placeholder: 'name@email.com',
181
+ default: '',
182
+ required: true,
183
+ ...on('problem'),
184
+ },
185
+ ];
@@ -0,0 +1,246 @@
1
+ /* Reading camcorder and broadcast streams in the browser: MPEG-TS (.ts, .m2ts,
2
+ .mts), MPEG program streams (.mpg, .vob) and FLV.
3
+
4
+ These used to be named and refused, and priced by hand through a form. They
5
+ are what AVCHD camcorders, dashcams and broadcast recorders write, so the
6
+ customers most likely to need faces removed were the ones typing numbers.
7
+
8
+ None of them keeps a tidy header with the three figures a price needs, so
9
+ they are measured the way ffprobe does: the length from the first and last
10
+ presentation timestamps, the framerate from the spacing between frames, and
11
+ the picture size from the codec's own sequence header (H.264 SPS, MPEG-2
12
+ sequence header). Nothing is uploaded; the head and tail of the file are
13
+ read here. Anything not understood returns null, and the page falls back to
14
+ asking — the server measures the real file on arrival either way.
15
+ */
16
+ "use strict";
17
+
18
+ const STREAM = (() => {
19
+ const EDGE = 2 << 20; // bytes read at each end
20
+ const TS_VIDEO = { 0x1B: "h264", 0x02: "mpeg2video", 0x01: "mpeg1video" };
21
+ const FPS_CODES = [0, 24000 / 1001, 24, 25, 30000 / 1001, 30, 50, 60000 / 1001, 60];
22
+
23
+ const slice = (file, a, b) => file.slice(Math.max(0, a), Math.min(b, file.size))
24
+ .arrayBuffer().then(x => new Uint8Array(x));
25
+
26
+ /* --- bit reading for codec headers -------------------------------------- */
27
+ function bits(bytes) {
28
+ const rbsp = []; // drop emulation-prevention 0x03
29
+ for (let i = 0; i < bytes.length; i++) {
30
+ if (i > 1 && bytes[i] === 3 && bytes[i - 1] === 0 && bytes[i - 2] === 0) continue;
31
+ rbsp.push(bytes[i]);
32
+ }
33
+ let pos = 0;
34
+ const u = n => { let v = 0; for (let i = 0; i < n; i++, pos++)
35
+ v = v * 2 + ((rbsp[pos >> 3] >> (7 - (pos & 7))) & 1); return v; };
36
+ const ue = () => { let z = 0; while (u(1) === 0) { if (++z > 31) throw 0; }
37
+ return (2 ** z) - 1 + u(z); };
38
+ const se = () => { const k = ue(); return k & 1 ? (k + 1) / 2 : -k / 2; };
39
+ return { u, ue, se };
40
+ }
41
+
42
+ function h264Size(nal) { // nal starts after the 1-byte header
43
+ const b = bits(nal);
44
+ const profile = b.u(8); b.u(16); b.ue();
45
+ let chroma = 1, separate = 0;
46
+ if ([100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 139, 134, 135].includes(profile)) {
47
+ chroma = b.ue(); if (chroma === 3) separate = b.u(1);
48
+ b.ue(); b.ue(); b.u(1);
49
+ if (b.u(1)) for (let i = 0; i < (chroma === 3 ? 12 : 8); i++) if (b.u(1)) {
50
+ let last = 8, next = 8;
51
+ for (let j = 0; j < (i < 6 ? 16 : 64); j++) {
52
+ if (next !== 0) next = (last + b.se() + 256) % 256;
53
+ last = next === 0 ? last : next;
54
+ }
55
+ }
56
+ }
57
+ b.ue();
58
+ const poc = b.ue();
59
+ if (poc === 0) b.ue();
60
+ else if (poc === 1) { b.u(1); b.se(); b.se(); const n = b.ue(); for (let i = 0; i < n; i++) b.se(); }
61
+ b.ue(); b.u(1);
62
+ const wMbs = b.ue() + 1, hMaps = b.ue() + 1, frameOnly = b.u(1);
63
+ if (!frameOnly) b.u(1);
64
+ b.u(1);
65
+ let cl = 0, cr = 0, ct = 0, cb = 0;
66
+ if (b.u(1)) { cl = b.ue(); cr = b.ue(); ct = b.ue(); cb = b.ue(); }
67
+ const cx = separate || chroma === 0 ? 1 : (chroma === 3 ? 1 : 2);
68
+ const cy = (separate || chroma === 0 ? 1 : (chroma === 1 ? 2 : 1)) * (2 - frameOnly);
69
+ return { width: wMbs * 16 - cx * (cl + cr),
70
+ height: (2 - frameOnly) * hMaps * 16 - cy * (ct + cb) };
71
+ }
72
+
73
+ // The first sequence header in a run of elementary-stream bytes.
74
+ function esSize(es, codec) {
75
+ for (let i = 0; i + 8 < es.length; i++) {
76
+ if (es[i] !== 0 || es[i + 1] !== 0 || es[i + 2] !== 1) continue;
77
+ if (codec === "h264" && (es[i + 3] & 0x1F) === 7) {
78
+ try { return h264Size(es.subarray(i + 4, i + 4 + 256)); } catch (e) { return null; }
79
+ }
80
+ if (codec !== "h264" && es[i + 3] === 0xB3) {
81
+ return { width: (es[i + 4] << 4) | (es[i + 5] >> 4),
82
+ height: ((es[i + 5] & 0x0F) << 8) | es[i + 6],
83
+ fps: FPS_CODES[es[i + 7] & 0x0F] || 0 };
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+
89
+ function pts(p, off) { // PES header at off; 33-bit PTS or null
90
+ if (p[off] !== 0 || p[off + 1] !== 0 || p[off + 2] !== 1 || !(p[off + 7] & 0x80)) return null;
91
+ const q = off + 9;
92
+ return ((p[q] & 0x0E) * 2 ** 29) + (p[q + 1] << 22) + ((p[q + 2] & 0xFE) << 14)
93
+ + (p[q + 3] << 7) + (p[q + 4] >> 1);
94
+ }
95
+
96
+ // Frame spacing from a set of timestamps, which arrive in DECODE order, so
97
+ // sort them first. The smallest step is one frame.
98
+ function fpsFrom(stamps) {
99
+ const s = [...new Set(stamps)].sort((a, b) => a - b);
100
+ let step = Infinity;
101
+ for (let i = 1; i < s.length; i++) if (s[i] - s[i - 1] > 0) step = Math.min(step, s[i] - s[i - 1]);
102
+ return isFinite(step) ? 90000 / step : 0;
103
+ }
104
+
105
+ const finish = (m, container) => m && m.width > 15 && m.height > 15 && m.duration > 0.04
106
+ && m.fps > 0.5 && m.fps < 1000 ? Object.assign(m, { container }) : null;
107
+
108
+ /* --- MPEG transport stream ------------------------------------------------ */
109
+ function tsPackets(bytes, size) {
110
+ let start = 0;
111
+ while (start < size * 2 && !(bytes[start] === 0x47 && bytes[start + size] === 0x47)) start++;
112
+ const out = [];
113
+ // `start` is the sync byte itself; in M2TS it sits 4 bytes into each unit.
114
+ for (let i = start; i + 188 <= bytes.length; i += size) {
115
+ const p = bytes.subarray(i, i + 188);
116
+ if (p[0] !== 0x47) continue;
117
+ const pid = ((p[1] & 0x1F) << 8) | p[2], unit = !!(p[1] & 0x40), afc = (p[3] >> 4) & 3;
118
+ let off = 4; if (afc & 2) off += 1 + p[4];
119
+ if (afc & 1 && off < 188) out.push({ pid, unit, data: p.subarray(off) });
120
+ }
121
+ return out;
122
+ }
123
+
124
+ function tsVideo(packets) { // PAT -> PMT -> first video stream
125
+ const pat = packets.find(k => k.pid === 0 && k.unit);
126
+ if (!pat) return null;
127
+ const d = pat.data.subarray(1 + pat.data[0]);
128
+ const pmtPid = ((d[10] & 0x1F) << 8) | d[11];
129
+ const pmt = packets.find(k => k.pid === pmtPid && k.unit);
130
+ if (!pmt) return null;
131
+ const m = pmt.data.subarray(1 + pmt.data[0]);
132
+ const end = 3 + (((m[1] & 0x0F) << 8) | m[2]) - 4;
133
+ for (let i = 12 + (((m[10] & 0x0F) << 8) | m[11]); i + 5 <= end;) {
134
+ const type = m[i], pid = ((m[i + 1] & 0x1F) << 8) | m[i + 2];
135
+ if (TS_VIDEO[type]) return { pid, codec: TS_VIDEO[type] };
136
+ i += 5 + (((m[i + 3] & 0x0F) << 8) | m[i + 4]);
137
+ }
138
+ return null;
139
+ }
140
+
141
+ async function readTS(file, size) {
142
+ const head = tsPackets(await slice(file, 0, EDGE), size);
143
+ const video = tsVideo(head);
144
+ if (!video) return null;
145
+ const mine = head.filter(k => k.pid === video.pid);
146
+ const stamps = mine.filter(k => k.unit).map(k => pts(k.data, 0)).filter(x => x !== null);
147
+ const tail = tsPackets(await slice(file, file.size - EDGE, file.size), size)
148
+ .filter(k => k.pid === video.pid && k.unit).map(k => pts(k.data, 0)).filter(x => x !== null);
149
+ if (!stamps.length || !tail.length) return null;
150
+ const shape = esSize(Uint8Array.from(mine.flatMap(k => [...k.data])), video.codec);
151
+ if (!shape) return null;
152
+ const fps = fpsFrom(stamps) || shape.fps;
153
+ let span = Math.max(...tail) - Math.min(...stamps);
154
+ if (span < 0) span += 2 ** 33; // the 33-bit clock wrapped
155
+ return finish({ width: shape.width, height: shape.height, fps, codec: video.codec,
156
+ duration: span / 90000 + (fps ? 1 / fps : 0) },
157
+ size === 192 ? "MPEG-TS (M2TS)" : "MPEG-TS");
158
+ }
159
+
160
+ /* --- MPEG program stream (.mpg, .vob) ------------------------------------ */
161
+ function psStamps(b) {
162
+ const out = [];
163
+ for (let i = 0; i + 14 < b.length; i++)
164
+ if (b[i] === 0 && b[i + 1] === 0 && b[i + 2] === 1 && (b[i + 3] & 0xF0) === 0xE0) {
165
+ const t = pts(b, i); if (t !== null) out.push(t);
166
+ }
167
+ return out;
168
+ }
169
+
170
+ async function readPS(file) {
171
+ const head = await slice(file, 0, EDGE), tail = await slice(file, file.size - EDGE, file.size);
172
+ const shape = esSize(head, "mpeg2video");
173
+ const a = psStamps(head), z = psStamps(tail);
174
+ if (!shape || !a.length || !z.length) return null;
175
+ const fps = shape.fps || fpsFrom(a);
176
+ return finish({ width: shape.width, height: shape.height, fps, codec: "mpeg2video",
177
+ duration: (Math.max(...z) - Math.min(...a)) / 90000 + 1 / fps },
178
+ "MPEG program stream");
179
+ }
180
+
181
+ /* --- FLV ---------------------------------------------------------------------- */
182
+ const FLV_CODEC = { 2: "flv1", 3: "screen", 4: "vp6", 5: "vp6a", 7: "h264", 12: "hevc" };
183
+
184
+ function amf(v, o) { // one AMF0 value at o -> [value, next]
185
+ const t = v[o++], dv = new DataView(v.buffer, v.byteOffset);
186
+ const str = (at, n) => [new TextDecoder().decode(v.subarray(at, at + n)), at + n];
187
+ if (t === 0) return [dv.getFloat64(o), o + 8];
188
+ if (t === 1) return [!!v[o], o + 1];
189
+ if (t === 2) return str(o + 2, dv.getUint16(o));
190
+ if (t === 3 || t === 8) {
191
+ const out = {}; if (t === 8) o += 4;
192
+ for (let guard = 0; guard < 512; guard++) {
193
+ const n = dv.getUint16(o); if (n === 0 && v[o + 2] === 9) return [out, o + 3];
194
+ const [k, at] = str(o + 2, n); const [val, next] = amf(v, at); out[k] = val; o = next;
195
+ }
196
+ return [out, o];
197
+ }
198
+ if (t === 5 || t === 6) return [null, o];
199
+ if (t === 10) { const n = dv.getUint32(o); o += 4; const a = [];
200
+ for (let i = 0; i < n && i < 4096; i++) { const [x, next] = amf(v, o); a.push(x); o = next; }
201
+ return [a, o]; }
202
+ if (t === 11) return [dv.getFloat64(o), o + 10];
203
+ throw new Error("amf");
204
+ }
205
+
206
+ async function readFLV(file) {
207
+ const v = await slice(file, 0, EDGE), dv = new DataView(v.buffer);
208
+ let o = dv.getUint32(5) + 4, meta = {}, stamps = [], codec = "";
209
+ while (o + 11 < v.length && stamps.length < 60) {
210
+ const type = v[o], size = (v[o + 1] << 16) | (v[o + 2] << 8) | v[o + 3];
211
+ const ts = ((v[o + 7] << 24) | (v[o + 4] << 16) | (v[o + 5] << 8) | v[o + 6]) >>> 0;
212
+ if (type === 18) {
213
+ try { const [name, at] = amf(v, o + 11); if (name === "onMetaData") meta = amf(v, at)[0] || {}; }
214
+ catch (e) { /* metadata is optional */ }
215
+ } else if (type === 9) { stamps.push(ts * 90); codec = codec || FLV_CODEC[v[o + 11] & 0x0F] || ""; }
216
+ o += 11 + size + 4;
217
+ }
218
+ let duration = meta.duration;
219
+ if (!(duration > 0)) { // streamed: no duration written
220
+ const t = await slice(file, file.size - 4, file.size);
221
+ const last = new DataView(t.buffer).getUint32(0);
222
+ const tag = await slice(file, file.size - 4 - last, file.size - 4 - last + 8);
223
+ duration = (((tag[7] << 24) | (tag[4] << 16) | (tag[5] << 8) | tag[6]) >>> 0) / 1000;
224
+ }
225
+ const fps = meta.framerate || fpsFrom(stamps);
226
+ return finish({ width: meta.width, height: meta.height, fps, duration,
227
+ codec: codec || String(meta.videocodecid || "") }, "FLV");
228
+ }
229
+
230
+ /* --- dispatch ------------------------------------------------------------------ */
231
+ async function read(file) {
232
+ const h = await slice(file, 0, 400);
233
+ if (h.length < 16) return null;
234
+ try {
235
+ if (h[0] === 0x46 && h[1] === 0x4C && h[2] === 0x56) return await readFLV(file);
236
+ if (h[0] === 0x47 && h[188] === 0x47) return await readTS(file, 188);
237
+ if (h[4] === 0x47 && h[196] === 0x47) return await readTS(file, 192);
238
+ if (h[0] === 0 && h[1] === 0 && h[2] === 1 && h[3] === 0xBA) return await readPS(file);
239
+ } catch (e) { return null; }
240
+ return null;
241
+ }
242
+
243
+ return { read, h264Size, fpsFrom };
244
+ })();
245
+
246
+ if (typeof module !== "undefined") module.exports = STREAM; // tests
@@ -0,0 +1,284 @@
1
+ /* Container header parsing, in the browser.
2
+
3
+ The browser can only tell us a video's dimensions if it can DECODE it, and
4
+ it refuses most of what people actually have: Matroska in any form, HEVC,
5
+ AVI, ProRes. Those files were being rejected at the door with "is it a
6
+ video?" while the FAQ promised we accept them.
7
+
8
+ Reading the header ourselves fixes that without breaking the promise that
9
+ nothing is uploaded to price a job — we parse the same fields ffprobe would,
10
+ from a slice of the file, on the customer's own machine.
11
+
12
+ Every parser returns null rather than a guess. A wrong duration is a wrong
13
+ price, and a wrong price is money. */
14
+ "use strict";
15
+
16
+ const CONTAINER = (() => {
17
+
18
+ const buf = (file, start, end) =>
19
+ file.slice(start, Math.min(end, file.size)).arrayBuffer().then(b => new DataView(b));
20
+ const ascii = (v, off, n) => {
21
+ let s = "";
22
+ for (let i = 0; i < n; i++) s += String.fromCharCode(v.getUint8(off + i));
23
+ return s;
24
+ };
25
+ // Plausibility, applied to every parser's output. Nonsense is worse than a
26
+ // failure: a failure asks the customer, nonsense quietly misprices the job.
27
+ const sane = m =>
28
+ m && m.width > 15 && m.height > 15 && m.width < 33000 && m.height < 33000
29
+ && m.duration > 0.04 && m.duration < 86400 * 2
30
+ && m.fps > 0.5 && m.fps < 1000 ? m : null;
31
+
32
+ /* --- Matroska / WebM (EBML) --------------------------------------------- */
33
+ // Elements are id + size + payload, both written as variable-length ints.
34
+ // Ids keep their marker bits (that is how the spec writes them); sizes have
35
+ // theirs stripped.
36
+
37
+ const HEAD_BYTES = 4 << 20; // Info and Tracks precede the first Cluster
38
+
39
+ function vint(v, off, keepMarker) {
40
+ const first = v.getUint8(off);
41
+ if (first === 0) return null; // reserved / corrupt
42
+ let len = 1;
43
+ for (let mask = 0x80; !(first & mask); mask >>= 1) len++;
44
+ let value = keepMarker ? first : first & (0xff >> len);
45
+ for (let i = 1; i < len; i++) value = value * 256 + v.getUint8(off + i);
46
+ return { value, len };
47
+ }
48
+
49
+ const uint = (v, off, n) => {
50
+ let x = 0;
51
+ for (let i = 0; i < n; i++) x = x * 256 + v.getUint8(off + i);
52
+ return x;
53
+ };
54
+
55
+ // Walk one level of children, calling visit(id, payloadOffset, size). Return
56
+ // true from visit to descend into that element instead of skipping it.
57
+ //
58
+ // Depth is capped because a file can nest as deeply as it likes: Segment
59
+ // inside Segment inside Segment costs nothing to write and would recurse
60
+ // until the tab's stack gave out. Real files nest four levels.
61
+ const MAX_DEPTH = 8;
62
+
63
+ function ebmlWalk(v, off, end, visit, depth) {
64
+ if ((depth || 0) > MAX_DEPTH) return;
65
+ while (off < end) {
66
+ const id = vint(v, off, true);
67
+ if (!id) return;
68
+ const sz = vint(v, off + id.len, false);
69
+ if (!sz) return;
70
+ const start = off + id.len + sz.len;
71
+ // A size of all ones means "unknown" — descend rather than skip.
72
+ const unknown = sz.value >= Math.pow(2, 7 * sz.len) - 1;
73
+ const stop = unknown ? end : Math.min(end, start + sz.value);
74
+ if (visit(id.value, start, stop) === true)
75
+ ebmlWalk(v, start, stop, visit, (depth || 0) + 1);
76
+ if (unknown) return;
77
+ off = stop;
78
+ if (stop <= start && sz.value !== 0) return; // no forward progress
79
+ }
80
+ }
81
+
82
+ const text = (v, off, n) => {
83
+ let s = "";
84
+ for (let i = 0; i < n; i++) {
85
+ const c = v.getUint8(off + i);
86
+ if (c === 0) break;
87
+ s += String.fromCharCode(c);
88
+ }
89
+ return s;
90
+ };
91
+
92
+ function parseEBML(v, raw) {
93
+ let scale = 1e6, ticks = 0, w = 0, h = 0, frameNs = 0, codec = "";
94
+ let type = 0, trackW = 0, trackH = 0, trackNs = 0, trackCodec = "";
95
+ ebmlWalk(v, 0, v.byteLength, (id, at, to) => {
96
+ const n = to - at;
97
+ switch (id) {
98
+ case 0x18538067: case 0x1549A966: case 0x1654AE6B: return true; // Segment/Info/Tracks
99
+ case 0x2AD7B1: scale = uint(v, at, n) || scale; return; // TimestampScale
100
+ case 0x4489: // Duration (float)
101
+ ticks = n === 4 ? v.getFloat32(at) : n === 8 ? v.getFloat64(at) : ticks; return;
102
+ case 0xAE: // TrackEntry
103
+ type = trackW = trackH = trackNs = 0; trackCodec = "";
104
+ ebmlWalk(v, at, to, (tid, tat, tto) => {
105
+ const tn = tto - tat;
106
+ if (tid === 0x83) type = uint(v, tat, tn); // TrackType
107
+ else if (tid === 0x86) trackCodec = text(v, tat, tn); // CodecID
108
+ else if (tid === 0x23E383) trackNs = uint(v, tat, tn); // DefaultDuration
109
+ else if (tid === 0xE0) return true; // Video
110
+ else if (tid === 0xB0) trackW = uint(v, tat, tn); // PixelWidth
111
+ else if (tid === 0xBA) trackH = uint(v, tat, tn); // PixelHeight
112
+ });
113
+ if (type === 1 && !w) {
114
+ w = trackW; h = trackH; frameNs = trackNs; codec = trackCodec;
115
+ }
116
+ return;
117
+ }
118
+ });
119
+ const out = { width: w, height: h, duration: ticks * scale / 1e9,
120
+ fps: frameNs ? 1e9 / frameNs : 0,
121
+ container: "Matroska/WebM", codec };
122
+ return raw ? out : sane(out);
123
+ }
124
+
125
+ /* --- MP4 / MOV (ISO base media) ----------------------------------------- */
126
+ // moov sits at either end of the file, so top-level boxes are walked by
127
+ // reading 16-byte headers and seeking — never by reading the whole file.
128
+
129
+ async function findMoov(file) {
130
+ let off = 0;
131
+ while (off + 8 <= file.size) {
132
+ const v = await buf(file, off, off + 16);
133
+ if (v.byteLength < 8) return null;
134
+ let size = v.getUint32(0), head = 8;
135
+ const type = ascii(v, 4, 4);
136
+ if (size === 1) {
137
+ if (v.byteLength < 16) return null;
138
+ size = v.getUint32(8) * 4294967296 + v.getUint32(12);
139
+ head = 16;
140
+ } else if (size === 0) size = file.size - off;
141
+ if (type === "moov") return { start: off + head, end: off + size };
142
+ if (size < 8) return null;
143
+ off += size;
144
+ }
145
+ return null;
146
+ }
147
+
148
+ // Children of one box, as {type, at, to} — boxes here are small enough that
149
+ // the whole moov is already in memory.
150
+ function boxes(v, off, end) {
151
+ const out = [];
152
+ while (off + 8 <= end) {
153
+ let size = v.getUint32(off), head = 8;
154
+ const type = ascii(v, off + 4, 4);
155
+ if (size === 1) { size = v.getUint32(off + 8) * 4294967296 + v.getUint32(off + 12); head = 16; }
156
+ else if (size === 0) size = end - off;
157
+ if (size < head) return out;
158
+ out.push({ type, at: off + head, to: Math.min(end, off + size) });
159
+ off += size;
160
+ }
161
+ return out;
162
+ }
163
+
164
+ const find = (v, list, type) => list.find(b => b.type === type);
165
+
166
+ function parseMoov(v, end, raw) {
167
+ const moov = boxes(v, 0, end);
168
+ for (const trak of moov.filter(b => b.type === "trak")) {
169
+ const t = boxes(v, trak.at, trak.to);
170
+ const mdia = find(v, t, "mdia");
171
+ if (!mdia) continue;
172
+ const md = boxes(v, mdia.at, mdia.to);
173
+ const hdlr = find(v, md, "hdlr");
174
+ if (!hdlr || ascii(v, hdlr.at + 8, 4) !== "vide") continue;
175
+
176
+ const mdhd = find(v, md, "mdhd");
177
+ if (!mdhd) continue;
178
+ const v1 = v.getUint8(mdhd.at) === 1;
179
+ const timescale = v1 ? v.getUint32(mdhd.at + 20) : v.getUint32(mdhd.at + 12);
180
+ const dur = v1 ? v.getUint32(mdhd.at + 24) * 4294967296 + v.getUint32(mdhd.at + 28)
181
+ : v.getUint32(mdhd.at + 16);
182
+ const duration = timescale ? dur / timescale : 0;
183
+
184
+ const minf = find(v, md, "minf");
185
+ const stbl = minf && find(v, boxes(v, minf.at, minf.to), "stbl");
186
+ if (!stbl) continue;
187
+ const st = boxes(v, stbl.at, stbl.to);
188
+
189
+ // Coded size from the sample description, not tkhd: tkhd carries the
190
+ // DISPLAY size, which differs on anamorphic footage.
191
+ let width = 0, height = 0, codec = "";
192
+ const stsd = find(v, st, "stsd");
193
+ if (stsd && v.getUint32(stsd.at + 4) > 0) {
194
+ const entry = stsd.at + 8;
195
+ // The sample entry's own four-character type IS the codec: avc1, hvc1,
196
+ // hev1, av01, vp09, ap4h for ProRes.
197
+ codec = ascii(v, entry + 4, 4);
198
+ width = v.getUint16(entry + 24);
199
+ height = v.getUint16(entry + 26);
200
+ }
201
+ if (!width) {
202
+ const tkhd = find(v, t, "tkhd");
203
+ if (tkhd) {
204
+ const off = v.getUint8(tkhd.at) === 1 ? 88 : 76;
205
+ width = v.getUint32(tkhd.at + off) / 65536;
206
+ height = v.getUint32(tkhd.at + off + 4) / 65536;
207
+ }
208
+ }
209
+
210
+ const stsz = find(v, st, "stsz");
211
+ const samples = stsz ? v.getUint32(stsz.at + 8) : 0;
212
+ const out = { width, height, duration,
213
+ fps: duration ? samples / duration : 0,
214
+ container: "MP4/MOV", codec };
215
+ return raw ? out : sane(out);
216
+ }
217
+ return null;
218
+ }
219
+
220
+ /* --- AVI (RIFF) ---------------------------------------------------------- */
221
+
222
+ function parseAVI(v, raw) {
223
+ // hdrl/avih sits immediately after the 12-byte RIFF header in every file
224
+ // ffmpeg or a camera writes; anything else falls through to null.
225
+ const list = boxes4cc(v, 12, v.byteLength, "avih");
226
+ if (!list) return null;
227
+ const usPerFrame = v.getUint32(list, true);
228
+ const frames = v.getUint32(list + 16, true);
229
+ const fps = usPerFrame ? 1e6 / usPerFrame : 0;
230
+ const out = { width: v.getUint32(list + 32, true),
231
+ height: v.getUint32(list + 36, true),
232
+ duration: fps ? frames / fps : 0, fps,
233
+ container: "AVI", codec: "" };
234
+ return raw ? out : sane(out);
235
+ }
236
+
237
+ // RIFF chunks are little-endian and LIST chunks nest; find one payload.
238
+ // Depth-capped for the same reason as ebmlWalk: nesting is free to write.
239
+ function boxes4cc(v, off, end, want, depth) {
240
+ if ((depth || 0) > MAX_DEPTH) return null;
241
+ while (off + 8 <= end) {
242
+ const id = ascii(v, off, 4);
243
+ const size = v.getUint32(off + 4, true);
244
+ if (id === want) return off + 8;
245
+ if (id === "LIST") {
246
+ const hit = boxes4cc(v, off + 12, Math.min(end, off + 8 + size), want,
247
+ (depth || 0) + 1);
248
+ if (hit) return hit;
249
+ }
250
+ off += 8 + size + (size & 1);
251
+ }
252
+ return null;
253
+ }
254
+
255
+ /* --- dispatch ------------------------------------------------------------ */
256
+
257
+ async function moovMeta(file, raw) {
258
+ const moov = await findMoov(file);
259
+ if (!moov) return null;
260
+ return parseMoov(await buf(file, moov.start, moov.end),
261
+ moov.end - moov.start, raw);
262
+ }
263
+
264
+ // Streams (TS, M2TS, MPEG-PS, FLV) live in probe-stream.js, loaded before
265
+ // this file in the page and required under the test runner.
266
+ const stream = () => typeof STREAM !== "undefined" ? STREAM
267
+ : (typeof require !== "undefined" ? require("./probe-stream.js") : null);
268
+
269
+ async function read(file) {
270
+ const head = await buf(file, 0, 16);
271
+ if (head.byteLength < 12) return null;
272
+ if (head.getUint32(0) === 0x1A45DFA3)
273
+ return parseEBML(await buf(file, 0, HEAD_BYTES));
274
+ if (ascii(head, 0, 4) === "RIFF" && ascii(head, 8, 4) === "AVI ")
275
+ return parseAVI(await buf(file, 0, HEAD_BYTES));
276
+ const s = stream() && await stream().read(file);
277
+ return s || await moovMeta(file, false);
278
+ }
279
+
280
+ return { read, moovMeta, buf, ascii, parseEBML, parseMoov, parseAVI,
281
+ boxes, findMoov };
282
+ })();
283
+
284
+ if (typeof module !== "undefined") module.exports = CONTAINER; // tests
package/index.js ADDED
@@ -0,0 +1 @@
1
+ module.exports = {};
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "n8n-nodes-blurwerk",
3
+ "version": "0.1.0",
4
+ "description": "n8n node for blurwerk: anonymize video — every face mosaiced, voices disguised or muted, metadata removed. Processed in Germany.",
5
+ "keywords": [
6
+ "n8n-community-node-package",
7
+ "video",
8
+ "anonymization",
9
+ "gdpr",
10
+ "dsgvo",
11
+ "face blur",
12
+ "privacy"
13
+ ],
14
+ "license": "MIT",
15
+ "homepage": "https://blurwerk.de/?ref=n8n",
16
+ "author": {
17
+ "name": "blurwerk",
18
+ "email": "contact@blurwerk.de"
19
+ },
20
+ "main": "index.js",
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "scripts": {
25
+ "vendor": "node scripts/vendor.mjs",
26
+ "build": "node scripts/vendor.mjs && tsc && node scripts/assets.mjs",
27
+ "test": "npm run build && node --test test/",
28
+ "prepublishOnly": "npm test && node scripts/check-publish.mjs"
29
+ },
30
+ "n8n": {
31
+ "n8nNodesApiVersion": 1,
32
+ "strict": true,
33
+ "credentials": [
34
+ "dist/credentials/BlurwerkApi.credentials.js"
35
+ ],
36
+ "nodes": [
37
+ "dist/nodes/Blurwerk/Blurwerk.node.js"
38
+ ]
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^22.0.0",
42
+ "n8n-workflow": "2.16.0",
43
+ "typescript": "^5.6.0"
44
+ },
45
+ "peerDependencies": {
46
+ "n8n-workflow": "*"
47
+ }
48
+ }