Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions templates/clips/app/components/player/video-player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -277,9 +277,18 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
// without this guard that effect would treat the two URLs as the "same
// resource" and immediately revert our retry before `.load()` completes.
const recoveringFromErrorRef = useRef(false);
const prevMseModeRef = useRef("");
// Render-phase mirrors of currentMs / isPlaying so the MSE-fallback effect
// below can read the pre-failure values. By the time effects run, React has
// already committed the new <video src>, causing the browser to reset
// currentTime -> 0 and paused -> true, making the element values useless.
const currentMsRef = useRef(startMs ?? 0);
const isPlayingRef = useRef(false);
const [activeVideoSrc, setActiveVideoSrc] = useState(resolvedVideoSrc);
const [isPlaying, setIsPlaying] = useState(false);
const [currentMs, setCurrentMs] = useState(startMs ?? 0);
currentMsRef.current = currentMs;
isPlayingRef.current = isPlaying;
const [loomStartMs, setLoomStartMs] = useState<number | null>(null);
const [volume, setVolume] = useState(1);
// Autoplaying players (e.g. the Slack unfurl embed, `?autoplay=1`) must
Expand Down Expand Up @@ -392,6 +401,45 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
? undefined
: activeVideoSrc;

// When the MSE pipeline breaks mid-stream (premature 416 or
// ERR_CONTENT_LENGTH_MISMATCH after the backing GCS object is replaced by
// the compressed version), the loader calls onFatal which flips mse.mode to
// "native". This effect detects that transition and:
// 1. saves the playback position via currentMsRef (v.currentTime is already 0)
// 2. cache-busts activeVideoSrc so the native <video> path fetches fresh
// headers rather than a proxy-cached content-length from the old object
// 3. if the user was playing, arms playAttemptPendingRef so the existing
// retryPendingPlay call in onLoadedData resumes without user interaction
useEffect(() => {
const prev = prevMseModeRef.current;
prevMseModeRef.current = mse.mode;
if (prev !== "mse" || mse.mode !== "native") return;
Comment thread
shomix marked this conversation as resolved.

const wasPlaying = isPlayingRef.current;
const posMs = currentMsRef.current > 0 ? currentMsRef.current : null;
if (posMs != null) resumeAfterReloadMsRef.current = posMs;

if (activeVideoSrc) {
recoveringFromErrorRef.current = true;
setActiveVideoSrc(
setUrlSearchParam(activeVideoSrc, "cb", String(Date.now())),
);
}

if (wasPlaying) {
const nextId = playAttemptIdRef.current + 1;
playAttemptIdRef.current = nextId;
playAttemptPendingRef.current = true;
setIsPlayPending(true);
setIsBuffering(true);
} else {
setIsPlaying(false);
setIsBuffering(false);
}
setIsPreparing(true);
setCanPlay(false);
}, [mse.mode, activeVideoSrc]);

useEffect(() => {
if (!resolvedVideoSrc) {
setActiveVideoSrc(undefined);
Expand Down
13 changes: 13 additions & 0 deletions templates/clips/app/lib/fmp4.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@ describe("isFragmentedMp4Head", () => {
expect(isFragmentedMp4Head(new Uint8Array([...ftyp, ...moov]))).toBe(false);
});

it("returns false when classic mp4 binary data contains the bytes 'mvex' inside mvhd", () => {
// A regular MP4 whose mvhd payload happens to contain the byte sequence
// 0x6d766578 ("mvex") — a false positive the old raw-scan code would hit.
const ftyp = box("ftyp", [
...Array.from(enc.encode("isom")),
...u32(0),
...Array.from(enc.encode("mp42")),
]);
const mvexBytes = Array.from(enc.encode("mvex")); // 6d 76 65 78
const moov = box("moov", box("mvhd", [...u32(0), ...mvexBytes, ...u32(0)]));
expect(isFragmentedMp4Head(new Uint8Array([...ftyp, ...moov]))).toBe(false);
});

it("returns false when it is not an mp4", () => {
expect(
isFragmentedMp4Head(new Uint8Array(enc.encode("not an mp4 file"))),
Expand Down
30 changes: 26 additions & 4 deletions templates/clips/app/lib/fmp4.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,14 +208,36 @@ export function findMoofOffset(bytes: Uint8Array): number {
* True when the head of an MP4 shows the fragmented shape: the `hlsf` brand in
* `ftyp`, or an `mvex` box inside `moov` (present only in fragmented files).
* `headBytes` should be the first few KB of the file.
*
* Both checks walk the ISO BMFF box structure rather than scanning raw bytes.
* Numeric fields inside moov children (timestamps, matrix values, codec config)
* can accidentally contain the byte sequences "mvex" or "hlsf", producing false
* positives if we just scan the whole buffer.
*/
export function isFragmentedMp4Head(headBytes: Uint8Array): boolean {
if (headBytes.byteLength < 8) return false;
// Must look like an MP4 at all.

if (indexOfAscii(headBytes, "ftyp") !== 4) return false;
if (indexOfAscii(headBytes, "hlsf") !== -1) return true;
if (indexOfAscii(headBytes, "mvex") !== -1) return true;
return false;

const ftypSize = readU32(headBytes, 0);
if (ftypSize < 12) return false;
const ftypEnd = Math.min(ftypSize, headBytes.byteLength);

// Offset 12 is minor_version (uint32), not a brand — start compatible brands at 16.
if (ftypEnd >= 12 && readType(headBytes, 8) === "hlsf") return true;
for (let i = 16; i + 4 <= ftypEnd; i += 4) {
if (readType(headBytes, i) === "hlsf") return true;
}

const boxes = readTopLevelBoxes(headBytes);
const moov = boxes.find((b) => b.type === "moov");
if (!moov || moov.size === 0) return false;
const moovPayloadEnd = Math.min(moov.start + moov.size, headBytes.byteLength);
const moovPayload = headBytes.subarray(
moov.start + moov.headerSize,
moovPayloadEnd,
);
return readTopLevelBoxes(moovPayload).some((b) => b.type === "mvex");
}

/** Cache detection by URL identity so we sniff each asset only once. */
Expand Down
15 changes: 13 additions & 2 deletions templates/clips/app/lib/mse-video-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -555,11 +555,22 @@ export class MseVideoLoader {
headers: { Range: `bytes=${start}-${end}` },
signal: controller.signal,
});
// 416 means we asked past the end — treat as a clean end-of-stream.
if (res.status === 416) return { bytes: new Uint8Array(0), eof: true };
if (res.status === 416) {
// A 416 at an offset before the known file total means the backing object
// was replaced with a smaller compressed version while we were streaming.
// Throw so runPump's catch calls fail() -> onFatal -> native path recovery.
// When totalKnown is false (cross-origin CDN hides Content-Range), we
// cannot tell premature from real EOF so we keep the old safe-EOF behaviour.
if (this.totalKnown && start < this.totalBytes) {
throw new Error("Range 416 before known EOF: backing file replaced");
}
return { bytes: new Uint8Array(0), eof: true };
}
if (!res.ok) {
throw new Error(`Range request failed: ${res.status}`);
}
// If the backing file shrank mid-response (ERR_CONTENT_LENGTH_MISMATCH),
// arrayBuffer() throws here, which also routes through fail() -> onFatal.
const buffer = await res.arrayBuffer();
const bytes = new Uint8Array(buffer);

Expand Down
Loading