diff --git a/package.json b/package.json index f73a581..fc1606c 100644 --- a/package.json +++ b/package.json @@ -26,10 +26,10 @@ "check:no-linked-sdk": "node scripts/check-no-linked-sdk.mjs" }, "dependencies": { - "@rendobar/sdk": "^3.0.0", + "@clack/prompts": "^1.2.0", + "@rendobar/sdk": "^3.0.1", "citty": "^0.2.0", - "picocolors": "^1.1.1", - "@clack/prompts": "^1.2.0" + "picocolors": "^1.1.1" }, "devDependencies": { "@commitlint/cli": "^21.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 124f53c..a46d37c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^1.2.0 version: 1.5.1 '@rendobar/sdk': - specifier: ^3.0.0 - version: 3.0.0 + specifier: ^3.0.1 + version: 3.0.1 citty: specifier: ^0.2.0 version: 0.2.2 @@ -136,8 +136,8 @@ packages: conventional-commits-parser: optional: true - '@rendobar/sdk@3.0.0': - resolution: {integrity: sha512-i9RrML7qq7E0j9Tl4oT8QIt5lizwYE+9sqdq7PESE/zcrLYA+/YuMcBF1fMmhKtOHAsEdgzzpGGLIZQjSdBrSw==} + '@rendobar/sdk@3.0.1': + resolution: {integrity: sha512-pYN6I8EeDNZqTywaDEln78bMZkoJsEOQH46BLjan1u89qoo9xyCHEjomswQU687gePXLZkMI+4VzH6qzjNVEyw==} engines: {node: '>=18'} '@simple-libs/child-process-utils@1.0.2': @@ -379,8 +379,8 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} - partysocket@1.1.16: - resolution: {integrity: sha512-d7xFv+ZC7x0p/DAHWJ5FhxQhimIx+ucyZY+kxL0cKddLBmK9c4p2tEA/L+dOOrWm6EYrRwrBjKQV0uSzOY9x1w==} + partysocket@1.1.19: + resolution: {integrity: sha512-hPwsXSdUc8PKNCinET6TD3JQOxzQ2JaP0bUZQXBVl6UM8UuLn1odgf1LcJXHy4UHSQwWL/RU3AnyhEsGM+W+sg==} peerDependencies: react: '>=17' peerDependenciesMeta: @@ -584,9 +584,9 @@ snapshots: optionalDependencies: conventional-commits-parser: 6.4.0 - '@rendobar/sdk@3.0.0': + '@rendobar/sdk@3.0.1': dependencies: - partysocket: 1.1.16 + partysocket: 1.1.19 transitivePeerDependencies: - react @@ -798,7 +798,7 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 - partysocket@1.1.16: + partysocket@1.1.19: dependencies: event-target-polyfill: 0.0.4 diff --git a/src/__tests__/upload.test.ts b/src/__tests__/upload.test.ts index d9e5c4a..52f4c90 100644 --- a/src/__tests__/upload.test.ts +++ b/src/__tests__/upload.test.ts @@ -82,4 +82,43 @@ describe("uploadLocalFiles", () => { await expect(uploadLocalFiles(args, inputs, mockClient)).rejects.toThrow("File not found"); }); + + it("passes the file's sha256 checksum so the server can dedup", async () => { + const localPath = createTempFile("video.mp4", "fake"); + const mockUpload = mock((_data: Uint8Array, _options: { checksum?: string }) => + Promise.resolve({ url: "https://cdn.rendobar.com/uploads/abc.mp4" }), + ); + const mockClient = { uploads: { create: mockUpload } } as unknown as Parameters[2]; + + await uploadLocalFiles(["-i", localPath, "out.mp4"], [{ index: 1, value: localPath, isLocal: true }], mockClient); + + // Hash of the same content, computed independently of the code under test. + const expected = new Bun.CryptoHasher("sha256").update("fake").digest("hex"); + expect(mockUpload.mock.calls[0]![1].checksum).toBe(expected); + }); + + it("forwards SDK progress events to onFileProgress with file context", async () => { + const localPath = createTempFile("video.mp4", "fake-bytes"); + const mockUpload = mock( + async (_data: Uint8Array, options: { onProgress?: (p: { loaded: number; total: number }) => void }) => { + options.onProgress?.({ loaded: 5, total: 10 }); + options.onProgress?.({ loaded: 10, total: 10 }); + return { url: "https://cdn.rendobar.com/uploads/abc.mp4" }; + }, + ); + const mockClient = { uploads: { create: mockUpload } } as unknown as Parameters[2]; + + const events: unknown[][] = []; + await uploadLocalFiles( + ["-i", localPath, "out.mp4"], + [{ index: 1, value: localPath, isLocal: true }], + mockClient, + { onFileProgress: (...args) => events.push(args) }, + ); + + expect(events).toEqual([ + ["video.mp4", 5, 10, 0, 1], + ["video.mp4", 10, 10, 0, 1], + ]); + }); }); diff --git a/src/commands/ffmpeg.ts b/src/commands/ffmpeg.ts index 0199e36..eac09b4 100644 --- a/src/commands/ffmpeg.ts +++ b/src/commands/ffmpeg.ts @@ -19,6 +19,7 @@ import { downloadUrlToFile, downloadFilesToDir, outputUrl, + fmtBytes, type MachineContext, } from "../lib/progress.js"; @@ -199,8 +200,15 @@ export default defineCommand({ const localInputs = parsed.inputs.filter((i) => i.isLocal); if (localInputs.length > 0) { - rewrittenArgs = await steps.step("Uploading", async () => { - return uploadLocalFiles(ffmpegArgs, parsed.inputs, client); + rewrittenArgs = await steps.step("Uploading", async (update) => { + const filePrefix = (index: number, count: number) => + count > 1 ? `${index + 1}/${count} ` : ""; + return uploadLocalFiles(ffmpegArgs, parsed.inputs, client, { + onFileStart: (filename, size, index, count) => + update(`${filePrefix(index, count)}${filename} · ${fmtBytes(size)}`), + onFileProgress: (filename, loaded, size, index, count) => + update(`${filePrefix(index, count)}${filename} · ${fmtBytes(loaded)} / ${fmtBytes(size)}`), + }); }); } diff --git a/src/lib/progress.ts b/src/lib/progress.ts index 710b1d1..be74d38 100644 --- a/src/lib/progress.ts +++ b/src/lib/progress.ts @@ -103,6 +103,13 @@ function fmtMs(ms: number): string { return `${Math.round(ms / 1000)}s`; } +export function fmtBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`; + if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`; + return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + // ── Step renderer ────────────────────────────────────────────── export class StepRenderer { diff --git a/src/lib/upload.ts b/src/lib/upload.ts index 9850c51..943c90a 100644 --- a/src/lib/upload.ts +++ b/src/lib/upload.ts @@ -4,6 +4,7 @@ import type { ParsedInput } from "./parse-ffmpeg-args.js"; export interface UploadCallbacks { onFileStart?: (filename: string, size: number, index: number, total: number) => void; + onFileProgress?: (filename: string, loaded: number, size: number, index: number, total: number) => void; onFileDone?: (filename: string, index: number, total: number) => void; } @@ -34,8 +35,17 @@ export async function uploadLocalFiles( const buffer = await file.arrayBuffer(); const filename = path.basename(input.value); + // sha256 enables server-side dedup: re-uploading the same bytes skips the + // transfer entirely (the API returns the existing ready asset). + const checksum = new Bun.CryptoHasher("sha256").update(buffer).digest("hex"); + callbacks?.onFileStart?.(filename, file.size, i, total); - const asset = await client.uploads.create(new Uint8Array(buffer), { filename }); + const asset = await client.uploads.create(new Uint8Array(buffer), { + filename, + checksum, + onProgress: ({ loaded, total: size }) => + callbacks?.onFileProgress?.(filename, loaded, size, i, total), + }); callbacks?.onFileDone?.(filename, i, total); result[input.index] = asset.url;