Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/__tests__/ffmpeg-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,47 @@ describe("ffmpeg command flow", () => {
expect(commandString).toBe("ffmpeg -i https://cdn.rendobar.com/video.mp4 -c:v libx264 -preset fast -vf scale=1920:1080 output.mp4");
});
});

// ── --compute flag → submit params ─────────────────────────────
//
// Mirrors how `rb ffmpeg` constructs the job params object: `compute` is only
// included when the user passed a value, and that value is validated against
// auto|cpu|gpu before submission.

type Compute = "auto" | "cpu" | "gpu";

function isCompute(value: string): value is Compute {
return value === "auto" || value === "cpu" || value === "gpu";
}

function buildParams(command: string, timeout: number, compute: Compute | null): Record<string, unknown> {
return { command, timeout, ...(compute ? { compute } : {}) };
}

describe("ffmpeg --compute flag", () => {
it("includes compute in params when --compute gpu is passed", () => {
const compute = "gpu";
expect(isCompute(compute)).toBe(true);
const params = buildParams("ffmpeg -i in.mp4 out.mp4", 120, compute);
expect(params.compute).toBe("gpu");
});

it("accepts auto and cpu as valid compute modes", () => {
expect(isCompute("auto")).toBe(true);
expect(isCompute("cpu")).toBe(true);
expect(buildParams("ffmpeg -i in.mp4 out.mp4", 120, "cpu").compute).toBe("cpu");
});

it("rejects an invalid compute value", () => {
expect(isCompute("turbo")).toBe(false);
expect(isCompute("GPU")).toBe(false);
expect(isCompute("")).toBe(false);
});

it("omits compute from params when no --compute flag is passed", () => {
const params = buildParams("ffmpeg -i in.mp4 out.mp4", 120, null);
expect("compute" in params).toBe(false);
expect(params.command).toBe("ffmpeg -i in.mp4 out.mp4");
expect(params.timeout).toBe(120);
});
});
4 changes: 2 additions & 2 deletions src/__tests__/progress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,13 @@ describe("buildResult — unified output", () => {
it("surfaces a structured error (code + message + detail) on failure", () => {
const r = buildResult("failed", undefined, {
error: {
code: "PROVIDER_ERROR",
code: "RUNNER_ERROR",
message: "Job failed",
detail: "frame= 100\n[error] Conversion failed!",
retryable: false,
},
});
expect(r.error?.code).toBe("PROVIDER_ERROR");
expect(r.error?.code).toBe("RUNNER_ERROR");
expect(r.error?.message).toBe("Job failed");
expect(r.error?.detail).toContain("Conversion failed!");
expect(r.error?.retryable).toBe(false);
Expand Down
31 changes: 26 additions & 5 deletions src/commands/ffmpeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ function localManifestPath(written: string[], manifestRemotePath: string): strin

// ── Flags ──────────────────────────────────────────────────────

type Compute = "auto" | "cpu" | "gpu";
const COMPUTE_MODES: readonly Compute[] = ["auto", "cpu", "gpu"];

function isCompute(value: string): value is Compute {
return (COMPUTE_MODES as readonly string[]).includes(value);
}

interface GlobalFlags {
json: boolean;
urlOnly: boolean;
Expand All @@ -62,6 +69,7 @@ interface GlobalFlags {
output: string | null;
outputDir: string | null;
timeout: number;
compute: Compute | null;
}

function extractGlobalFlags(): GlobalFlags {
Expand All @@ -75,6 +83,7 @@ function extractGlobalFlags(): GlobalFlags {
output: null,
outputDir: null,
timeout: 120,
compute: null,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
Expand All @@ -94,6 +103,14 @@ function extractGlobalFlags(): GlobalFlags {
const val = parseInt(argv[i + 1]!, 10);
if (!Number.isNaN(val) && val > 0) flags.timeout = Math.min(val, 900);
i++;
} else if (arg === "--compute" && i + 1 < argv.length) {
const val = argv[i + 1]!; // Guarded by i + 1 < argv.length
if (!isCompute(val)) {
process.stderr.write(pc.red(` ✗ Invalid --compute value "${val}". Expected one of: auto, cpu, gpu.\n`));
process.exit(2);
}
flags.compute = val;
i++;
}
}
return flags;
Expand All @@ -104,7 +121,7 @@ function extractFfmpegArgs(): string[] {
const ffmpegIdx = argv.indexOf("ffmpeg");
if (ffmpegIdx === -1) return [];
const globalFlags = new Set(["--json", "--url-only", "--quiet", "--no-wait", "--no-download"]);
const globalFlagsWithValue = new Set(["--timeout", "--output", "--output-dir"]);
const globalFlagsWithValue = new Set(["--timeout", "--output", "--output-dir", "--compute"]);
const result: string[] = [];
for (let i = ffmpegIdx + 1; i < argv.length; i++) {
const arg = argv[i]!;
Expand Down Expand Up @@ -135,10 +152,11 @@ ${pc.bold("Flags:")}
--quiet No output, exit code only
--no-wait Submit and exit immediately (prints job ID)
--timeout N Max execution time in seconds (default: 120, max: 900)
--compute <mode> Run on cpu or gpu hardware (auto, cpu, gpu; gpu needs Pro)

${pc.dim("Outputs download to your folder by default — like running ffmpeg locally.")}
${pc.dim("Local files are auto-uploaded before job submission.")}
${pc.dim("All FFmpeg flags are passed through to the cloud executor.")}
${pc.dim("All FFmpeg flags are passed through to the cloud runner.")}
`);
}

Expand Down Expand Up @@ -224,7 +242,10 @@ export default defineCommand({

const job = await steps.step("Submitting", async () => {
return client.jobs.create(
{ type: "ffmpeg", params: { command, timeout: flags.timeout } },
{
type: "ffmpeg",
params: { command, timeout: flags.timeout, ...(flags.compute ? { compute: flags.compute } : {}) },
},
{ signal: controller.signal },
);
});
Expand All @@ -238,7 +259,7 @@ export default defineCommand({
}

// ── 3. Wait for cloud execution ──────────────────────
// Phase 1: "Queued" spinner until job.context arrives (executor started)
// Phase 1: "Queued" spinner until job.context arrives (runner started)
// Phase 2: "Executing" spinner with machine specs until completion
// Final: replace spinner with server-timed "Executed" line
let machine: MachineContext | undefined;
Expand All @@ -255,7 +276,7 @@ export default defineCommand({
signal: controller.signal,
onContext(ctx) {
machine = ctx;
// job.context = executor started = queue phase over
// job.context = runner started = queue phase over
// Print "Queued ✓" with elapsed time, start "Executing" spinner
const queuedElapsed = Date.now() - queuedStart;
steps.stopSpinnerRaw();
Expand Down
4 changes: 2 additions & 2 deletions src/lib/progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export interface ProgressResult {
duration: number;
/** Created → Dispatched (API processing + queue dispatch) */
dispatchMs: number;
/** Dispatched → Started (waiting for executor machine) */
/** Dispatched → Started (waiting for runner machine) */
queueMs: number;
/** Started → Completed (actual execution) */
execMs: number;
Expand Down Expand Up @@ -253,7 +253,7 @@ export function buildResult(status: string, machine: MachineContext | undefined,

// Dispatch time: Created → Dispatched (API processing + queue dispatch)
const dispatchMs = dispatchedAt && createdAt ? dispatchedAt - createdAt : 0;
// Queue time: Dispatched → Started (waiting for executor machine)
// Queue time: Dispatched → Started (waiting for runner machine)
const queueMs = startedAt && dispatchedAt ? startedAt - dispatchedAt : 0;
// Execution time: Started → Completed (FFmpeg running)
const execMs = completedAt && startedAt ? completedAt - startedAt : 0;
Expand Down