@drantoniou/uploadcheck 0.1.0 → 0.2.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 +88 -0
- package/index.mjs +12 -58
- package/package.json +5 -2
- package/qc-profiles.mjs +123 -0
- package/request-builder.mjs +34 -30
- package/watch.mjs +200 -0
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 {
|
|
3
|
-
import {
|
|
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
|
|
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 === "
|
|
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
|
|
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.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.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",
|
package/qc-profiles.mjs
ADDED
|
@@ -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
|
+
}
|
package/request-builder.mjs
CHANGED
|
@@ -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,14 @@ 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());
|
|
178
193
|
} else {
|
|
179
194
|
throw new Error(`Unknown option: ${arg}`);
|
|
180
195
|
}
|
|
@@ -271,26 +286,14 @@ export function buildCostBasisRequest(options = {}) {
|
|
|
271
286
|
};
|
|
272
287
|
}
|
|
273
288
|
|
|
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
289
|
export function buildEstimateRequest(options = {}) {
|
|
289
290
|
const apiBaseUrl = trimTrailingSlash(options.apiBaseUrl || process.env.UPLOADCHECK_API_BASE_URL || DEFAULT_API_BASE_URL);
|
|
290
291
|
const payload = {};
|
|
292
|
+
if (options.profile) payload.profile = options.profile;
|
|
291
293
|
if (options.checks) payload.checks = options.checks;
|
|
292
294
|
if (options.minutes) payload.minutes = Number(options.minutes);
|
|
293
295
|
if (options.durationSeconds) payload.duration_seconds = Number(options.durationSeconds);
|
|
296
|
+
applyProfilePayload(payload);
|
|
294
297
|
attachCostOptions(payload, options);
|
|
295
298
|
return {
|
|
296
299
|
apiBaseUrl,
|
|
@@ -315,19 +318,6 @@ export function formatJobSummary(payload) {
|
|
|
315
318
|
return `UploadCheck job ${payload.jobId || payload.id || "(unknown)"}: ${status} / ${verdict} | ${minutes} min${ingressSuffix}${suffix}`;
|
|
316
319
|
}
|
|
317
320
|
|
|
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
321
|
export function formatLaunchStatusSummary(payload) {
|
|
332
322
|
const status = payload.product_hunt_ready ? "READY" : "NOT READY";
|
|
333
323
|
const blockers = (payload.remaining_blockers || []).map((blocker) => blocker.id).filter(Boolean);
|
|
@@ -486,6 +476,20 @@ function attachCostOptions(payload, options) {
|
|
|
486
476
|
if (options.costGuardrail) payload.cost_guardrail = options.costGuardrail;
|
|
487
477
|
}
|
|
488
478
|
|
|
479
|
+
function applyProfilePayload(payload, context = {}) {
|
|
480
|
+
const resolved = applyProfileToInput({
|
|
481
|
+
...payload,
|
|
482
|
+
media_kind: payload.media_kind,
|
|
483
|
+
duration_seconds: payload.duration_seconds,
|
|
484
|
+
mediaKind: payload.media_kind || context.mediaKind,
|
|
485
|
+
filename: payload.filename || context.filename,
|
|
486
|
+
media_content_type: payload.media_content_type || context.contentType
|
|
487
|
+
});
|
|
488
|
+
if (resolved.profile) payload.profile = resolved.profile;
|
|
489
|
+
if (resolved.requested_profile) payload.requested_profile = resolved.requested_profile;
|
|
490
|
+
if (resolved.checks) payload.checks = resolved.checks;
|
|
491
|
+
}
|
|
492
|
+
|
|
489
493
|
function formatMediaIngress(mediaIngress) {
|
|
490
494
|
if (!mediaIngress?.mode) return "";
|
|
491
495
|
const detail = [mediaIngress.mode, mediaIngress.contentType, formatBytes(mediaIngress.bytes), formatSha256(mediaIngress.sha256)].filter(Boolean).join(" ");
|
package/watch.mjs
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
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 { buildJobRequest, CONTENT_TYPES } from "./request-builder.mjs";
|
|
24
|
+
import { submitJob, pollJobUntilDone, fetchReport, fetchMarkerCsv, sleep } from "./api-client.mjs";
|
|
25
|
+
|
|
26
|
+
const LEDGER_NAME = ".uploadcheck-watch.json";
|
|
27
|
+
const AGGREGATE_CSV = "uploadcheck-report.csv";
|
|
28
|
+
const DEFAULT_SETTLE_MS = 5000;
|
|
29
|
+
const DEFAULT_CONCURRENCY = 2;
|
|
30
|
+
const DEFAULT_POLL_MS = 4000;
|
|
31
|
+
const SWEEP_INTERVAL_MS = 3000;
|
|
32
|
+
|
|
33
|
+
export const isMediaFile = (name) => CONTENT_TYPES.has(extname(name).toLowerCase());
|
|
34
|
+
// Our own output files + the ledger must never be treated as inputs.
|
|
35
|
+
export const isOwnOutput = (name) => name === LEDGER_NAME || name === AGGREGATE_CSV ||
|
|
36
|
+
name.endsWith(".uploadcheck.json") || name.endsWith(".uploadcheck.markers.csv");
|
|
37
|
+
|
|
38
|
+
const sourceKeyFor = (dir, filePath, stat) =>
|
|
39
|
+
createHash("sha256").update(`${stat.size}:${Math.round(stat.mtimeMs)}:${relative(dir, filePath)}`).digest("hex").slice(0, 16);
|
|
40
|
+
|
|
41
|
+
const reportPathFor = (filePath) => `${filePath}.uploadcheck.json`;
|
|
42
|
+
const markerPathFor = (filePath) => `${filePath}.uploadcheck.markers.csv`;
|
|
43
|
+
|
|
44
|
+
function loadLedger(dir) {
|
|
45
|
+
const p = join(dir, LEDGER_NAME);
|
|
46
|
+
if (!existsSync(p)) return { done: {} };
|
|
47
|
+
try { return JSON.parse(readFileSync(p, "utf8")); } catch { return { done: {} }; }
|
|
48
|
+
}
|
|
49
|
+
function saveLedger(dir, ledger) {
|
|
50
|
+
try { writeFileSync(join(dir, LEDGER_NAME), JSON.stringify(ledger, null, 2)); } catch { /* best-effort */ }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A file is "settled" once it's old enough and unchanged since we last saw it.
|
|
54
|
+
function isSettled(filePath, stat, seen, settleMs) {
|
|
55
|
+
const ageMs = Date.now() - stat.mtimeMs;
|
|
56
|
+
const prev = seen.get(filePath);
|
|
57
|
+
seen.set(filePath, { size: stat.size, mtimeMs: stat.mtimeMs });
|
|
58
|
+
if (ageMs < settleMs) return false;
|
|
59
|
+
if (!prev) return false; // need at least one prior observation to confirm stability
|
|
60
|
+
return prev.size === stat.size && prev.mtimeMs === stat.mtimeMs;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function discoverCandidates(dir) {
|
|
64
|
+
const out = [];
|
|
65
|
+
let entries;
|
|
66
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
|
|
67
|
+
for (const ent of entries) {
|
|
68
|
+
if (!ent.isFile()) continue;
|
|
69
|
+
if (isOwnOutput(ent.name) || ent.name.startsWith(".")) continue;
|
|
70
|
+
if (!isMediaFile(ent.name)) continue;
|
|
71
|
+
out.push(join(dir, ent.name));
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function appendAggregateRow(dir, row) {
|
|
77
|
+
const p = join(dir, AGGREGATE_CSV);
|
|
78
|
+
const header = "file,verdict,status,flags,job_id,checked_at\n";
|
|
79
|
+
const line = [row.file, row.verdict, row.status, row.flags, row.jobId, row.checkedAt]
|
|
80
|
+
.map((v) => `"${String(v ?? "").replace(/"/g, '""')}"`).join(",") + "\n";
|
|
81
|
+
try {
|
|
82
|
+
if (!existsSync(p)) writeFileSync(p, header + line);
|
|
83
|
+
else writeFileSync(p, readFileSync(p, "utf8") + line);
|
|
84
|
+
} catch { /* best-effort */ }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function processFile(filePath, { apiBaseUrl, apiKey, options, dir, ledger, nowIso }) {
|
|
88
|
+
const name = basename(filePath);
|
|
89
|
+
// Build the job exactly like `uploadcheck check` (inline vs signed-upload auto-switch).
|
|
90
|
+
// A deterministic idempotency key from the content key means a re-submit of the same
|
|
91
|
+
// bytes is a no-op server-side, even if the local ledger was wiped.
|
|
92
|
+
const stat = statSync(filePath);
|
|
93
|
+
const sourceKey = sourceKeyFor(dir, filePath, stat);
|
|
94
|
+
const request = buildJobRequest(filePath, { ...options, idempotencyKey: options.idempotencyKey || `watch-${sourceKey}` });
|
|
95
|
+
|
|
96
|
+
console.log(`[watch] submitting ${name} ...`);
|
|
97
|
+
const job = await submitJob(request, apiKey);
|
|
98
|
+
const jobId = job.jobId;
|
|
99
|
+
const finished = await pollJobUntilDone(apiBaseUrl, jobId, apiKey, {
|
|
100
|
+
intervalMs: Number(options.pollMs) || DEFAULT_POLL_MS,
|
|
101
|
+
onProgress: (j) => { if (j.progressPct != null && j.status !== "completed") process.stdout.write(`\r[watch] ${name}: ${j.status} ${j.progressPct}% `); }
|
|
102
|
+
});
|
|
103
|
+
process.stdout.write("\r");
|
|
104
|
+
|
|
105
|
+
let report = null;
|
|
106
|
+
if (finished.status === "completed") {
|
|
107
|
+
report = await fetchReport(apiBaseUrl, jobId, apiKey);
|
|
108
|
+
writeFileSync(reportPathFor(filePath), JSON.stringify({ sourceKey, job: finished, report }, null, 2));
|
|
109
|
+
const csv = await fetchMarkerCsv(apiBaseUrl, jobId, apiKey);
|
|
110
|
+
if (csv) writeFileSync(markerPathFor(filePath), csv);
|
|
111
|
+
} else {
|
|
112
|
+
// Still record the terminal outcome so a failed job isn't retried forever.
|
|
113
|
+
writeFileSync(reportPathFor(filePath), JSON.stringify({ sourceKey, job: finished, report: null }, null, 2));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const verdict = (report && (report.verdict || report.summary?.verdict)) || finished.verdict || finished.status;
|
|
117
|
+
const flagCount = report ? (report.flags?.length ?? (report.summary?.blocked?.length || 0)) : 0;
|
|
118
|
+
appendAggregateRow(dir, { file: name, verdict, status: finished.status, flags: flagCount, jobId, checkedAt: nowIso });
|
|
119
|
+
ledger.done[sourceKey] = { file: name, jobId, verdict, status: finished.status, at: nowIso };
|
|
120
|
+
saveLedger(dir, ledger);
|
|
121
|
+
|
|
122
|
+
const mark = finished.status !== "completed" ? "⚠️" : (String(verdict).toUpperCase().includes("BLOCK") ? "❌ BLOCK" : (String(verdict).toUpperCase().includes("WATCH") ? "⚠️ WATCH" : "✅"));
|
|
123
|
+
console.log(`[watch] ${name}: ${mark} (${flagCount} flag${flagCount === 1 ? "" : "s"}) → ${basename(reportPathFor(filePath))}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Run a list of files through processFile with bounded concurrency.
|
|
127
|
+
async function runPool(files, concurrency, worker) {
|
|
128
|
+
const queue = [...files];
|
|
129
|
+
const runners = Array.from({ length: Math.max(1, concurrency) }, async () => {
|
|
130
|
+
for (;;) {
|
|
131
|
+
const next = queue.shift();
|
|
132
|
+
if (!next) return;
|
|
133
|
+
try { await worker(next); }
|
|
134
|
+
catch (err) { console.error(`[watch] ${basename(next)}: ${err.message}`); }
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
await Promise.all(runners);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function runWatch(target, options, apiKey) {
|
|
141
|
+
if (!target) throw new Error("Usage: uploadcheck watch <folder> [--once] [--concurrency N] [--settle-ms MS]");
|
|
142
|
+
const dir = resolve(target);
|
|
143
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new Error(`Not a folder: ${dir}`);
|
|
144
|
+
if (!apiKey) throw new Error("Set UPLOADCHECK_API_KEY or pass --api-key.");
|
|
145
|
+
|
|
146
|
+
const apiBaseUrl = (options.apiBaseUrl || process.env.UPLOADCHECK_API_BASE_URL || "https://api.uploadcheck.app").replace(/\/+$/, "");
|
|
147
|
+
const concurrency = Number(options.concurrency) || DEFAULT_CONCURRENCY;
|
|
148
|
+
const settleMs = Number(options.settleMs) || DEFAULT_SETTLE_MS;
|
|
149
|
+
const ledger = loadLedger(dir);
|
|
150
|
+
const seen = new Map(); // filePath -> last {size, mtimeMs} for stable-write gating
|
|
151
|
+
const inFlight = new Set();
|
|
152
|
+
|
|
153
|
+
const alreadyDone = (filePath) => {
|
|
154
|
+
if (existsSync(reportPathFor(filePath))) return true; // report next to source = done
|
|
155
|
+
try {
|
|
156
|
+
const key = sourceKeyFor(dir, filePath, statSync(filePath));
|
|
157
|
+
return Boolean(ledger.done[key]);
|
|
158
|
+
} catch { return false; }
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const nowIso = () => new Date().toISOString();
|
|
162
|
+
|
|
163
|
+
async function sweep() {
|
|
164
|
+
const candidates = discoverCandidates(dir).filter((f) => !inFlight.has(f) && !alreadyDone(f));
|
|
165
|
+
const ready = [];
|
|
166
|
+
for (const f of candidates) {
|
|
167
|
+
let stat;
|
|
168
|
+
try { stat = statSync(f); } catch { continue; }
|
|
169
|
+
if (isSettled(f, stat, seen, settleMs)) ready.push(f);
|
|
170
|
+
}
|
|
171
|
+
if (!ready.length) return;
|
|
172
|
+
ready.forEach((f) => inFlight.add(f));
|
|
173
|
+
await runPool(ready, concurrency, (f) =>
|
|
174
|
+
processFile(f, { apiBaseUrl, apiKey, options, dir, ledger, nowIso: nowIso() }).finally(() => inFlight.delete(f))
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (options.once) {
|
|
179
|
+
console.log(`[watch] one-shot scan of ${dir}`);
|
|
180
|
+
// In --once mode files are already on disk and settled — submit immediately.
|
|
181
|
+
const ready = discoverCandidates(dir).filter((f) => !alreadyDone(f));
|
|
182
|
+
if (!ready.length) { console.log("[watch] nothing new to scan."); return; }
|
|
183
|
+
ready.forEach((f) => inFlight.add(f));
|
|
184
|
+
await runPool(ready, concurrency, (f) => processFile(f, { apiBaseUrl, apiKey, options, dir, ledger, nowIso: nowIso() }));
|
|
185
|
+
console.log(`[watch] done — ${ready.length} file(s) scanned. Aggregate: ${join(dir, AGGREGATE_CSV)}`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
console.log(`[watch] watching ${dir} (concurrency ${concurrency}, settle ${settleMs}ms). Drop media files in; Ctrl-C to stop.`);
|
|
190
|
+
let watcher = null;
|
|
191
|
+
try { watcher = fsWatch(dir, { persistent: true }, () => {}); } catch { /* sweep still covers it */ }
|
|
192
|
+
let stopped = false;
|
|
193
|
+
process.on("SIGINT", () => { stopped = true; if (watcher) watcher.close(); console.log("\n[watch] stopping."); process.exit(0); });
|
|
194
|
+
|
|
195
|
+
await sweep(); // catch backlog first
|
|
196
|
+
while (!stopped) {
|
|
197
|
+
await sleep(SWEEP_INTERVAL_MS);
|
|
198
|
+
await sweep();
|
|
199
|
+
}
|
|
200
|
+
}
|