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 +229 -2
- package/dist/api-client.d.ts +52 -0
- package/dist/api-client.js +258 -0
- package/dist/api-contract.d.ts +120 -0
- package/dist/api-contract.js +24 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +122 -0
- package/dist/config-store.d.ts +23 -0
- package/dist/config-store.js +54 -0
- package/dist/job-status-format.d.ts +6 -0
- package/dist/job-status-format.js +23 -0
- package/dist/mcp-server.d.ts +10 -0
- package/dist/mcp-server.js +65 -0
- package/dist/probe-video.d.ts +3 -0
- package/dist/probe-video.js +98 -0
- package/dist/resume-state.d.ts +22 -0
- package/dist/resume-state.js +90 -0
- package/dist/run-file.d.ts +19 -0
- package/dist/run-file.js +72 -0
- package/dist/scan-videos.d.ts +9 -0
- package/dist/scan-videos.js +77 -0
- package/dist/state-lock.d.ts +9 -0
- package/dist/state-lock.js +151 -0
- package/dist/terminal-prompts.d.ts +2 -0
- package/dist/terminal-prompts.js +23 -0
- package/dist/upscale-runner.d.ts +22 -0
- package/dist/upscale-runner.js +113 -0
- package/package.json +29 -4
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Upscale4K REST v1 wire types and paths; align future server changes here. */
|
|
2
|
+
export const DEFAULT_API_URL = "https://upscale4k.io";
|
|
3
|
+
export const VERSION = "0.1.0";
|
|
4
|
+
export const MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
|
|
5
|
+
export const MIN_LONG_SIDE = 500;
|
|
6
|
+
export const MAX_LONG_SIDE = 2560;
|
|
7
|
+
export const paths = {
|
|
8
|
+
me: "/api/v1/me",
|
|
9
|
+
jobs: "/api/v1/jobs",
|
|
10
|
+
job: (id) => `/api/v1/jobs/${encodeURIComponent(id)}`,
|
|
11
|
+
submit: (id) => `${paths.job(id)}/submit`,
|
|
12
|
+
cancel: (id) => `${paths.job(id)}/cancel`,
|
|
13
|
+
retry: (id) => `${paths.job(id)}/retry`,
|
|
14
|
+
download: (id) => `${paths.job(id)}/download`,
|
|
15
|
+
multipart: (id) => `${paths.job(id)}/multipart`,
|
|
16
|
+
};
|
|
17
|
+
export const errorCodes = [
|
|
18
|
+
"invalid_api_key", "api_locked", "account_suspended", "rate_limited", "validation_error",
|
|
19
|
+
"duration_limit", "resolution_limit", "frame_rate_limit", "no_credits", "daily_limit", "active_limit",
|
|
20
|
+
"idempotency_required", "idempotency_mismatch", "not_found", "conflict", "expired",
|
|
21
|
+
"locked_for_review", "uploads_closed", "consent_required", "consent_outdated",
|
|
22
|
+
"range_not_satisfiable", "upgrade_required", "storage_unavailable", "engine_unavailable", "key_limit",
|
|
23
|
+
];
|
|
24
|
+
export const jobStates = ["uploading", "queued", "claimed", "running", "done", "failed", "rejected", "canceled", "expired"];
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Upscale4K terminal entry point; MCP mode reserves stdout for protocol messages. */
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { ApiClient, ApiError } from "./api-client.js";
|
|
5
|
+
import { VERSION } from "./api-contract.js";
|
|
6
|
+
import { assertAccess, authenticated, logout, maskKey, saveConfig } from "./config-store.js";
|
|
7
|
+
import { formatAccountLimits, formatJobLine, formatJobs } from "./job-status-format.js";
|
|
8
|
+
import { startMcpServer } from "./mcp-server.js";
|
|
9
|
+
import { ask, agree } from "./terminal-prompts.js";
|
|
10
|
+
import { upscale } from "./upscale-runner.js";
|
|
11
|
+
import { scanVideos } from "./scan-videos.js";
|
|
12
|
+
const help = `upscale4k ${VERSION} — upscale local MP4/MOV videos to 4K
|
|
13
|
+
Usage:
|
|
14
|
+
upscale4k login
|
|
15
|
+
upscale4k upscale <file|dir> --self-likeness <yes|no>
|
|
16
|
+
[--yes-self-likeness-consent] [--out <dir>] [--concurrency 3]
|
|
17
|
+
[--wait|--no-wait] [--json]
|
|
18
|
+
upscale4k status [job_id] [--json]
|
|
19
|
+
upscale4k credits [--json]
|
|
20
|
+
upscale4k logout
|
|
21
|
+
upscale4k mcp
|
|
22
|
+
upscale4k --version | --help
|
|
23
|
+
Environment: UPSCALE4K_API_KEY, UPSCALE4K_API_URL
|
|
24
|
+
Agents without a terminal: set UPSCALE4K_API_KEY; no login needed.
|
|
25
|
+
Deprecated --accept-terms is accepted but has no effect.
|
|
26
|
+
`;
|
|
27
|
+
async function main() {
|
|
28
|
+
const { values, positionals } = parseArgs({ allowPositionals: true, options: {
|
|
29
|
+
help: { type: "boolean", short: "h" }, version: { type: "boolean", short: "v" },
|
|
30
|
+
"self-likeness": { type: "string" }, "yes-self-likeness-consent": { type: "boolean" },
|
|
31
|
+
out: { type: "string" }, concurrency: { type: "string" }, wait: { type: "boolean" },
|
|
32
|
+
"no-wait": { type: "boolean" }, json: { type: "boolean" }, "accept-terms": { type: "boolean" },
|
|
33
|
+
} });
|
|
34
|
+
if (values.version) {
|
|
35
|
+
console.log(VERSION);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (values.help || positionals.length === 0) {
|
|
39
|
+
console.log(help);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const [command, input] = positionals;
|
|
43
|
+
if (command === "mcp") {
|
|
44
|
+
await startMcpServer();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (command === "logout") {
|
|
48
|
+
await logout();
|
|
49
|
+
console.log("Local login removed. UPSCALE4K_API_KEY, if set, still overrides local config.");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (command === "login") {
|
|
53
|
+
if (!process.env.UPSCALE4K_API_KEY && !process.stdin.isTTY)
|
|
54
|
+
throw new Error("Set UPSCALE4K_API_KEY to log in without a terminal.");
|
|
55
|
+
const key = process.env.UPSCALE4K_API_KEY || await ask("Upscale4K API key (hidden): ", true);
|
|
56
|
+
if (!/^cx_live_[A-Za-z0-9_-]+$/.test(key))
|
|
57
|
+
throw new Error("Invalid API key format.");
|
|
58
|
+
const account = await new ApiClient(key).me();
|
|
59
|
+
assertAccess(account);
|
|
60
|
+
await saveConfig({ key });
|
|
61
|
+
console.log(`Logged in (${maskKey(key)}).`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!["upscale", "status", "credits"].includes(command))
|
|
65
|
+
throw new Error(`Unknown command: ${command}. Use --help.`);
|
|
66
|
+
if (positionals.length > (command === "credits" ? 1 : 2))
|
|
67
|
+
throw new Error("Too many arguments. Use --help.");
|
|
68
|
+
if (command === "upscale" && (!input || !["yes", "no"].includes(values["self-likeness"] ?? ""))) {
|
|
69
|
+
throw new Error("Usage: upscale4k upscale <file|dir> --self-likeness <yes|no>");
|
|
70
|
+
}
|
|
71
|
+
if (values.wait && values["no-wait"])
|
|
72
|
+
throw new Error("Choose --wait or --no-wait.");
|
|
73
|
+
const controller = new AbortController();
|
|
74
|
+
const interrupt = () => controller.abort();
|
|
75
|
+
if (command === "upscale")
|
|
76
|
+
process.on("SIGINT", interrupt);
|
|
77
|
+
// Request timeouts use unref'd timers; without this a stalled fetch lets Node exit 0 mid-upload.
|
|
78
|
+
const keepAlive = setInterval(() => undefined, 60_000);
|
|
79
|
+
try {
|
|
80
|
+
const scan = command === "upscale" ? await scanVideos(input, values.out) : undefined;
|
|
81
|
+
const { client, account } = await authenticated({ signal: controller.signal });
|
|
82
|
+
if (command === "credits") {
|
|
83
|
+
console.log(JSON.stringify(account));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (command === "status") {
|
|
87
|
+
const value = input ? await client.getJob(input) : await client.listJobs();
|
|
88
|
+
if (!values.json && !input)
|
|
89
|
+
console.log(formatAccountLimits(account));
|
|
90
|
+
console.log(values.json ? JSON.stringify(value) : "jobs" in value ? formatJobs(value) : formatJobLine(value));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const selfLikeness = values["self-likeness"] === "yes";
|
|
94
|
+
if (selfLikeness) {
|
|
95
|
+
if (!values["yes-self-likeness-consent"])
|
|
96
|
+
await agree(account.consent.self_likeness_statement);
|
|
97
|
+
else
|
|
98
|
+
process.stderr.write(`${account.consent.self_likeness_statement}\n`);
|
|
99
|
+
}
|
|
100
|
+
const result = await upscale(client, account, {
|
|
101
|
+
path: input, selfLikeness, selfLikenessConsent: selfLikeness,
|
|
102
|
+
outputDir: values.out, concurrency: values.concurrency === undefined ? 3 : Number(values.concurrency),
|
|
103
|
+
wait: !values["no-wait"], signal: controller.signal, scan,
|
|
104
|
+
onEvent: (event) => { console.log(values.json ? JSON.stringify(event) : `${event.event}: ${JSON.stringify(Object.fromEntries(Object.entries(event).filter(([key]) => key !== "event")))}`); },
|
|
105
|
+
});
|
|
106
|
+
if (result.stopped || result.results.some((item) => item.state === "failed"))
|
|
107
|
+
process.exitCode = 1;
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
clearInterval(keepAlive);
|
|
111
|
+
process.removeListener("SIGINT", interrupt);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
main().catch((error) => {
|
|
115
|
+
const message = error instanceof Error ? error.message : "Command failed";
|
|
116
|
+
const code = error instanceof ApiError ? error.code : "cli_error";
|
|
117
|
+
if (process.argv.includes("--json") && !process.argv.includes("mcp"))
|
|
118
|
+
console.log(JSON.stringify({ event: "failed", code, message }));
|
|
119
|
+
else
|
|
120
|
+
process.stderr.write(`${code}: ${message}\n`);
|
|
121
|
+
process.exitCode = 1;
|
|
122
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ApiClient } from "./api-client.js";
|
|
2
|
+
import type { Account } from "./api-contract.js";
|
|
3
|
+
export interface Config {
|
|
4
|
+
key: string;
|
|
5
|
+
terms_version?: string;
|
|
6
|
+
policy_version?: string;
|
|
7
|
+
accepted_at?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function configPath(): string;
|
|
10
|
+
export declare function readConfig(path?: string): Promise<Config | undefined>;
|
|
11
|
+
export declare function saveConfig(config: Config, path?: string): Promise<void>;
|
|
12
|
+
export declare function logout(path?: string): Promise<void>;
|
|
13
|
+
export declare const maskKey: (key: string) => string;
|
|
14
|
+
export declare function assertAccess(account: Account): void;
|
|
15
|
+
export declare function authenticated(options?: {
|
|
16
|
+
configFile?: string;
|
|
17
|
+
baseUrl?: string;
|
|
18
|
+
acceptTerms?: boolean;
|
|
19
|
+
signal?: AbortSignal;
|
|
20
|
+
}): Promise<{
|
|
21
|
+
client: ApiClient;
|
|
22
|
+
account: Account;
|
|
23
|
+
}>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Store credentials locally; environment credentials take precedence. */
|
|
2
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { ApiClient, ApiError } from "./api-client.js";
|
|
6
|
+
export function configPath() {
|
|
7
|
+
return process.platform === "win32"
|
|
8
|
+
? join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "upscale4k", "config.json")
|
|
9
|
+
: join(homedir(), ".config", "upscale4k", "config.json");
|
|
10
|
+
}
|
|
11
|
+
export async function readConfig(path = configPath()) {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error.code === "ENOENT")
|
|
17
|
+
return undefined;
|
|
18
|
+
throw new Error("Cannot read Upscale4K config; run npx upscale4k login.");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export async function saveConfig(config, path = configPath()) {
|
|
22
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
23
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
24
|
+
await writeFile(temporary, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
|
|
25
|
+
if (process.platform !== "win32")
|
|
26
|
+
await chmod(temporary, 0o600);
|
|
27
|
+
await rename(temporary, path);
|
|
28
|
+
if (process.platform !== "win32")
|
|
29
|
+
await chmod(path, 0o600);
|
|
30
|
+
}
|
|
31
|
+
export async function logout(path = configPath()) { await rm(path, { force: true }); }
|
|
32
|
+
export const maskKey = (key) => `${key.slice(0, 12)}…`;
|
|
33
|
+
export function assertAccess(account) {
|
|
34
|
+
if (!account.access.unlocked)
|
|
35
|
+
throw new ApiError("api_locked", account.access.reason ?? "API access is locked; check your Upscale4K account.");
|
|
36
|
+
}
|
|
37
|
+
export async function authenticated(options = {}) {
|
|
38
|
+
// An environment credential is sufficient, even when an old local config is unreadable.
|
|
39
|
+
const key = process.env.UPSCALE4K_API_KEY || (await readConfig(options.configFile))?.key;
|
|
40
|
+
if (!key)
|
|
41
|
+
throw new ApiError("invalid_api_key", "Set UPSCALE4K_API_KEY or run npx upscale4k login in a terminal first.");
|
|
42
|
+
const client = new ApiClient(key, { baseUrl: options.baseUrl });
|
|
43
|
+
try {
|
|
44
|
+
const account = await client.me(options.signal);
|
|
45
|
+
assertAccess(account);
|
|
46
|
+
return { client, account };
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error instanceof ApiError && error.code === "invalid_api_key") {
|
|
50
|
+
throw new ApiError(error.code, "The API key was rejected; create a new key in your Upscale4K account and set UPSCALE4K_API_KEY.");
|
|
51
|
+
}
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Human-readable job status lines for `upscale4k status` without --json. */
|
|
2
|
+
import type { Account, Job, JobList } from "./api-contract.js";
|
|
3
|
+
export declare function formatAccountLimits(account: Account): string;
|
|
4
|
+
/** One line per job: state first, then whatever the user can act on next. */
|
|
5
|
+
export declare function formatJobLine(job: Job): string;
|
|
6
|
+
export declare function formatJobs(list: JobList): string;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const when = (value) => value === undefined || value === null ? null : new Date(value).toLocaleString();
|
|
2
|
+
const failure = (error) => !error ? null : typeof error === "string" ? error : error.message;
|
|
3
|
+
export function formatAccountLimits(account) {
|
|
4
|
+
return `Active jobs: ${account.active_count} / ${account.max_active === null ? "unlimited" : account.max_active}`;
|
|
5
|
+
}
|
|
6
|
+
/** One line per job: state first, then whatever the user can act on next. */
|
|
7
|
+
export function formatJobLine(job) {
|
|
8
|
+
const details = [
|
|
9
|
+
job.state === "queued" && job.queue_position != null ? `queue #${job.queue_position}` : null,
|
|
10
|
+
["claimed", "running"].includes(job.state) && job.progress != null ? `${job.progress}%` : null,
|
|
11
|
+
job.state === "done" && job.expires_at != null ? `download before ${when(job.expires_at)}` : null,
|
|
12
|
+
failure(job.error),
|
|
13
|
+
].filter(Boolean);
|
|
14
|
+
return `${job.state.padEnd(9)} ${job.id} ${job.filename ?? "(no name)"}${details.length ? ` (${details.join(", ")})` : ""}`;
|
|
15
|
+
}
|
|
16
|
+
export function formatJobs(list) {
|
|
17
|
+
if (list.jobs.length === 0)
|
|
18
|
+
return "No jobs yet.";
|
|
19
|
+
const lines = list.jobs.map(formatJobLine);
|
|
20
|
+
if (list.next_cursor)
|
|
21
|
+
lines.push("More jobs exist; use --json for the full list cursor.");
|
|
22
|
+
return lines.join("\n");
|
|
23
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
interface Options {
|
|
3
|
+
configFile?: string;
|
|
4
|
+
baseUrl?: string;
|
|
5
|
+
pollMs?: number;
|
|
6
|
+
acceptTerms?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function createMcpServer(options?: Options): McpServer;
|
|
9
|
+
export declare function startMcpServer(options?: Options): Promise<void>;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Expose five consent-aware tools over MCP without writing application logs to stdout. */
|
|
2
|
+
import { basename, extname, join, resolve } from "node:path";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { ApiError } from "./api-client.js";
|
|
7
|
+
import { jobStates, VERSION } from "./api-contract.js";
|
|
8
|
+
import { authenticated } from "./config-store.js";
|
|
9
|
+
import { upscale } from "./upscale-runner.js";
|
|
10
|
+
import { scanVideos } from "./scan-videos.js";
|
|
11
|
+
export function createMcpServer(options = {}) {
|
|
12
|
+
const server = new McpServer({ name: "upscale4k", version: VERSION });
|
|
13
|
+
const context = (signal) => authenticated({ ...options, signal });
|
|
14
|
+
// Text keeps older clients working; structuredContent must be an object, so list results are wrapped.
|
|
15
|
+
const content = (value) => ({ content: [{ type: "text", text: JSON.stringify(value) }],
|
|
16
|
+
structuredContent: (value !== null && typeof value === "object" && !Array.isArray(value) ? value : { results: value }) });
|
|
17
|
+
async function guarded(operation) {
|
|
18
|
+
try {
|
|
19
|
+
return content(await operation());
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
return { ...content({ error: { code: error instanceof ApiError ? error.code : "local_error", message: error instanceof Error ? error.message : "Tool failed" } }), isError: true };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
server.registerTool("upscale_video", {
|
|
26
|
+
description: "Upscale local MP4/MOV files or a folder. Ask the user whether this upload contains their own likeness/voice (self_likeness); never guess. If true, obtain explicit self_likeness_consent to the account's statement. Authenticate with UPSCALE4K_API_KEY or npx upscale4k login.",
|
|
27
|
+
inputSchema: {
|
|
28
|
+
path: z.string().min(1), self_likeness: z.boolean(), self_likeness_consent: z.boolean().optional(),
|
|
29
|
+
output_dir: z.string().min(1).optional(), wait: z.boolean().default(true),
|
|
30
|
+
},
|
|
31
|
+
}, async (args, extra) => guarded(async () => {
|
|
32
|
+
const scan = await scanVideos(args.path, args.output_dir);
|
|
33
|
+
const { client, account } = await context(extra.signal);
|
|
34
|
+
let progress = 0;
|
|
35
|
+
const result = await upscale(client, account, {
|
|
36
|
+
path: args.path, selfLikeness: args.self_likeness, selfLikenessConsent: args.self_likeness_consent,
|
|
37
|
+
outputDir: args.output_dir, wait: args.wait, pollMs: options.pollMs, signal: extra.signal, scan,
|
|
38
|
+
onEvent: async (event) => {
|
|
39
|
+
const token = extra._meta?.progressToken;
|
|
40
|
+
if (args.wait && token !== undefined)
|
|
41
|
+
await extra.sendNotification({ method: "notifications/progress",
|
|
42
|
+
params: { progressToken: token, progress: ++progress, message: JSON.stringify(event) } });
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
const locked = result.results.find((item) => item.error?.startsWith("state_locked: "));
|
|
46
|
+
if (locked)
|
|
47
|
+
throw new ApiError("state_locked", locked.error.slice("state_locked: ".length));
|
|
48
|
+
return result.results;
|
|
49
|
+
}));
|
|
50
|
+
server.registerTool("get_job", { description: "Get Upscale4K job status.", inputSchema: { job_id: z.string().min(1) } }, ({ job_id }) => guarded(async () => (await context()).client.getJob(job_id)));
|
|
51
|
+
server.registerTool("list_jobs", { description: "List this account's Upscale4K jobs.", inputSchema: { state: z.enum(jobStates).optional(), limit: z.number().int().min(1).max(100).optional() } }, (args) => guarded(async () => (await context()).client.listJobs(args)));
|
|
52
|
+
server.registerTool("get_account", { description: "Get credits, tier, caps and exact consent statements. Ask the user about self_likeness for every upload; never guess.", inputSchema: {} }, () => guarded(async () => (await context()).account));
|
|
53
|
+
server.registerTool("download_result", { description: "Download a completed Upscale4K job to a local directory.", inputSchema: { job_id: z.string().min(1), output_dir: z.string().min(1) } }, ({ job_id, output_dir }) => guarded(async () => {
|
|
54
|
+
const { client } = await context(), job = await client.getJob(job_id);
|
|
55
|
+
if (job.state !== "done")
|
|
56
|
+
throw new ApiError("conflict", `Job is ${job.state}; wait until done.`);
|
|
57
|
+
// The server returns the sanitized original name; fall back to the job id for older responses.
|
|
58
|
+
const stem = job.filename ? basename(job.filename, extname(job.filename)) : encodeURIComponent(job_id);
|
|
59
|
+
const output = join(resolve(output_dir), `${stem} 4K.mp4`);
|
|
60
|
+
await client.download(job_id, output, job.download_url);
|
|
61
|
+
return { job_id, output_path: output, state: "done" };
|
|
62
|
+
}));
|
|
63
|
+
return server;
|
|
64
|
+
}
|
|
65
|
+
export async function startMcpServer(options = {}) { await createMcpServer(options).connect(new StdioServerTransport()); }
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** Probe MP4/QuickTime metadata through bounded reads, with ffprobe as a fallback. */
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
async function read(file, position, size) {
|
|
6
|
+
const buffer = Buffer.alloc(size);
|
|
7
|
+
const { bytesRead } = await file.read(buffer, 0, size, position);
|
|
8
|
+
if (bytesRead !== size)
|
|
9
|
+
throw new Error("Truncated movie metadata");
|
|
10
|
+
return buffer;
|
|
11
|
+
}
|
|
12
|
+
async function boxes(file, start, end) {
|
|
13
|
+
const result = [];
|
|
14
|
+
for (let pos = start; pos + 8 <= end;) {
|
|
15
|
+
const header = await read(file, pos, 8);
|
|
16
|
+
let size = header.readUInt32BE(0), headerSize = 8;
|
|
17
|
+
if (size === 1) {
|
|
18
|
+
const large = (await read(file, pos + 8, 8)).readBigUInt64BE();
|
|
19
|
+
if (large > BigInt(Number.MAX_SAFE_INTEGER))
|
|
20
|
+
throw new Error("Box exceeds safe file offsets");
|
|
21
|
+
size = Number(large);
|
|
22
|
+
headerSize = 16;
|
|
23
|
+
}
|
|
24
|
+
else if (size === 0)
|
|
25
|
+
size = end - pos;
|
|
26
|
+
if (size < headerSize || pos + size > end)
|
|
27
|
+
throw new Error("Invalid movie box size");
|
|
28
|
+
result.push({ type: header.toString("ascii", 4, 8), start: pos + headerSize, end: pos + size });
|
|
29
|
+
pos += size;
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
function valid(info) {
|
|
34
|
+
if (!Object.values(info).every((n) => Number.isFinite(n) && n > 0))
|
|
35
|
+
throw new Error("Unreadable video metadata");
|
|
36
|
+
return info;
|
|
37
|
+
}
|
|
38
|
+
export async function probeBmff(path) {
|
|
39
|
+
const file = await open(path, "r");
|
|
40
|
+
try {
|
|
41
|
+
const top = await boxes(file, 0, (await file.stat()).size);
|
|
42
|
+
const moov = top.find((box) => box.type === "moov");
|
|
43
|
+
if (!moov)
|
|
44
|
+
throw new Error("Missing moov box");
|
|
45
|
+
const children = await boxes(file, moov.start, moov.end);
|
|
46
|
+
const mvhd = children.find((box) => box.type === "mvhd");
|
|
47
|
+
if (!mvhd)
|
|
48
|
+
throw new Error("Missing mvhd box");
|
|
49
|
+
const version = (await read(file, mvhd.start, 1))[0];
|
|
50
|
+
if (version !== 0 && version !== 1)
|
|
51
|
+
throw new Error("Unsupported mvhd version");
|
|
52
|
+
const length = version === 1 ? 32 : 20;
|
|
53
|
+
if (mvhd.end - mvhd.start < length)
|
|
54
|
+
throw new Error("Truncated mvhd");
|
|
55
|
+
const movie = await read(file, mvhd.start, length);
|
|
56
|
+
const timescale = movie.readUInt32BE(version === 1 ? 20 : 12);
|
|
57
|
+
const ticks = version === 1 ? Number(movie.readBigUInt64BE(24)) : movie.readUInt32BE(16);
|
|
58
|
+
for (const track of children.filter((box) => box.type === "trak")) {
|
|
59
|
+
const trackBoxes = await boxes(file, track.start, track.end);
|
|
60
|
+
const mdia = trackBoxes.find((box) => box.type === "mdia");
|
|
61
|
+
if (!mdia)
|
|
62
|
+
continue;
|
|
63
|
+
const handler = (await boxes(file, mdia.start, mdia.end)).find((box) => box.type === "hdlr");
|
|
64
|
+
if (!handler || handler.end - handler.start < 12 || (await read(file, handler.start + 8, 4)).toString("ascii") !== "vide")
|
|
65
|
+
continue;
|
|
66
|
+
const tkhd = trackBoxes.find((box) => box.type === "tkhd");
|
|
67
|
+
if (!tkhd)
|
|
68
|
+
continue;
|
|
69
|
+
const trackVersion = (await read(file, tkhd.start, 1))[0];
|
|
70
|
+
if (trackVersion !== 0 && trackVersion !== 1)
|
|
71
|
+
throw new Error("Unsupported tkhd version");
|
|
72
|
+
const offset = trackVersion === 1 ? 88 : 76;
|
|
73
|
+
if (tkhd.end - tkhd.start < offset + 8)
|
|
74
|
+
throw new Error("Truncated tkhd");
|
|
75
|
+
const dimensions = await read(file, tkhd.start + offset, 8);
|
|
76
|
+
return valid({ duration: ticks / timescale, width: dimensions.readUInt32BE(0) / 65536, height: dimensions.readUInt32BE(4) / 65536 });
|
|
77
|
+
}
|
|
78
|
+
throw new Error("Missing video track");
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
await file.close();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export async function probeVideo(path) {
|
|
85
|
+
try {
|
|
86
|
+
return await probeBmff(path);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
try {
|
|
90
|
+
const { stdout } = await promisify(execFile)("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,duration:format=duration", "-of", "json", path], { timeout: 15_000, maxBuffer: 1024 * 1024, windowsHide: true });
|
|
91
|
+
const data = JSON.parse(stdout);
|
|
92
|
+
return valid({ duration: Number(data.format?.duration ?? data.streams?.[0]?.duration), width: Number(data.streams?.[0]?.width), height: Number(data.streams?.[0]?.height) });
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
throw new Error("Cannot probe video. Use a readable MP4/MOV, or install ffprobe on PATH.");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { CreateJobBody } from "./api-contract.js";
|
|
2
|
+
import { type LockOptions } from "./state-lock.js";
|
|
3
|
+
export interface ResumeEntry {
|
|
4
|
+
source: string;
|
|
5
|
+
size: number;
|
|
6
|
+
mtime: number;
|
|
7
|
+
output: string;
|
|
8
|
+
idempotency_key: string;
|
|
9
|
+
job_id?: string;
|
|
10
|
+
created_at: number;
|
|
11
|
+
body: CreateJobBody;
|
|
12
|
+
api_key_prefix?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare class ResumeState {
|
|
15
|
+
readonly path: string;
|
|
16
|
+
private readonly lockOptions?;
|
|
17
|
+
constructor(path: string, lockOptions?: LockOptions | undefined);
|
|
18
|
+
private update;
|
|
19
|
+
prepare(source: string, size: number, mtime: number, output: string, body: CreateJobBody, replace?: ResumeEntry, apiKeyPrefix?: string): Promise<ResumeEntry>;
|
|
20
|
+
setJob(entry: ResumeEntry, id: string): Promise<void>;
|
|
21
|
+
withSource(source: string, operation: () => Promise<void>): Promise<boolean>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** Atomically persist request identity before any remote job creation. */
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { ApiError } from "./api-client.js";
|
|
5
|
+
import { acquireStateLock, withStateLock } from "./state-lock.js";
|
|
6
|
+
const sourceIdentity = (source) => process.platform === "win32" ? source.toLowerCase() : source;
|
|
7
|
+
export class ResumeState {
|
|
8
|
+
path;
|
|
9
|
+
lockOptions;
|
|
10
|
+
constructor(path, lockOptions) {
|
|
11
|
+
this.path = path;
|
|
12
|
+
this.lockOptions = lockOptions;
|
|
13
|
+
}
|
|
14
|
+
async update(operation, signal) {
|
|
15
|
+
return withStateLock(`${this.path}.lock`, async () => {
|
|
16
|
+
let entries = [];
|
|
17
|
+
try {
|
|
18
|
+
const data = JSON.parse(await readFile(this.path, "utf8"));
|
|
19
|
+
if (data.version !== 1 || !Array.isArray(data.entries))
|
|
20
|
+
throw new Error("Unsupported state format");
|
|
21
|
+
entries = data.entries;
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error.code !== "ENOENT")
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
const result = operation(entries);
|
|
28
|
+
const temporary = `${this.path}.${randomUUID()}.tmp`;
|
|
29
|
+
try {
|
|
30
|
+
await writeFile(temporary, JSON.stringify({ version: 1, entries }, null, 2) + "\n", { flag: "wx", mode: 0o600 });
|
|
31
|
+
await rename(temporary, this.path);
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
await unlink(temporary).catch((error) => { if (error.code !== "ENOENT")
|
|
35
|
+
throw error; });
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}, { ...this.lockOptions, signal, refreshMs: 0 });
|
|
39
|
+
}
|
|
40
|
+
async prepare(source, size, mtime, output, body, replace, apiKeyPrefix) {
|
|
41
|
+
return this.update((entries) => {
|
|
42
|
+
const index = entries.findIndex((entry) => sourceIdentity(entry.source) === sourceIdentity(source) && entry.size === size && entry.mtime === mtime);
|
|
43
|
+
const existing = entries[index];
|
|
44
|
+
if (existing && (!replace || existing.idempotency_key !== replace.idempotency_key)) {
|
|
45
|
+
if (existing.body.consent.selfLikeness !== body.consent.selfLikeness ||
|
|
46
|
+
Boolean(existing.body.consent.selfLikenessConsent) !== Boolean(body.consent.selfLikenessConsent)) {
|
|
47
|
+
throw new ApiError("conflict", "Saved job has different consent. Resume with the original explicit choice or inspect/cancel that job first.");
|
|
48
|
+
}
|
|
49
|
+
existing.output = output;
|
|
50
|
+
return existing;
|
|
51
|
+
}
|
|
52
|
+
const entry = { source, size, mtime, output, body, idempotency_key: randomUUID(), created_at: Date.now() };
|
|
53
|
+
if (apiKeyPrefix !== undefined)
|
|
54
|
+
entry.api_key_prefix = apiKeyPrefix.slice(0, 12);
|
|
55
|
+
if (index < 0)
|
|
56
|
+
entries.push(entry);
|
|
57
|
+
else
|
|
58
|
+
entries[index] = entry;
|
|
59
|
+
return entry;
|
|
60
|
+
}, this.lockOptions?.signal);
|
|
61
|
+
}
|
|
62
|
+
async setJob(entry, id) {
|
|
63
|
+
await this.update((entries) => {
|
|
64
|
+
const saved = entries.find((candidate) => candidate.idempotency_key === entry.idempotency_key);
|
|
65
|
+
if (!saved || saved.job_id && saved.job_id !== id)
|
|
66
|
+
throw new ApiError("conflict", "Saved job identity changed during resume.");
|
|
67
|
+
saved.job_id = id;
|
|
68
|
+
});
|
|
69
|
+
entry.job_id = id;
|
|
70
|
+
}
|
|
71
|
+
async withSource(source, operation) {
|
|
72
|
+
const hash = createHash("sha256").update(sourceIdentity(source)).digest("hex");
|
|
73
|
+
let release;
|
|
74
|
+
try {
|
|
75
|
+
release = await acquireStateLock(`${this.path}.${hash}.lock`, { refreshMs: 30_000, ...this.lockOptions, waitMs: 0 });
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (error instanceof ApiError && error.code === "state_locked")
|
|
79
|
+
return false;
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
await operation();
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
await release();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Resume, upload, submit and optionally wait for one durable job. */
|
|
2
|
+
import { ApiClient } from "./api-client.js";
|
|
3
|
+
import type { CreateJobBody } from "./api-contract.js";
|
|
4
|
+
import type { ResumeEntry, ResumeState } from "./resume-state.js";
|
|
5
|
+
export interface RunEvent {
|
|
6
|
+
event: "scan" | "skipped" | "created" | "uploaded" | "queued" | "progress" | "done" | "failed" | "summary";
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
}
|
|
9
|
+
export interface FileResult {
|
|
10
|
+
source: string;
|
|
11
|
+
output_path?: string;
|
|
12
|
+
job_id?: string;
|
|
13
|
+
state: string;
|
|
14
|
+
error?: string;
|
|
15
|
+
reason?: string;
|
|
16
|
+
message?: string;
|
|
17
|
+
}
|
|
18
|
+
export type Emit = (event: RunEvent) => void | Promise<void>;
|
|
19
|
+
export declare function runFile(client: ApiClient, store: ResumeState, entry: ResumeEntry, output: string, wait: boolean, emit: Emit, pollMs: number, currentBody?: CreateJobBody, signal?: AbortSignal): Promise<FileResult>;
|
package/dist/run-file.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Resume, upload, submit and optionally wait for one durable job. */
|
|
2
|
+
import { ApiError, sleep, throwIfCancelled } from "./api-client.js";
|
|
3
|
+
import { outputExists } from "./scan-videos.js";
|
|
4
|
+
import { withStateLock } from "./state-lock.js";
|
|
5
|
+
export async function runFile(client, store, entry, output, wait, emit, pollMs, currentBody = entry.body, signal) {
|
|
6
|
+
throwIfCancelled(signal);
|
|
7
|
+
let job;
|
|
8
|
+
let old = Date.now() - entry.created_at >= 24 * 60 * 60 * 1000;
|
|
9
|
+
if (entry.job_id) {
|
|
10
|
+
try {
|
|
11
|
+
job = await client.getJob(entry.job_id, signal);
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (!(error instanceof ApiError && error.code === "not_found" && entry.api_key_prefix !== undefined && entry.api_key_prefix !== client.keyPrefix))
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
if (job && old && ["failed", "canceled", "expired"].includes(job.state)) {
|
|
18
|
+
// Only these terminal states permit a new request after server deduplication expires.
|
|
19
|
+
const previousId = entry.job_id;
|
|
20
|
+
entry = await store.prepare(entry.source, entry.size, entry.mtime, output, currentBody, entry, client.keyPrefix);
|
|
21
|
+
job = entry.job_id && entry.job_id !== previousId ? await client.getJob(entry.job_id, signal) : undefined;
|
|
22
|
+
old = Date.now() - entry.created_at >= 24 * 60 * 60 * 1000;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (!job || job.state === "uploading") {
|
|
26
|
+
if (entry.api_key_prefix !== undefined && entry.api_key_prefix !== client.keyPrefix) {
|
|
27
|
+
throw new ApiError("resume_ambiguous", `This run was started with another API key. Inspect/cancel the old job${entry.job_id ? ` (${entry.job_id})` : ""} or rerun with the original key; refusing a possible duplicate.`);
|
|
28
|
+
}
|
|
29
|
+
if (old) {
|
|
30
|
+
// No refresh endpoint exists once an uploading job's idempotency record expires.
|
|
31
|
+
throw new ApiError("resume_ambiguous", "Saved upload is older than 24 hours. Inspect/cancel the old job before retrying; refusing a possible duplicate.");
|
|
32
|
+
}
|
|
33
|
+
const created = await client.create(entry.body, entry.idempotency_key, signal);
|
|
34
|
+
if (entry.job_id && entry.job_id !== created.id)
|
|
35
|
+
throw new ApiError("conflict", "Server changed an existing idempotent job identity.");
|
|
36
|
+
await store.setJob(entry, created.id);
|
|
37
|
+
await emit({ event: "created", source: entry.source, job_id: created.id });
|
|
38
|
+
if (created.upload_method === "multipart") {
|
|
39
|
+
// Completing a long upload is its admission; there is no separate submit.
|
|
40
|
+
const completed = await client.uploadLong(created, entry.source, entry.size, signal);
|
|
41
|
+
await emit({ event: "uploaded", source: entry.source, job_id: created.id });
|
|
42
|
+
job = { id: created.id, state: completed.state };
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
await client.upload(created, entry.source, entry.size, signal);
|
|
46
|
+
await emit({ event: "uploaded", source: entry.source, job_id: created.id });
|
|
47
|
+
const submitted = await client.submit(created.id, signal);
|
|
48
|
+
job = { id: created.id, ...submitted };
|
|
49
|
+
}
|
|
50
|
+
await emit({ event: "queued", source: entry.source, job_id: job.id, state: job.state, queue_position: job.queue_position });
|
|
51
|
+
}
|
|
52
|
+
throwIfCancelled(signal);
|
|
53
|
+
if (!wait)
|
|
54
|
+
return { source: entry.source, job_id: job.id, state: job.state };
|
|
55
|
+
while (["queued", "claimed", "running", "uploading"].includes(job.state)) {
|
|
56
|
+
await emit({ event: "progress", source: entry.source, job_id: job.id, state: job.state, progress: job.progress, queue_position: job.queue_position });
|
|
57
|
+
await sleep(pollMs, signal);
|
|
58
|
+
job = await client.getJob(job.id, signal);
|
|
59
|
+
}
|
|
60
|
+
if (job.state !== "done") {
|
|
61
|
+
const message = typeof job.error === "string" ? job.error : job.error?.message ?? `Job ${job.state}`;
|
|
62
|
+
throw new ApiError(job.error && typeof job.error === "object" ? job.error.code : job.state, message);
|
|
63
|
+
}
|
|
64
|
+
// Serialize the final existence check and transfer across concurrent runs.
|
|
65
|
+
await withStateLock(`${output}.upscale4k-download.lock`, async () => {
|
|
66
|
+
throwIfCancelled(signal);
|
|
67
|
+
if (!await outputExists(entry.source, output))
|
|
68
|
+
await client.download(job.id, output, job.download_url, signal);
|
|
69
|
+
}, { signal, refreshMs: 30_000 });
|
|
70
|
+
await emit({ event: "done", source: entry.source, job_id: job.id, output_path: output });
|
|
71
|
+
return { source: entry.source, output_path: output, job_id: job.id, state: "done" };
|
|
72
|
+
}
|