@jant/core 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -46,6 +46,7 @@ import {
46
46
  uploadAndInsertInlineImage,
47
47
  adoptPendingInlineImageUploads,
48
48
  } from "../tiptap/inline-image-upload.js";
49
+ import { getClipboardFiles } from "../tiptap/paste-media.js";
49
50
  import { isSafeAbsoluteUrl } from "../../lib/url.js";
50
51
  import { randomUUID } from "../random-uuid.js";
51
52
  import {
@@ -276,6 +277,13 @@ export class JantComposeEditor extends LitElement {
276
277
  "jant:slash-command-discovered",
277
278
  this._onSlashCommandDiscovered,
278
279
  );
280
+ this.addEventListener("dragenter", this._onDragEnter);
281
+ // Capture phase: a file dragover is stopped here before ProseMirror sees
282
+ // it, so its drop cursor never appears for file drags (the drop position
283
+ // is decided by _shouldPasteInlineImage, not the cursor).
284
+ this.addEventListener("dragover", this._onDragOver, true);
285
+ this.addEventListener("dragleave", this._onDragLeave);
286
+ this.addEventListener("drop", this._onDrop);
279
287
  }
280
288
 
281
289
  disconnectedCallback() {
@@ -291,6 +299,10 @@ export class JantComposeEditor extends LitElement {
291
299
  this._onSlashCommandDiscovered,
292
300
  );
293
301
  document.removeEventListener("click", this._onDocClickBound);
302
+ this.removeEventListener("dragenter", this._onDragEnter);
303
+ this.removeEventListener("dragover", this._onDragOver, true);
304
+ this.removeEventListener("dragleave", this._onDragLeave);
305
+ this.removeEventListener("drop", this._onDrop);
294
306
  hideSlashCommandHint(this);
295
307
  this._emojiContainer?.remove();
296
308
  this._emojiPickerEl = null;
@@ -298,6 +310,66 @@ export class JantComposeEditor extends LitElement {
298
310
  this._filePickerCleanup = null;
299
311
  }
300
312
 
313
+ // Tracks dragenter/dragleave nesting so the highlight only clears when the
314
+ // pointer actually leaves the editor, not when it crosses a child element.
315
+ #dragDepth = 0;
316
+
317
+ #dragHasFiles(event: DragEvent): boolean {
318
+ const types = event.dataTransfer?.types;
319
+ return types ? Array.from(types).includes("Files") : false;
320
+ }
321
+
322
+ private _onDragEnter = (event: DragEvent) => {
323
+ if (!this.#dragHasFiles(event)) return;
324
+ this.#dragDepth += 1;
325
+ this.classList.add("compose-editor-dragover");
326
+ };
327
+
328
+ private _onDragOver = (event: DragEvent) => {
329
+ if (!this.#dragHasFiles(event)) return;
330
+ // Keep the file dragover away from ProseMirror so its drop cursor stays
331
+ // hidden; internal content drags still bubble through untouched.
332
+ event.stopPropagation();
333
+ event.preventDefault();
334
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
335
+ };
336
+
337
+ private _onDragLeave = (event: DragEvent) => {
338
+ if (!this.#dragHasFiles(event)) return;
339
+ this.#dragDepth = Math.max(0, this.#dragDepth - 1);
340
+ if (this.#dragDepth === 0) {
341
+ this.classList.remove("compose-editor-dragover");
342
+ }
343
+ };
344
+
345
+ private _onDrop = (event: DragEvent) => {
346
+ this.#dragDepth = 0;
347
+ this.classList.remove("compose-editor-dragover");
348
+ // Drops onto the TipTap body are claimed by the pasteMedia plugin's
349
+ // handleDrop, which calls preventDefault — skip those to avoid handling
350
+ // the same files twice.
351
+ if (event.defaultPrevented) return;
352
+ const files = getClipboardFiles(event.dataTransfer);
353
+ if (files.length === 0) return;
354
+ event.preventDefault();
355
+
356
+ const inlineFiles: File[] = [];
357
+ const attachmentFiles: File[] = [];
358
+ for (const file of files) {
359
+ if (this._editor && this._shouldPasteInlineImage(file)) {
360
+ inlineFiles.push(file);
361
+ } else {
362
+ attachmentFiles.push(file);
363
+ }
364
+ }
365
+ for (const file of inlineFiles) {
366
+ this._uploadAndInsertImage(file);
367
+ }
368
+ if (attachmentFiles.length > 0) {
369
+ this.addFiles(attachmentFiles);
370
+ }
371
+ };
372
+
301
373
  private _onSlashCommandDiscovered = () => {
302
374
  markSlashCommandDiscovered();
303
375
  };
@@ -280,46 +280,130 @@ export class JantMediaLightbox extends LitElement {
280
280
  #handleKeydown = (e: Event) => {
281
281
  const ke = e as globalThis.KeyboardEvent;
282
282
  const target = e.target as HTMLElement | null;
283
+
283
284
  if (ke.key === "Escape") {
284
285
  e.preventDefault();
285
286
  this.close();
286
287
  return;
287
288
  }
288
- if (ke.key !== "ArrowLeft" && ke.key !== "ArrowRight") return;
289
289
 
290
- // Let the progress slider's native arrow-key seeking through.
291
- if (target?.classList.contains("media-lightbox-short-progress")) return;
290
+ // Don't hijack keys aimed at a focused control — the short-video progress
291
+ // slider, the mute/close/nav buttons, or the <video> itself (when focused,
292
+ // its native shortcuts already handle these keys). Let their native
293
+ // behavior run instead of double-handling.
294
+ if (
295
+ target instanceof HTMLInputElement ||
296
+ target instanceof HTMLButtonElement ||
297
+ target instanceof HTMLVideoElement
298
+ ) {
299
+ return;
300
+ }
292
301
 
293
- // On videos, arrow keys scrub the playhead. Item switching happens via
294
- // the on-screen prev/next buttons — matches YouTube/native player conventions.
295
302
  const currentImage = this._images[this._currentIndex];
296
- if (currentImage?.mimeType?.startsWith("video/")) {
297
- const video = this.querySelector<HTMLVideoElement>(
298
- ".media-lightbox-video",
299
- );
300
- if (video) {
303
+ if (!currentImage?.mimeType?.startsWith("video/")) {
304
+ // Image galleries: arrow keys switch items.
305
+ if (ke.key === "ArrowLeft") {
306
+ e.preventDefault();
307
+ this.#prev();
308
+ } else if (ke.key === "ArrowRight") {
301
309
  e.preventDefault();
302
- const step = 5;
303
- const delta = ke.key === "ArrowLeft" ? -step : step;
304
- const duration =
305
- Number.isFinite(video.duration) && video.duration > 0
306
- ? video.duration
307
- : null;
308
- const nextTime =
309
- duration != null
310
- ? Math.max(0, Math.min(video.currentTime + delta, duration))
311
- : Math.max(0, video.currentTime + delta);
312
- video.currentTime = nextTime;
313
- this._videoCurrentTime = nextTime;
310
+ this.#next();
314
311
  }
315
312
  return;
316
313
  }
317
314
 
318
- e.preventDefault();
319
- if (ke.key === "ArrowLeft") this.#prev();
320
- else this.#next();
315
+ const video = this.querySelector<HTMLVideoElement>(".media-lightbox-video");
316
+ if (video) this.#handleVideoKeydown(ke, video);
321
317
  };
322
318
 
319
+ // Video shortcuts — play/pause, seek, volume, mute, fullscreen — handled at
320
+ // the dialog level so they work regardless of what's focused. Item switching
321
+ // happens via the on-screen prev/next buttons, matching YouTube/native
322
+ // player conventions.
323
+ #handleVideoKeydown(ke: globalThis.KeyboardEvent, video: HTMLVideoElement) {
324
+ const duration =
325
+ Number.isFinite(video.duration) && video.duration > 0
326
+ ? video.duration
327
+ : null;
328
+ const seekTo = (time: number) => {
329
+ const next =
330
+ duration != null
331
+ ? Math.max(0, Math.min(time, duration))
332
+ : Math.max(0, time);
333
+ video.currentTime = next;
334
+ this._videoCurrentTime = next;
335
+ };
336
+ const key = ke.key;
337
+ const lower = key.toLowerCase();
338
+
339
+ if (key === " " || lower === "k") {
340
+ ke.preventDefault();
341
+ if (video.paused) void video.play().catch(() => {});
342
+ else video.pause();
343
+ } else if (key === "ArrowLeft") {
344
+ ke.preventDefault();
345
+ seekTo(video.currentTime - 5);
346
+ } else if (key === "ArrowRight") {
347
+ ke.preventDefault();
348
+ seekTo(video.currentTime + 5);
349
+ } else if (key === "Home") {
350
+ ke.preventDefault();
351
+ seekTo(0);
352
+ } else if (key === "End") {
353
+ if (duration != null) {
354
+ ke.preventDefault();
355
+ seekTo(duration);
356
+ }
357
+ } else if (key.length === 1 && key >= "0" && key <= "9") {
358
+ if (duration != null) {
359
+ ke.preventDefault();
360
+ seekTo((Number(key) / 10) * duration);
361
+ }
362
+ } else if (key === "ArrowUp") {
363
+ ke.preventDefault();
364
+ video.volume = Math.min(1, video.volume + 0.05);
365
+ } else if (key === "ArrowDown") {
366
+ ke.preventDefault();
367
+ video.volume = Math.max(0, video.volume - 0.05);
368
+ } else if (lower === "m") {
369
+ ke.preventDefault();
370
+ const muted = !video.muted;
371
+ video.muted = muted;
372
+ this._videoMuted = muted;
373
+ } else if (lower === "f") {
374
+ ke.preventDefault();
375
+ this.#toggleVideoFullscreen(video);
376
+ }
377
+ }
378
+
379
+ #toggleVideoFullscreen(video: HTMLVideoElement) {
380
+ const doc = document as globalThis.Document & {
381
+ webkitFullscreenElement?: globalThis.Element | null;
382
+ webkitExitFullscreen?: () => void;
383
+ };
384
+ const el = video as HTMLVideoElement & {
385
+ webkitRequestFullscreen?: () => void;
386
+ webkitEnterFullscreen?: () => void;
387
+ };
388
+
389
+ if (document.fullscreenElement ?? doc.webkitFullscreenElement) {
390
+ if (document.exitFullscreen) {
391
+ void document.exitFullscreen().catch(() => {});
392
+ } else {
393
+ doc.webkitExitFullscreen?.();
394
+ }
395
+ return;
396
+ }
397
+
398
+ if (video.requestFullscreen) {
399
+ void video.requestFullscreen().catch(() => {});
400
+ } else if (el.webkitRequestFullscreen) {
401
+ el.webkitRequestFullscreen();
402
+ } else if (el.webkitEnterFullscreen) {
403
+ el.webkitEnterFullscreen();
404
+ }
405
+ }
406
+
323
407
  #handleDialogClick = (e: Event) => {
324
408
  const target = e.target as HTMLElement;
325
409
  // Close on backdrop click (dialog itself or the content wrapper, not media/buttons)
@@ -365,24 +449,23 @@ export class JantMediaLightbox extends LitElement {
365
449
  this.querySelector<HTMLVideoElement>(".media-lightbox-video")?.pause();
366
450
  }
367
451
 
368
- // Focus the active video so space/native shortcuts work without an extra
369
- // click. Falls back to the content wrapper for images focusing the close
370
- // button would show a focus ring during arrow-key nav.
452
+ // Move focus to the content wrapper on open / item change not the close
453
+ // button (its focus ring would show during arrow-key nav) and not the
454
+ // <video> (a focused <video> routes keydown to its own native handler,
455
+ // bypassing the dialog-level shortcuts in #handleVideoKeydown).
371
456
  #focusCurrentMedia() {
372
- const currentImage = this._images[this._currentIndex];
373
- const isVideo = currentImage?.mimeType?.startsWith("video/");
374
- if (isVideo) {
375
- const video = this.querySelector<HTMLVideoElement>(
376
- ".media-lightbox-video",
377
- );
378
- if (video) {
379
- video.focus();
380
- return;
381
- }
382
- }
383
457
  this.querySelector<HTMLElement>(".media-lightbox-content")?.focus();
384
458
  }
385
459
 
460
+ // Browsers focus a <video> when it's clicked. Bounce focus back to the
461
+ // content wrapper so keydown keeps reaching the dialog-level shortcut
462
+ // handler instead of the video's native key handling.
463
+ #handleVideoFocus = () => {
464
+ this.querySelector<HTMLElement>(".media-lightbox-content")?.focus({
465
+ preventScroll: true,
466
+ });
467
+ };
468
+
386
469
  #resetShortVideoState(image?: LightboxImage) {
387
470
  this._videoCurrentTime = 0;
388
471
  this._videoDuration =
@@ -543,6 +626,7 @@ export class JantMediaLightbox extends LitElement {
543
626
  playsinline
544
627
  loop
545
628
  ?muted=${this._videoMuted}
629
+ @focus=${this.#handleVideoFocus}
546
630
  @loadedmetadata=${this.#handleShortVideoLoadedMetadata}
547
631
  @timeupdate=${this.#handleShortVideoTimeUpdate}
548
632
  ></video>
@@ -596,6 +680,7 @@ export class JantMediaLightbox extends LitElement {
596
680
  controls
597
681
  autoplay
598
682
  playsinline
683
+ @focus=${this.#handleVideoFocus}
599
684
  ></video>`
600
685
  : html`<img
601
686
  class=${`media-lightbox-img${isScrollableImage ? " media-lightbox-img-scroll" : ""}`}
@@ -76,6 +76,47 @@ export const PasteMedia = Extension.create<PasteMediaOptions>({
76
76
  addProseMirrorPlugins() {
77
77
  const extension = this;
78
78
 
79
+ /**
80
+ * Routes dropped/pasted files into inline images or attachments using the
81
+ * same decision as the host (`shouldInsertInline`). Returns false when
82
+ * there is nothing this extension can handle, so the caller leaves the
83
+ * event to the editor's default behavior.
84
+ */
85
+ const routeFiles = (files: File[]): boolean => {
86
+ const inlineFiles = files.filter(
87
+ (file) => extension.options.shouldInsertInline?.(file) === true,
88
+ );
89
+ const attachmentFiles = files.filter(
90
+ (file) => !inlineFiles.includes(file),
91
+ );
92
+
93
+ if (
94
+ inlineFiles.length === 0 &&
95
+ (attachmentFiles.length === 0 ||
96
+ extension.options.onPasteFiles === undefined)
97
+ ) {
98
+ return false;
99
+ }
100
+
101
+ for (const file of inlineFiles) {
102
+ const uploadInlineImage = extension.options.uploadInlineImage;
103
+ if (uploadInlineImage) {
104
+ void uploadInlineImage(file);
105
+ continue;
106
+ }
107
+ void uploadAndInsertInlineImage(extension.editor, file);
108
+ }
109
+
110
+ if (
111
+ attachmentFiles.length > 0 &&
112
+ extension.options.onPasteFiles !== undefined
113
+ ) {
114
+ extension.options.onPasteFiles(attachmentFiles);
115
+ }
116
+
117
+ return true;
118
+ };
119
+
79
120
  return [
80
121
  new Plugin({
81
122
  key: pasteMediaPluginKey,
@@ -83,40 +124,15 @@ export const PasteMedia = Extension.create<PasteMediaOptions>({
83
124
  handlePaste(_view, event) {
84
125
  const files = getClipboardFiles(event.clipboardData);
85
126
  if (files.length === 0) return false;
86
-
87
- const inlineFiles = files.filter(
88
- (file) => extension.options.shouldInsertInline?.(file) === true,
89
- );
90
- const attachmentFiles = files.filter(
91
- (file) => !inlineFiles.includes(file),
92
- );
93
-
94
- if (
95
- inlineFiles.length === 0 &&
96
- (attachmentFiles.length === 0 ||
97
- extension.options.onPasteFiles === undefined)
98
- ) {
99
- return false;
100
- }
101
-
127
+ if (!routeFiles(files)) return false;
128
+ event.preventDefault();
129
+ return true;
130
+ },
131
+ handleDrop(_view, event) {
132
+ const files = getClipboardFiles(event.dataTransfer);
133
+ if (files.length === 0) return false;
134
+ if (!routeFiles(files)) return false;
102
135
  event.preventDefault();
103
-
104
- for (const file of inlineFiles) {
105
- const uploadInlineImage = extension.options.uploadInlineImage;
106
- if (uploadInlineImage) {
107
- void uploadInlineImage(file);
108
- continue;
109
- }
110
- void uploadAndInsertInlineImage(extension.editor, file);
111
- }
112
-
113
- if (
114
- attachmentFiles.length > 0 &&
115
- extension.options.onPasteFiles !== undefined
116
- ) {
117
- extension.options.onPasteFiles(attachmentFiles);
118
- }
119
-
120
136
  return true;
121
137
  },
122
138
  },
@@ -7,6 +7,8 @@
7
7
  * - Strips spurious rotation metadata from the output (mediabunny may
8
8
  * bake rotation into pixels AND write a display matrix, causing the
9
9
  * browser to double-rotate)
10
+ * - Clears the alternate_group track flag (mediabunny sets it non-zero,
11
+ * which stops Safari's native video controls from auto-hiding)
10
12
  * - Extracts poster frame + blurhash during processing
11
13
  *
12
14
  * Requires WebCodecs API support — check `isSupported()` before use.
@@ -25,6 +27,7 @@ import {
25
27
  } from "mediabunny";
26
28
  import { encode } from "blurhash";
27
29
  import { normalizeDurationSeconds } from "../lib/video-playback.js";
30
+ import { zeroTrackAlternateGroups } from "../lib/mp4-track-flags.js";
28
31
 
29
32
  /** Maximum pixels for the long edge of the output video. */
30
33
  const MAX_LONG_EDGE = 1920;
@@ -222,6 +225,12 @@ async function processToFile(
222
225
  const buffer = target.buffer;
223
226
  if (!buffer) throw new Error("Video processing produced no output");
224
227
 
228
+ // Mediabunny tags each track with a non-zero alternate_group, which makes
229
+ // Safari treat tracks as mutually exclusive alternates and never auto-hide
230
+ // the native <video> control bar during playback. Zero it so the controls
231
+ // behave like any other MP4.
232
+ zeroTrackAlternateGroups(buffer);
233
+
225
234
  // Detect whether this browser double-rotates. Chrome's WebCodecs
226
235
  // bakes rotation into the pixel data AND mediabunny writes a display
227
236
  // matrix → the browser applies the matrix again (double-rotation).
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { zeroTrackAlternateGroups } from "../mp4-track-flags.js";
3
+
4
+ const str4 = (s: string): number[] => [...s].map((c) => c.charCodeAt(0));
5
+ const u32 = (n: number): number[] => [
6
+ (n >>> 24) & 0xff,
7
+ (n >>> 16) & 0xff,
8
+ (n >>> 8) & 0xff,
9
+ n & 0xff,
10
+ ];
11
+ const u16 = (n: number): number[] => [(n >>> 8) & 0xff, n & 0xff];
12
+ const box = (type: string, ...payload: number[][]): number[] => {
13
+ const body = payload.flat();
14
+ return [...u32(body.length + 8), ...str4(type), ...body];
15
+ };
16
+
17
+ /** A `tkhd` box with a given version and alternate_group value. */
18
+ function tkhd(version: 0 | 1, alternateGroup: number): number[] {
19
+ // creation, modification, trackID, reserved: 4 fields, 4 or 8 bytes each.
20
+ const idAndTimes =
21
+ version === 1 ? new Array(24).fill(0) : new Array(16).fill(0);
22
+ const duration = version === 1 ? new Array(8).fill(0) : new Array(4).fill(0);
23
+ return box(
24
+ "tkhd",
25
+ [version, 0, 0, 0], // version + flags
26
+ idAndTimes,
27
+ duration,
28
+ new Array(8).fill(0), // reserved[2]
29
+ u16(0), // layer
30
+ u16(alternateGroup),
31
+ new Array(48).fill(0), // volume, reserved, matrix, width, height
32
+ );
33
+ }
34
+
35
+ describe("zeroTrackAlternateGroups", () => {
36
+ it("zeroes a non-zero alternate_group in every tkhd (v0)", () => {
37
+ const bytes = new Uint8Array(
38
+ box(
39
+ "moov",
40
+ box("trak", tkhd(0, 1), box("mdia", [])),
41
+ box("trak", tkhd(0, 2), box("mdia", [])),
42
+ ),
43
+ );
44
+ zeroTrackAlternateGroups(bytes.buffer);
45
+
46
+ const view = new DataView(bytes.buffer);
47
+ // Locate both tkhd boxes and confirm alternate_group is now 0.
48
+ const groups: number[] = [];
49
+ for (let i = 0; i + 8 <= bytes.length; i++) {
50
+ if (
51
+ String.fromCharCode(
52
+ bytes[i + 4],
53
+ bytes[i + 5],
54
+ bytes[i + 6],
55
+ bytes[i + 7],
56
+ ) === "tkhd"
57
+ ) {
58
+ groups.push(view.getUint16(i + 8 + 4 + 20 + 8 + 2));
59
+ }
60
+ }
61
+ expect(groups).toEqual([0, 0]);
62
+ });
63
+
64
+ it("handles version 1 tkhd boxes", () => {
65
+ const bytes = new Uint8Array(box("moov", box("trak", tkhd(1, 7))));
66
+ zeroTrackAlternateGroups(bytes.buffer);
67
+
68
+ const view = new DataView(bytes.buffer);
69
+ let offset = -1;
70
+ for (let i = 0; i + 8 <= bytes.length; i++) {
71
+ if (
72
+ String.fromCharCode(
73
+ bytes[i + 4],
74
+ bytes[i + 5],
75
+ bytes[i + 6],
76
+ bytes[i + 7],
77
+ ) === "tkhd"
78
+ ) {
79
+ offset = i + 8 + 4 + 32 + 8 + 2;
80
+ }
81
+ }
82
+ expect(view.getUint16(offset)).toBe(0);
83
+ });
84
+
85
+ it("touches nothing but the alternate_group field", () => {
86
+ const original = new Uint8Array(
87
+ box(
88
+ "moov",
89
+ box("trak", tkhd(0, 0x0102), box("mdia", box("mdhd", u32(0)))),
90
+ ),
91
+ );
92
+ const copy = new Uint8Array(original);
93
+ zeroTrackAlternateGroups(copy.buffer);
94
+
95
+ const diffs: number[] = [];
96
+ for (let i = 0; i < original.length; i++) {
97
+ if (original[i] !== copy[i]) diffs.push(i);
98
+ }
99
+ // Exactly the two bytes of one alternate_group field changed.
100
+ expect(diffs).toHaveLength(2);
101
+ expect(diffs[1]).toBe(diffs[0] + 1);
102
+ });
103
+
104
+ it("leaves an already-zero alternate_group untouched", () => {
105
+ const original = new Uint8Array(box("moov", box("trak", tkhd(0, 0))));
106
+ const copy = new Uint8Array(original);
107
+ zeroTrackAlternateGroups(copy.buffer);
108
+ expect([...copy]).toEqual([...original]);
109
+ });
110
+
111
+ it("ignores files with no tkhd box", () => {
112
+ const original = new Uint8Array(box("ftyp", str4("isom")));
113
+ const copy = new Uint8Array(original);
114
+ zeroTrackAlternateGroups(copy.buffer);
115
+ expect([...copy]).toEqual([...original]);
116
+ });
117
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * In-place patching of MP4 track header (`tkhd`) flags.
3
+ *
4
+ * Mediabunny's MP4 muxer writes a non-zero `alternate_group` into every
5
+ * `tkhd` box (video → 1, audio → 2). Per ISO-BMFF, a non-zero
6
+ * `alternate_group` marks a track as one of several mutually exclusive
7
+ * alternates. Safari's native `<video>` player reacts to this by never
8
+ * auto-hiding the control bar during playback — the controls stay pinned.
9
+ * ffmpeg writes 0 here; matching that restores normal control-bar behavior.
10
+ */
11
+
12
+ /** ISO-BMFF container boxes that hold a `tkhd` somewhere below them. */
13
+ const CONTAINER_TYPES = new Set(["moov", "trak"]);
14
+
15
+ /**
16
+ * Byte offset of the `alternate_group` field within a `tkhd` box, measured
17
+ * from the start of the box. The field sits after the box header, the
18
+ * version/flags word, the (version-sized) time/duration fields, the two
19
+ * reserved words, and the 2-byte `layer` field.
20
+ */
21
+ function alternateGroupOffset(version: number): number {
22
+ // header(8) + version/flags(4) + variable middle + reserved(8) + layer(2):
23
+ // v0: creation(4) modification(4) trackID(4) reserved(4) duration(4) = 20
24
+ // v1: creation(8) modification(8) trackID(4) reserved(4) duration(8) = 32
25
+ return 8 + 4 + (version === 1 ? 32 : 20) + 8 + 2;
26
+ }
27
+
28
+ /**
29
+ * Zero the `alternate_group` field of every `tkhd` box in an MP4 buffer,
30
+ * operating in place. Safe to call on any ISO-BMFF file; boxes without a
31
+ * `tkhd` are left untouched.
32
+ *
33
+ * @param buffer - The MP4 file bytes. Mutated in place.
34
+ * @example
35
+ * zeroTrackAlternateGroups(mediabunnyOutput);
36
+ */
37
+ export function zeroTrackAlternateGroups(buffer: ArrayBuffer): void {
38
+ const view = new DataView(buffer);
39
+
40
+ const walk = (start: number, end: number): void => {
41
+ let pos = start;
42
+ while (pos + 8 <= end) {
43
+ let size = view.getUint32(pos);
44
+ const type = String.fromCharCode(
45
+ view.getUint8(pos + 4),
46
+ view.getUint8(pos + 5),
47
+ view.getUint8(pos + 6),
48
+ view.getUint8(pos + 7),
49
+ );
50
+ if (size === 0) size = end - pos;
51
+ if (size < 8 || pos + size > end) break;
52
+
53
+ if (type === "tkhd") {
54
+ const version = view.getUint8(pos + 8);
55
+ const fieldOffset = pos + alternateGroupOffset(version);
56
+ if (
57
+ fieldOffset + 2 <= pos + size &&
58
+ view.getUint16(fieldOffset) !== 0
59
+ ) {
60
+ view.setUint16(fieldOffset, 0);
61
+ }
62
+ } else if (CONTAINER_TYPES.has(type)) {
63
+ walk(pos + 8, pos + size);
64
+ }
65
+
66
+ pos += size;
67
+ }
68
+ };
69
+
70
+ walk(0, buffer.byteLength);
71
+ }