@officexapp/vidfarm-devcli 0.21.58 → 0.21.60

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.
@@ -0,0 +1,465 @@
1
+ // `vidfarm gigs verify-proof` — the sanity check that runs BEFORE a proof is sent.
2
+ //
3
+ // A pending proof CAN be pulled back — `gigs withdraw` returns it to a draft —
4
+ // but only until the buyer rules on it, and they open it days later in a swipe
5
+ // deck, so in practice the first thing they see is the thing that decides it.
6
+ // The two ways a finished video loses the payout are both mechanical, both land
7
+ // before any undo would, and both are checkable from here:
8
+ //
9
+ // 1. THE LINK DOES NOT LOAD FOR THEM. It 403s because the s3 object was never
10
+ // made public, it answers an AccessDenied XML body with a 200-looking url,
11
+ // it is a presigned link whose read token expires in an hour, it is a
12
+ // Drive/Dropbox page behind a login, or it is http://localhost:8000 on the
13
+ // worker's own machine. Every one of those reads to a buyer as "no proof".
14
+ // 2. THE TWO HALVES ARE SWAPPED. `proofs[]` is public — the buyer downloads it
15
+ // and so can anyone they forward it to. `private_note` is escrowed until a
16
+ // rollup stamps `paid_out_at`. Putting the clean master in `proofs[]` while
17
+ // the note holds nothing means the buyer has everything before paying.
18
+ //
19
+ // Check 1 is a HARD FAIL: there is no reading of the brief under which a dead
20
+ // link is correct. Check 2 is a WARNING with an acknowledgement flag, because
21
+ // handing the final cut over in the proof itself is a legitimate choice — it is
22
+ // just one the worker has to make on purpose rather than by accident.
23
+ //
24
+ // Split shape-only rules (pure, no network) from the probe (one ranged GET per
25
+ // url) so the rules stay unit-testable and an offline caller still gets most of
26
+ // the value.
27
+ //
28
+ // Backend-free (Node built-ins + fetch) so it ships in the public cloud-only CLI.
29
+ import { statSync } from "node:fs";
30
+ const MEDIA_EXTENSIONS = new Set([
31
+ "mp4", "mov", "webm", "m4v", "mkv", "avi",
32
+ "mp3", "wav", "m4a", "aac", "ogg",
33
+ "png", "jpg", "jpeg", "webp", "gif", "avif"
34
+ ]);
35
+ /** Hosts that answer a stranger with a sign-in page, not the file. */
36
+ const LOGIN_WALLED = {
37
+ "drive.google.com": "Google Drive",
38
+ "docs.google.com": "Google Docs",
39
+ "www.dropbox.com": "Dropbox (a /scl or ?dl=0 link is a preview page)",
40
+ "dropbox.com": "Dropbox (a /scl or ?dl=0 link is a preview page)",
41
+ "wetransfer.com": "WeTransfer (and it expires)",
42
+ "we.tl": "WeTransfer (and it expires)",
43
+ "www.notion.so": "Notion",
44
+ "notion.so": "Notion",
45
+ "onedrive.live.com": "OneDrive",
46
+ "1drv.ms": "OneDrive",
47
+ "mega.nz": "MEGA",
48
+ "www.icloud.com": "iCloud"
49
+ };
50
+ /** Filenames that say this is the unwatermarked file. */
51
+ const MASTER_NAME = /(clean|master|no[-_.]?water|unwatermark|nomark|raw[-_.]?final|final[-_.]?raw)/i;
52
+ /**
53
+ * Pure url inspection: everything that can be decided from the string itself.
54
+ * No network, no side effects — this is the half that is unit-tested.
55
+ */
56
+ export function inspectProofUrl(raw) {
57
+ const value = (raw ?? "").trim();
58
+ const empty = {
59
+ raw: value, valid: false, scheme: "", host: "", filename: "",
60
+ expiring: null, login_wall: null, private_host: false, looks_like_master: false
61
+ };
62
+ let url;
63
+ try {
64
+ url = new URL(value);
65
+ }
66
+ catch {
67
+ return empty;
68
+ }
69
+ const scheme = url.protocol.replace(":", "").toLowerCase();
70
+ const host = url.hostname.toLowerCase();
71
+ const filename = decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() ?? "");
72
+ return {
73
+ raw: value,
74
+ valid: scheme === "http" || scheme === "https",
75
+ scheme,
76
+ host,
77
+ filename,
78
+ expiring: expiringToken(url),
79
+ login_wall: LOGIN_WALLED[host] ?? null,
80
+ private_host: isPrivateHost(host),
81
+ looks_like_master: MASTER_NAME.test(filename)
82
+ };
83
+ }
84
+ function isPrivateHost(host) {
85
+ if (host === "localhost" || host === "::1" || host.endsWith(".local") || host.endsWith(".localhost"))
86
+ return true;
87
+ if (/^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host))
88
+ return true;
89
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(host))
90
+ return true;
91
+ return false;
92
+ }
93
+ /**
94
+ * Signed, time-limited read access. The whole point of a proof url is that it
95
+ * still plays when the buyer gets round to the deck, so ANY of these is a fail
96
+ * even while it is still valid today.
97
+ */
98
+ function expiringToken(url) {
99
+ const q = url.searchParams;
100
+ const has = (name) => q.has(name) || q.has(name.toLowerCase());
101
+ if (has("X-Amz-Signature") || has("X-Amz-Credential")) {
102
+ return { kind: "aws-sigv4 presigned", expires_in_seconds: amzRemaining(q) };
103
+ }
104
+ if (has("Key-Pair-Id") || (has("Signature") && has("Expires"))) {
105
+ const expires = Number(q.get("Expires") ?? q.get("expires"));
106
+ const remaining = Number.isFinite(expires) ? Math.round(expires - Date.now() / 1000) : null;
107
+ return { kind: "CloudFront signed", expires_in_seconds: remaining };
108
+ }
109
+ if (has("sig") && has("se"))
110
+ return { kind: "Azure SAS", expires_in_seconds: null };
111
+ if (has("GoogleAccessId") || has("X-Goog-Signature"))
112
+ return { kind: "GCS signed", expires_in_seconds: null };
113
+ return null;
114
+ }
115
+ /** `X-Amz-Date=20260825T120000Z` + `X-Amz-Expires=3600` → seconds left. */
116
+ function amzRemaining(q) {
117
+ const date = q.get("X-Amz-Date") ?? q.get("x-amz-date");
118
+ const expires = Number(q.get("X-Amz-Expires") ?? q.get("x-amz-expires"));
119
+ if (!date || !Number.isFinite(expires))
120
+ return null;
121
+ const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(date);
122
+ if (!m)
123
+ return null;
124
+ const signedAt = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6]));
125
+ return Math.round((signedAt + expires * 1000 - Date.now()) / 1000);
126
+ }
127
+ /**
128
+ * Shape rules for one `proofs[]` url — the offline half of the check.
129
+ */
130
+ export function shapeChecks(raw) {
131
+ const shape = inspectProofUrl(raw);
132
+ const checks = [];
133
+ const target = shape.raw || String(raw);
134
+ if (!shape.valid) {
135
+ checks.push({
136
+ level: "fail",
137
+ code: "not_a_url",
138
+ target,
139
+ message: "This is not an http(s) url, so the buyer's deck has nothing to play.",
140
+ hint: "Upload the file and submit the url it hands back: vidfarm gigs upload ./final-watermarked.mp4"
141
+ });
142
+ return checks;
143
+ }
144
+ if (shape.scheme === "http") {
145
+ checks.push({
146
+ level: "fail",
147
+ code: "insecure_scheme",
148
+ target,
149
+ message: "http:// is blocked as mixed content inside the review deck — the card stays blank.",
150
+ hint: "Serve the same file over https."
151
+ });
152
+ }
153
+ if (shape.private_host) {
154
+ checks.push({
155
+ level: "fail",
156
+ code: "private_host",
157
+ target,
158
+ message: `${shape.host} only exists on your own machine — nobody else can open it.`,
159
+ hint: "vidfarm gigs upload ./final-watermarked.mp4 puts it on a durable public url."
160
+ });
161
+ }
162
+ if (shape.login_wall) {
163
+ checks.push({
164
+ level: "fail",
165
+ code: "login_wall",
166
+ target,
167
+ message: `${shape.login_wall} serves a sign-in page to a stranger, not a playable file.`,
168
+ hint: "A proof must play inline with no account. Re-host it: vidfarm gigs upload <file>"
169
+ });
170
+ }
171
+ if (shape.expiring) {
172
+ const left = shape.expiring.expires_in_seconds;
173
+ const when = left === null ? "it expires on a clock" : left <= 0 ? "it has ALREADY expired" : `about ${Math.round(left / 3600)}h of read access is left`;
174
+ checks.push({
175
+ level: "fail",
176
+ code: "expiring_link",
177
+ target,
178
+ message: `${shape.expiring.kind} url — ${when}. Buyers review days later, so this dies before it is watched.`,
179
+ hint: "Submit a durable public url instead. A presigned url is for the UPLOAD, never for the proof."
180
+ });
181
+ }
182
+ return checks;
183
+ }
184
+ /**
185
+ * Container sniffing from the first bytes. The header lies often enough to be
186
+ * worth ignoring: a misconfigured bucket happily returns `video/mp4` on an
187
+ * AccessDenied XML body, and that is the exact failure this catches.
188
+ */
189
+ export function sniffMagic(bytes) {
190
+ const at = (i) => bytes[i] ?? -1;
191
+ const ascii = (start, length) => String.fromCharCode(...Array.from(bytes.slice(start, start + length)));
192
+ if (bytes.length < 4)
193
+ return null;
194
+ if (ascii(4, 4) === "ftyp")
195
+ return "mp4";
196
+ if (at(0) === 0x1a && at(1) === 0x45 && at(2) === 0xdf && at(3) === 0xa3)
197
+ return "webm";
198
+ if (ascii(0, 4) === "RIFF")
199
+ return ascii(8, 4) === "WEBP" ? "webp" : "riff";
200
+ if (at(0) === 0x89 && ascii(1, 3) === "PNG")
201
+ return "png";
202
+ if (at(0) === 0xff && at(1) === 0xd8 && at(2) === 0xff)
203
+ return "jpeg";
204
+ if (ascii(0, 3) === "GIF")
205
+ return "gif";
206
+ if (ascii(0, 3) === "ID3")
207
+ return "mp3";
208
+ if (at(0) === 0xff && (at(1) & 0xe0) === 0xe0)
209
+ return "mp3";
210
+ if (ascii(0, 4) === "OggS")
211
+ return "ogg";
212
+ const head = ascii(0, Math.min(64, bytes.length)).trimStart().toLowerCase();
213
+ if (head.startsWith("<?xml") || head.startsWith("<html") || head.startsWith("<!doctype"))
214
+ return "markup";
215
+ return null;
216
+ }
217
+ /** Read at most `limit` bytes off a response, then drop the rest on the floor. */
218
+ async function firstBytes(response, limit = 2048) {
219
+ if (!response.body)
220
+ return new Uint8Array(0);
221
+ const reader = response.body.getReader();
222
+ const chunks = [];
223
+ let total = 0;
224
+ while (total < limit) {
225
+ const { done, value } = await reader.read();
226
+ if (done || !value)
227
+ break;
228
+ chunks.push(value);
229
+ total += value.length;
230
+ }
231
+ await reader.cancel().catch(() => { });
232
+ const out = new Uint8Array(total);
233
+ let offset = 0;
234
+ for (const chunk of chunks) {
235
+ out.set(chunk, offset);
236
+ offset += chunk.length;
237
+ }
238
+ return out.slice(0, limit);
239
+ }
240
+ /**
241
+ * One ranged GET, exactly as a stranger's browser would do it: no key, no
242
+ * cookie, follow redirects. A HEAD is not enough — plenty of buckets answer 200
243
+ * to a HEAD and an error body to a GET.
244
+ */
245
+ export async function probeUrl(url, timeoutMs = 20000) {
246
+ const result = { url, ok: false, status: null, content_type: null, bytes: null, magic: null, error: null };
247
+ try {
248
+ const response = await fetch(url, {
249
+ method: "GET",
250
+ headers: { range: "bytes=0-2047" },
251
+ redirect: "follow",
252
+ signal: AbortSignal.timeout(timeoutMs)
253
+ });
254
+ result.status = response.status;
255
+ result.content_type = (response.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase() || null;
256
+ const range = response.headers.get("content-range");
257
+ const total = range ? Number(range.split("/").pop()) : Number(response.headers.get("content-length"));
258
+ result.bytes = Number.isFinite(total) ? total : null;
259
+ const head = await firstBytes(response);
260
+ result.magic = sniffMagic(head);
261
+ result.ok = response.ok;
262
+ }
263
+ catch (error) {
264
+ result.error = error?.name === "TimeoutError" ? "timed out after 20s" : String(error?.message ?? error);
265
+ }
266
+ return result;
267
+ }
268
+ /** Turn one probe into checks. Pure, so the verdict logic is testable. */
269
+ export function probeChecks(probe) {
270
+ const checks = [];
271
+ const target = probe.url;
272
+ if (probe.error) {
273
+ checks.push({
274
+ level: "fail",
275
+ code: "unreachable",
276
+ target,
277
+ message: `The link did not load: ${probe.error}`,
278
+ hint: "Open it in a private browser window. If it needs your session, the buyer cannot watch it."
279
+ });
280
+ return checks;
281
+ }
282
+ if (probe.status !== null && !probe.ok) {
283
+ const why = probe.status === 403
284
+ ? "403 — uploaded, but the object is not readable by the public."
285
+ : probe.status === 404
286
+ ? "404 — nothing is at that key. The upload did not land where you think."
287
+ : `HTTP ${probe.status}.`;
288
+ checks.push({
289
+ level: "fail",
290
+ code: "http_status",
291
+ target,
292
+ message: `The buyer gets ${why}`,
293
+ hint: "vidfarm gigs upload <file> returns a url that is public by construction."
294
+ });
295
+ return checks;
296
+ }
297
+ if (probe.magic === "markup" || (probe.content_type ?? "").startsWith("text/html")) {
298
+ checks.push({
299
+ level: "fail",
300
+ code: "html_body",
301
+ target,
302
+ message: "That url answers a page, not a file — usually an S3 AccessDenied body or a sign-in wall.",
303
+ hint: "A deck card cannot play a web page. Submit the direct file url."
304
+ });
305
+ return checks;
306
+ }
307
+ if (probe.bytes === 0) {
308
+ checks.push({ level: "fail", code: "empty_body", target, message: "The file is 0 bytes — the upload did not finish." });
309
+ return checks;
310
+ }
311
+ const typeIsMedia = /^(video|audio|image)\//.test(probe.content_type ?? "");
312
+ if (!probe.magic && !typeIsMedia) {
313
+ checks.push({
314
+ level: "fail",
315
+ code: "not_media",
316
+ target,
317
+ message: `Neither the bytes nor the content-type (${probe.content_type ?? "none"}) look like a video, image or audio file.`,
318
+ hint: "The buyer swipes a deck and the card plays the first url inline."
319
+ });
320
+ return checks;
321
+ }
322
+ if (!typeIsMedia) {
323
+ checks.push({
324
+ level: "warn",
325
+ code: "octet_stream",
326
+ target,
327
+ message: `The bytes are a ${probe.magic} file but the server sends content-type "${probe.content_type ?? "none"}".`,
328
+ hint: "Some players refuse to inline that. Re-upload with the right content type."
329
+ });
330
+ }
331
+ if (probe.bytes !== null && probe.bytes > 0 && probe.bytes < 50_000) {
332
+ checks.push({
333
+ level: "warn",
334
+ code: "tiny_file",
335
+ target,
336
+ message: `Only ${probe.bytes} bytes. That is too small to be a finished cut.`
337
+ });
338
+ }
339
+ if (checks.length === 0) {
340
+ const size = probe.bytes === null ? "" : ` · ${(probe.bytes / 1_000_000).toFixed(1)} MB`;
341
+ checks.push({
342
+ level: "ok",
343
+ code: "playable",
344
+ target,
345
+ message: `Loads for a stranger: ${probe.magic ?? probe.content_type}${size}`
346
+ });
347
+ }
348
+ return checks;
349
+ }
350
+ /**
351
+ * The half that is a recommendation, not a law: watermark what is public, seal
352
+ * the master in the note. Warnings here, plus two genuine mistakes that fail.
353
+ */
354
+ export function usageChecks(bundle) {
355
+ const checks = [];
356
+ const note = (bundle.privateNote ?? "").trim();
357
+ const hasMaster = Boolean(bundle.cleanMaster);
358
+ const noteUrls = note.match(/https?:\/\/\S+/g) ?? [];
359
+ const proofSet = new Set(bundle.proofs.map((p) => p.trim()));
360
+ if (bundle.cleanMaster) {
361
+ let size = -1;
362
+ try {
363
+ size = statSync(bundle.cleanMaster).size;
364
+ }
365
+ catch {
366
+ size = -1;
367
+ }
368
+ if (size < 0) {
369
+ checks.push({ level: "fail", code: "clean_master_missing", target: bundle.cleanMaster, message: "That clean-master file does not exist on disk." });
370
+ }
371
+ else if (size === 0) {
372
+ checks.push({ level: "fail", code: "clean_master_empty", target: bundle.cleanMaster, message: "The clean-master file is 0 bytes." });
373
+ }
374
+ else {
375
+ const ext = bundle.cleanMaster.toLowerCase().split(".").pop() ?? "";
376
+ if (!MEDIA_EXTENSIONS.has(ext)) {
377
+ checks.push({ level: "warn", code: "clean_master_not_media", target: bundle.cleanMaster, message: `".${ext}" is not a media extension — check you are sealing the video, not a note file.` });
378
+ }
379
+ }
380
+ }
381
+ // The sealed half points at the file you already published: the escrow is
382
+ // empty and the buyer has everything before paying.
383
+ const leaked = noteUrls.filter((u) => proofSet.has(u.replace(/[.,)]+$/, "")));
384
+ if (leaked.length) {
385
+ checks.push({
386
+ level: "fail",
387
+ code: "same_url_both_sides",
388
+ target: leaked[0],
389
+ message: "The private note points at a url you also submitted publicly, so the sealed half hands over nothing new.",
390
+ hint: "Seal the CLEAN master in the note and publish the WATERMARKED cut in --proof."
391
+ });
392
+ }
393
+ const duplicates = bundle.proofs.length - new Set(bundle.proofs).size;
394
+ if (duplicates > 0) {
395
+ checks.push({ level: "warn", code: "duplicate_proof_url", target: bundle.proofs[0] ?? "", message: "The same url is submitted more than once." });
396
+ }
397
+ if (!note && !hasMaster) {
398
+ if (bundle.cleanInProof) {
399
+ checks.push({
400
+ level: "ok",
401
+ code: "clean_in_proof_acknowledged",
402
+ target: "private_note",
403
+ message: "No private note, by your own choice — the proof url IS the final deliverable and the buyer holds it before payout."
404
+ });
405
+ }
406
+ else {
407
+ checks.push({
408
+ level: "warn",
409
+ code: "no_private_note",
410
+ target: "private_note",
411
+ message: "No private note. If that proof url is your unwatermarked final, the buyer has the whole job before paying for it.",
412
+ hint: "Recommended: --proof <watermarked url> --clean-master ./final-clean.mp4. Meant to hand it over? Pass --clean-in-proof."
413
+ });
414
+ }
415
+ }
416
+ else if (note && !hasMaster && noteUrls.length === 0) {
417
+ checks.push({
418
+ level: "warn",
419
+ code: "note_without_file",
420
+ target: "private_note",
421
+ message: "The private note is text only — it unseals on payment and the buyer receives no file from it.",
422
+ hint: "Put the clean master in it: --clean-master ./final-clean.mp4"
423
+ });
424
+ }
425
+ for (const url of bundle.proofs) {
426
+ const shape = inspectProofUrl(url);
427
+ if (shape.looks_like_master && (note || hasMaster)) {
428
+ checks.push({
429
+ level: "warn",
430
+ code: "master_name_in_proof",
431
+ target: url,
432
+ message: `"${shape.filename}" is named like the clean master, but you are also sealing one in the note.`,
433
+ hint: "Check you did not publish the unwatermarked cut by mistake — a proof url is public forever."
434
+ });
435
+ }
436
+ }
437
+ return checks;
438
+ }
439
+ /**
440
+ * Shape rules → one probe per url → the proof/note usage rules. `ok` is false
441
+ * whenever anything failed, which is what makes `verify-proof && gigs submit …`
442
+ * a real gate rather than a reminder.
443
+ */
444
+ export async function verifyProof(bundle, options = {}) {
445
+ const checks = [];
446
+ const probes = [];
447
+ for (const url of bundle.proofs) {
448
+ const shape = shapeChecks(url);
449
+ checks.push(...shape);
450
+ // A url that is already condemned by its shape is not worth a round trip —
451
+ // and a localhost url would hang the check for the timeout.
452
+ if (shape.some((c) => c.level === "fail"))
453
+ continue;
454
+ if (options.probe === false)
455
+ continue;
456
+ const probe = await probeUrl(url, options.timeoutMs);
457
+ probes.push(probe);
458
+ checks.push(...probeChecks(probe));
459
+ }
460
+ checks.push(...usageChecks(bundle));
461
+ const failures = checks.filter((c) => c.level === "fail");
462
+ const warnings = checks.filter((c) => c.level === "warn");
463
+ return { ok: failures.length === 0, failures, warnings, checks, probes };
464
+ }
465
+ //# sourceMappingURL=proof-verify.js.map
@@ -117,6 +117,8 @@ export const PACK_TOPICS = [
117
117
  blurb: "The 5-stage ladder — what the viewer knows, what the video must do, and what it may ask for" },
118
118
  { topic: "problem-angles", aliases: ["angle", "lenses", "problem-angle"], doc: "references/content-ideas.md", heading: "The problem angles",
119
119
  blurb: "44 angles on the problem — hold the frame, change the angle when a topic is \"already covered\"" },
120
+ { topic: "ad-formats", aliases: ["ad-format", "formats", "format", "executions"], doc: "references/content-ideas.md", heading: "The ad formats",
121
+ blurb: "40 ad formats — what the finished video IS (iphone notes, tier list, trustpilot reviews, warning), and the honesty rule for the ones that imitate a record" },
120
122
  { topic: "meme-recaption", // `meme_recaption` needs no alias: resolvePackTopic folds `_` to `-` first.
121
123
  aliases: ["meme", "recaption", "meme-caption"], doc: "references/editor-workflows.md", heading: "Writing a meme recaption",
122
124
  blurb: "Recaption a meme at a pain or a win the niche knows — the cold-viewer test. Building one from scratch? the full format is vidfarm.cc/experimental/meme-recaption.md" },
@@ -248,6 +250,18 @@ export function loadAngleBank(name = DEFAULT_PACK) {
248
250
  const { frames, families } = parseBulletBank(contents, "The problem angles", true);
249
251
  return { angles: frames, families };
250
252
  }
253
+ /**
254
+ * The ad-format bank — the FOURTH axis. A frame says what the video is the
255
+ * story of and an angle says which side of the problem it comes from; a format
256
+ * says what the finished thing IS (an iPhone note, a tier list, a warning
257
+ * label). Left unnamed, a director shoots the same talking head all month, so
258
+ * it belongs next to the other banks rather than in the prose nobody reads.
259
+ */
260
+ export function loadAdFormatBank(name = DEFAULT_PACK) {
261
+ const { contents } = readPackDoc("references/content-ideas.md", name);
262
+ const { frames, families } = parseBulletBank(contents, "The ad formats", true);
263
+ return { formats: frames, families };
264
+ }
251
265
  export function loadAwarenessLadder(name = DEFAULT_PACK) {
252
266
  const { contents } = readPackDoc("references/content-ideas.md", name);
253
267
  const section = extractSection(contents, "The awareness ladder");