upscale4k 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,230 @@
1
- # Temporary Holding Version
1
+ # Upscale4K
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Upscale local MP4/MOV videos to 4K with [Upscale4K](https://upscale4k.io) from a terminal or an MCP agent. Node.js 20+
4
+ is required. No install is needed:
5
+
6
+ ```sh
7
+ npx upscale4k --help
8
+ npx upscale4k login
9
+ ```
10
+
11
+ Create an API key in your Upscale4K account first. Login hides typed characters,
12
+ validates the key and API access, and saves the credential. Credentials live in
13
+ `~/.config/upscale4k/config.json`, or `%APPDATA%\upscale4k\config.json` on Windows.
14
+ The config has mode 600 on POSIX. `UPSCALE4K_API_KEY` overrides its credential.
15
+ `UPSCALE4K_API_URL` overrides the default `https://upscale4k.io` API origin.
16
+ Keep the config private. `npx upscale4k logout` removes local login; unset any
17
+ environment credential separately.
18
+
19
+ ### Agents and scripts without a terminal
20
+
21
+ Set `UPSCALE4K_API_KEY`. No login, config file, or general terms acceptance is
22
+ needed. The legacy `--accept-terms` flag is accepted
23
+ as a deprecated no-op and does not record or log acceptance. Existing configs
24
+ containing old terms metadata remain readable; version changes do not block access.
25
+
26
+ ```sh
27
+ UPSCALE4K_API_KEY=cx_live_... npx upscale4k upscale ./clip.mp4 --self-likeness no --json
28
+ ```
29
+
30
+ PowerShell:
31
+
32
+ ```powershell
33
+ npx upscale4k upscale "D:\videos\clip.mov" --self-likeness no
34
+ npx upscale4k upscale "D:\videos\batch" --self-likeness yes --out "D:\results" --concurrency 3
35
+ ```
36
+
37
+ Bash:
38
+
39
+ ```bash
40
+ npx upscale4k upscale ./clip.mp4 --self-likeness no
41
+ npx upscale4k upscale ./batch --self-likeness yes --out ./results --concurrency 3
42
+ ```
43
+
44
+ `--self-likeness yes|no` is required for every run: declare whether the upload
45
+ contains your own image or voice. With `yes`, the exact account consent statement
46
+ is displayed for confirmation. Scripts may supply `--yes-self-likeness-consent`
47
+ only after obtaining that explicit consent. Agents must ask the user; never guess.
48
+
49
+ The CLI scans folders recursively. Results are adjacent to inputs by default:
50
+ `clip.mov` becomes `clip 4K.mp4`. With `--out`, a folder's basename and structure
51
+ are mirrored: `batch/sub/clip.mov` becomes `results/batch/sub/clip 4K.mp4`.
52
+ Sources with the same case-insensitive stem in one output folder receive
53
+ extension-qualified names: `clip.mp4` and `clip.mov` become `clip (mp4) 4K.mp4`
54
+ and `clip (mov) 4K.mp4`. Extensions in these names are lowercase. This applies
55
+ to both colocated and mirrored outputs. A lone `clip.mov` keeps `clip 4K.mp4`.
56
+ Single-file CLI and MCP upscale requests account for supported siblings in the
57
+ source folder, so their names agree with folder runs.
58
+
59
+ For colocated outputs and grouped siblings, only each source's computed output
60
+ name counts as an existing result; reruns skip exactly those completed sources.
61
+ For a lone source with `--out` pointing to a different folder, its computed name,
62
+ `<stem>.mp4` or `<stem>.mov` in the destination count (the input itself is
63
+ excluded). Matches are case-insensitive. `.part` files do not count. Generated
64
+ ` 4K.mp4` files are excluded from directory scans and grouping. If computed output
65
+ paths still collide, the run stops with `local_error` before contacting the API;
66
+ rename one source before retrying.
67
+
68
+ Uploads must be at most 500 MiB (500 × 1024 × 1024 bytes), have a longer side
69
+ between 500 and 2560 pixels, and fit the account's current `/me` `max_seconds`.
70
+ Out-of-limit files are reported and skipped; the server makes the final admission
71
+ decision and its error is shown as is. The service also rejects videos whose average frame rate exceeds its server-side
72
+ limit (`frame_rate_limit` at admission or `input_frame_rate_high` after upload);
73
+ export at 30 fps or lower.
74
+ Metadata is read with a built-in MP4/QuickTime parser; install `ffprobe` on PATH
75
+ for files needing the fallback. Unsupported/unreadable files are reported as failures.
76
+
77
+ ### Not yet available
78
+
79
+ Long-video upload is not yet available through the Upscale4K API: `/me` does not
80
+ advertise `long_video`, and `/api/v1/jobs/{id}/multipart` is not served. Files
81
+ over the account's `max_seconds` are skipped with an explanatory message.
82
+ The client retains multipart support for compatible servers that advertise
83
+ `long_video.eligible`: those uploads may run up to
84
+ `long_video.max_seconds["runway-4k"]` and weigh up to `long_video.max_upload_bytes`.
85
+ Completing the multipart upload queues the job without a separate submit call.
86
+ If a server advertises multipart without serving its endpoint, the client reports
87
+ the unsupported upload and preserves the saved job for inspection.
88
+
89
+ Upscale4K web also does not yet send `x-upscale4k-latest` or retire clients with
90
+ 426 `upgrade_required`. The client supports both: it prints one update notice
91
+ per client on stderr when a newer numeric version is advertised and stops a
92
+ retired run with `npx -y upscale4k@latest` resume instructions. No header means no notice.
93
+
94
+ `--wait` is the default. `--no-wait` returns submitted job IDs without downloading.
95
+ Inspect them with:
96
+
97
+ ```sh
98
+ npx upscale4k status
99
+ npx upscale4k status JOB_ID
100
+ npx upscale4k credits
101
+ ```
102
+
103
+ For agents, `--json` emits one JSON object per event line:
104
+
105
+ ```sh
106
+ npx upscale4k upscale ./batch --self-likeness no --json
107
+ npx upscale4k upscale ./batch --self-likeness yes --yes-self-likeness-consent --no-wait --json
108
+ ```
109
+
110
+ Events: `scan`, `skipped`, `created`, `uploaded`, `queued`, `progress`, `done`,
111
+ `failed`, `summary`. Consent statements and interactive prompts use stderr.
112
+ Failed files or an early stop set exit status 1. Already in-flight workers may
113
+ finish after a server admission stop; no further files are started. Ctrl+C or
114
+ MCP request cancellation stops scheduling, polling, transfers and retries;
115
+ already-created jobs stay on the server and can be resumed. Concurrency defaults to 3
116
+ (allowed range 1–100).
117
+
118
+ Rerun the same command after a crash. `.upscale4k-state.json` in `--out` (or the
119
+ source directory) saves source path, size, mtime, output, exact request body,
120
+ idempotency key, creation time, job ID and the API key's first 12 characters
121
+ (never the full key). Do not delete it during recovery.
122
+ Create retries reuse their key; existing jobs are inspected before continuing.
123
+ After API key rotation, an unfinished create/upload stops with `resume_ambiguous`:
124
+ inspect/cancel the old job or rerun with the original key. Jobs already past
125
+ uploading continue normally; legacy state without a key prefix keeps its prior behaviour.
126
+ After 24 hours, only a confirmed failed/canceled/expired job is replaced.
127
+ An old uploading job or unknown job ID stops with `resume_ambiguous` because
128
+ the contract has no upload-refresh endpoint after idempotency expires. Inspect
129
+ and cancel that old job before manually resolving its state. Legacy request bodies
130
+ are replayed unchanged to preserve idempotency. New requests send only the explicit
131
+ likeness choice and consent when applicable. A changed consent choice conflicts
132
+ with a saved request instead of silently changing the upload.
133
+ Downloads stream to `<output>.part`, restart from zero after transfer failures,
134
+ and are renamed only on completion. Rate limits honour `Retry-After` (seconds
135
+ or an HTTP date); transient server/network failures retry with bounded delays.
136
+
137
+ Concurrent CLI runs and MCP calls sharing a state directory skip sources owned
138
+ by another run immediately (`in_progress_elsewhere`), without failing or stopping
139
+ the batch; other files proceed in parallel. Rerun later to pick up the result:
140
+ the saved request and job are reused, so nothing is duplicated. Per-source leases
141
+ refresh every 30 seconds for at most six hours; unrefreshed leases expire after
142
+ two minutes, and dead local owners are recovered immediately. Short state-file
143
+ transactions have no heartbeat and wait at most 60 seconds before `state_locked`.
144
+
145
+ ## MCP
146
+
147
+ Either run `npx upscale4k login` once in a terminal, or give the server
148
+ `UPSCALE4K_API_KEY`.
149
+ MCP never accepts likeness consent on the user's behalf. Stdio stdout contains
150
+ protocol messages only.
151
+
152
+ Headless setup, for example Claude Code:
153
+
154
+ ```sh
155
+ claude mcp add upscale4k -e UPSCALE4K_API_KEY=cx_live_... -- npx -y upscale4k mcp
156
+ ```
157
+
158
+ Claude Code:
159
+
160
+ ```sh
161
+ claude mcp add upscale4k -- npx -y upscale4k mcp
162
+ ```
163
+
164
+ Claude Desktop config:
165
+
166
+ ```json
167
+ {
168
+ "mcpServers": {
169
+ "upscale4k": { "command": "npx", "args": ["-y", "upscale4k", "mcp"] }
170
+ }
171
+ }
172
+ ```
173
+
174
+ Cursor `.cursor/mcp.json`:
175
+
176
+ ```json
177
+ {
178
+ "mcpServers": {
179
+ "upscale4k": { "command": "npx", "args": ["-y", "upscale4k", "mcp"] }
180
+ }
181
+ }
182
+ ```
183
+
184
+ If your Windows MCP host cannot resolve `npx`, use `"command": "cmd"` and
185
+ `"args": ["/c", "npx", "-y", "upscale4k", "mcp"]` instead.
186
+
187
+ Five tools: `upscale_video({path,self_likeness,self_likeness_consent?,output_dir?,wait?})`,
188
+ `get_job({job_id})`, `list_jobs({state?,limit?})`, `get_account()`, and
189
+ `download_result({job_id,output_dir})`. Every tool returns JSON text and the same value as `structuredContent`.
190
+ Upscale defaults to waiting and returns an array of
191
+ `{source,output_path|job_id,state,error?}` (in `structuredContent` as `{results:[...]}`).
192
+ A client progress token enables progress notifications. Downloading by job ID
193
+ names the file `<original name> 4K.mp4`, or `<job_id> 4K.mp4` when the name is unknown.
194
+ Cancelled tool handlers return an `isError` result with error code `cancelled`.
195
+ The MCP SDK suppresses responses after protocol cancellation; SDK clients abort
196
+ their pending call locally while the server stops work and releases its leases.
197
+
198
+ `npx upscale4k status` prints the active-job count and account cap (`unlimited`
199
+ when `max_active` is null), followed by one readable line per job. A job ID prints
200
+ only that job's line. Add `--json` for the raw response; account fields and null
201
+ caps remain unchanged in `credits --json` and MCP `get_account`.
202
+
203
+ ## Errors
204
+
205
+ | Code | Action |
206
+ | --- | --- |
207
+ | `invalid_api_key` | Run `npx upscale4k login` with a valid, unrevoked key. |
208
+ | `consent_required` | Ask the user about likeness and supply explicit consent when it applies. |
209
+ | `consent_outdated` | Inspect the server response and saved job; login does not accept terms. |
210
+ | `api_locked` | Unlock API access in your Upscale4K account, then rerun. |
211
+ | `account_suspended` | Contact Upscale4K support. |
212
+ | `no_credits` | Add credits and rerun; the batch stops early. |
213
+ | `daily_limit` | Wait for the next daily allowance; the batch stops early. |
214
+ | `active_limit` | Wait for active jobs to finish or lower concurrency. |
215
+ | `engine_unavailable` | Check engine access and maintenance in your Upscale4K account, then retry later. |
216
+ | `key_limit` | Revoke an unused API key in your account before creating another. |
217
+ | `rate_limited` | Retried automatically according to `Retry-After`; rerun if exhausted. |
218
+ | `validation_error`, `duration_limit`, `resolution_limit`, `frame_rate_limit` | Check input format, dimensions, byte size, frame rate and account duration cap. |
219
+ | `idempotency_required`, `idempotency_mismatch` | Preserve the state file and exact request; report persistent contract errors. |
220
+ | `not_found`, `expired` | Inspect job status and result retention. |
221
+ | `conflict` | Inspect job state or the saved upload's consent choice. |
222
+ | `locked_for_review` | Wait for review or contact support. |
223
+ | `uploads_closed` | Wait until account uploads reopen. |
224
+ | `range_not_satisfiable` | The requested download byte range is invalid; this client restarts full downloads. |
225
+ | `resume_ambiguous` | Inspect the old job; no duplicate is created automatically. |
226
+ | `network_error` | Check connectivity and rerun the same command to resume. |
227
+ | `upgrade_required` | Rerun with `npx -y upscale4k@latest` to resume. |
228
+
229
+ REST v1 is defined in `src/api-contract.ts`. This package is verified against a
230
+ local fake v1 server; a live server acceptance run and npm publication are separate.
@@ -0,0 +1,52 @@
1
+ import { type Account, type CanceledJob, type CreateJobBody, type CreatedJob, type Job, type JobList, type MultipartCompleted, type SubmittedJob } from "./api-contract.js";
2
+ export declare class ApiError extends Error {
3
+ code: string;
4
+ status: number;
5
+ constructor(code: string, message: string, status?: number);
6
+ }
7
+ export declare function throwIfCancelled(signal?: AbortSignal): void;
8
+ export declare const sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
9
+ /** True when dotted version a sorts before b; non-numeric parts never count as older. */
10
+ export declare function versionOlder(a: string, b: string): boolean;
11
+ interface Options {
12
+ baseUrl?: string;
13
+ attempts?: number;
14
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
15
+ /** Receives the one-time update notice; stderr by default so --json stdout and MCP stdio stay clean. */
16
+ notice?: (message: string) => void;
17
+ }
18
+ export declare class ApiClient {
19
+ private readonly key;
20
+ readonly baseUrl: string;
21
+ private readonly attempts;
22
+ private readonly pause;
23
+ private readonly notice;
24
+ private readonly userAgent;
25
+ private noticeShown;
26
+ constructor(key: string, options?: Options);
27
+ private safe;
28
+ get keyPrefix(): string;
29
+ private requestSignal;
30
+ private retry;
31
+ /** The API names its newest CLI in x-upscale4k-latest; tell the user once per run when this one is older. */
32
+ private noteLatest;
33
+ private checked;
34
+ private json;
35
+ me(signal?: AbortSignal): Promise<Account>;
36
+ create(body: CreateJobBody, key: string, signal?: AbortSignal): Promise<CreatedJob>;
37
+ submit(id: string, signal?: AbortSignal): Promise<SubmittedJob>;
38
+ getJob(id: string, signal?: AbortSignal): Promise<Job>;
39
+ listJobs(query?: {
40
+ state?: string;
41
+ limit?: number;
42
+ cursor?: string;
43
+ }): Promise<JobList>;
44
+ cancel(id: string): Promise<CanceledJob>;
45
+ retryJob(id: string, key: string): Promise<Job>;
46
+ upload(created: CreatedJob, file: string, size: number, signal?: AbortSignal): Promise<void>;
47
+ /** Upload a long job's file in parts and complete it, which queues the job; replaying complete is safe. */
48
+ uploadLong(created: CreatedJob, file: string, size: number, signal?: AbortSignal, parallel?: number): Promise<MultipartCompleted>;
49
+ private putPart;
50
+ download(id: string, output: string, relativeUrl?: string | null, signal?: AbortSignal): Promise<void>;
51
+ }
52
+ export {};
@@ -0,0 +1,258 @@
1
+ /** Stream REST v1 transfers, retrying only safe/idempotent requests. */
2
+ import { createReadStream, createWriteStream } from "node:fs";
3
+ import { mkdir, rename } from "node:fs/promises";
4
+ import { dirname } from "node:path";
5
+ import { Readable } from "node:stream";
6
+ import { pipeline } from "node:stream/promises";
7
+ import { DEFAULT_API_URL, VERSION, paths, } from "./api-contract.js";
8
+ export class ApiError extends Error {
9
+ code;
10
+ status;
11
+ constructor(code, message, status = 0) {
12
+ super(message);
13
+ this.code = code;
14
+ this.status = status;
15
+ }
16
+ }
17
+ export function throwIfCancelled(signal) {
18
+ if (signal?.aborted)
19
+ throw new ApiError("cancelled", "Upscale cancelled; rerun the same command to resume.");
20
+ }
21
+ export const sleep = (ms, signal) => new Promise((resolve, reject) => {
22
+ try {
23
+ throwIfCancelled(signal);
24
+ }
25
+ catch (error) {
26
+ reject(error);
27
+ return;
28
+ }
29
+ const abort = () => { clearTimeout(timer); signal?.removeEventListener("abort", abort); reject(new ApiError("cancelled", "Upscale cancelled; rerun the same command to resume.")); };
30
+ const timer = setTimeout(() => { signal?.removeEventListener("abort", abort); resolve(); }, ms);
31
+ signal?.addEventListener("abort", abort, { once: true });
32
+ });
33
+ /** True when dotted version a sorts before b; non-numeric parts never count as older. */
34
+ export function versionOlder(a, b) {
35
+ const x = a.split(".").map(Number), y = b.split(".").map(Number);
36
+ for (let index = 0; index < 3; index++)
37
+ if (x[index] !== y[index])
38
+ return x[index] < y[index];
39
+ return false;
40
+ }
41
+ const writeNotice = (message) => { process.stderr.write(`${message}\n`); };
42
+ export class ApiClient {
43
+ key;
44
+ baseUrl;
45
+ attempts;
46
+ pause;
47
+ notice;
48
+ userAgent = `upscale4k/${VERSION} node/${process.versions.node}`;
49
+ noticeShown = false;
50
+ constructor(key, options = {}) {
51
+ this.key = key;
52
+ this.baseUrl = options.baseUrl ?? process.env.UPSCALE4K_API_URL ?? DEFAULT_API_URL;
53
+ this.attempts = options.attempts ?? 5;
54
+ this.pause = options.sleep ?? sleep;
55
+ this.notice = options.notice ?? writeNotice;
56
+ }
57
+ safe(message) { return this.key ? message.split(this.key).join("[redacted]") : message; }
58
+ get keyPrefix() { return this.key.slice(0, 12); }
59
+ requestSignal(signal) {
60
+ const timeout = AbortSignal.timeout(120_000);
61
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
62
+ }
63
+ async retry(operation, signal) {
64
+ for (let attempt = 0;; attempt++) {
65
+ throwIfCancelled(signal);
66
+ try {
67
+ const result = await operation();
68
+ throwIfCancelled(signal);
69
+ return result;
70
+ }
71
+ catch (error) {
72
+ throwIfCancelled(signal);
73
+ const retryable = !(error instanceof ApiError) || error.code === "rate_limited" || error.status >= 500;
74
+ if (!retryable || attempt >= this.attempts - 1) {
75
+ if (error instanceof ApiError)
76
+ throw error;
77
+ throw new ApiError("network_error", "Network or transfer failed; rerun to resume safely.");
78
+ }
79
+ const delay = (error instanceof RetryError ? error.delay : undefined) ?? Math.min(30_000, 500 * 2 ** attempt);
80
+ await this.pause(delay, signal);
81
+ }
82
+ }
83
+ }
84
+ /** The API names its newest CLI in x-upscale4k-latest; tell the user once per run when this one is older. */
85
+ noteLatest(response) {
86
+ // Server versioning contract: /api/v1/* sets x-upscale4k-latest and retires User-Agents with 426 upgrade_required.
87
+ // Upscale4K web does not send it yet; an absent header means no notice.
88
+ const latest = response.headers.get("x-upscale4k-latest");
89
+ if (this.noticeShown || !latest || !/^\d+\.\d+\.\d+$/.test(latest) || !versionOlder(VERSION, latest))
90
+ return;
91
+ this.noticeShown = true;
92
+ this.notice(`upscale4k ${latest} is available (this is ${VERSION}). Update with: npx -y upscale4k@latest`);
93
+ }
94
+ async checked(response) {
95
+ this.noteLatest(response);
96
+ if (response.ok)
97
+ return response;
98
+ const payload = await response.json().catch(() => undefined);
99
+ const code = payload?.error?.code ?? `http_${response.status}`;
100
+ const messages = {
101
+ engine_unavailable: "The requested Upscale4K engine is unavailable for this account or under maintenance. Check your account and try again later.",
102
+ key_limit: "Your Upscale4K account has reached its API key limit. Revoke an unused key before creating another.",
103
+ };
104
+ const message = this.safe(payload?.error?.message ?? messages[code] ?? `HTTP ${response.status}`);
105
+ const header = response.headers.get("retry-after");
106
+ const seconds = header === null ? NaN : Number(header);
107
+ const delay = Number.isFinite(seconds) ? seconds * 1000 : header ? Date.parse(header) - Date.now() : NaN;
108
+ if (response.status === 429 && code === "rate_limited" || response.status >= 500) {
109
+ throw new RetryError(code, message, response.status, Number.isFinite(delay) ? Math.max(0, delay) : undefined);
110
+ }
111
+ throw new ApiError(code, message, response.status);
112
+ }
113
+ async json(path, method = "GET", body, key, signal) {
114
+ return this.retry(async () => {
115
+ const response = await this.checked(await fetch(new URL(path, this.baseUrl), {
116
+ method, redirect: "error", signal: this.requestSignal(signal),
117
+ headers: { Authorization: `Bearer ${this.key}`, "User-Agent": this.userAgent, ...(body === undefined ? {} : { "Content-Type": "application/json" }),
118
+ ...(key ? { "Idempotency-Key": key } : {}) },
119
+ body: body === undefined ? undefined : JSON.stringify(body),
120
+ }));
121
+ return await response.json();
122
+ }, signal);
123
+ }
124
+ me(signal) { return this.json(paths.me, "GET", undefined, undefined, signal); }
125
+ create(body, key, signal) { return this.json(paths.jobs, "POST", body, key, signal); }
126
+ submit(id, signal) { return this.json(paths.submit(id), "POST", undefined, undefined, signal); }
127
+ getJob(id, signal) { return this.json(paths.job(id), "GET", undefined, undefined, signal); }
128
+ listJobs(query = {}) {
129
+ const params = new URLSearchParams();
130
+ for (const [key, value] of Object.entries(query))
131
+ if (value !== undefined)
132
+ params.set(key, String(value));
133
+ return this.json(`${paths.jobs}?${params}`);
134
+ }
135
+ cancel(id) { return this.json(paths.cancel(id), "POST"); }
136
+ retryJob(id, key) { return this.json(paths.retry(id), "POST", {}, key); }
137
+ async upload(created, file, size, signal) {
138
+ await this.retry(async () => {
139
+ // A new stream per attempt is essential: fetch consumes the previous one.
140
+ const stream = createReadStream(file);
141
+ // Headers.set replaces any server-sent content-length whatever its case; Node 24+ rejects duplicates.
142
+ const headers = new Headers(created.upload_headers ?? {});
143
+ headers.set("Content-Length", String(size));
144
+ try {
145
+ if (!created.upload_url || created.upload_method !== "PUT")
146
+ throw new ApiError("validation_error", "This job uploads in parts; use uploadLong.");
147
+ const response = await this.checked(await fetch(created.upload_url, {
148
+ method: "PUT", redirect: "error", signal: this.requestSignal(signal),
149
+ headers,
150
+ body: stream, duplex: "half",
151
+ }));
152
+ await response.arrayBuffer();
153
+ }
154
+ finally {
155
+ stream.destroy();
156
+ }
157
+ }, signal);
158
+ }
159
+ /** Upload a long job's file in parts and complete it, which queues the job; replaying complete is safe. */
160
+ async uploadLong(created, file, size, signal, parallel = 4) {
161
+ const call = async (body) => {
162
+ try {
163
+ return await this.json(paths.multipart(created.id), "POST", body, undefined, signal);
164
+ }
165
+ catch (error) {
166
+ if (error instanceof ApiError && error.status === 404)
167
+ throw new ApiError("not_found", "Long-video multipart upload is not yet available through the Upscale4K API. The server advertised multipart upload but its endpoint is unavailable; inspect the saved job before retrying.", 404);
168
+ throw error;
169
+ }
170
+ };
171
+ const { upload_id, part_size, completed } = await call({ action: "create" });
172
+ // Already assembled by an earlier run whose admission failed: only retry admission.
173
+ if (completed)
174
+ return call({ action: "complete", upload_id, parts: [] });
175
+ if (!Number.isSafeInteger(part_size) || part_size <= 0)
176
+ throw new ApiError("validation_error", "Server returned an invalid part size.");
177
+ const total = Math.max(1, Math.ceil(size / part_size)), etags = new Map();
178
+ // Sign at most 20 parts per call; each call also renews the 30-minute upload idle timer.
179
+ for (let first = 1; first <= total; first += 20) {
180
+ const batch = Array.from({ length: Math.min(20, total - first + 1) }, (_, i) => first + i);
181
+ const { urls } = await call({ action: "sign", upload_id, parts: batch });
182
+ // One failed part stops its siblings, and every transfer settles before the error leaves this call.
183
+ const stop = new AbortController(), partSignal = signal ? AbortSignal.any([signal, stop.signal]) : stop.signal;
184
+ let next = 0, failure;
185
+ const worker = async () => {
186
+ while (next < batch.length && !stop.signal.aborted) {
187
+ const part = batch[next++], url = urls[String(part)];
188
+ try {
189
+ if (!url)
190
+ throw new ApiError("validation_error", `Server did not sign part ${part}.`);
191
+ const start = (part - 1) * part_size, end = Math.min(size, start + part_size) - 1;
192
+ etags.set(part, await this.putPart(url, file, start, end, partSignal));
193
+ }
194
+ catch (error) {
195
+ if (!stop.signal.aborted) {
196
+ failure = error;
197
+ stop.abort();
198
+ }
199
+ }
200
+ }
201
+ };
202
+ await Promise.all(Array.from({ length: Math.min(parallel, batch.length) }, worker));
203
+ throwIfCancelled(signal);
204
+ if (failure !== undefined)
205
+ throw failure;
206
+ }
207
+ const parts = [...etags].sort(([a], [b]) => a - b).map(([part, etag]) => ({ part, etag }));
208
+ return call({ action: "complete", upload_id, parts });
209
+ }
210
+ async putPart(url, file, start, end, signal) {
211
+ return this.retry(async () => {
212
+ const stream = createReadStream(file, { start, end });
213
+ try {
214
+ // The presigned part URL carries its own authorization; never send the Bearer key to storage.
215
+ const response = await this.checked(await fetch(url, {
216
+ method: "PUT", redirect: "error", signal: this.requestSignal(signal),
217
+ headers: { "Content-Length": String(end - start + 1) },
218
+ body: stream, duplex: "half",
219
+ }));
220
+ await response.arrayBuffer();
221
+ const etag = response.headers.get("etag");
222
+ if (!etag)
223
+ throw new Error("Storage returned no ETag");
224
+ return etag;
225
+ }
226
+ finally {
227
+ stream.destroy();
228
+ }
229
+ }, signal);
230
+ }
231
+ async download(id, output, relativeUrl = paths.download(id), signal) {
232
+ throwIfCancelled(signal);
233
+ if (relativeUrl === null)
234
+ throw new ApiError("expired", "This Upscale4K job has no downloadable result. Check its status and result retention before retrying.");
235
+ const url = new URL(relativeUrl, this.baseUrl);
236
+ if (url.origin !== new URL(this.baseUrl).origin || !relativeUrl.startsWith("/")) {
237
+ throw new ApiError("validation_error", "Download URL must be a relative API path.");
238
+ }
239
+ await mkdir(dirname(output), { recursive: true });
240
+ await this.retry(async () => {
241
+ const response = await this.checked(await fetch(url, {
242
+ headers: { Authorization: `Bearer ${this.key}`, "User-Agent": this.userAgent }, redirect: "error", signal: this.requestSignal(signal),
243
+ }));
244
+ if (!response.body)
245
+ throw new Error("Missing download body");
246
+ await pipeline(Readable.fromWeb(response.body), createWriteStream(`${output}.part`), { signal });
247
+ throwIfCancelled(signal);
248
+ await rename(`${output}.part`, output);
249
+ }, signal);
250
+ }
251
+ }
252
+ class RetryError extends ApiError {
253
+ delay;
254
+ constructor(code, message, status, delay) {
255
+ super(code, message, status);
256
+ this.delay = delay;
257
+ }
258
+ }
@@ -0,0 +1,120 @@
1
+ /** Upscale4K REST v1 wire types and paths; align future server changes here. */
2
+ export declare const DEFAULT_API_URL = "https://upscale4k.io";
3
+ export declare const VERSION = "0.1.0";
4
+ export declare const MAX_UPLOAD_BYTES: number;
5
+ export declare const MIN_LONG_SIDE = 500;
6
+ export declare const MAX_LONG_SIDE = 2560;
7
+ export declare const paths: {
8
+ me: string;
9
+ jobs: string;
10
+ job: (id: string) => string;
11
+ submit: (id: string) => string;
12
+ cancel: (id: string) => string;
13
+ retry: (id: string) => string;
14
+ download: (id: string) => string;
15
+ multipart: (id: string) => string;
16
+ };
17
+ export declare const errorCodes: readonly ["invalid_api_key", "api_locked", "account_suspended", "rate_limited", "validation_error", "duration_limit", "resolution_limit", "frame_rate_limit", "no_credits", "daily_limit", "active_limit", "idempotency_required", "idempotency_mismatch", "not_found", "conflict", "expired", "locked_for_review", "uploads_closed", "consent_required", "consent_outdated", "range_not_satisfiable", "upgrade_required", "storage_unavailable", "engine_unavailable", "key_limit"];
18
+ export type ErrorCode = typeof errorCodes[number];
19
+ export interface ErrorEnvelope {
20
+ error: {
21
+ code: ErrorCode;
22
+ message: string;
23
+ [key: string]: unknown;
24
+ };
25
+ }
26
+ export interface Account {
27
+ email: string;
28
+ tier: string;
29
+ credits: {
30
+ free: number;
31
+ mine: number;
32
+ };
33
+ max_seconds: number;
34
+ daily_used: number;
35
+ daily_cap: number | null;
36
+ active_count: number;
37
+ max_active: number | null;
38
+ engines?: string[];
39
+ engines_maintenance?: string[];
40
+ rate_per_minute?: number;
41
+ api_daily_jobs?: {
42
+ used: number;
43
+ limit: number | null;
44
+ };
45
+ limits?: Pick<Account, "max_seconds" | "daily_used" | "daily_cap" | "active_count" | "max_active" | "rate_per_minute">;
46
+ access: {
47
+ unlocked: boolean;
48
+ reason: string | null;
49
+ };
50
+ /** Absent on servers older than long-video API support; treat that as short clips only. */
51
+ long_video?: {
52
+ enabled: boolean;
53
+ eligible: boolean;
54
+ min_seconds: number;
55
+ max_upload_bytes: number;
56
+ max_seconds: Record<string, number>;
57
+ };
58
+ consent: {
59
+ terms_version?: string;
60
+ policy_version?: string;
61
+ statement?: string;
62
+ self_likeness_statement: string;
63
+ };
64
+ }
65
+ export interface VideoInfo {
66
+ duration: number;
67
+ width: number;
68
+ height: number;
69
+ }
70
+ export interface UploadConsent {
71
+ selfLikeness: boolean;
72
+ selfLikenessConsent?: true;
73
+ terms?: true;
74
+ termsVersion?: string;
75
+ policyVersion?: string;
76
+ }
77
+ export interface CreateJobBody extends VideoInfo {
78
+ filename: string;
79
+ size: number;
80
+ consent: UploadConsent;
81
+ }
82
+ export interface CreatedJob {
83
+ id: string;
84
+ kind?: "single" | "long";
85
+ upload_url: string | null;
86
+ upload_method: "PUT" | "multipart";
87
+ multipart_url?: string;
88
+ upload_headers: Record<string, string> | null;
89
+ expires_in: number;
90
+ }
91
+ export interface MultipartCreated {
92
+ upload_id: string;
93
+ part_size: number;
94
+ completed?: true;
95
+ }
96
+ export interface MultipartCompleted {
97
+ ok: true;
98
+ state: JobState;
99
+ }
100
+ export declare const jobStates: readonly ["uploading", "queued", "claimed", "running", "done", "failed", "rejected", "canceled", "expired"];
101
+ export type JobState = typeof jobStates[number];
102
+ export interface Job {
103
+ id: string;
104
+ filename?: string;
105
+ state: JobState;
106
+ progress?: number;
107
+ queue_position?: number | null;
108
+ error?: string | ErrorEnvelope["error"] | null;
109
+ expires_at?: string | number | null;
110
+ download_url?: string | null;
111
+ }
112
+ export interface JobList {
113
+ jobs: Job[];
114
+ next_cursor: string | null;
115
+ }
116
+ export type SubmittedJob = Pick<Job, "state" | "queue_position">;
117
+ export interface CanceledJob {
118
+ ok: true;
119
+ state: "canceled";
120
+ }