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,9 @@
|
|
|
1
|
+
export interface Scan {
|
|
2
|
+
files: string[];
|
|
3
|
+
sourceRoot: string;
|
|
4
|
+
stateRoot: string;
|
|
5
|
+
outputFor: (file: string) => string;
|
|
6
|
+
}
|
|
7
|
+
export declare function assertUniqueOutputs(scan: Pick<Scan, "files" | "outputFor">): void;
|
|
8
|
+
export declare function scanVideos(input: string, out?: string): Promise<Scan>;
|
|
9
|
+
export declare function outputExists(source: string, output: string): Promise<boolean>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** Discover inputs and apply the desktop output naming and skip rules. */
|
|
2
|
+
import { readdir, stat } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
4
|
+
import { ApiError } from "./api-client.js";
|
|
5
|
+
const supportedSource = (name) => /\.(mp4|mov)$/i.test(name) && !/ 4k\.mp4$/i.test(name);
|
|
6
|
+
const pathIdentity = (path) => process.platform === "win32" ? resolve(path).toLowerCase() : resolve(path);
|
|
7
|
+
export function assertUniqueOutputs(scan) {
|
|
8
|
+
const owners = new Map();
|
|
9
|
+
for (const source of scan.files) {
|
|
10
|
+
const output = scan.outputFor(source), identity = JSON.stringify([pathIdentity(dirname(output)), basename(output).toLowerCase()]);
|
|
11
|
+
const previous = owners.get(identity);
|
|
12
|
+
if (previous !== undefined)
|
|
13
|
+
throw new ApiError("local_error", `Sources "${previous}" and "${source}" resolve to the same output path "${output}". Rename one source before retrying.`);
|
|
14
|
+
owners.set(identity, source);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function scanVideos(input, out) {
|
|
18
|
+
const source = resolve(input), info = await stat(source);
|
|
19
|
+
const sourceRoot = info.isDirectory() ? source : dirname(source);
|
|
20
|
+
const stateRoot = out ? resolve(out) : sourceRoot;
|
|
21
|
+
const files = [];
|
|
22
|
+
async function walk(path) {
|
|
23
|
+
for (const item of await readdir(path, { withFileTypes: true })) {
|
|
24
|
+
const child = join(path, item.name);
|
|
25
|
+
if (item.isDirectory()) {
|
|
26
|
+
if (out && resolve(child).toLowerCase() === stateRoot.toLowerCase())
|
|
27
|
+
continue;
|
|
28
|
+
await walk(child);
|
|
29
|
+
}
|
|
30
|
+
else if (item.isFile() && supportedSource(item.name))
|
|
31
|
+
files.push(child);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (info.isDirectory())
|
|
35
|
+
await walk(source);
|
|
36
|
+
else if (info.isFile() && /\.(mp4|mov)$/i.test(source))
|
|
37
|
+
files.push(source);
|
|
38
|
+
else
|
|
39
|
+
throw new Error("Input must be an MP4/MOV file or directory.");
|
|
40
|
+
files.sort();
|
|
41
|
+
// A single-file request still accounts for its supported siblings so its
|
|
42
|
+
// output name agrees with a folder scan, including previously finished jobs.
|
|
43
|
+
const siblings = info.isDirectory() ? files : (await readdir(sourceRoot, { withFileTypes: true }))
|
|
44
|
+
.filter((item) => item.isFile() && supportedSource(item.name)).map((item) => join(sourceRoot, item.name));
|
|
45
|
+
const containsSource = siblings.some((file) => pathIdentity(file) === pathIdentity(source));
|
|
46
|
+
if (!info.isDirectory() && !containsSource)
|
|
47
|
+
siblings.push(source);
|
|
48
|
+
const folderFor = (file) => out
|
|
49
|
+
? join(stateRoot, ...(info.isDirectory() ? [basename(sourceRoot)] : []), dirname(relative(sourceRoot, file))) : dirname(file);
|
|
50
|
+
const groupFor = (file) => JSON.stringify([pathIdentity(folderFor(file)), basename(file, extname(file)).toLowerCase()]);
|
|
51
|
+
const groups = new Map();
|
|
52
|
+
for (const file of siblings)
|
|
53
|
+
groups.set(groupFor(file), (groups.get(groupFor(file)) ?? 0) + 1);
|
|
54
|
+
const outputFor = (file) => {
|
|
55
|
+
const extension = extname(file), stem = basename(file, extension);
|
|
56
|
+
const suffix = (groups.get(groupFor(file)) ?? 0) > 1 ? ` (${extension.slice(1).toLowerCase()})` : "";
|
|
57
|
+
return join(folderFor(file), `${stem}${suffix} 4K.mp4`);
|
|
58
|
+
};
|
|
59
|
+
assertUniqueOutputs({ files: siblings, outputFor });
|
|
60
|
+
return { files, sourceRoot, stateRoot, outputFor };
|
|
61
|
+
}
|
|
62
|
+
export async function outputExists(source, output) {
|
|
63
|
+
const stem = basename(source, extname(source)).toLowerCase();
|
|
64
|
+
const colocated = pathIdentity(dirname(output)) === pathIdentity(dirname(source));
|
|
65
|
+
const computedName = basename(output).toLowerCase();
|
|
66
|
+
const allowAlternates = !colocated && computedName === `${stem} 4k.mp4`;
|
|
67
|
+
const names = new Set(allowAlternates ? [computedName, `${stem}.mp4`, `${stem}.mov`] : [computedName]);
|
|
68
|
+
try {
|
|
69
|
+
return (await readdir(dirname(output), { withFileTypes: true })).some((entry) => entry.isFile() && names.has(entry.name.toLowerCase()) &&
|
|
70
|
+
pathIdentity(join(dirname(output), entry.name)) !== pathIdentity(source));
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error.code === "ENOENT")
|
|
74
|
+
return false;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface LockOptions {
|
|
2
|
+
waitMs?: number;
|
|
3
|
+
staleMs?: number;
|
|
4
|
+
refreshMs?: number;
|
|
5
|
+
maxLeaseMs?: number;
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
}
|
|
8
|
+
export declare function acquireStateLock(path: string, options?: LockOptions): Promise<() => Promise<void>>;
|
|
9
|
+
export declare function withStateLock<T>(path: string, operation: () => Promise<T>, options?: LockOptions): Promise<T>;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/** Filesystem leases shared by CLI processes and MCP calls. */
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, readdir, rename, rmdir, stat, unlink, utimes, writeFile } from "node:fs/promises";
|
|
4
|
+
import { hostname } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { performance } from "node:perf_hooks";
|
|
7
|
+
import { ApiError, sleep, throwIfCancelled } from "./api-client.js";
|
|
8
|
+
const code = (error) => error.code;
|
|
9
|
+
const transient = (error) => ["EPERM", "EACCES", "EBUSY"].includes(code(error) ?? "");
|
|
10
|
+
async function removeEmpty(path) {
|
|
11
|
+
try {
|
|
12
|
+
await rmdir(path);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (code(error) === "ENOENT")
|
|
17
|
+
return true;
|
|
18
|
+
if (transient(error) || ["ENOTEMPTY", "EEXIST"].includes(code(error) ?? ""))
|
|
19
|
+
return false;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function validOwner(value) {
|
|
24
|
+
const owner = value;
|
|
25
|
+
return !!owner && Number.isSafeInteger(owner.pid) && owner.pid > 0 &&
|
|
26
|
+
typeof owner.hostname === "string" && owner.hostname.length > 0 && Number.isFinite(owner.timestamp) && owner.timestamp > 0;
|
|
27
|
+
}
|
|
28
|
+
async function ownerMtime(ownerPath, path) {
|
|
29
|
+
try {
|
|
30
|
+
return (await stat(ownerPath)).mtimeMs;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return (await stat(path)).mtimeMs;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function recover(path, staleMs) {
|
|
37
|
+
try {
|
|
38
|
+
const names = await readdir(path);
|
|
39
|
+
if (!names.length)
|
|
40
|
+
return removeEmpty(path);
|
|
41
|
+
if (names.length !== 1)
|
|
42
|
+
return false;
|
|
43
|
+
const ownerPath = join(path, names[0]);
|
|
44
|
+
let owner;
|
|
45
|
+
try {
|
|
46
|
+
owner = JSON.parse(await readFile(ownerPath, "utf8"));
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (code(error) === "ENOENT")
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
let dead = false;
|
|
53
|
+
if (validOwner(owner) && owner.hostname === hostname()) {
|
|
54
|
+
try {
|
|
55
|
+
process.kill(owner.pid, 0);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
dead = code(error) === "ESRCH";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Age also covers hung processes, PID reuse, permission-denied probes and corrupt metadata.
|
|
62
|
+
// Recheck the heartbeat after reading/probing so a refreshed lease is preserved.
|
|
63
|
+
if (!dead && Date.now() - await ownerMtime(ownerPath, path) < staleMs)
|
|
64
|
+
return false;
|
|
65
|
+
// Remove only this owner's unique file. A delayed reaper cannot unlink a successor.
|
|
66
|
+
await unlink(ownerPath);
|
|
67
|
+
return removeEmpty(path);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (code(error) === "ENOENT")
|
|
71
|
+
return true;
|
|
72
|
+
if (transient(error))
|
|
73
|
+
return false;
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export async function acquireStateLock(path, options = {}) {
|
|
78
|
+
throwIfCancelled(options.signal);
|
|
79
|
+
const waitMs = options.waitMs ?? 60_000, staleMs = options.staleMs ?? 120_000;
|
|
80
|
+
const started = performance.now();
|
|
81
|
+
await mkdir(dirname(path), { recursive: true });
|
|
82
|
+
const token = randomUUID(), candidate = `${path}.${token}.tmp`, name = `${token}.json`;
|
|
83
|
+
await mkdir(candidate);
|
|
84
|
+
let acquired = false;
|
|
85
|
+
try {
|
|
86
|
+
await writeFile(join(candidate, name), JSON.stringify({ pid: process.pid, hostname: hostname(), timestamp: Date.now() }), { flag: "wx", mode: 0o600 });
|
|
87
|
+
for (let attempt = 0;; attempt++) {
|
|
88
|
+
throwIfCancelled(options.signal);
|
|
89
|
+
try {
|
|
90
|
+
// Publish a nonempty directory atomically: rename cannot replace another owner.
|
|
91
|
+
// Unique children also make stale removal safe without a check/unlink race.
|
|
92
|
+
await rename(candidate, path);
|
|
93
|
+
acquired = true;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (!transient(error) && !["EEXIST", "ENOTEMPTY"].includes(code(error) ?? ""))
|
|
98
|
+
throw error;
|
|
99
|
+
if (await recover(path, staleMs)) {
|
|
100
|
+
// Even a nonwaiting caller must be able to claim a recovered lease.
|
|
101
|
+
try {
|
|
102
|
+
await rename(candidate, path);
|
|
103
|
+
acquired = true;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
catch (retryError) {
|
|
107
|
+
if (!transient(retryError) && !["EEXIST", "ENOTEMPTY"].includes(code(retryError) ?? ""))
|
|
108
|
+
throw retryError;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const remaining = waitMs - (performance.now() - started);
|
|
113
|
+
if (remaining <= 0)
|
|
114
|
+
throw new ApiError("state_locked", `State is locked by another run (${path}). Wait for it to finish, then rerun.`);
|
|
115
|
+
await sleep(Math.min(remaining, 20 * 2 ** Math.min(attempt, 4)), options.signal);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
if (!acquired) {
|
|
120
|
+
await unlink(join(candidate, name)).catch((error) => { if (code(error) !== "ENOENT")
|
|
121
|
+
throw error; });
|
|
122
|
+
await removeEmpty(candidate);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const ownerPath = join(path, name);
|
|
126
|
+
const acquiredAt = performance.now(), maxLeaseMs = options.maxLeaseMs ?? 6 * 60 * 60 * 1000;
|
|
127
|
+
const refresh = options.refreshMs ? setInterval(() => {
|
|
128
|
+
if (performance.now() - acquiredAt >= maxLeaseMs) {
|
|
129
|
+
clearInterval(refresh);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const now = new Date();
|
|
133
|
+
void utimes(ownerPath, now, now).catch(() => { });
|
|
134
|
+
}, options.refreshMs) : undefined;
|
|
135
|
+
refresh?.unref();
|
|
136
|
+
return async () => {
|
|
137
|
+
clearInterval(refresh);
|
|
138
|
+
await unlink(ownerPath).catch((error) => { if (code(error) !== "ENOENT")
|
|
139
|
+
throw error; });
|
|
140
|
+
await removeEmpty(path);
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
export async function withStateLock(path, operation, options) {
|
|
144
|
+
const release = await acquireStateLock(path, options);
|
|
145
|
+
try {
|
|
146
|
+
return await operation();
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
await release();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Terminal prompts with credential echo suppressed for login. */
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { Writable } from "node:stream";
|
|
4
|
+
export async function ask(question, hidden = false) {
|
|
5
|
+
if (!process.stdin.isTTY)
|
|
6
|
+
throw new Error("Interactive input requires a terminal. For likeness consent, obtain the user's agreement before supplying --yes-self-likeness-consent.");
|
|
7
|
+
process.stderr.write(question);
|
|
8
|
+
const sink = hidden ? new Writable({ write(_chunk, _encoding, done) { done(); } }) : process.stderr;
|
|
9
|
+
const reader = createInterface({ input: process.stdin, output: sink, terminal: true });
|
|
10
|
+
try {
|
|
11
|
+
return (await reader.question("")).trim();
|
|
12
|
+
}
|
|
13
|
+
finally {
|
|
14
|
+
reader.close();
|
|
15
|
+
if (hidden)
|
|
16
|
+
process.stderr.write("\n");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export async function agree(statement) {
|
|
20
|
+
process.stderr.write(`${statement}\n`);
|
|
21
|
+
if (!/^(y|yes)$/i.test(await ask("Agree? (y/N) ")))
|
|
22
|
+
throw new Error("Consent declined; nothing submitted.");
|
|
23
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ApiClient } from "./api-client.js";
|
|
2
|
+
import { type Account } from "./api-contract.js";
|
|
3
|
+
import { type Scan } from "./scan-videos.js";
|
|
4
|
+
import { type Emit, type FileResult } from "./run-file.js";
|
|
5
|
+
export type { RunEvent, FileResult } from "./run-file.js";
|
|
6
|
+
export interface UpscaleOptions {
|
|
7
|
+
path: string;
|
|
8
|
+
selfLikeness: boolean;
|
|
9
|
+
selfLikenessConsent?: boolean;
|
|
10
|
+
outputDir?: string;
|
|
11
|
+
concurrency?: number;
|
|
12
|
+
wait?: boolean;
|
|
13
|
+
pollMs?: number;
|
|
14
|
+
onEvent?: Emit;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
scan?: Scan;
|
|
17
|
+
}
|
|
18
|
+
export interface RunResult {
|
|
19
|
+
results: FileResult[];
|
|
20
|
+
stopped?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function upscale(client: ApiClient, account: Account, options: UpscaleOptions): Promise<RunResult>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** Coordinate admission, bounded file workers, durable resume and event reporting. */
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { ApiError, throwIfCancelled } from "./api-client.js";
|
|
5
|
+
import { MAX_LONG_SIDE, MIN_LONG_SIDE, MAX_UPLOAD_BYTES } from "./api-contract.js";
|
|
6
|
+
import { probeVideo } from "./probe-video.js";
|
|
7
|
+
import { ResumeState } from "./resume-state.js";
|
|
8
|
+
import { assertUniqueOutputs, outputExists, scanVideos } from "./scan-videos.js";
|
|
9
|
+
import { runFile } from "./run-file.js";
|
|
10
|
+
const stopCodes = new Set(["no_credits", "daily_limit", "api_locked", "consent_outdated", "state_locked", "upgrade_required"]);
|
|
11
|
+
const actions = {
|
|
12
|
+
no_credits: "Add credits to your Upscale4K account, then rerun to resume.",
|
|
13
|
+
daily_limit: "Daily limit reached. Wait for the next daily allowance, then rerun.",
|
|
14
|
+
api_locked: "Unlock API access in your Upscale4K account before resuming.",
|
|
15
|
+
consent_outdated: "The server rejected the saved consent. Inspect the job and server response before resuming.",
|
|
16
|
+
state_locked: "Another run holds this state directory. Let it finish, then rerun to resume.",
|
|
17
|
+
upgrade_required: "This upscale4k version is no longer supported. Rerun with npx -y upscale4k@latest to resume.",
|
|
18
|
+
};
|
|
19
|
+
export async function upscale(client, account, options) {
|
|
20
|
+
throwIfCancelled(options.signal);
|
|
21
|
+
if (typeof options.selfLikeness !== "boolean")
|
|
22
|
+
throw new ApiError("validation_error", "self_likeness must be explicitly supplied; ask the user, never guess.");
|
|
23
|
+
if (options.selfLikeness && options.selfLikenessConsent !== true)
|
|
24
|
+
throw new ApiError("consent_required", account.consent.self_likeness_statement);
|
|
25
|
+
const concurrency = options.concurrency ?? 3;
|
|
26
|
+
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 100)
|
|
27
|
+
throw new ApiError("validation_error", "Concurrency must be an integer from 1 to 100.");
|
|
28
|
+
const emit = options.onEvent ?? (() => { });
|
|
29
|
+
const scan = options.scan ?? await scanVideos(options.path, options.outputDir);
|
|
30
|
+
assertUniqueOutputs(scan);
|
|
31
|
+
const store = new ResumeState(join(scan.stateRoot, ".upscale4k-state.json"), { signal: options.signal });
|
|
32
|
+
await emit({ event: "scan", count: scan.files.length, path: options.path });
|
|
33
|
+
// Snapshot existing results before any paid work begins.
|
|
34
|
+
const existingOutputs = new Map(await Promise.all(scan.files.map(async (source) => [source, await outputExists(source, scan.outputFor(source))])));
|
|
35
|
+
const results = [];
|
|
36
|
+
let index = 0, stopped;
|
|
37
|
+
async function worker() {
|
|
38
|
+
while (!stopped && !options.signal?.aborted && index < scan.files.length) {
|
|
39
|
+
const source = scan.files[index++], output = scan.outputFor(source);
|
|
40
|
+
try {
|
|
41
|
+
let reason, message;
|
|
42
|
+
const info = await stat(source);
|
|
43
|
+
if (existingOutputs.get(source))
|
|
44
|
+
reason = "output_exists";
|
|
45
|
+
else if (info.size <= 0)
|
|
46
|
+
reason = "size_limit";
|
|
47
|
+
let body;
|
|
48
|
+
if (!reason) {
|
|
49
|
+
const video = await probeVideo(source);
|
|
50
|
+
const long = account.long_video, isLong = video.duration > account.max_seconds;
|
|
51
|
+
// Long clips need an eligible account and upload in parts, so they get the larger long-video size cap.
|
|
52
|
+
const longOk = isLong && long?.eligible === true && video.duration <= (long.max_seconds["runway-4k"] ?? 0);
|
|
53
|
+
if (isLong && !longOk) {
|
|
54
|
+
reason = "duration_limit";
|
|
55
|
+
message = !long?.eligible
|
|
56
|
+
? `Long videos (over this account's max_seconds of ${account.max_seconds} seconds) are not yet available through the Upscale4K API for this account.`
|
|
57
|
+
: `Video exceeds this account's long-video limit of ${long.max_seconds["runway-4k"] ?? 0} seconds.`;
|
|
58
|
+
}
|
|
59
|
+
else if (info.size > (isLong ? long.max_upload_bytes : MAX_UPLOAD_BYTES))
|
|
60
|
+
reason = "size_limit";
|
|
61
|
+
else if (Math.max(video.width, video.height) > MAX_LONG_SIDE || Math.max(video.width, video.height) < MIN_LONG_SIDE)
|
|
62
|
+
reason = "resolution_limit";
|
|
63
|
+
body = { filename: basename(source), size: info.size, ...video, consent: {
|
|
64
|
+
selfLikeness: options.selfLikeness,
|
|
65
|
+
...(options.selfLikenessConsent ? { selfLikenessConsent: true } : {}),
|
|
66
|
+
} };
|
|
67
|
+
}
|
|
68
|
+
if (reason) {
|
|
69
|
+
results.push({ source, output_path: output, state: "skipped", ...(message ? { reason, message } : {}) });
|
|
70
|
+
await emit({ event: "skipped", source, reason, ...(message ? { message } : {}) });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
throwIfCancelled(options.signal);
|
|
74
|
+
if (stopped)
|
|
75
|
+
break;
|
|
76
|
+
const acquired = await store.withSource(source, async () => {
|
|
77
|
+
throwIfCancelled(options.signal);
|
|
78
|
+
if (stopped)
|
|
79
|
+
return;
|
|
80
|
+
const entry = await store.prepare(source, info.size, info.mtimeMs, output, body, undefined, client.keyPrefix);
|
|
81
|
+
throwIfCancelled(options.signal);
|
|
82
|
+
if (stopped)
|
|
83
|
+
return;
|
|
84
|
+
results.push(await runFile(client, store, entry, output, options.wait ?? true, emit, options.pollMs ?? 2000, body, options.signal));
|
|
85
|
+
});
|
|
86
|
+
if (!acquired) {
|
|
87
|
+
results.push({ source, state: "skipped", reason: "in_progress_elsewhere" });
|
|
88
|
+
await emit({ event: "skipped", source, reason: "in_progress_elsewhere" });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
const code = error instanceof ApiError ? error.code : "local_error";
|
|
93
|
+
const message = error instanceof Error ? error.message : "Upscale failed";
|
|
94
|
+
if (code === "cancelled")
|
|
95
|
+
stopped = "cancelled: Rerun the same command to resume.";
|
|
96
|
+
if (stopCodes.has(code))
|
|
97
|
+
stopped = `${code}: ${actions[code]}`;
|
|
98
|
+
results.push({ source, state: "failed", error: `${code}: ${message}` });
|
|
99
|
+
await emit({ event: "failed", source, code, message, ...(stopped ? { action: stopped } : {}) });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, scan.files.length) }, worker));
|
|
104
|
+
if (options.signal?.aborted && !stopped) {
|
|
105
|
+
stopped = "cancelled: Rerun the same command to resume.";
|
|
106
|
+
await emit({ event: "failed", code: "cancelled", message: "Upscale cancelled; rerun the same command to resume." });
|
|
107
|
+
}
|
|
108
|
+
await emit({ event: "summary", scanned: scan.files.length, processed: results.length,
|
|
109
|
+
done: results.filter((r) => r.state === "done").length, skipped: results.filter((r) => r.state === "skipped").length,
|
|
110
|
+
failed: results.filter((r) => r.state === "failed").length, stopped });
|
|
111
|
+
throwIfCancelled(options.signal);
|
|
112
|
+
return { results, stopped };
|
|
113
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "upscale4k",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Upscale short videos to 4K with Upscale4K from the terminal or any MCP-capable AI agent.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"upscale4k": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc -p .",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"prepack": "npm run build"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
24
|
+
"zod": "^4.6.7"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^22.20.5",
|
|
28
|
+
"typescript": "^5.9.2",
|
|
29
|
+
"vitest": "^4.1.12"
|
|
30
|
+
}
|
|
31
|
+
}
|