@drantoniou/uploadcheck 0.1.0 → 0.3.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/api-client.mjs ADDED
@@ -0,0 +1,88 @@
1
+ // Shared UploadCheck API HTTP client — the submit/poll/report primitives used by
2
+ // both `uploadcheck check` (one-shot) and `uploadcheck watch` (folder daemon).
3
+ // Every call authenticates with the workspace key (Bearer UPLOADCHECK_API_KEY) and
4
+ // therefore draws on the same prepaid minutes; the watcher adds no new server surface.
5
+ import { createReadStream } from "node:fs";
6
+
7
+ const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]);
8
+
9
+ export async function postJson(apiBaseUrl, path, payload, apiKey) {
10
+ const response = await fetch(`${apiBaseUrl}${path}`, {
11
+ method: "POST",
12
+ headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
13
+ body: JSON.stringify(payload)
14
+ });
15
+ const body = await response.json();
16
+ if (!response.ok) throw new Error(`UploadCheck API ${response.status}: ${JSON.stringify(body)}`);
17
+ return body;
18
+ }
19
+
20
+ export async function getJson(apiBaseUrl, path, apiKey) {
21
+ const response = await fetch(`${apiBaseUrl}${path}`, {
22
+ method: "GET",
23
+ headers: apiKey ? { authorization: `Bearer ${apiKey}` } : undefined
24
+ });
25
+ const body = await response.json();
26
+ if (!response.ok) throw new Error(`UploadCheck API ${response.status}: ${JSON.stringify(body)}`);
27
+ return body;
28
+ }
29
+
30
+ export async function getText(apiBaseUrl, path, apiKey) {
31
+ const response = await fetch(`${apiBaseUrl}${path}`, {
32
+ method: "GET",
33
+ headers: apiKey ? { authorization: `Bearer ${apiKey}` } : undefined
34
+ });
35
+ const text = await response.text();
36
+ if (!response.ok) throw new Error(`UploadCheck API ${response.status}: ${text}`);
37
+ return text;
38
+ }
39
+
40
+ // Submit a built job request (inline or signed-upload) and return the job payload.
41
+ export async function submitJob(request, apiKey) {
42
+ if (request.kind === "signed_upload") return runSignedUploadJob(request, apiKey);
43
+ return postJson(request.apiBaseUrl, request.path, request.payload, apiKey);
44
+ }
45
+
46
+ async function runSignedUploadJob(request, apiKey) {
47
+ const upload = await postJson(request.apiBaseUrl, request.createUpload.path, request.createUpload.payload, apiKey);
48
+ const putResponse = await fetch(upload.signedPutUrl, {
49
+ method: "PUT",
50
+ headers: { "content-type": request.contentType, "content-length": String(request.sizeBytes) },
51
+ body: createReadStream(request.filePath),
52
+ duplex: "half"
53
+ });
54
+ if (!putResponse.ok) {
55
+ const text = await putResponse.text();
56
+ throw new Error(`UploadCheck upload ${putResponse.status}: ${text}`);
57
+ }
58
+ return postJson(request.apiBaseUrl, request.createJob.path, { ...request.createJob.payload, upload_id: upload.uploadId }, apiKey);
59
+ }
60
+
61
+ // Poll a job until it reaches a terminal status (completed/failed/cancelled).
62
+ // onProgress(job) is called on each poll so the caller can show a progress line.
63
+ export async function pollJobUntilDone(apiBaseUrl, jobId, apiKey, { intervalMs = 4000, timeoutMs = 30 * 60 * 1000, onProgress = null } = {}) {
64
+ const started = Date.now();
65
+ for (;;) {
66
+ const job = await getJson(apiBaseUrl, `/v1/qc/jobs/${jobId}`, apiKey);
67
+ if (onProgress) onProgress(job);
68
+ if (TERMINAL_STATUSES.has(job.status)) return job;
69
+ if (Date.now() - started > timeoutMs) throw new Error(`Timed out waiting for job ${jobId} (last status: ${job.status})`);
70
+ await sleep(intervalMs);
71
+ }
72
+ }
73
+
74
+ export function fetchReport(apiBaseUrl, jobId, apiKey) {
75
+ return getJson(apiBaseUrl, `/v1/qc/jobs/${jobId}/report`, apiKey);
76
+ }
77
+
78
+ // Marker CSV (Premiere/Resolve) — may not exist for every job; returns null on 404.
79
+ export async function fetchMarkerCsv(apiBaseUrl, jobId, apiKey) {
80
+ try {
81
+ return await getText(apiBaseUrl, `/v1/qc/jobs/${jobId}/artifacts/markers`, apiKey);
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
88
+ export { TERMINAL_STATUSES };
package/index.mjs CHANGED
@@ -1,17 +1,23 @@
1
1
  #!/usr/bin/env node
2
- import { createReadStream } from "node:fs";
3
- import { buildCostBasisRequest, buildEstimateRequest, buildJobRequest, buildLaunchDoctorRequest, buildLaunchEvidenceRequest, buildLaunchHandoffRequest, buildLaunchStatusRequest, buildNpoPipelineHandoffRequest, buildPipelineHandoffRequest, buildPipelineRecipesRequest, buildRemoteLaunchEvidence, buildUsageRequest, formatCostBasisSummary, formatJobSummary, formatLaunchDoctorSummary, formatLaunchEvidenceSummary, formatLaunchHandoffSummary, formatLaunchStatusSummary, formatNpoPipelineHandoffSummary, formatPipelineHandoffSummary, formatPipelineRecipesSummary, formatUsageSummary, parseArgs } from "./request-builder.mjs";
2
+ import { buildCostBasisRequest, buildEstimateRequest, buildJobRequest, buildLaunchDoctorRequest, buildLaunchEvidenceRequest, buildLaunchHandoffRequest, buildLaunchStatusRequest, buildNpoPipelineHandoffRequest, buildPipelineHandoffRequest, buildPipelineRecipesRequest, buildRemoteLaunchEvidence, formatCostBasisSummary, formatJobSummary, formatLaunchDoctorSummary, formatLaunchEvidenceSummary, formatLaunchHandoffSummary, formatLaunchStatusSummary, formatNpoPipelineHandoffSummary, formatPipelineHandoffSummary, formatPipelineRecipesSummary, parseArgs } from "./request-builder.mjs";
3
+ import { getJson, postJson, submitJob } from "./api-client.mjs";
4
+ import { runWatch } from "./watch.mjs";
4
5
 
5
6
  try {
6
7
  const { command, target, options } = parseArgs(process.argv.slice(2));
7
- const apiKey = options.apiKey || process.env.UPLOADCHECK_API_KEY || process.env.QCGENIE_API_KEY;
8
+ const apiKey = options.apiKey || process.env.UPLOADCHECK_API_KEY;
9
+
10
+ if (command === "watch") {
11
+ await runWatch(target, options, apiKey);
12
+ process.exit(process.exitCode || 0);
13
+ }
8
14
 
9
15
  const request = command === "estimate"
10
16
  ? buildEstimateRequest(options)
11
- : (command === "usage" ? buildUsageRequest(options) : (command === "launch-status" ? buildLaunchStatusRequest(options) : (command === "launch-handoff" ? buildLaunchHandoffRequest(options) : (command === "launch-doctor" ? buildLaunchDoctorRequest(options) : (command === "launch-evidence" ? buildLaunchEvidenceRequest(options) : (command === "pipeline-handoff" ? buildPipelineHandoffRequest(options) : (command === "npo-pipeline-handoff" ? buildNpoPipelineHandoffRequest(options) : (command === "recipes" ? buildPipelineRecipesRequest(options) : (command === "cost-basis" ? buildCostBasisRequest(options) : buildJobRequest(target, options))))))))));
17
+ : (command === "launch-status" ? buildLaunchStatusRequest(options) : (command === "launch-handoff" ? buildLaunchHandoffRequest(options) : (command === "launch-doctor" ? buildLaunchDoctorRequest(options) : (command === "launch-evidence" ? buildLaunchEvidenceRequest(options) : (command === "pipeline-handoff" ? buildPipelineHandoffRequest(options) : (command === "npo-pipeline-handoff" ? buildNpoPipelineHandoffRequest(options) : (command === "recipes" ? buildPipelineRecipesRequest(options) : (command === "cost-basis" ? buildCostBasisRequest(options) : buildJobRequest(target, options)))))))));
12
18
  if (!request.public && !apiKey) throw new Error("Set UPLOADCHECK_API_KEY or pass --api-key.");
13
- const rawPayload = request.kind === "signed_upload"
14
- ? await runSignedUploadJob(request, apiKey)
19
+ const rawPayload = (request.kind === "signed_upload" || request.kind === "job")
20
+ ? await submitJob(request, apiKey)
15
21
  : (request.method === "GET" ? await getJson(request.apiBaseUrl, request.path, apiKey) : await postJson(request.apiBaseUrl, request.path, request.payload, apiKey));
16
22
  const payload = request.kind === "launch_evidence" && rawPayload.name !== "UploadCheck.app Remote Launch Evidence"
17
23
  ? buildRemoteLaunchEvidence(rawPayload, { source: `${request.apiBaseUrl}${request.path}` })
@@ -24,7 +30,6 @@ try {
24
30
  }
25
31
 
26
32
  function formatSummary(kind, payload) {
27
- if (kind === "usage") return formatUsageSummary(payload);
28
33
  if (kind === "launch_status") return formatLaunchStatusSummary(payload);
29
34
  if (kind === "launch_handoff") return formatLaunchHandoffSummary(payload);
30
35
  if (kind === "launch_doctor") return formatLaunchDoctorSummary(payload);
@@ -36,54 +41,3 @@ function formatSummary(kind, payload) {
36
41
  return formatJobSummary(payload);
37
42
  }
38
43
 
39
- async function runSignedUploadJob(request, apiKey) {
40
- const upload = await postJson(request.apiBaseUrl, request.createUpload.path, request.createUpload.payload, apiKey);
41
- const putResponse = await fetch(upload.signedPutUrl, {
42
- method: "PUT",
43
- headers: {
44
- "content-type": request.contentType,
45
- "content-length": String(request.sizeBytes)
46
- },
47
- body: createReadStream(request.filePath),
48
- duplex: "half"
49
- });
50
- if (!putResponse.ok) {
51
- const text = await putResponse.text();
52
- throw new Error(`UploadCheck upload ${putResponse.status}: ${text}`);
53
- }
54
- const jobPayload = {
55
- ...request.createJob.payload,
56
- upload_id: upload.uploadId
57
- };
58
- return postJson(request.apiBaseUrl, request.createJob.path, jobPayload, apiKey);
59
- }
60
-
61
- async function postJson(apiBaseUrl, path, payload, apiKey) {
62
- const response = await fetch(`${apiBaseUrl}${path}`, {
63
- method: "POST",
64
- headers: {
65
- "content-type": "application/json",
66
- authorization: `Bearer ${apiKey}`
67
- },
68
- body: JSON.stringify(payload)
69
- });
70
- const body = await response.json();
71
- if (!response.ok) {
72
- throw new Error(`UploadCheck API ${response.status}: ${JSON.stringify(body)}`);
73
- }
74
- return body;
75
- }
76
-
77
- async function getJson(apiBaseUrl, path, apiKey) {
78
- const response = await fetch(`${apiBaseUrl}${path}`, {
79
- method: "GET",
80
- headers: apiKey ? {
81
- authorization: `Bearer ${apiKey}`
82
- } : undefined
83
- });
84
- const body = await response.json();
85
- if (!response.ok) {
86
- throw new Error(`UploadCheck API ${response.status}: ${JSON.stringify(body)}`);
87
- }
88
- return body;
89
- }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@drantoniou/uploadcheck",
3
- "version": "0.1.0",
4
- "description": "Quality check videos, podcasts, and clips before you upload.",
3
+ "version": "0.3.0",
4
+ "description": "The quality gate for AI-generated media. Check videos, podcasts, and clips before you publish — or `watch` a folder to auto-QC every render.",
5
5
  "homepage": "https://uploadcheck.app",
6
6
  "repository": {
7
7
  "type": "git",
@@ -18,7 +18,10 @@
18
18
  },
19
19
  "files": [
20
20
  "index.mjs",
21
+ "api-client.mjs",
22
+ "watch.mjs",
21
23
  "launch-evidence.mjs",
24
+ "qc-profiles.mjs",
22
25
  "request-builder.mjs"
23
26
  ],
24
27
  "license": "UNLICENSED",
@@ -0,0 +1,123 @@
1
+ export const NTO_LONG_FORM_CHECKS = [
2
+ "canvas_fill",
3
+ "loop_freeze",
4
+ "repeat_fatigue",
5
+ "speaker_visual_binding",
6
+ "static_head_dominance",
7
+ "literal_subject_match",
8
+ "visual_narration_match",
9
+ "first_three_seconds",
10
+ "end_screen_tease",
11
+ "rehook_cadence",
12
+ "contact_sheet_evidence",
13
+ "text_crop_jitter",
14
+ "text_overlap",
15
+ "thumbnail_text_readability",
16
+ "hallucinated_plate_text",
17
+ "clean_segment_source_scrub",
18
+ "voiceover_music_balance",
19
+ "dead_air",
20
+ "text_contrast",
21
+ "text_safe_area"
22
+ ];
23
+
24
+ export const PROFILE_CHECKS = {
25
+ nto_long_form: NTO_LONG_FORM_CHECKS,
26
+ generic_creator_video: [
27
+ "canvas_fill",
28
+ "loop_freeze",
29
+ "repeat_fatigue",
30
+ "dead_air",
31
+ "text_contrast",
32
+ "text_overlap",
33
+ "text_safe_area"
34
+ ],
35
+ shorts: [
36
+ "canvas_fill",
37
+ "shorts_format",
38
+ "opening_footer_text_presence",
39
+ "first_three_seconds",
40
+ "end_screen_tease",
41
+ "text_contrast",
42
+ "text_safe_area",
43
+ "text_crop_jitter",
44
+ "text_overlap",
45
+ "repeat_fatigue",
46
+ "sentence_boundary",
47
+ "dialogue_in_music_short",
48
+ "voiceover_music_balance",
49
+ "dead_air"
50
+ ],
51
+ audio: [
52
+ "dead_air",
53
+ "voiceover_music_balance",
54
+ "spoken_leaks",
55
+ "pronunciation_watchlist",
56
+ "script_faithfulness",
57
+ "sentence_boundary",
58
+ "chunk_sidecar_failures"
59
+ ],
60
+ npo_podcast_or_audio: [
61
+ "dead_air",
62
+ "voiceover_music_balance",
63
+ "spoken_leaks",
64
+ "pronunciation_watchlist",
65
+ "script_faithfulness",
66
+ "sentence_boundary",
67
+ "chunk_sidecar_failures"
68
+ ],
69
+ thumbnail: [
70
+ "text_overlap",
71
+ "thumbnail_text_readability"
72
+ ]
73
+ };
74
+
75
+ export const PROFILE_IDS = [...Object.keys(PROFILE_CHECKS), "auto"];
76
+
77
+ export function normalizeProfileId(value) {
78
+ const normalized = String(value || "").trim().toLowerCase().replaceAll("-", "_");
79
+ if (!normalized) return null;
80
+ if (normalized === "long_form" || normalized === "nto") return "nto_long_form";
81
+ if (normalized === "short" || normalized === "youtube_short" || normalized === "reels" || normalized === "tiktok") return "shorts";
82
+ if (normalized === "podcast" || normalized === "npo_audio") return "npo_podcast_or_audio";
83
+ if (normalized === "generic_video" || normalized === "creator_video") return "generic_creator_video";
84
+ if (PROFILE_IDS.includes(normalized)) return normalized;
85
+ return null;
86
+ }
87
+
88
+ export function checksForProfile(profile, context = {}) {
89
+ const normalized = normalizeProfileId(profile);
90
+ if (!normalized) return null;
91
+ const resolved = normalized === "auto" ? inferProfile(context) : normalized;
92
+ const checks = PROFILE_CHECKS[resolved];
93
+ return checks ? { profile: resolved, requestedProfile: normalized, checks: checks.join(",") } : null;
94
+ }
95
+
96
+ export function inferProfile(context = {}) {
97
+ const mediaKind = String(context.mediaKind || context.media_kind || "").toLowerCase();
98
+ if (mediaKind === "audio") return "audio";
99
+ if (mediaKind === "image") return "thumbnail";
100
+ const durationSeconds = Number(context.durationSeconds ?? context.duration_seconds ?? 0) || 0;
101
+ const filename = String(context.filename || context.mediaFilename || context.media_filename || "").toLowerCase();
102
+ if ((durationSeconds >= 45 && durationSeconds <= 75) || filename.includes("short") || filename.includes("reel") || filename.includes("tiktok")) {
103
+ return "shorts";
104
+ }
105
+ return "generic_creator_video";
106
+ }
107
+
108
+ export function applyProfileToInput(input = {}) {
109
+ const profile = input.profile || input.qc_profile || input.qcProfile;
110
+ if (input.checks || !profile) return { ...input };
111
+ const resolved = checksForProfile(profile, {
112
+ mediaKind: input.media_kind || input.mediaKind,
113
+ durationSeconds: input.duration_seconds || input.durationSeconds,
114
+ filename: input.filename || input.media_filename || input.mediaFilename
115
+ });
116
+ if (!resolved) return { ...input };
117
+ return {
118
+ ...input,
119
+ profile: resolved.profile,
120
+ requested_profile: resolved.requestedProfile,
121
+ checks: resolved.checks
122
+ };
123
+ }
@@ -1,11 +1,12 @@
1
1
  import { extname, basename, join, relative } from "node:path";
2
2
  import { statSync, readFileSync, existsSync, readdirSync } from "node:fs";
3
+ import { applyProfileToInput } from "./qc-profiles.mjs";
3
4
 
4
5
  const DEFAULT_API_BASE_URL = "https://api.uploadcheck.app";
5
6
  const DEFAULT_MAX_INLINE_MB = 128;
6
7
  const LAUNCH_PROOF_CONTRACT_VERSION = "2026-06-06.render-web-proof";
7
8
 
8
- const CONTENT_TYPES = new Map([
9
+ export const CONTENT_TYPES = new Map([
9
10
  [".mp4", "video/mp4"],
10
11
  [".mov", "video/quicktime"],
11
12
  [".m4v", "video/x-m4v"],
@@ -53,7 +54,9 @@ export function buildJobRequest(target, options = {}) {
53
54
  payload.filename = basename(target);
54
55
  }
55
56
 
57
+ if (options.profile) payload.profile = options.profile;
56
58
  if (options.checks) payload.checks = options.checks;
59
+ applyProfilePayload(payload);
57
60
  attachManifest(payload, options);
58
61
  attachTranscript(payload, options);
59
62
  attachWatchlist(payload, options);
@@ -78,7 +81,9 @@ export function buildSignedUploadPlan(target, options = {}, fileStat = null) {
78
81
  const apiBaseUrl = trimTrailingSlash(options.apiBaseUrl || process.env.UPLOADCHECK_API_BASE_URL || DEFAULT_API_BASE_URL);
79
82
  const contentType = inferContentType(target);
80
83
  const jobPayload = {};
84
+ if (options.profile) jobPayload.profile = options.profile;
81
85
  if (options.checks) jobPayload.checks = options.checks;
86
+ applyProfilePayload(jobPayload, { contentType, mediaKind: inferMediaKind(contentType), filename: basename(target) });
82
87
  attachManifest(jobPayload, options);
83
88
  attachTranscript(jobPayload, options);
84
89
  attachWatchlist(jobPayload, options);
@@ -114,9 +119,9 @@ export function buildSignedUploadPlan(target, options = {}, fileStat = null) {
114
119
  export function parseArgs(argv) {
115
120
  const args = [...argv];
116
121
  const command = args.shift();
117
- if (!["check", "estimate", "usage", "launch-status", "launch-handoff", "launch-doctor", "launch-evidence", "pipeline-handoff", "npo-pipeline-handoff", "recipes", "cost-basis"].includes(command)) throw new Error("Usage: uploadcheck check <file-or-url> | uploadcheck estimate --minutes N | uploadcheck usage | uploadcheck launch-status | uploadcheck launch-handoff | uploadcheck launch-doctor | uploadcheck launch-evidence | uploadcheck pipeline-handoff | uploadcheck npo-pipeline-handoff | uploadcheck recipes | uploadcheck cost-basis");
122
+ if (!["check", "watch", "estimate", "usage", "launch-status", "launch-handoff", "launch-doctor", "launch-evidence", "pipeline-handoff", "npo-pipeline-handoff", "recipes", "cost-basis"].includes(command)) throw new Error("Usage: uploadcheck check <file-or-url> | uploadcheck watch <folder> | uploadcheck estimate --minutes N | uploadcheck usage | uploadcheck launch-status | uploadcheck launch-handoff | uploadcheck launch-doctor | uploadcheck launch-evidence | uploadcheck pipeline-handoff | uploadcheck npo-pipeline-handoff | uploadcheck recipes | uploadcheck cost-basis");
118
123
 
119
- const target = command === "check" ? args.shift() : null;
124
+ const target = (command === "check" || command === "watch") ? args.shift() : null;
120
125
  const options = { json: false };
121
126
 
122
127
  while (args.length) {
@@ -129,6 +134,8 @@ export function parseArgs(argv) {
129
134
  options.apiKey = requireValue(arg, args.shift());
130
135
  } else if (arg === "--checks") {
131
136
  options.checks = requireValue(arg, args.shift());
137
+ } else if (arg === "--profile") {
138
+ options.profile = requireValue(arg, args.shift());
132
139
  } else if (arg === "--manifest") {
133
140
  options.manifestPath = requireValue(arg, args.shift());
134
141
  } else if (arg === "--transcript") {
@@ -175,6 +182,18 @@ export function parseArgs(argv) {
175
182
  const mode = requireValue(arg, args.shift());
176
183
  if (!["auto", "inline", "signed"].includes(mode)) throw new Error("--upload-mode must be auto, inline, or signed");
177
184
  options.uploadMode = mode;
185
+ } else if (arg === "--once") {
186
+ options.once = true;
187
+ } else if (arg === "--concurrency") {
188
+ options.concurrency = requireValue(arg, args.shift());
189
+ } else if (arg === "--settle-ms") {
190
+ options.settleMs = requireValue(arg, args.shift());
191
+ } else if (arg === "--poll-ms") {
192
+ options.pollMs = requireValue(arg, args.shift());
193
+ } else if (arg === "--on-pass") {
194
+ options.onPass = requireValue(arg, args.shift());
195
+ } else if (arg === "--on-fail") {
196
+ options.onFail = requireValue(arg, args.shift());
178
197
  } else {
179
198
  throw new Error(`Unknown option: ${arg}`);
180
199
  }
@@ -271,26 +290,14 @@ export function buildCostBasisRequest(options = {}) {
271
290
  };
272
291
  }
273
292
 
274
- export function buildUsageRequest(options = {}) {
275
- const apiBaseUrl = trimTrailingSlash(options.apiBaseUrl || process.env.UPLOADCHECK_API_BASE_URL || DEFAULT_API_BASE_URL);
276
- const params = new URLSearchParams();
277
- if (options.billingPeriod) params.set("billing_period", options.billingPeriod);
278
- if (options.limit) params.set("limit", Number(options.limit));
279
- const query = params.toString();
280
- return {
281
- apiBaseUrl,
282
- path: `/v1/usage/margins${query ? `?${query}` : ""}`,
283
- method: "GET",
284
- kind: "usage"
285
- };
286
- }
287
-
288
293
  export function buildEstimateRequest(options = {}) {
289
294
  const apiBaseUrl = trimTrailingSlash(options.apiBaseUrl || process.env.UPLOADCHECK_API_BASE_URL || DEFAULT_API_BASE_URL);
290
295
  const payload = {};
296
+ if (options.profile) payload.profile = options.profile;
291
297
  if (options.checks) payload.checks = options.checks;
292
298
  if (options.minutes) payload.minutes = Number(options.minutes);
293
299
  if (options.durationSeconds) payload.duration_seconds = Number(options.durationSeconds);
300
+ applyProfilePayload(payload);
294
301
  attachCostOptions(payload, options);
295
302
  return {
296
303
  apiBaseUrl,
@@ -315,19 +322,6 @@ export function formatJobSummary(payload) {
315
322
  return `UploadCheck job ${payload.jobId || payload.id || "(unknown)"}: ${status} / ${verdict} | ${minutes} min${ingressSuffix}${suffix}`;
316
323
  }
317
324
 
318
- export function formatUsageSummary(payload) {
319
- const summary = payload.summary || {};
320
- const minutes = Number(summary.minutes || 0);
321
- const cogs = Number(summary.estimatedCogsCents || 0) / 100;
322
- const costPerMinuteCents = Number(summary.estimatedCostPerMinuteCents || 0);
323
- const grossMarginPct = Number(summary.estimatedGrossMarginPct || 0);
324
- const status = summary.marginSafe === false ? "MARGIN RISK" : "MARGIN SAFE";
325
- const observed = summary.observedProviderUsageEntries > 0
326
- ? ` | observed cost/min ${Number(summary.observedCostPerMinuteCents || 0).toFixed(4)}c | observed margin ${Number(summary.observedGrossMarginPct || 0).toFixed(2)}%`
327
- : "";
328
- return `UploadCheck usage: ${status} | ${minutes} min | est. COGS $${cogs.toFixed(4)} | cost/min ${costPerMinuteCents.toFixed(4)}c | margin ${grossMarginPct.toFixed(2)}%${observed}`;
329
- }
330
-
331
325
  export function formatLaunchStatusSummary(payload) {
332
326
  const status = payload.product_hunt_ready ? "READY" : "NOT READY";
333
327
  const blockers = (payload.remaining_blockers || []).map((blocker) => blocker.id).filter(Boolean);
@@ -486,6 +480,20 @@ function attachCostOptions(payload, options) {
486
480
  if (options.costGuardrail) payload.cost_guardrail = options.costGuardrail;
487
481
  }
488
482
 
483
+ function applyProfilePayload(payload, context = {}) {
484
+ const resolved = applyProfileToInput({
485
+ ...payload,
486
+ media_kind: payload.media_kind,
487
+ duration_seconds: payload.duration_seconds,
488
+ mediaKind: payload.media_kind || context.mediaKind,
489
+ filename: payload.filename || context.filename,
490
+ media_content_type: payload.media_content_type || context.contentType
491
+ });
492
+ if (resolved.profile) payload.profile = resolved.profile;
493
+ if (resolved.requested_profile) payload.requested_profile = resolved.requested_profile;
494
+ if (resolved.checks) payload.checks = resolved.checks;
495
+ }
496
+
489
497
  function formatMediaIngress(mediaIngress) {
490
498
  if (!mediaIngress?.mode) return "";
491
499
  const detail = [mediaIngress.mode, mediaIngress.contentType, formatBytes(mediaIngress.bytes), formatSha256(mediaIngress.sha256)].filter(Boolean).join(" ");
package/watch.mjs ADDED
@@ -0,0 +1,225 @@
1
+ // `uploadcheck watch <folder>` — auto-QC every media file dropped into a folder.
2
+ //
3
+ // This is the automation layer for AI/agency pipelines: a video generator (or a
4
+ // human editor) drops renders into an inbox and UploadCheck reviews each one,
5
+ // writing the report next to the source file. It is a thin orchestration loop over
6
+ // the same submit/poll/report primitives `uploadcheck check` already uses — no new
7
+ // server endpoint, no extra dependency. Auth is the workspace key, so scans draw on
8
+ // the same prepaid minutes.
9
+ //
10
+ // Design notes:
11
+ // * Discovery uses a periodic readdir sweep (reliable across network drives) plus
12
+ // fs.watch for low-latency wakeups. fs.watch alone is unreliable; the sweep is
13
+ // the workhorse.
14
+ // * Stable-write gating: a file is only submitted once its size+mtime have stayed
15
+ // unchanged across two sweeps AND it is older than --settle-ms, so a half-written
16
+ // multi-GB render is never QC'd mid-copy.
17
+ // * Dedup is two-layered: an on-disk ledger keyed by sha(size:mtime:relpath), plus
18
+ // a shortcut that skips any file whose <file>.uploadcheck.json report already
19
+ // exists. Restarts never re-meter minutes.
20
+ import { readdirSync, statSync, existsSync, writeFileSync, readFileSync, watch as fsWatch } from "node:fs";
21
+ import { join, extname, basename, dirname, resolve, relative } from "node:path";
22
+ import { createHash } from "node:crypto";
23
+ import { spawn } from "node:child_process";
24
+ import { buildJobRequest, CONTENT_TYPES } from "./request-builder.mjs";
25
+ import { submitJob, pollJobUntilDone, fetchReport, fetchMarkerCsv, sleep } from "./api-client.mjs";
26
+
27
+ const LEDGER_NAME = ".uploadcheck-watch.json";
28
+ const AGGREGATE_CSV = "uploadcheck-report.csv";
29
+ const DEFAULT_SETTLE_MS = 5000;
30
+ const DEFAULT_CONCURRENCY = 2;
31
+ const DEFAULT_POLL_MS = 4000;
32
+ const SWEEP_INTERVAL_MS = 3000;
33
+
34
+ export const isMediaFile = (name) => CONTENT_TYPES.has(extname(name).toLowerCase());
35
+ // Our own output files + the ledger must never be treated as inputs.
36
+ export const isOwnOutput = (name) => name === LEDGER_NAME || name === AGGREGATE_CSV ||
37
+ name.endsWith(".uploadcheck.json") || name.endsWith(".uploadcheck.markers.csv");
38
+
39
+ const sourceKeyFor = (dir, filePath, stat) =>
40
+ createHash("sha256").update(`${stat.size}:${Math.round(stat.mtimeMs)}:${relative(dir, filePath)}`).digest("hex").slice(0, 16);
41
+
42
+ const reportPathFor = (filePath) => `${filePath}.uploadcheck.json`;
43
+ const markerPathFor = (filePath) => `${filePath}.uploadcheck.markers.csv`;
44
+
45
+ function loadLedger(dir) {
46
+ const p = join(dir, LEDGER_NAME);
47
+ if (!existsSync(p)) return { done: {} };
48
+ try { return JSON.parse(readFileSync(p, "utf8")); } catch { return { done: {} }; }
49
+ }
50
+ function saveLedger(dir, ledger) {
51
+ try { writeFileSync(join(dir, LEDGER_NAME), JSON.stringify(ledger, null, 2)); } catch { /* best-effort */ }
52
+ }
53
+
54
+ // A file is "settled" once it's old enough and unchanged since we last saw it.
55
+ function isSettled(filePath, stat, seen, settleMs) {
56
+ const ageMs = Date.now() - stat.mtimeMs;
57
+ const prev = seen.get(filePath);
58
+ seen.set(filePath, { size: stat.size, mtimeMs: stat.mtimeMs });
59
+ if (ageMs < settleMs) return false;
60
+ if (!prev) return false; // need at least one prior observation to confirm stability
61
+ return prev.size === stat.size && prev.mtimeMs === stat.mtimeMs;
62
+ }
63
+
64
+ function discoverCandidates(dir) {
65
+ const out = [];
66
+ let entries;
67
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
68
+ for (const ent of entries) {
69
+ if (!ent.isFile()) continue;
70
+ if (isOwnOutput(ent.name) || ent.name.startsWith(".")) continue;
71
+ if (!isMediaFile(ent.name)) continue;
72
+ out.push(join(dir, ent.name));
73
+ }
74
+ return out;
75
+ }
76
+
77
+ function appendAggregateRow(dir, row) {
78
+ const p = join(dir, AGGREGATE_CSV);
79
+ const header = "file,verdict,status,flags,job_id,checked_at\n";
80
+ const line = [row.file, row.verdict, row.status, row.flags, row.jobId, row.checkedAt]
81
+ .map((v) => `"${String(v ?? "").replace(/"/g, '""')}"`).join(",") + "\n";
82
+ try {
83
+ if (!existsSync(p)) writeFileSync(p, header + line);
84
+ else writeFileSync(p, readFileSync(p, "utf8") + line);
85
+ } catch { /* best-effort */ }
86
+ }
87
+
88
+ // Run a user-supplied shell command after a verdict (--on-pass / --on-fail).
89
+ // The file path is exposed to the command as $UPLOADCHECK_FILE (and the verdict as
90
+ // $UPLOADCHECK_VERDICT), so `mv "$UPLOADCHECK_FILE" done/` style commands work.
91
+ // Failures are logged, never fatal.
92
+ function runHook(command, filePath, verdict) {
93
+ if (!command) return Promise.resolve();
94
+ return new Promise((resolve) => {
95
+ const child = spawn(command, {
96
+ shell: true,
97
+ stdio: "inherit",
98
+ env: { ...process.env, UPLOADCHECK_FILE: filePath, UPLOADCHECK_VERDICT: String(verdict || "") }
99
+ });
100
+ child.on("error", (e) => { console.error(`[watch] hook error: ${e.message}`); resolve(); });
101
+ child.on("exit", () => resolve());
102
+ });
103
+ }
104
+
105
+ async function processFile(filePath, { apiBaseUrl, apiKey, options, dir, ledger, nowIso }) {
106
+ const name = basename(filePath);
107
+ // Build the job exactly like `uploadcheck check` (inline vs signed-upload auto-switch).
108
+ // A deterministic idempotency key from the content key means a re-submit of the same
109
+ // bytes is a no-op server-side, even if the local ledger was wiped.
110
+ const stat = statSync(filePath);
111
+ const sourceKey = sourceKeyFor(dir, filePath, stat);
112
+ const request = buildJobRequest(filePath, { ...options, idempotencyKey: options.idempotencyKey || `watch-${sourceKey}` });
113
+
114
+ console.log(`[watch] submitting ${name} ...`);
115
+ const job = await submitJob(request, apiKey);
116
+ const jobId = job.jobId;
117
+ const finished = await pollJobUntilDone(apiBaseUrl, jobId, apiKey, {
118
+ intervalMs: Number(options.pollMs) || DEFAULT_POLL_MS,
119
+ onProgress: (j) => { if (j.progressPct != null && j.status !== "completed") process.stdout.write(`\r[watch] ${name}: ${j.status} ${j.progressPct}% `); }
120
+ });
121
+ process.stdout.write("\r");
122
+
123
+ let report = null;
124
+ if (finished.status === "completed") {
125
+ report = await fetchReport(apiBaseUrl, jobId, apiKey);
126
+ writeFileSync(reportPathFor(filePath), JSON.stringify({ sourceKey, job: finished, report }, null, 2));
127
+ const csv = await fetchMarkerCsv(apiBaseUrl, jobId, apiKey);
128
+ if (csv) writeFileSync(markerPathFor(filePath), csv);
129
+ } else {
130
+ // Still record the terminal outcome so a failed job isn't retried forever.
131
+ writeFileSync(reportPathFor(filePath), JSON.stringify({ sourceKey, job: finished, report: null }, null, 2));
132
+ }
133
+
134
+ const verdict = (report && (report.verdict || report.summary?.verdict)) || finished.verdict || finished.status;
135
+ const flagCount = report ? (report.flags?.length ?? (report.summary?.blocked?.length || 0)) : 0;
136
+ appendAggregateRow(dir, { file: name, verdict, status: finished.status, flags: flagCount, jobId, checkedAt: nowIso });
137
+ ledger.done[sourceKey] = { file: name, jobId, verdict, status: finished.status, at: nowIso };
138
+ saveLedger(dir, ledger);
139
+
140
+ const verdictUpper = String(verdict).toUpperCase();
141
+ const passed = finished.status === "completed" && !verdictUpper.includes("BLOCK");
142
+ const mark = finished.status !== "completed" ? "⚠️" : (verdictUpper.includes("BLOCK") ? "❌ BLOCK" : (verdictUpper.includes("WATCH") ? "⚠️ WATCH" : "✅"));
143
+ console.log(`[watch] ${name}: ${mark} (${flagCount} flag${flagCount === 1 ? "" : "s"}) → ${basename(reportPathFor(filePath))}`);
144
+
145
+ // Pipeline glue: run --on-pass when nothing blocks (a clean PASS or WATCH), or
146
+ // --on-fail on a BLOCK / failed job. WATCH counts as a pass — it ships with notes.
147
+ if (passed && options.onPass) await runHook(options.onPass, filePath, verdict);
148
+ else if (!passed && options.onFail) await runHook(options.onFail, filePath, verdict);
149
+ }
150
+
151
+ // Run a list of files through processFile with bounded concurrency.
152
+ async function runPool(files, concurrency, worker) {
153
+ const queue = [...files];
154
+ const runners = Array.from({ length: Math.max(1, concurrency) }, async () => {
155
+ for (;;) {
156
+ const next = queue.shift();
157
+ if (!next) return;
158
+ try { await worker(next); }
159
+ catch (err) { console.error(`[watch] ${basename(next)}: ${err.message}`); }
160
+ }
161
+ });
162
+ await Promise.all(runners);
163
+ }
164
+
165
+ export async function runWatch(target, options, apiKey) {
166
+ if (!target) throw new Error("Usage: uploadcheck watch <folder> [--once] [--concurrency N] [--settle-ms MS]");
167
+ const dir = resolve(target);
168
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new Error(`Not a folder: ${dir}`);
169
+ if (!apiKey) throw new Error("Set UPLOADCHECK_API_KEY or pass --api-key.");
170
+
171
+ const apiBaseUrl = (options.apiBaseUrl || process.env.UPLOADCHECK_API_BASE_URL || "https://api.uploadcheck.app").replace(/\/+$/, "");
172
+ const concurrency = Number(options.concurrency) || DEFAULT_CONCURRENCY;
173
+ const settleMs = Number(options.settleMs) || DEFAULT_SETTLE_MS;
174
+ const ledger = loadLedger(dir);
175
+ const seen = new Map(); // filePath -> last {size, mtimeMs} for stable-write gating
176
+ const inFlight = new Set();
177
+
178
+ const alreadyDone = (filePath) => {
179
+ if (existsSync(reportPathFor(filePath))) return true; // report next to source = done
180
+ try {
181
+ const key = sourceKeyFor(dir, filePath, statSync(filePath));
182
+ return Boolean(ledger.done[key]);
183
+ } catch { return false; }
184
+ };
185
+
186
+ const nowIso = () => new Date().toISOString();
187
+
188
+ async function sweep() {
189
+ const candidates = discoverCandidates(dir).filter((f) => !inFlight.has(f) && !alreadyDone(f));
190
+ const ready = [];
191
+ for (const f of candidates) {
192
+ let stat;
193
+ try { stat = statSync(f); } catch { continue; }
194
+ if (isSettled(f, stat, seen, settleMs)) ready.push(f);
195
+ }
196
+ if (!ready.length) return;
197
+ ready.forEach((f) => inFlight.add(f));
198
+ await runPool(ready, concurrency, (f) =>
199
+ processFile(f, { apiBaseUrl, apiKey, options, dir, ledger, nowIso: nowIso() }).finally(() => inFlight.delete(f))
200
+ );
201
+ }
202
+
203
+ if (options.once) {
204
+ console.log(`[watch] one-shot scan of ${dir}`);
205
+ // In --once mode files are already on disk and settled — submit immediately.
206
+ const ready = discoverCandidates(dir).filter((f) => !alreadyDone(f));
207
+ if (!ready.length) { console.log("[watch] nothing new to scan."); return; }
208
+ ready.forEach((f) => inFlight.add(f));
209
+ await runPool(ready, concurrency, (f) => processFile(f, { apiBaseUrl, apiKey, options, dir, ledger, nowIso: nowIso() }));
210
+ console.log(`[watch] done — ${ready.length} file(s) scanned. Aggregate: ${join(dir, AGGREGATE_CSV)}`);
211
+ return;
212
+ }
213
+
214
+ console.log(`[watch] watching ${dir} (concurrency ${concurrency}, settle ${settleMs}ms). Drop media files in; Ctrl-C to stop.`);
215
+ let watcher = null;
216
+ try { watcher = fsWatch(dir, { persistent: true }, () => {}); } catch { /* sweep still covers it */ }
217
+ let stopped = false;
218
+ process.on("SIGINT", () => { stopped = true; if (watcher) watcher.close(); console.log("\n[watch] stopping."); process.exit(0); });
219
+
220
+ await sweep(); // catch backlog first
221
+ while (!stopped) {
222
+ await sleep(SWEEP_INTERVAL_MS);
223
+ await sweep();
224
+ }
225
+ }