Skip to content

Commit 58b4a8b

Browse files
talmoclaude
andauthored
Add Labels.addVideo() and support File/Blob sources in video factory (#79)
Enable adding standalone videos to existing Labels projects in the browser by accepting File/Blob objects throughout the video loading pipeline. Closes #77 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent eb93688 commit 58b4a8b

File tree

5 files changed

+88
-14
lines changed

5 files changed

+88
-14
lines changed

src/io/main.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,17 +161,19 @@ export async function saveSlpSet(
161161
/**
162162
* Load a video file and create a Video object with an active backend.
163163
*
164-
* @param filename - Path to video file
164+
* @param source - Path to video file, or a browser File object
165165
* @param options - Video loading options
166166
* @param options.dataset - HDF5 dataset path for embedded videos
167167
* @param options.openBackend - Whether to open the backend (default: true)
168+
* @param options.backend - Explicit backend selection
168169
* @returns Video object with backend
169170
*/
170171
export async function loadVideo(
171-
filename: string,
172+
source: string | File,
172173
options?: { dataset?: string; openBackend?: boolean; backend?: VideoBackendType }
173174
): Promise<Video> {
174-
const backend = await createVideoBackend(filename, {
175+
const filename = typeof source === "string" ? source : source.name;
176+
const backend = await createVideoBackend(source, {
175177
dataset: options?.dataset,
176178
backend: options?.backend,
177179
});

src/model/labels.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,12 +153,16 @@ export class Labels {
153153
});
154154
}
155155

156+
addVideo(video: Video): void {
157+
if (!this.videos.includes(video)) {
158+
this.videos.push(video);
159+
}
160+
}
161+
156162
append(frame: LabeledFrame): void {
157163
if (this._lazyFrameList) this.materialize();
158164
this.labeledFrames.push(frame);
159-
if (!this.videos.includes(frame.video)) {
160-
this.videos.push(frame.video);
161-
}
165+
this.addVideo(frame.video);
162166
}
163167

164168
toDict(options?: { video?: Video | number; skipEmptyFrames?: boolean }) {

src/video/factory.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export type VideoBackendType = "mp4box" | "mediabunny" | "media";
1212
const MEDIABUNNY_EXTENSIONS = ["webm", "mkv", "ogg", "mov", "mpeg", "avi"];
1313

1414
export async function createVideoBackend(
15-
filename: string,
15+
source: string | File | Blob,
1616
options?: {
1717
dataset?: string;
1818
embedded?: boolean;
@@ -25,9 +25,16 @@ export async function createVideoBackend(
2525
backend?: VideoBackendType;
2626
}
2727
): Promise<VideoBackend> {
28+
const isBlob = typeof Blob !== "undefined" && source instanceof Blob;
29+
const filename = isBlob
30+
? (source as File).name ?? ""
31+
: (source as string);
32+
const normalized = filename.split("?")[0]?.toLowerCase() ?? "";
33+
const ext = normalized.split(".").pop() ?? "";
34+
2835
// HDF5/SLP files always use the HDF5 backend (not overridable)
29-
if (options?.embedded || filename.endsWith(".slp") || filename.endsWith(".h5") || filename.endsWith(".hdf5")) {
30-
const { file } = await openH5File(filename);
36+
if (options?.embedded || ext === "slp" || ext === "h5" || ext === "hdf5") {
37+
const { file } = await openH5File(isBlob ? (source as File) : filename);
3138
const datasetPath = options?.dataset ?? "";
3239
return new Hdf5VideoBackend({
3340
filename,
@@ -44,12 +51,14 @@ export async function createVideoBackend(
4451

4552
// User-specified backend override
4653
if (options?.backend === "mediabunny") {
54+
if (isBlob) return MediaBunnyVideoBackend.fromBlob(source as Blob, filename);
4755
return MediaBunnyVideoBackend.fromUrl(filename);
4856
}
4957
if (options?.backend === "mp4box") {
50-
return new Mp4BoxVideoBackend(filename);
58+
return new Mp4BoxVideoBackend(source);
5159
}
5260
if (options?.backend === "media") {
61+
if (isBlob) return new MediaVideoBackend(URL.createObjectURL(source as Blob));
5362
return new MediaVideoBackend(filename);
5463
}
5564

@@ -59,19 +68,18 @@ export async function createVideoBackend(
5968
typeof window.VideoDecoder !== "undefined" &&
6069
typeof window.EncodedVideoChunk !== "undefined";
6170

62-
const normalized = filename.split("?")[0]?.toLowerCase() ?? "";
63-
const ext = normalized.split(".").pop() ?? "";
64-
6571
// MP4: prefer Mp4Box (better sequential performance)
6672
if (supportsWebCodecs && ext === "mp4") {
67-
return new Mp4BoxVideoBackend(filename);
73+
return new Mp4BoxVideoBackend(source);
6874
}
6975

7076
// Non-MP4 video formats: use MediaBunny
7177
if (supportsWebCodecs && MEDIABUNNY_EXTENSIONS.includes(ext)) {
78+
if (isBlob) return MediaBunnyVideoBackend.fromBlob(source as Blob, filename);
7279
return MediaBunnyVideoBackend.fromUrl(filename);
7380
}
7481

7582
// Fallback: HTML5 video element
83+
if (isBlob) return new MediaVideoBackend(URL.createObjectURL(source as Blob));
7684
return new MediaVideoBackend(filename);
7785
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/* @vitest-environment node */
2+
import { describe, it, expect } from "vitest";
3+
import { Labels } from "../../src/model/labels.js";
4+
import { LabeledFrame } from "../../src/model/labeled-frame.js";
5+
import { Video } from "../../src/model/video.js";
6+
7+
describe("Labels.addVideo()", () => {
8+
it("adds a video to the labels", () => {
9+
const labels = new Labels();
10+
const video = new Video({ filename: "video.mp4" });
11+
labels.addVideo(video);
12+
expect(labels.videos).toHaveLength(1);
13+
expect(labels.videos[0]).toBe(video);
14+
});
15+
16+
it("does not add duplicate videos", () => {
17+
const labels = new Labels();
18+
const video = new Video({ filename: "video.mp4" });
19+
labels.addVideo(video);
20+
labels.addVideo(video);
21+
expect(labels.videos).toHaveLength(1);
22+
});
23+
24+
it("adds multiple distinct videos", () => {
25+
const labels = new Labels();
26+
const v1 = new Video({ filename: "a.mp4" });
27+
const v2 = new Video({ filename: "b.mp4" });
28+
labels.addVideo(v1);
29+
labels.addVideo(v2);
30+
expect(labels.videos).toHaveLength(2);
31+
});
32+
33+
it("works alongside append()", () => {
34+
const labels = new Labels();
35+
const video = new Video({ filename: "video.mp4" });
36+
labels.addVideo(video);
37+
38+
const frame = new LabeledFrame({ video, frameIdx: 0 });
39+
labels.append(frame);
40+
41+
// Video should still appear only once
42+
expect(labels.videos).toHaveLength(1);
43+
expect(labels.videos[0]).toBe(video);
44+
});
45+
});

tests/video/factory.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,19 @@ describe("createVideoBackend", () => {
5656
createVideoBackend("video.mp4", { backend: "mediabunny" })
5757
).rejects.toThrow(/MediaBunny/);
5858
});
59+
60+
it("selects MediaBunny for Blob with .webm filename", async () => {
61+
const { createVideoBackend } = await import("../../src/video/factory.js");
62+
const blob = new Blob(["fake"], { type: "video/webm" });
63+
const file = new File([blob], "clip.webm");
64+
await expect(createVideoBackend(file)).rejects.toThrow(/MediaBunny/);
65+
});
66+
67+
it("routes Blob to MediaBunny when backend='mediabunny'", async () => {
68+
const { createVideoBackend } = await import("../../src/video/factory.js");
69+
const file = new File([new Blob(["fake"])], "clip.mp4");
70+
await expect(
71+
createVideoBackend(file, { backend: "mediabunny" })
72+
).rejects.toThrow(/MediaBunny/);
73+
});
5974
});

0 commit comments

Comments
 (0)