@petercjl/topazlabscli 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Peter Chen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # topazlabscli
2
+
3
+ `topazlabscli` is an npm-distributed CLI and portable Agent Skill for sending video-enhancement jobs to an authorized Windows workstation running Topaz Video AI. It uses the workstation's existing SSH service, Topaz installation, GPU, models, and license. Nothing in this package installs, redistributes, licenses, or unlocks Topaz software.
4
+
5
+ ## Requirements
6
+
7
+ - Client: Node.js 20+, OpenSSH `ssh` and `sftp`.
8
+ - Worker: Windows, OpenSSH Server, Topaz Video AI with its bundled FFmpeg/FFprobe, a logged-in licensed user, and downloaded model files.
9
+ - Network access is configured separately. Each endpoint is ordinary user configuration; the package does not contain or manage VPN settings.
10
+
11
+ ## Install
12
+
13
+ ```powershell
14
+ npm install --global @petercjl/topazlabscli
15
+ topazlabscli skill install --agent all
16
+ ```
17
+
18
+ ## Configure a target
19
+
20
+ Use one or more SSH endpoints in priority order. A LAN-only user configures only the LAN entry.
21
+
22
+ ```powershell
23
+ topazlabscli target add gpu-workstation `
24
+ --endpoint lan=gpu-workstation `
25
+ --user Administrator `
26
+ --identity "$HOME\.ssh\gpu_workstation_ed25519" `
27
+ --workspace "E:\topazlab_workspace" `
28
+ --default
29
+ ```
30
+
31
+ An authorized roaming user can add a second endpoint that resolves through their own VPN configuration:
32
+
33
+ ```powershell
34
+ topazlabscli target add gpu-workstation `
35
+ --endpoint lan=gpu-workstation `
36
+ --endpoint vpn=gpu-workstation-vpn `
37
+ --user Administrator `
38
+ --identity "$HOME\.ssh\gpu_workstation_ed25519" `
39
+ --workspace "E:\topazlab_workspace" `
40
+ --default
41
+ ```
42
+
43
+ The CLI tries endpoints in the order provided. It never starts or changes a VPN.
44
+
45
+ ## Set up and check the worker
46
+
47
+ ```powershell
48
+ topazlabscli connection check --json
49
+ topazlabscli worker install --json
50
+ topazlabscli doctor --json
51
+ topazlabscli model status --json
52
+ ```
53
+
54
+ The worker uses a global Windows mutex and one queue consumer, so jobs from multiple clients run serially.
55
+
56
+ ## Process a video
57
+
58
+ ```powershell
59
+ topazlabscli process .\input.mp4 --output .\output-1080p.mp4 --json
60
+ ```
61
+
62
+ Asynchronous form:
63
+
64
+ ```powershell
65
+ topazlabscli job submit .\input.mp4 --json
66
+ topazlabscli job status JOB_ID --json
67
+ topazlabscli job wait JOB_ID --json
68
+ topazlabscli job download JOB_ID --output .\output-1080p.mp4 --json
69
+ ```
70
+
71
+ Version 0.1 includes one preset: `seedance-human-1080p`, using Proteus v4 (`prob-4`), source FPS, aspect-preserving 1080p output, and NVIDIA H.264 encoding.
72
+
73
+ ## Configuration
74
+
75
+ Configuration is stored outside the package:
76
+
77
+ - Windows: `%APPDATA%\topazlabscli\config.json`
78
+ - macOS/Linux: `${XDG_CONFIG_HOME:-~/.config}/topazlabscli/config.json`
79
+ - Override for testing or automation: `TOPAZLABSCLI_CONFIG`
80
+
81
+ Do not publish configuration files, keys, internal addresses, media, Topaz model files, or authentication data.
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../src/cli.mjs";
3
+
4
+ main(process.argv.slice(2)).catch((error) => {
5
+ const json = process.argv.includes("--json");
6
+ const payload = {
7
+ ok: false,
8
+ error: {
9
+ code: error.code || "UNEXPECTED_ERROR",
10
+ message: error.message || String(error),
11
+ details: error.details || null
12
+ }
13
+ };
14
+ if (json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
15
+ else process.stderr.write(`[${payload.error.code}] ${payload.error.message}\n`);
16
+ process.exitCode = Number.isInteger(error.exitCode) ? error.exitCode : 1;
17
+ });
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@petercjl/topazlabscli",
3
+ "version": "0.1.0",
4
+ "description": "Cross-Agent CLI and portable Skill for queued remote Topaz Video AI processing",
5
+ "type": "module",
6
+ "bin": {
7
+ "topazlabscli": "bin/topazlabscli.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "worker",
13
+ "skill",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
20
+ "scripts": {
21
+ "test": "node --test",
22
+ "check": "node --check bin/topazlabscli.mjs && node --check src/cli.mjs && npm test"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "registry": "https://registry.npmjs.org/"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/petercjl/topazlabscli.git"
31
+ },
32
+ "homepage": "https://github.com/petercjl/topazlabscli#readme",
33
+ "bugs": {
34
+ "url": "https://github.com/petercjl/topazlabscli/issues"
35
+ },
36
+ "license": "MIT",
37
+ "keywords": [
38
+ "topaz",
39
+ "video-ai",
40
+ "ssh",
41
+ "gpu",
42
+ "agent-skill"
43
+ ]
44
+ }
@@ -0,0 +1,49 @@
1
+ ---
2
+ name: topazlabscli
3
+ description: Operate a configured remote Windows Topaz Video AI workstation through the stable topazlabscli CLI. Use for connection checks, Proteus model readiness, queued 480p-to-1080p enhancement jobs, status, download, cancellation, and installation diagnostics. Do not use for installing or licensing Topaz itself.
4
+ ---
5
+
6
+ # TopazLabs CLI
7
+
8
+ Use the CLI as the single execution surface. Do not reproduce SSH, SFTP, queue, or Topaz FFmpeg commands manually when the CLI supports the operation.
9
+
10
+ ## Input → Strategy → Output
11
+
12
+ - Input: a local video path or a job identifier, the user's requested action, and an already configured target.
13
+ - Strategy: inspect capabilities and health, select the configured reachable endpoint, submit one deterministic preset to the serialized Windows queue, observe terminal state, and download only when requested.
14
+ - Output: structured CLI evidence, a job identifier and state, and for completed processing an explicitly requested local output file.
15
+
16
+ ## Main Line
17
+
18
+ 1. Run `topazlabscli capabilities --json` when the live contract is not already known.
19
+ 2. Run `topazlabscli doctor --json` before the first job in a conversation or after a connection/model failure.
20
+ 3. For a processing request, confirm the input exists and the output path is authorized.
21
+ 4. Prefer `topazlabscli process INPUT --output OUTPUT --json` for submit, wait, and download as one operation. Use separate `job` commands when the user wants asynchronous control.
22
+ 5. Inspect the final JSON. Completion requires `state=completed`, a successful download result, and a real local output file.
23
+ 6. Report the selected target/endpoint, job ID, output path, preset/model, dimensions, and any warning or failure.
24
+
25
+ ## Preset Boundary
26
+
27
+ `seedance-human-1080p` is the only built-in preset in version 0.1. It uses Proteus v4 (`prob-4`), preserves source FPS and aspect ratio, targets a 1080-pixel short edge, and relies on the remote queue's single concurrency slot. Do not silently substitute another model, frame interpolation, stabilization, motion deblur, or 4K output.
28
+
29
+ ## Branches
30
+
31
+ - Connection failure: report `CONNECTION_FAILED` and the endpoint attempts. The CLI does not start, reconfigure, or grant access to a VPN.
32
+ - Worker/model not ready: run `worker status` or `model status`; stop with the returned dependency error. Installing/licensing Topaz and downloading models remain GUI administration tasks.
33
+ - Long-running work: use `job submit`, return the job ID, then `job wait` when the user asks to remain attached. Do not resubmit merely because a wait timed out.
34
+ - Cancellation: queued work may cancel immediately. A running task records a cancellation request but is not forcibly killed in version 0.1.
35
+ - Missing capability: return `CAPABILITY_UNAVAILABLE` or the CLI's structured error. Do not invent a platform-specific workaround.
36
+
37
+ ## Configuration and Safety
38
+
39
+ Configuration, hostnames, addresses, usernames, SSH identities, VPN details, media, Topaz binaries, models, and credentials are external to this Skill and npm package. Installation does not grant access to a workstation. Treat the configured server and Topaz license as user-managed resources.
40
+
41
+ Do not overwrite a local output unless the user has authorized that exact existing target. The remote worker retains job inputs, outputs, status, and logs for operator review; cleanup is an administrative action outside version 0.1.
42
+
43
+ ## Skill Management
44
+
45
+ The npm package is the canonical source. Discover it with `topazlabscli skill source --json`; use `skill status`, `skill install`, and `skill update` for Codex and SealSeek targets. Do not edit installed links or copies as independent sources.
46
+
47
+ ## QA and Evolution
48
+
49
+ Use `doctor --json` plus the final job status as runtime evidence. Treat Codex and SealSeek adapters as `implemented` until each has a recorded real runtime test. New models, presets, cleanup rules, or worker behavior require an authorized package update with CLI, worker, Skill, capability, and test changes together.
@@ -0,0 +1,29 @@
1
+ {
2
+ "schema": "portable-skill-adapter",
3
+ "schema_version": "1.0.0",
4
+ "platform": "codex",
5
+ "mappings": [
6
+ {
7
+ "capability_id": "command.execute",
8
+ "implementation": { "kind": "cli", "command": "topazlabscli" },
9
+ "features": ["local-process", "stdout-json", "exit-code"],
10
+ "status": "tested",
11
+ "tested_date": "2026-09-20",
12
+ "evidence": ["Codex invoked topazlabscli process --json through the local terminal and received a completed remote job contract."],
13
+ "configuration": ["topazlabscli must resolve from PATH"],
14
+ "permissions": ["local process execution", "network access to configured SSH target"],
15
+ "normalization": "Invoke with --json and consume {ok,data,error}."
16
+ },
17
+ {
18
+ "capability_id": "filesystem.local",
19
+ "implementation": { "kind": "agent-native", "name": "Codex filesystem tools" },
20
+ "features": ["read-file", "write-file", "inspect-metadata"],
21
+ "status": "tested",
22
+ "tested_date": "2026-09-20",
23
+ "evidence": ["Codex verified the downloaded MP4 with local ffprobe after the end-to-end job completed."],
24
+ "configuration": [],
25
+ "permissions": ["input and output paths must be within the user's authorized scope"],
26
+ "normalization": "Return absolute local paths and verified metadata."
27
+ }
28
+ ]
29
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "schema": "portable-skill-adapter",
3
+ "schema_version": "1.0.0",
4
+ "platform": "sealseek",
5
+ "mappings": [
6
+ {
7
+ "capability_id": "command.execute",
8
+ "implementation": { "kind": "cli", "command": "topazlabscli" },
9
+ "features": ["local-process", "stdout-json", "exit-code"],
10
+ "status": "implemented",
11
+ "configuration": ["topazlabscli must resolve from PATH"],
12
+ "permissions": ["local process execution", "network access to configured SSH target"],
13
+ "normalization": "Invoke with --json and consume {ok,data,error}."
14
+ },
15
+ {
16
+ "capability_id": "filesystem.local",
17
+ "implementation": { "kind": "agent-native", "name": "SealSeek/OpenClaw filesystem capability" },
18
+ "features": ["read-file", "write-file", "inspect-metadata"],
19
+ "status": "implemented",
20
+ "configuration": [],
21
+ "permissions": ["input and output paths must be within the user's authorized scope"],
22
+ "normalization": "Return absolute local paths and verified metadata."
23
+ }
24
+ ]
25
+ }
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "TopazLabs CLI"
3
+ short_description: "Queue remote Topaz Video AI enhancement jobs"
4
+ default_prompt: "Use topazlabscli to check the configured workstation and process a video safely."
@@ -0,0 +1,36 @@
1
+ {
2
+ "schema": "portable-skill-capabilities",
3
+ "schema_version": "1.0.0",
4
+ "skill": "topazlabscli",
5
+ "target_platforms": ["codex", "sealseek"],
6
+ "capabilities": [
7
+ {
8
+ "id": "command.execute",
9
+ "purpose": "Run the stable topazlabscli executable and parse JSON results.",
10
+ "required": true,
11
+ "required_features": ["local-process", "stdout-json", "exit-code"],
12
+ "input_fields": ["argv"],
13
+ "output_fields": ["ok", "data", "error"],
14
+ "side_effects": {
15
+ "external_mutation": "May upload videos and enqueue remote processing only when the user requests processing.",
16
+ "may_cost_money": false,
17
+ "authorization": "Requires the user's configured SSH access and an explicit processing request."
18
+ },
19
+ "standard_errors": ["CAPABILITY_UNAVAILABLE", "AUTH_REQUIRED", "PERMISSION_REQUIRED", "PROVIDER_FAILURE", "OUTPUT_CONTRACT_FAILED"]
20
+ },
21
+ {
22
+ "id": "filesystem.local",
23
+ "purpose": "Read input videos and write downloaded outputs.",
24
+ "required": true,
25
+ "required_features": ["read-file", "write-file", "inspect-metadata"],
26
+ "input_fields": ["path"],
27
+ "output_fields": ["exists", "path", "size"],
28
+ "side_effects": {
29
+ "external_mutation": "Writes only to the output path requested by the user.",
30
+ "may_cost_money": false,
31
+ "authorization": "The path must be within the user's authorized filesystem scope."
32
+ },
33
+ "standard_errors": ["CAPABILITY_UNAVAILABLE", "PERMISSION_REQUIRED", "OUTPUT_CONTRACT_FAILED"]
34
+ }
35
+ ]
36
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,285 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import { createRequire } from "node:module";
5
+ import { loadConfig, saveConfig, resolveTarget } from "./config.mjs";
6
+ import { configPath, workerScript } from "./paths.mjs";
7
+ import { CliError, requireValue } from "./errors.mjs";
8
+ import { run } from "./process.mjs";
9
+ import { psLiteral, runPowerShell, selectEndpoint, sftpGet, sftpPut, startPowerShellDetached } from "./ssh.mjs";
10
+ import { skillInstall, skillSource, skillStatus } from "./skill.mjs";
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const pkg = require("../package.json");
14
+ const PRESET = "seedance-human-1080p";
15
+
16
+ const CAPABILITIES = {
17
+ schema_version: 1,
18
+ package: pkg.name,
19
+ version: pkg.version,
20
+ commands: ["version", "capabilities", "doctor", "target", "connection", "worker", "model", "job", "process", "skill", "update"],
21
+ presets: [{ id: PRESET, model: "prob-4", output: "aspect-preserving 1080p", fps: "source", concurrency: 1 }],
22
+ agents: { codex: "tested", sealseek: "implemented" },
23
+ worker_os: ["windows"],
24
+ transport: ["ssh", "sftp"]
25
+ };
26
+
27
+ function option(args, name, { multiple = false } = {}) {
28
+ const values = [];
29
+ for (let i = 0; i < args.length; i += 1) {
30
+ if (args[i] === name) {
31
+ if (i + 1 >= args.length) throw new CliError("ARGUMENT_REQUIRED", `${name} requires a value.`);
32
+ values.push(args[i + 1]);
33
+ args.splice(i, 2);
34
+ i -= 1;
35
+ } else if (args[i].startsWith(`${name}=`)) {
36
+ values.push(args[i].slice(name.length + 1));
37
+ args.splice(i, 1);
38
+ i -= 1;
39
+ }
40
+ }
41
+ return multiple ? values : values.at(-1);
42
+ }
43
+
44
+ function flag(args, name) {
45
+ const index = args.indexOf(name);
46
+ if (index < 0) return false;
47
+ args.splice(index, 1);
48
+ return true;
49
+ }
50
+
51
+ function output(value, json) {
52
+ if (json) process.stdout.write(`${JSON.stringify({ ok: true, data: value }, null, 2)}\n`);
53
+ else if (typeof value === "string") process.stdout.write(`${value}\n`);
54
+ else process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
55
+ }
56
+
57
+ function help() {
58
+ return `topazlabscli ${pkg.version}\n\n` +
59
+ `Commands:\n` +
60
+ ` version | capabilities | doctor\n` +
61
+ ` target add <name> --endpoint <label=host>... --user <user> [--identity <path>] [--workspace <windows-path>] [--default]\n` +
62
+ ` target list\n` +
63
+ ` connection check [--target <name>]\n` +
64
+ ` worker install|status [--target <name>]\n` +
65
+ ` model status [--target <name>]\n` +
66
+ ` job submit <video> [--target <name>] [--preset ${PRESET}]\n` +
67
+ ` job list [--target <name>]\n` +
68
+ ` job status|wait|download|cancel <job-id> [--target <name>] [--output <path>]\n` +
69
+ ` process <video> --output <path> [--target <name>]\n` +
70
+ ` skill source|status|install|update [--agent codex|sealseek|all] [--copy]\n` +
71
+ ` update\n\nUse --json for machine-readable output.`;
72
+ }
73
+
74
+ function parseEndpoint(value) {
75
+ const split = value.indexOf("=");
76
+ const name = split > 0 ? value.slice(0, split) : "default";
77
+ const address = split > 0 ? value.slice(split + 1) : value;
78
+ const match = address.match(/^(.+?)(?::(\d+))?$/);
79
+ if (!match || !match[1]) throw new CliError("ENDPOINT_INVALID", `Invalid endpoint: ${value}`);
80
+ return { name, host: match[1], ...(match[2] ? { port: Number(match[2]) } : {}) };
81
+ }
82
+
83
+ function remoteRoot(target) {
84
+ return target.workspace || "E:\\topazlab_workspace";
85
+ }
86
+
87
+ function workerInvocation(root, action, extra = "") {
88
+ const script = `${root}\\.topazlabscli\\worker\\topazlabs-worker.ps1`;
89
+ return `& ${psLiteral(script)} -Action ${psLiteral(action)} -Root ${psLiteral(root)} ${extra}`;
90
+ }
91
+
92
+ function parseRemoteJson(result, code = "REMOTE_ERROR") {
93
+ if (result.code !== 0) throw new CliError(code, result.stderr.trim() || result.stdout.trim() || "Remote command failed.");
94
+ const text = result.stdout.trim();
95
+ try { return JSON.parse(text); }
96
+ catch { throw new CliError("REMOTE_OUTPUT_INVALID", "Remote worker did not return valid JSON.", { output: text }); }
97
+ }
98
+
99
+ async function remoteAction(target, endpoint, action, params = {}) {
100
+ const encoded = Buffer.from(JSON.stringify(params), "utf8").toString("base64");
101
+ const extra = Object.keys(params).length ? `-PayloadBase64 ${psLiteral(encoded)}` : "";
102
+ return parseRemoteJson(await runPowerShell(target, endpoint, workerInvocation(remoteRoot(target), action, extra)));
103
+ }
104
+
105
+ async function getConnectedTarget(requested) {
106
+ const target = resolveTarget(loadConfig({ required: true }), requested);
107
+ const selected = await selectEndpoint(target);
108
+ return { target, ...selected };
109
+ }
110
+
111
+ async function submitJob(inputPath, requestedTarget, preset = PRESET) {
112
+ const input = path.resolve(inputPath);
113
+ if (!fs.existsSync(input) || !fs.statSync(input).isFile()) throw new CliError("INPUT_NOT_FOUND", `Video not found: ${input}`);
114
+ if (preset !== PRESET) throw new CliError("PRESET_UNSUPPORTED", `Unsupported preset: ${preset}`);
115
+ const { target, endpoint, attempts } = await getConnectedTarget(requestedTarget);
116
+ const id = `${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)}-${crypto.randomBytes(3).toString("hex")}`;
117
+ const safeName = path.basename(input).replace(/[^A-Za-z0-9._-]/g, "_");
118
+ const prepared = await remoteAction(target, endpoint, "Prepare", { id, input_name: safeName });
119
+ await sftpPut(target, endpoint, input, prepared.input_path);
120
+ const job = await remoteAction(target, endpoint, "Enqueue", { id, input_name: safeName, preset });
121
+ const runnerPid = startPowerShellDetached(target, endpoint, workerInvocation(remoteRoot(target), "Run"));
122
+ return { ...job, target: target.name, endpoint: endpoint.name, runner_pid: runnerPid, connection_attempts: attempts };
123
+ }
124
+
125
+ async function waitJob(id, requestedTarget, intervalSeconds = 10, timeoutSeconds = 86400) {
126
+ const { target, endpoint } = await getConnectedTarget(requestedTarget);
127
+ const started = Date.now();
128
+ while (true) {
129
+ const status = await remoteAction(target, endpoint, "JobStatus", { id });
130
+ if (["completed", "failed", "cancelled"].includes(status.state)) return { ...status, target: target.name, endpoint: endpoint.name };
131
+ if ((Date.now() - started) / 1000 > timeoutSeconds) throw new CliError("WAIT_TIMEOUT", `Timed out waiting for job ${id}.`);
132
+ await new Promise((resolve) => setTimeout(resolve, intervalSeconds * 1000));
133
+ }
134
+ }
135
+
136
+ async function downloadJob(id, requestedTarget, outputPath) {
137
+ const { target, endpoint } = await getConnectedTarget(requestedTarget);
138
+ const status = await remoteAction(target, endpoint, "JobStatus", { id });
139
+ if (status.state !== "completed") throw new CliError("JOB_NOT_COMPLETE", `Job ${id} is ${status.state}.`);
140
+ const destination = path.resolve(outputPath || status.output_name || `${id}.mp4`);
141
+ if (fs.existsSync(destination)) throw new CliError("OUTPUT_EXISTS", `Refusing to overwrite existing output: ${destination}`);
142
+ await sftpGet(target, endpoint, status.output_path, destination);
143
+ return { id, target: target.name, endpoint: endpoint.name, output: destination };
144
+ }
145
+
146
+ async function doctor(requestedTarget) {
147
+ const checks = [];
148
+ for (const command of ["ssh", "sftp", "node", "npm"]) {
149
+ const args = command === "node" || command === "npm" ? ["--version"] : command === "sftp" ? ["-h"] : ["-V"];
150
+ const result = await run(command, args);
151
+ const detail = (result.stdout || result.stderr).trim().split("\n")[0];
152
+ const ok = result.code === 0 || (command === "sftp" && /usage:\s*sftp/i.test(result.stderr));
153
+ checks.push({ id: `local.${command}`, ok, detail });
154
+ }
155
+ checks.push({ id: "config", ok: fs.existsSync(configPath()), detail: configPath() });
156
+ if (requestedTarget || fs.existsSync(configPath())) {
157
+ try {
158
+ const { target, endpoint } = await getConnectedTarget(requestedTarget);
159
+ checks.push({ id: "connection", ok: true, detail: `${target.name}/${endpoint.name}` });
160
+ try {
161
+ const status = await remoteAction(target, endpoint, "Status");
162
+ checks.push({ id: "worker", ok: Boolean(status.installed), detail: status });
163
+ } catch (error) {
164
+ checks.push({ id: "worker", ok: false, detail: error.message });
165
+ }
166
+ } catch (error) {
167
+ checks.push({ id: "connection", ok: false, detail: error.details || error.message });
168
+ }
169
+ }
170
+ return { ok: checks.every((item) => item.ok), checks };
171
+ }
172
+
173
+ export async function main(rawArgs) {
174
+ const args = [...rawArgs];
175
+ const json = flag(args, "--json");
176
+ if (args.length === 0 || ["help", "--help", "-h"].includes(args[0])) return output(help(), false);
177
+ const command = args.shift();
178
+ if (command === "version" || command === "--version" || command === "-V") return output(pkg.version, json);
179
+ if (command === "capabilities") return output(CAPABILITIES, json);
180
+ if (command === "doctor") return output(await doctor(option(args, "--target")), json);
181
+
182
+ if (command === "target") {
183
+ const action = args.shift();
184
+ if (action === "list") return output({ path: configPath(), ...loadConfig() }, json);
185
+ if (action === "add") {
186
+ const name = requireValue(args.shift(), "TARGET_REQUIRED", "target add requires a name.");
187
+ const endpoints = option(args, "--endpoint", { multiple: true }).map(parseEndpoint);
188
+ if (endpoints.length === 0) throw new CliError("ENDPOINT_REQUIRED", "Provide at least one --endpoint label=host.");
189
+ const config = loadConfig();
190
+ config.targets[name] = {
191
+ user: option(args, "--user") || null,
192
+ identity_file: option(args, "--identity") || null,
193
+ workspace: option(args, "--workspace") || "E:\\topazlab_workspace",
194
+ endpoints
195
+ };
196
+ if (flag(args, "--default") || !config.default_target) config.default_target = name;
197
+ const saved = saveConfig(config);
198
+ return output({ name, path: saved, target: config.targets[name], default: config.default_target === name }, json);
199
+ }
200
+ throw new CliError("COMMAND_UNKNOWN", `Unknown target action: ${action || ""}`);
201
+ }
202
+
203
+ if (command === "connection" && args.shift() === "check") {
204
+ const { target, endpoint, attempts } = await getConnectedTarget(option(args, "--target"));
205
+ return output({ target: target.name, selected: endpoint, attempts }, json);
206
+ }
207
+
208
+ if (command === "worker") {
209
+ const action = args.shift();
210
+ const requested = option(args, "--target");
211
+ const { target, endpoint, attempts } = await getConnectedTarget(requested);
212
+ if (action === "install") {
213
+ const root = remoteRoot(target);
214
+ const remote = `${root}\\.topazlabscli\\worker\\topazlabs-worker.ps1`;
215
+ const workerDirectory = `${root}\\.topazlabscli\\worker`;
216
+ const prep = `$p=${psLiteral(workerDirectory)}; New-Item -ItemType Directory -Force -Path $p | Out-Null; [Console]::Out.Write('{"ok":true}')`;
217
+ parseRemoteJson(await runPowerShell(target, endpoint, prep));
218
+ await sftpPut(target, endpoint, workerScript, remote);
219
+ const installed = await remoteAction(target, endpoint, "Install");
220
+ return output({ ...installed, target: target.name, endpoint: endpoint.name, connection_attempts: attempts }, json);
221
+ }
222
+ if (action === "status") return output(await remoteAction(target, endpoint, "Status"), json);
223
+ throw new CliError("COMMAND_UNKNOWN", `Unknown worker action: ${action || ""}`);
224
+ }
225
+
226
+ if (command === "model" && args.shift() === "status") {
227
+ const { target, endpoint } = await getConnectedTarget(option(args, "--target"));
228
+ const status = await remoteAction(target, endpoint, "Status");
229
+ return output({ model: "prob-4", ready: status.model_ready, definitions: status.model_definitions, weights: status.model_weights }, json);
230
+ }
231
+
232
+ if (command === "job") {
233
+ const action = args.shift();
234
+ const requested = option(args, "--target");
235
+ if (action === "submit") return output(await submitJob(requireValue(args.shift(), "INPUT_REQUIRED", "job submit requires a video."), requested, option(args, "--preset") || PRESET), json);
236
+ if (action === "list") {
237
+ const { target, endpoint } = await getConnectedTarget(requested);
238
+ return output(await remoteAction(target, endpoint, "ListJobs"), json);
239
+ }
240
+ const id = requireValue(args.shift(), "JOB_ID_REQUIRED", `job ${action || ""} requires a job id.`);
241
+ if (action === "status") {
242
+ const { target, endpoint } = await getConnectedTarget(requested);
243
+ return output(await remoteAction(target, endpoint, "JobStatus", { id }), json);
244
+ }
245
+ if (action === "wait") return output(await waitJob(id, requested, Number(option(args, "--interval") || 10), Number(option(args, "--timeout") || 86400)), json);
246
+ if (action === "download") return output(await downloadJob(id, requested, option(args, "--output")), json);
247
+ if (action === "cancel") {
248
+ const { target, endpoint } = await getConnectedTarget(requested);
249
+ return output(await remoteAction(target, endpoint, "Cancel", { id }), json);
250
+ }
251
+ throw new CliError("COMMAND_UNKNOWN", `Unknown job action: ${action || ""}`);
252
+ }
253
+
254
+ if (command === "process") {
255
+ const input = requireValue(args.shift(), "INPUT_REQUIRED", "process requires a video.");
256
+ const destination = requireValue(option(args, "--output"), "OUTPUT_REQUIRED", "process requires --output.");
257
+ const requested = option(args, "--target");
258
+ const submitted = await submitJob(input, requested, option(args, "--preset") || PRESET);
259
+ const finished = await waitJob(submitted.id, requested, Number(option(args, "--interval") || 10), Number(option(args, "--timeout") || 86400));
260
+ if (finished.state !== "completed") throw new CliError("JOB_FAILED", `Job ${submitted.id} ended as ${finished.state}.`, finished);
261
+ return output({ submitted, finished, downloaded: await downloadJob(submitted.id, requested, destination) }, json);
262
+ }
263
+
264
+ if (command === "skill") {
265
+ const action = args.shift();
266
+ const agent = option(args, "--agent") || "all";
267
+ if (action === "source") return output(skillSource(), json);
268
+ if (action === "status") return output(skillStatus(agent), json);
269
+ if (action === "install") return output(skillInstall(agent, flag(args, "--copy") ? "copy" : "link", false), json);
270
+ if (action === "update") return output(skillInstall(agent, flag(args, "--copy") ? "copy" : "link", true), json);
271
+ throw new CliError("COMMAND_UNKNOWN", `Unknown skill action: ${action || ""}`);
272
+ }
273
+
274
+ if (command === "update") {
275
+ const previousSkills = skillStatus("all");
276
+ const result = await run("npm", ["install", "-g", `${pkg.name}@latest`]);
277
+ if (result.code !== 0) throw new CliError("UPDATE_FAILED", result.stderr.trim() || "npm update failed.");
278
+ const skills = [];
279
+ for (const existing of previousSkills.filter((item) => item.installed)) {
280
+ skills.push(...skillInstall(existing.agent, existing.mode || "link", true));
281
+ }
282
+ return output({ package: pkg.name, updated: true, skills, detail: result.stdout.trim() }, json);
283
+ }
284
+ throw new CliError("COMMAND_UNKNOWN", `Unknown command: ${command}`);
285
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,47 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { configPath } from "./paths.mjs";
4
+ import { CliError } from "./errors.mjs";
5
+
6
+ export function emptyConfig() {
7
+ return { schema_version: 1, default_target: null, targets: {} };
8
+ }
9
+
10
+ export function loadConfig({ required = false } = {}) {
11
+ const filename = configPath();
12
+ if (!fs.existsSync(filename)) {
13
+ if (required) throw new CliError("CONFIG_NOT_FOUND", `Configuration not found: ${filename}`);
14
+ return emptyConfig();
15
+ }
16
+ try {
17
+ const parsed = JSON.parse(fs.readFileSync(filename, "utf8"));
18
+ if (parsed.schema_version !== 1 || typeof parsed.targets !== "object") {
19
+ throw new Error("unsupported configuration schema");
20
+ }
21
+ return parsed;
22
+ } catch (error) {
23
+ throw new CliError("CONFIG_INVALID", `Cannot read configuration: ${error.message}`, { path: filename });
24
+ }
25
+ }
26
+
27
+ export function saveConfig(config) {
28
+ const filename = configPath();
29
+ fs.mkdirSync(path.dirname(filename), { recursive: true, mode: 0o700 });
30
+ if (fs.existsSync(filename)) fs.copyFileSync(filename, `${filename}.bak`);
31
+ const temporary = `${filename}.${process.pid}.tmp`;
32
+ fs.writeFileSync(temporary, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
33
+ fs.renameSync(temporary, filename);
34
+ try { fs.chmodSync(filename, 0o600); } catch {}
35
+ return filename;
36
+ }
37
+
38
+ export function resolveTarget(config, requested) {
39
+ const name = requested || config.default_target;
40
+ if (!name) throw new CliError("TARGET_REQUIRED", "Specify --target or configure a default target.");
41
+ const target = config.targets[name];
42
+ if (!target) throw new CliError("TARGET_NOT_FOUND", `Unknown target: ${name}`);
43
+ if (!Array.isArray(target.endpoints) || target.endpoints.length === 0) {
44
+ throw new CliError("TARGET_INVALID", `Target ${name} has no endpoints.`);
45
+ }
46
+ return { name, ...target };
47
+ }
package/src/errors.mjs ADDED
@@ -0,0 +1,16 @@
1
+ export class CliError extends Error {
2
+ constructor(code, message, details = null, exitCode = 1) {
3
+ super(message);
4
+ this.name = "CliError";
5
+ this.code = code;
6
+ this.details = details;
7
+ this.exitCode = exitCode;
8
+ }
9
+ }
10
+
11
+ export function requireValue(value, code, message) {
12
+ if (value === undefined || value === null || value === "") {
13
+ throw new CliError(code, message);
14
+ }
15
+ return value;
16
+ }
package/src/paths.mjs ADDED
@@ -0,0 +1,27 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const here = path.dirname(fileURLToPath(import.meta.url));
6
+ export const packageRoot = path.resolve(here, "..");
7
+ export const bundledSkill = path.join(packageRoot, "skill", "topazlabscli");
8
+ export const workerScript = path.join(packageRoot, "worker", "windows", "topazlabs-worker.ps1");
9
+
10
+ export function configPath() {
11
+ if (process.env.TOPAZLABSCLI_CONFIG) return path.resolve(process.env.TOPAZLABSCLI_CONFIG);
12
+ const base = process.platform === "win32"
13
+ ? (process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"))
14
+ : (process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"));
15
+ return path.join(base, "topazlabscli", "config.json");
16
+ }
17
+
18
+ export function skillTarget(agent) {
19
+ const home = os.homedir();
20
+ if (agent === "codex") {
21
+ return path.join(process.env.CODEX_HOME || path.join(home, ".codex"), "skills", "topazlabscli");
22
+ }
23
+ if (agent === "sealseek") {
24
+ return path.join(process.env.SEALSEEK_SKILLS_HOME || path.join(home, ".agents", "skills"), "topazlabscli");
25
+ }
26
+ throw new Error(`Unknown Agent: ${agent}`);
27
+ }
@@ -0,0 +1,21 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export function run(command, args, options = {}) {
4
+ return new Promise((resolve, reject) => {
5
+ const child = spawn(command, args, {
6
+ cwd: options.cwd,
7
+ env: options.env || process.env,
8
+ stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
9
+ windowsHide: true
10
+ });
11
+ let stdout = "";
12
+ let stderr = "";
13
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
14
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
15
+ child.on("error", reject);
16
+ child.on("close", (code, signal) => resolve({ code, signal, stdout, stderr }));
17
+ if (options.input !== undefined) {
18
+ child.stdin.end(options.input);
19
+ }
20
+ });
21
+ }
package/src/skill.mjs ADDED
@@ -0,0 +1,44 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { bundledSkill, skillTarget } from "./paths.mjs";
4
+ import { CliError } from "./errors.mjs";
5
+
6
+ const agents = ["codex", "sealseek"];
7
+
8
+ export function skillSource() {
9
+ return { path: bundledSkill, exists: fs.existsSync(path.join(bundledSkill, "SKILL.md")) };
10
+ }
11
+
12
+ export function skillStatus(selected = "all") {
13
+ const names = selected === "all" ? agents : [selected];
14
+ return names.map((agent) => {
15
+ const target = skillTarget(agent);
16
+ let installed = false;
17
+ let mode = null;
18
+ if (fs.existsSync(target)) {
19
+ installed = true;
20
+ mode = fs.lstatSync(target).isSymbolicLink() ? "link" : "copy";
21
+ }
22
+ return { agent, target, installed, mode, source: bundledSkill };
23
+ });
24
+ }
25
+
26
+ function installOne(agent, mode, update) {
27
+ const target = skillTarget(agent);
28
+ fs.mkdirSync(path.dirname(target), { recursive: true });
29
+ if (fs.existsSync(target)) {
30
+ if (!update) throw new CliError("SKILL_ALREADY_INSTALLED", `Skill target already exists: ${target}`);
31
+ const stat = fs.lstatSync(target);
32
+ if (stat.isSymbolicLink()) fs.unlinkSync(target);
33
+ else fs.rmSync(target, { recursive: true, force: true });
34
+ }
35
+ if (mode === "copy") fs.cpSync(bundledSkill, target, { recursive: true });
36
+ else fs.symlinkSync(bundledSkill, target, process.platform === "win32" ? "junction" : "dir");
37
+ return { agent, target, mode };
38
+ }
39
+
40
+ export function skillInstall(selected = "all", mode = "link", update = false) {
41
+ const names = selected === "all" ? agents : [selected];
42
+ for (const name of names) if (!agents.includes(name)) throw new CliError("AGENT_UNSUPPORTED", `Unsupported Agent: ${name}`);
43
+ return names.map((name) => installOne(name, mode, update));
44
+ }
package/src/ssh.mjs ADDED
@@ -0,0 +1,83 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { run } from "./process.mjs";
5
+ import { CliError } from "./errors.mjs";
6
+
7
+ function destination(target, endpoint) {
8
+ return target.user ? `${target.user}@${endpoint.host}` : endpoint.host;
9
+ }
10
+
11
+ function commonArgs(target, endpoint, { timeout = 7 } = {}) {
12
+ const args = ["-o", "BatchMode=yes", "-o", `ConnectTimeout=${timeout}`];
13
+ if (target.identity_file) args.push("-i", target.identity_file);
14
+ if (endpoint.port) args.push("-p", String(endpoint.port));
15
+ return args;
16
+ }
17
+
18
+ export function encodePowerShell(script) {
19
+ return Buffer.from(script, "utf16le").toString("base64");
20
+ }
21
+
22
+ export async function runPowerShell(target, endpoint, script, { timeout = 7 } = {}) {
23
+ const args = [...commonArgs(target, endpoint, { timeout }), destination(target, endpoint),
24
+ "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encodePowerShell(script)];
25
+ return run("ssh", args);
26
+ }
27
+
28
+ export function startPowerShellDetached(target, endpoint, script) {
29
+ const args = [...commonArgs(target, endpoint, { timeout: 10 }), destination(target, endpoint),
30
+ "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encodePowerShell(script)];
31
+ const child = spawn("ssh", args, { detached: true, stdio: "ignore", windowsHide: true });
32
+ child.unref();
33
+ return child.pid;
34
+ }
35
+
36
+ export async function selectEndpoint(target) {
37
+ const attempts = [];
38
+ for (const endpoint of target.endpoints) {
39
+ const result = await runPowerShell(target, endpoint, "[Console]::Out.Write('TOPAZLABSCLI_OK')");
40
+ const ok = result.code === 0 && result.stdout.includes("TOPAZLABSCLI_OK");
41
+ attempts.push({ name: endpoint.name, host: endpoint.host, ok, error: ok ? null : result.stderr.trim() });
42
+ if (ok) return { endpoint, attempts };
43
+ }
44
+ throw new CliError("CONNECTION_FAILED", `No endpoint is reachable for target ${target.name}.`, { attempts });
45
+ }
46
+
47
+ function quoteSftp(value) {
48
+ return `"${String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
49
+ }
50
+
51
+ export function windowsToSftp(remotePath) {
52
+ const normalized = String(remotePath).replaceAll("\\", "/");
53
+ if (/^[A-Za-z]:\//.test(normalized)) return `/${normalized}`;
54
+ return normalized;
55
+ }
56
+
57
+ export async function sftpPut(target, endpoint, localPath, remotePath) {
58
+ if (!fs.existsSync(localPath)) throw new CliError("INPUT_NOT_FOUND", `Local file not found: ${localPath}`);
59
+ const args = ["-b", "-", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10"];
60
+ if (target.identity_file) args.push("-i", target.identity_file);
61
+ if (endpoint.port) args.push("-P", String(endpoint.port));
62
+ args.push(destination(target, endpoint));
63
+ const input = `put ${quoteSftp(path.resolve(localPath))} ${quoteSftp(windowsToSftp(remotePath))}\n`;
64
+ const result = await run("sftp", args, { input });
65
+ if (result.code !== 0) throw new CliError("UPLOAD_FAILED", result.stderr.trim() || "SFTP upload failed.");
66
+ return result;
67
+ }
68
+
69
+ export async function sftpGet(target, endpoint, remotePath, localPath) {
70
+ fs.mkdirSync(path.dirname(path.resolve(localPath)), { recursive: true });
71
+ const args = ["-b", "-", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10"];
72
+ if (target.identity_file) args.push("-i", target.identity_file);
73
+ if (endpoint.port) args.push("-P", String(endpoint.port));
74
+ args.push(destination(target, endpoint));
75
+ const input = `get ${quoteSftp(windowsToSftp(remotePath))} ${quoteSftp(path.resolve(localPath))}\n`;
76
+ const result = await run("sftp", args, { input });
77
+ if (result.code !== 0) throw new CliError("DOWNLOAD_FAILED", result.stderr.trim() || "SFTP download failed.");
78
+ return result;
79
+ }
80
+
81
+ export function psLiteral(value) {
82
+ return `'${String(value).replaceAll("'", "''")}'`;
83
+ }
@@ -0,0 +1,237 @@
1
+ param(
2
+ [Parameter(Mandatory = $true)]
3
+ [ValidateSet('Install', 'Status', 'Prepare', 'Enqueue', 'Run', 'JobStatus', 'ListJobs', 'Cancel')]
4
+ [string]$Action,
5
+ [Parameter(Mandatory = $true)]
6
+ [string]$Root,
7
+ [string]$PayloadBase64
8
+ )
9
+
10
+ $ErrorActionPreference = 'Stop'
11
+ $WorkerVersion = '0.1.0'
12
+ $StateRoot = Join-Path $Root '.topazlabscli'
13
+ $QueueRoot = Join-Path $StateRoot 'queue'
14
+ $JobsRoot = Join-Path $StateRoot 'jobs'
15
+ $WorkerRoot = Join-Path $StateRoot 'worker'
16
+ $ConfigPath = Join-Path $StateRoot 'worker-config.json'
17
+
18
+ function Write-Json($Value) {
19
+ [Console]::Out.Write(($Value | ConvertTo-Json -Depth 8 -Compress))
20
+ }
21
+
22
+ function Read-Payload {
23
+ if ([string]::IsNullOrWhiteSpace($PayloadBase64)) { return @{} }
24
+ $json = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($PayloadBase64))
25
+ return $json | ConvertFrom-Json
26
+ }
27
+
28
+ function Ensure-Directories {
29
+ @($StateRoot, $QueueRoot, $JobsRoot, $WorkerRoot) | ForEach-Object {
30
+ New-Item -ItemType Directory -Force -Path $_ | Out-Null
31
+ }
32
+ }
33
+
34
+ function Assert-SafeName([string]$Value, [string]$Field) {
35
+ if ([string]::IsNullOrWhiteSpace($Value) -or $Value -notmatch '^[A-Za-z0-9._-]+$') {
36
+ throw "Invalid $Field."
37
+ }
38
+ }
39
+
40
+ function Read-WorkerConfig {
41
+ if (-not (Test-Path -LiteralPath $ConfigPath)) { throw 'Worker is not installed.' }
42
+ return Get-Content -Raw -LiteralPath $ConfigPath | ConvertFrom-Json
43
+ }
44
+
45
+ function Job-Directory([string]$Id) {
46
+ Assert-SafeName $Id 'job id'
47
+ return Join-Path $JobsRoot $Id
48
+ }
49
+
50
+ function Status-Path([string]$Id) {
51
+ return Join-Path (Job-Directory $Id) 'status.json'
52
+ }
53
+
54
+ function Set-JobStatus([string]$Id, [hashtable]$Values) {
55
+ $path = Status-Path $Id
56
+ $current = @{}
57
+ if (Test-Path -LiteralPath $path) {
58
+ $existing = Get-Content -Raw -LiteralPath $path | ConvertFrom-Json
59
+ $existing.PSObject.Properties | ForEach-Object { $current[$_.Name] = $_.Value }
60
+ }
61
+ $Values.Keys | ForEach-Object { $current[$_] = $Values[$_] }
62
+ $current['id'] = $Id
63
+ $current['updated_at'] = (Get-Date).ToUniversalTime().ToString('o')
64
+ $temporary = "$path.tmp"
65
+ $current | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $temporary -Encoding UTF8
66
+ Move-Item -Force -LiteralPath $temporary -Destination $path
67
+ return $current
68
+ }
69
+
70
+ function Get-ModelStatus($Config) {
71
+ $definition = Join-Path $Config.model_dir 'prob-4.json'
72
+ $weights = @(Get-ChildItem -LiteralPath $Config.model_data_dir -Filter 'prob-v4-*.tz' -ErrorAction SilentlyContinue)
73
+ return @{
74
+ model_ready = ((Test-Path -LiteralPath $definition) -and $weights.Count -gt 0)
75
+ model_definitions = @{ path = $definition; exists = (Test-Path -LiteralPath $definition) }
76
+ model_weights = @{ path = $Config.model_data_dir; count = $weights.Count }
77
+ }
78
+ }
79
+
80
+ function Invoke-Job($Job, $Config) {
81
+ $id = [string]$Job.id
82
+ $jobDir = Job-Directory $id
83
+ $inputPath = Join-Path (Join-Path $jobDir 'input') ([string]$Job.input_name)
84
+ $outputDir = Join-Path $jobDir 'output'
85
+ $outputName = ([IO.Path]::GetFileNameWithoutExtension([string]$Job.input_name)) + '-topaz-1080p.mp4'
86
+ $outputPath = Join-Path $outputDir $outputName
87
+ $logPath = Join-Path (Join-Path $jobDir 'logs') 'topaz-ffmpeg.log'
88
+ if (-not (Test-Path -LiteralPath $inputPath)) { throw "Input file is missing: $inputPath" }
89
+ if (-not (Test-Path -LiteralPath $Config.ffmpeg)) { throw "Topaz ffmpeg is missing: $($Config.ffmpeg)" }
90
+
91
+ Set-JobStatus $id @{ state = 'running'; started_at = (Get-Date).ToUniversalTime().ToString('o'); input_path = $inputPath; output_path = $outputPath; output_name = $outputName; preset = $Job.preset } | Out-Null
92
+ $probeText = & $Config.ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of json -- $inputPath
93
+ if ($LASTEXITCODE -ne 0) { throw 'ffprobe failed.' }
94
+ $probe = $probeText | ConvertFrom-Json
95
+ $width = [int]$probe.streams[0].width
96
+ $height = [int]$probe.streams[0].height
97
+ if ($width -ge $height) {
98
+ $targetHeight = 1080
99
+ $targetWidth = [int](2 * [Math]::Round((1080.0 * $width / $height) / 2.0))
100
+ } else {
101
+ $targetWidth = 1080
102
+ $targetHeight = [int](2 * [Math]::Round((1080.0 * $height / $width) / 2.0))
103
+ }
104
+ $env:TVAI_MODEL_DIR = [string]$Config.model_dir
105
+ $env:TVAI_MODEL_DATA_DIR = [string]$Config.model_data_dir
106
+ $filter = "tvai_up=model=prob-4:scale=0:w=${targetWidth}:h=${targetHeight}:preblur=0:noise=0:details=0:halo=0:blur=0:compression=0:estimate=20:blend=0.2:device=0:vram=1:instances=1"
107
+ $arguments = @(
108
+ '-hide_banner', '-nostdin', '-y', '-strict', '2', '-i', $inputPath,
109
+ '-sws_flags', 'spline+accurate_rnd+full_chroma_int', '-vf', $filter,
110
+ '-map', '0:v:0', '-map', '0:a?', '-map_metadata', '0',
111
+ '-c:v', 'h264_nvenc', '-preset', 'p5', '-cq', '18', '-b:v', '0', '-pix_fmt', 'yuv420p',
112
+ '-c:a', 'aac', '-b:a', '192k', '-fps_mode', 'passthrough', '-movflags', '+faststart', $outputPath
113
+ )
114
+ $oldPreference = $ErrorActionPreference
115
+ $ErrorActionPreference = 'Continue'
116
+ & $Config.ffmpeg @arguments 2>&1 | Tee-Object -FilePath $logPath | Out-Null
117
+ $exit = $LASTEXITCODE
118
+ $ErrorActionPreference = $oldPreference
119
+ if ($exit -ne 0 -or -not (Test-Path -LiteralPath $outputPath)) { throw "Topaz ffmpeg failed with exit code $exit. See $logPath" }
120
+ $size = (Get-Item -LiteralPath $outputPath).Length
121
+ Set-JobStatus $id @{ state = 'completed'; completed_at = (Get-Date).ToUniversalTime().ToString('o'); output_path = $outputPath; output_name = $outputName; output_bytes = $size; width = $targetWidth; height = $targetHeight; model = 'prob-4' } | Out-Null
122
+ }
123
+
124
+ Ensure-Directories
125
+
126
+ switch ($Action) {
127
+ 'Install' {
128
+ if (-not (Test-Path -LiteralPath $ConfigPath)) {
129
+ $config = @{
130
+ schema_version = 1
131
+ worker_version = $WorkerVersion
132
+ ffmpeg = 'C:\Program Files\Topaz Labs LLC\Topaz Video AI\ffmpeg.exe'
133
+ ffprobe = 'C:\Program Files\Topaz Labs LLC\Topaz Video AI\ffprobe.exe'
134
+ model_dir = 'C:\ProgramData\Topaz Labs LLC\Topaz Video AI\models'
135
+ model_data_dir = 'E:\topazlabs_model'
136
+ concurrency = 1
137
+ }
138
+ $config | ConvertTo-Json | Set-Content -LiteralPath $ConfigPath -Encoding UTF8
139
+ }
140
+ $current = Read-WorkerConfig
141
+ $model = Get-ModelStatus $current
142
+ Write-Json @{ ok = $true; installed = $true; worker_version = $WorkerVersion; root = $Root; model_ready = $model.model_ready }
143
+ }
144
+ 'Status' {
145
+ $installed = Test-Path -LiteralPath $ConfigPath
146
+ if (-not $installed) { Write-Json @{ ok = $true; installed = $false; root = $Root }; break }
147
+ $config = Read-WorkerConfig
148
+ $model = Get-ModelStatus $config
149
+ $queued = @(Get-ChildItem -LiteralPath $QueueRoot -Filter '*.json' -ErrorAction SilentlyContinue).Count
150
+ Write-Json @{ ok = $true; installed = $true; worker_version = $config.worker_version; root = $Root; concurrency = $config.concurrency; queued = $queued; ffmpeg = @{ path = $config.ffmpeg; exists = (Test-Path -LiteralPath $config.ffmpeg) }; ffprobe = @{ path = $config.ffprobe; exists = (Test-Path -LiteralPath $config.ffprobe) }; model_ready = $model.model_ready; model_definitions = $model.model_definitions; model_weights = $model.model_weights }
151
+ }
152
+ 'Prepare' {
153
+ $payload = Read-Payload
154
+ $id = [string]$payload.id
155
+ $name = [string]$payload.input_name
156
+ Assert-SafeName $id 'job id'
157
+ Assert-SafeName $name 'input name'
158
+ $jobDir = Job-Directory $id
159
+ @('input', 'output', 'logs') | ForEach-Object { New-Item -ItemType Directory -Force -Path (Join-Path $jobDir $_) | Out-Null }
160
+ $inputPath = Join-Path (Join-Path $jobDir 'input') $name
161
+ Set-JobStatus $id @{ state = 'uploading'; input_name = $name; input_path = $inputPath } | Out-Null
162
+ Write-Json @{ ok = $true; id = $id; input_path = $inputPath }
163
+ }
164
+ 'Enqueue' {
165
+ $payload = Read-Payload
166
+ $id = [string]$payload.id
167
+ Assert-SafeName $id 'job id'
168
+ Assert-SafeName ([string]$payload.input_name) 'input name'
169
+ if ($payload.preset -ne 'seedance-human-1080p') { throw 'Unsupported preset.' }
170
+ $queuePath = Join-Path $QueueRoot "$id.json"
171
+ $payload | ConvertTo-Json | Set-Content -LiteralPath "$queuePath.tmp" -Encoding UTF8
172
+ Move-Item -Force -LiteralPath "$queuePath.tmp" -Destination $queuePath
173
+ Set-JobStatus $id @{ state = 'queued'; queued_at = (Get-Date).ToUniversalTime().ToString('o'); preset = $payload.preset } | Out-Null
174
+ Write-Json @{ ok = $true; id = $id; state = 'queued' }
175
+ }
176
+ 'Run' {
177
+ $created = $false
178
+ $mutex = New-Object Threading.Mutex($true, 'Global\TopazLabsCliQueue', [ref]$created)
179
+ if (-not $created) { exit 0 }
180
+ try {
181
+ $config = Read-WorkerConfig
182
+ while ($true) {
183
+ $next = Get-ChildItem -LiteralPath $QueueRoot -Filter '*.json' | Sort-Object CreationTimeUtc, Name | Select-Object -First 1
184
+ if ($null -eq $next) { break }
185
+ $job = Get-Content -Raw -LiteralPath $next.FullName | ConvertFrom-Json
186
+ Remove-Item -Force -LiteralPath $next.FullName
187
+ $cancelPath = Join-Path (Job-Directory ([string]$job.id)) 'cancel.requested'
188
+ if (Test-Path -LiteralPath $cancelPath) {
189
+ Set-JobStatus ([string]$job.id) @{ state = 'cancelled'; completed_at = (Get-Date).ToUniversalTime().ToString('o') } | Out-Null
190
+ continue
191
+ }
192
+ try { Invoke-Job $job $config }
193
+ catch {
194
+ Set-JobStatus ([string]$job.id) @{ state = 'failed'; completed_at = (Get-Date).ToUniversalTime().ToString('o'); error = $_.Exception.Message } | Out-Null
195
+ }
196
+ }
197
+ } finally {
198
+ $mutex.ReleaseMutex()
199
+ $mutex.Dispose()
200
+ }
201
+ }
202
+ 'JobStatus' {
203
+ $payload = Read-Payload
204
+ $path = Status-Path ([string]$payload.id)
205
+ if (-not (Test-Path -LiteralPath $path)) { throw 'Job not found.' }
206
+ [Console]::Out.Write((Get-Content -Raw -LiteralPath $path).Trim())
207
+ }
208
+ 'ListJobs' {
209
+ $items = @()
210
+ Get-ChildItem -LiteralPath $JobsRoot -Directory -ErrorAction SilentlyContinue | ForEach-Object {
211
+ $path = Join-Path $_.FullName 'status.json'
212
+ if (Test-Path -LiteralPath $path) {
213
+ $items += (Get-Content -Raw -LiteralPath $path | ConvertFrom-Json)
214
+ }
215
+ }
216
+ $items = @($items | Sort-Object updated_at -Descending)
217
+ Write-Json @{ ok = $true; jobs = $items }
218
+ }
219
+ 'Cancel' {
220
+ $payload = Read-Payload
221
+ $id = [string]$payload.id
222
+ $statusPath = Status-Path $id
223
+ if (-not (Test-Path -LiteralPath $statusPath)) { throw 'Job not found.' }
224
+ $status = Get-Content -Raw -LiteralPath $statusPath | ConvertFrom-Json
225
+ if ($status.state -eq 'running') {
226
+ New-Item -ItemType File -Force -Path (Join-Path (Job-Directory $id) 'cancel.requested') | Out-Null
227
+ Set-JobStatus $id @{ cancel_requested = $true } | Out-Null
228
+ Write-Json @{ ok = $true; id = $id; state = 'running'; cancel_requested = $true; note = 'The current ffmpeg process is not forcibly terminated.' }
229
+ } elseif ($status.state -eq 'queued' -or $status.state -eq 'uploading') {
230
+ Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath (Join-Path $QueueRoot "$id.json")
231
+ Set-JobStatus $id @{ state = 'cancelled'; completed_at = (Get-Date).ToUniversalTime().ToString('o') } | Out-Null
232
+ Write-Json @{ ok = $true; id = $id; state = 'cancelled' }
233
+ } else {
234
+ Write-Json @{ ok = $true; id = $id; state = $status.state; changed = $false }
235
+ }
236
+ }
237
+ }