Skip to content

Commit 43f6bb1

Browse files
authored
feat: show upload progress and skip re-uploads via checksum dedup (#53)
* feat: show upload progress and skip re-uploads via checksum dedup * fix: pick up @rendobar/sdk 3.0.1 upload timeout fix
1 parent 7891933 commit 43f6bb1

6 files changed

Lines changed: 79 additions & 15 deletions

File tree

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@
2626
"check:no-linked-sdk": "node scripts/check-no-linked-sdk.mjs"
2727
},
2828
"dependencies": {
29-
"@rendobar/sdk": "^3.0.0",
29+
"@clack/prompts": "^1.2.0",
30+
"@rendobar/sdk": "^3.0.1",
3031
"citty": "^0.2.0",
31-
"picocolors": "^1.1.1",
32-
"@clack/prompts": "^1.2.0"
32+
"picocolors": "^1.1.1"
3333
},
3434
"devDependencies": {
3535
"@commitlint/cli": "^21.0.0",

pnpm-lock.yaml

Lines changed: 9 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/__tests__/upload.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,43 @@ describe("uploadLocalFiles", () => {
8282

8383
await expect(uploadLocalFiles(args, inputs, mockClient)).rejects.toThrow("File not found");
8484
});
85+
86+
it("passes the file's sha256 checksum so the server can dedup", async () => {
87+
const localPath = createTempFile("video.mp4", "fake");
88+
const mockUpload = mock((_data: Uint8Array, _options: { checksum?: string }) =>
89+
Promise.resolve({ url: "https://cdn.rendobar.com/uploads/abc.mp4" }),
90+
);
91+
const mockClient = { uploads: { create: mockUpload } } as unknown as Parameters<typeof uploadLocalFiles>[2];
92+
93+
await uploadLocalFiles(["-i", localPath, "out.mp4"], [{ index: 1, value: localPath, isLocal: true }], mockClient);
94+
95+
// Hash of the same content, computed independently of the code under test.
96+
const expected = new Bun.CryptoHasher("sha256").update("fake").digest("hex");
97+
expect(mockUpload.mock.calls[0]![1].checksum).toBe(expected);
98+
});
99+
100+
it("forwards SDK progress events to onFileProgress with file context", async () => {
101+
const localPath = createTempFile("video.mp4", "fake-bytes");
102+
const mockUpload = mock(
103+
async (_data: Uint8Array, options: { onProgress?: (p: { loaded: number; total: number }) => void }) => {
104+
options.onProgress?.({ loaded: 5, total: 10 });
105+
options.onProgress?.({ loaded: 10, total: 10 });
106+
return { url: "https://cdn.rendobar.com/uploads/abc.mp4" };
107+
},
108+
);
109+
const mockClient = { uploads: { create: mockUpload } } as unknown as Parameters<typeof uploadLocalFiles>[2];
110+
111+
const events: unknown[][] = [];
112+
await uploadLocalFiles(
113+
["-i", localPath, "out.mp4"],
114+
[{ index: 1, value: localPath, isLocal: true }],
115+
mockClient,
116+
{ onFileProgress: (...args) => events.push(args) },
117+
);
118+
119+
expect(events).toEqual([
120+
["video.mp4", 5, 10, 0, 1],
121+
["video.mp4", 10, 10, 0, 1],
122+
]);
123+
});
85124
});

src/commands/ffmpeg.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
downloadUrlToFile,
2020
downloadFilesToDir,
2121
outputUrl,
22+
fmtBytes,
2223
type MachineContext,
2324
} from "../lib/progress.js";
2425

@@ -199,8 +200,15 @@ export default defineCommand({
199200
const localInputs = parsed.inputs.filter((i) => i.isLocal);
200201

201202
if (localInputs.length > 0) {
202-
rewrittenArgs = await steps.step("Uploading", async () => {
203-
return uploadLocalFiles(ffmpegArgs, parsed.inputs, client);
203+
rewrittenArgs = await steps.step("Uploading", async (update) => {
204+
const filePrefix = (index: number, count: number) =>
205+
count > 1 ? `${index + 1}/${count} ` : "";
206+
return uploadLocalFiles(ffmpegArgs, parsed.inputs, client, {
207+
onFileStart: (filename, size, index, count) =>
208+
update(`${filePrefix(index, count)}${filename} · ${fmtBytes(size)}`),
209+
onFileProgress: (filename, loaded, size, index, count) =>
210+
update(`${filePrefix(index, count)}${filename} · ${fmtBytes(loaded)} / ${fmtBytes(size)}`),
211+
});
204212
});
205213
}
206214

src/lib/progress.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,13 @@ function fmtMs(ms: number): string {
103103
return `${Math.round(ms / 1000)}s`;
104104
}
105105

106+
export function fmtBytes(n: number): string {
107+
if (n < 1024) return `${n} B`;
108+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
109+
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
110+
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
111+
}
112+
106113
// ── Step renderer ──────────────────────────────────────────────
107114

108115
export class StepRenderer {

src/lib/upload.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ParsedInput } from "./parse-ffmpeg-args.js";
44

55
export interface UploadCallbacks {
66
onFileStart?: (filename: string, size: number, index: number, total: number) => void;
7+
onFileProgress?: (filename: string, loaded: number, size: number, index: number, total: number) => void;
78
onFileDone?: (filename: string, index: number, total: number) => void;
89
}
910

@@ -34,8 +35,17 @@ export async function uploadLocalFiles(
3435
const buffer = await file.arrayBuffer();
3536
const filename = path.basename(input.value);
3637

38+
// sha256 enables server-side dedup: re-uploading the same bytes skips the
39+
// transfer entirely (the API returns the existing ready asset).
40+
const checksum = new Bun.CryptoHasher("sha256").update(buffer).digest("hex");
41+
3742
callbacks?.onFileStart?.(filename, file.size, i, total);
38-
const asset = await client.uploads.create(new Uint8Array(buffer), { filename });
43+
const asset = await client.uploads.create(new Uint8Array(buffer), {
44+
filename,
45+
checksum,
46+
onProgress: ({ loaded, total: size }) =>
47+
callbacks?.onFileProgress?.(filename, loaded, size, i, total),
48+
});
3949
callbacks?.onFileDone?.(filename, i, total);
4050

4151
result[input.index] = asset.url;

0 commit comments

Comments
 (0)