@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.
- package/dist/{app-CPVwpmb3.js → app-BtNdUAqz.js} +15 -15
- package/dist/app-DLINgGBd.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-BErXNT6k.css +2 -0
- package/dist/client/_assets/{client-8pp1btGZ.js → client-CtAgWT8i.js} +10 -8
- package/dist/client/_assets/{client-auth-Ds3SVvGh.js → client-auth-DJ_5wx9N.js} +55 -44
- package/dist/{export-I9XFTWyO.js → export-CR9Megtb.js} +2 -2
- package/dist/{github-sync-C0Fi4LKt.js → github-sync-8Vv06aCr.js} +2 -2
- package/dist/{github-sync-DBAwA3H9.js → github-sync-DYZq9rQp.js} +1 -1
- package/dist/index.js +3 -3
- package/dist/node.js +4 -4
- package/package.json +1 -1
- package/src/client/components/jant-compose-dialog.ts +27 -6
- package/src/client/components/jant-compose-editor.ts +72 -0
- package/src/client/components/jant-media-lightbox.ts +124 -39
- package/src/client/tiptap/paste-media.ts +49 -33
- package/src/client/video-processor.ts +9 -0
- package/src/lib/__tests__/mp4-track-flags.test.ts +117 -0
- package/src/lib/mp4-track-flags.ts +71 -0
- package/src/services/export-theme/assets/client-site.js +9 -7
- package/src/styles/tokens.css +40 -0
- package/src/styles/ui.css +61 -7
- package/dist/app-Z83YzveI.js +0 -6
- package/dist/client/_assets/client-CFegXQty.css +0 -2
|
@@ -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
|
-
//
|
|
291
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
if (
|
|
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
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
+
}
|