Skip to content

Commit ef6c3d9

Browse files
authored
Merge pull request #240 from frankekn/upstream/video-duration
feat(obs): carry a video's length through the E2EE upload
2 parents 7dd9582 + 2bbc4a7 commit ef6c3d9

2 files changed

Lines changed: 173 additions & 1 deletion

File tree

packages/linejs/base/obs/mod.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,8 +334,14 @@ export class LineObs {
334334
filename?: string;
335335
/** Optional thumbnail; encrypted with the same keyMaterial. #103. */
336336
preview?: Blob;
337+
/**
338+
* Clip length in milliseconds, rounded to an integer for video `DURATION`.
339+
* Omitted unless the rounded value is a positive safe integer. Ignored for
340+
* other media types. The duration is caller-supplied, not inferred from data.
341+
*/
342+
durationMs?: number;
337343
}): Promise<Message> {
338-
const { data, oType, to, filename, preview } = options;
344+
const { data, oType, to, filename, preview, durationMs } = options;
339345
const typeSet: {
340346
image: [string, 1];
341347
video: [string, 2];
@@ -412,6 +418,18 @@ export class LineObs {
412418
contentType,
413419
);
414420

421+
// obs only ever sees the encrypted blob here, so it cannot read the length
422+
// out of the container the way it does for a plain upload; without this
423+
// LINE clients render the video as a 0:00 clip.
424+
const roundedDuration = typeof durationMs === "number"
425+
? Math.round(durationMs)
426+
: NaN;
427+
const durationMetadata: Record<string, string> =
428+
oType === "video" && Number.isSafeInteger(roundedDuration) &&
429+
roundedDuration > 0
430+
? { DURATION: roundedDuration.toString() }
431+
: {};
432+
415433
return await this.client.talk.sendMessage({
416434
to,
417435
chunks,
@@ -433,6 +451,7 @@ export class LineObs {
433451
),
434452
}
435453
: {},
454+
...durationMetadata,
436455
},
437456
});
438457
}

packages/linejs/base/obs/upload_e2ee.test.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,156 @@ Deno.test("uploadMediaByE2EE — file type → no preview upload at all", async
112112
});
113113
assertEquals(fake.records.length, 1);
114114
});
115+
116+
/** contentMetadata of the one sendMessage the upload ends with. */
117+
function sentMetadata(
118+
fake: ReturnType<typeof fakeClient>,
119+
): Record<string, string> {
120+
assertEquals(fake.sendMessageCalls.length, 1);
121+
return (fake.sendMessageCalls[0] as {
122+
contentMetadata: Record<string, string>;
123+
}).contentMetadata;
124+
}
125+
126+
Deno.test("uploadMediaByE2EE — a video carries its length as DURATION", async () => {
127+
const { obs, fake } = makeObs();
128+
await obs.uploadMediaByE2EE({
129+
data: new Blob([new Uint8Array(1_000)]),
130+
oType: "video",
131+
to: "u-recipient",
132+
durationMs: 4200.4,
133+
});
134+
assertEquals(sentMetadata(fake).DURATION, "4200");
135+
});
136+
137+
Deno.test("uploadMediaByE2EE — no DURATION when omitted, none on an image", async () => {
138+
const video = makeObs();
139+
await video.obs.uploadMediaByE2EE({
140+
data: new Blob([new Uint8Array(1_000)]),
141+
oType: "video",
142+
to: "u-recipient",
143+
});
144+
assertEquals(sentMetadata(video.fake).DURATION, undefined);
145+
146+
const image = makeObs();
147+
await image.obs.uploadMediaByE2EE({
148+
data: new Blob([new Uint8Array(1_000)]),
149+
oType: "image",
150+
to: "u-recipient",
151+
durationMs: 4200,
152+
});
153+
assertEquals(sentMetadata(image.fake).DURATION, undefined);
154+
});
155+
156+
Deno.test("uploadMediaByE2EE — a non-positive or non-finite duration is dropped", async () => {
157+
for (const durationMs of [0, -1, NaN, Infinity]) {
158+
const { obs, fake } = makeObs();
159+
await obs.uploadMediaByE2EE({
160+
data: new Blob([new Uint8Array(1_000)]),
161+
oType: "video",
162+
to: "u-recipient",
163+
durationMs,
164+
});
165+
assertEquals(sentMetadata(fake).DURATION, undefined);
166+
}
167+
});
168+
169+
Deno.test("uploadMediaByE2EE — rounded zero and unsafe durations are omitted", async () => {
170+
for (
171+
const durationMs of [
172+
0.1,
173+
0.49,
174+
Number.MIN_VALUE,
175+
-Infinity,
176+
Number.MAX_SAFE_INTEGER + 1,
177+
1e21,
178+
Number.MAX_VALUE,
179+
]
180+
) {
181+
const { obs, fake } = makeObs();
182+
await obs.uploadMediaByE2EE({
183+
data: new Blob(["video"]),
184+
oType: "video",
185+
to: "u-recipient",
186+
durationMs,
187+
});
188+
assertEquals(
189+
sentMetadata(fake).DURATION,
190+
undefined,
191+
`durationMs=${durationMs}`,
192+
);
193+
}
194+
});
195+
196+
Deno.test("uploadMediaByE2EE — rounds valid durations to positive integer milliseconds", async () => {
197+
for (
198+
const [durationMs, expected] of [[0.5, "1"], [1, "1"], [4200.5, "4201"], [
199+
Number.MAX_SAFE_INTEGER,
200+
"9007199254740991",
201+
]] as const
202+
) {
203+
const { obs, fake } = makeObs();
204+
await obs.uploadMediaByE2EE({
205+
data: new Blob(["video"]),
206+
oType: "video",
207+
to: "u-recipient",
208+
durationMs,
209+
});
210+
assertEquals(sentMetadata(fake).DURATION, expected);
211+
}
212+
});
213+
214+
Deno.test("uploadMediaByE2EE — duration does not change non-video metadata", async () => {
215+
for (const oType of ["audio", "file", "image", "gif"] as const) {
216+
const withDuration = makeObs();
217+
const withoutDuration = makeObs();
218+
const options = { data: new Blob(["media"]), oType, to: "u-recipient" };
219+
await withDuration.obs.uploadMediaByE2EE({ ...options, durationMs: 4200 });
220+
await withoutDuration.obs.uploadMediaByE2EE(options);
221+
assertEquals(
222+
sentMetadata(withDuration.fake),
223+
sentMetadata(withoutDuration.fake),
224+
);
225+
}
226+
});
227+
228+
Deno.test("uploadMediaByE2EE — video duration preserves encrypted preview and message metadata", async () => {
229+
const { obs, fake } = makeObs();
230+
await obs.uploadMediaByE2EE({
231+
data: new Blob([new Uint8Array(1000)], { type: "video/mp4" }),
232+
oType: "video",
233+
to: "c-group",
234+
filename: "clip.mp4",
235+
preview: new Blob([new Uint8Array(20)]),
236+
durationMs: 4200,
237+
});
238+
const metadata = sentMetadata(fake);
239+
assertEquals(metadata, {
240+
SID: "emv",
241+
OID: "OBJ-1",
242+
FILE_SIZE: "1032",
243+
e2eeVersion: "2",
244+
DURATION: "4200",
245+
MEDIA_CONTENT_INFO: JSON.stringify({
246+
category: "original",
247+
fileSize: 1032,
248+
extension: "mp4",
249+
animated: false,
250+
}),
251+
});
252+
assertEquals(fake.records.map((record) => record.size), [1032, 52]);
253+
assert(fake.records[0].obsPath.startsWith("talk/emv/reqid-"));
254+
assertEquals(fake.records[1].obsPath, "talk/emv/OBJ-1__ud-preview");
255+
assertEquals(
256+
fake.e2eeCalls[1].keyMaterial,
257+
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
258+
);
259+
const message = fake.sendMessageCalls[0] as {
260+
to: string;
261+
contentType: number;
262+
chunks: Uint8Array[];
263+
};
264+
assertEquals(message.to, "c-group");
265+
assertEquals(message.contentType, 2);
266+
assertEquals(message.chunks, [new Uint8Array()]);
267+
});

0 commit comments

Comments
 (0)