Preview compatible fragmented MP4 transfers while saving the verified copy

This commit is contained in:
Jonathan Sykes
2026-10-03 20:43:03 +08:00
parent 795a5aaed4
commit 5661eea6e6
7 changed files with 67 additions and 4 deletions

View File

@@ -16,7 +16,7 @@
if (!file) throw Error('Save this song on this device before sending it');
const hash = root.Sha256.create();
for (let at = 0; at < file.size; at += 1024 * 1024) hash.update(new Uint8Array(await file.slice(at, at + 1024 * 1024).arrayBuffer()));
const claim = root.DirectProtocol.file({ ...meta, cid: hash.hex(), size: file.size, title: meta.title || meta.id });
const claim = root.DirectProtocol.file({ ...meta, cid: hash.hex(), size: file.size, extension: file.name.split('.').pop().toLowerCase(), streamMime: await root.DirectStream?.inspect(file), title: meta.title || meta.id });
if (!claim) throw Error('Unsupported file metadata');
pending.set(`${key}:${to}:${claim.id}`, { file, claim });
send(key, { type: 'direct', action: 'invite', to, file: claim });
@@ -25,6 +25,7 @@
function finish(s, error) {
if (s.done) return; s.done = true; clearTimeout(s.timer); clearTimeout(s.idle);
s.pc?.close(); transfers.delete(s.token);
if (error) s.preview?.close(); else s.preview?.end();
if (s.worker) { s.worker.postMessage({ op: 'abort' }); setTimeout(() => s.worker.terminate(), 1000); }
if (error) { waiting.get(s.claim.id)?.resolve(false); waiting.delete(s.claim.id); hooks.notify(`${error}. Partial copy kept for resume.`); hooks.fallback(s.claim, () => offer(s.key, s.peer, s.claim).catch(e => hooks.notify(e.message))); }
}
@@ -44,7 +45,7 @@
try {
if (receiver) {
if (typeof e.data === 'string') { const m = JSON.parse(e.data); if (m.t === 'end') s.worker.postMessage({ op: 'finish' }); else throw Error('Unexpected file control'); }
else s.worker.postMessage({ op: 'chunk', buf: e.data }, [e.data]);
else { s.preview?.push(e.data); s.worker.postMessage({ op: 'chunk', buf: e.data }, [e.data]); }
} else {
if (typeof e.data !== 'string' || s.started) throw Error('Unexpected file request');
const m = JSON.parse(e.data); if (m.t !== 'get') throw Error('Unexpected file request');
@@ -69,6 +70,7 @@
}
async function accept(key, m) {
const s = { key, token: m.token, claim: m.file, peer: m.from, receiver: true }; transfers.set(m.token, s);
s.preview = m.file.streamMime ? root.DirectStream?.create(m.file.streamMime, m.file.title) : null;
connection(s, true);
s.worker = new Worker('/direct-recv-worker.js');
s.worker.onmessage = async e => {

View File

@@ -7,7 +7,8 @@
function file(v) {
if (!v || !id(v.id) || !/^[a-f0-9]{64}$/.test(v.cid) || !Number.isSafeInteger(v.size) || v.size <= 0 || v.size > 4 * 1024 ** 3) return null;
if (!text(v.title, 300) || !text(v.channel || '', 200)) return null;
return { id: v.id, cid: v.cid, size: v.size, title: v.title, channel: v.channel || '', duration: Number.isFinite(v.duration) && v.duration >= 0 ? v.duration : 0 };
const streamMime = typeof v.streamMime === 'string' && /^video\/mp4; codecs="avc1\.[a-f0-9]{6}(, mp4a\.40\.2)?"$/.test(v.streamMime) ? v.streamMime : '';
return { streamMime, extension: /^(mp4|m4a|webm|ogg|opus|mkv|mp3|wav|aac)$/.test(v.extension) ? v.extension : 'mp4', id: v.id, cid: v.cid, size: v.size, title: v.title, channel: v.channel || '', duration: Number.isFinite(v.duration) && v.duration >= 0 ? v.duration : 0 };
}
function parse(value) {
if (typeof value === 'string') { if (value.length > 65536) return null; try { value = JSON.parse(value); } catch { return null; } }

View File

@@ -27,11 +27,13 @@ async function run(m) {
else if (m.op === 'finish') {
await close();
if (pos !== claim.size || hash.hex() !== claim.cid) { await dir.removeEntry(name); throw Error('File verification failed; retry starts from zero'); }
const final = `${claim.id}.mp4`;
const extension = /^(mp4|m4a|webm|ogg|opus|mkv|mp3|wav|aac)$/.test(claim.extension) ? claim.extension : 'mp4';
const final = `${claim.id}.${extension}`;
// Copy only after verification; existing saved playback remains intact until then.
const out = await (await dir.getFileHandle(final, { create: true })).createSyncAccessHandle();
try { const f = await handle.getFile(); for (let at = 0; at < f.size; at += 1024 * 1024) out.write(new Uint8Array(await f.slice(at, at + 1024 * 1024).arrayBuffer()), { at }); out.truncate(f.size); out.flush(); } finally { out.close(); }
await dir.removeEntry(name);
for await (const [old, entry] of dir.entries()) { if (entry.kind === 'file' && old !== final && old.startsWith(claim.id + '.') && /\.(mp4|m4a|webm|ogg|opus|mkv|mp3|wav|aac)$/.test(old)) await dir.removeEntry(old); }
self.postMessage({ op: 'done', file: claim });
}
} catch (e) { await close(); self.postMessage({ op: 'error', error: e.message }); }

44
frontend/direct-stream.js Normal file
View File

@@ -0,0 +1,44 @@
/* Progressive preview is only safe for fragmented MP4, never ordinary MP4 chunks. */
(function (root) {
'use strict';
function inspectBytes(bytes) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const str = (at, n) => String.fromCharCode(...bytes.subarray(at, at + n));
let at = 0, moov = null;
while (at + 8 <= bytes.length) {
const size = view.getUint32(at); if (size < 8 || at + size > bytes.length) break;
if (str(at + 4, 4) === 'moov') { moov = bytes.subarray(at + 8, at + size); break; }
at += size;
}
if (!moov) return null;
const tagAt = (tag) => { for (let i = 4; i + 4 <= moov.length; i++) if (String.fromCharCode(...moov.subarray(i, i + 4)) === tag) return i; return -1; };
if (tagAt('mvex') < 0) return null;
const avc = tagAt('avcC'), audio = tagAt('mp4a');
if (avc < 0 || avc + 8 > moov.length || moov[avc + 4] !== 1) return null;
const codec = Array.from(moov.subarray(avc + 5, avc + 8), b => b.toString(16).padStart(2, '0')).join('');
return `video/mp4; codecs="avc1.${codec}${audio >= 0 ? ', mp4a.40.2' : ''}"`;
}
async function inspect(file) { return inspectBytes(new Uint8Array(await file.slice(0, 1024 * 1024).arrayBuffer())); }
function create(mime, title) {
if (!root.MediaSource?.isTypeSupported(mime)) return null;
const media = new MediaSource(), url = URL.createObjectURL(media), queue = [];
let buffer, queued = 0, ended = false, stopped = false;
const dialog = document.createElement('dialog'); dialog.className = 'direct-transfer-dialog';
const heading = document.createElement('h2'); heading.textContent = title;
const note = document.createElement('p'); note.textContent = 'Direct preview · the full copy is still being saved and verified. Tap Play when ready.';
const video = document.createElement('video'); video.controls = true; video.playsInline = true; video.src = url; video.style.width = '100%';
const close = document.createElement('button'); close.textContent = 'Close preview';
const dispose = () => { stopped = true; queue.length = 0; video.pause(); video.removeAttribute('src'); video.load(); URL.revokeObjectURL(url); dialog.remove(); };
close.onclick = dispose; dialog.oncancel = e => { e.preventDefault(); dispose(); }; dialog.append(heading, note, video, close); document.body.append(dialog); dialog.showModal();
function flush() {
if (stopped || !buffer || buffer.updating) return;
if (queue.length) { const b = queue.shift(); queued -= b.byteLength; try { buffer.appendBuffer(b); } catch { dispose(); } }
else if (ended && media.readyState === 'open') { try { media.endOfStream(); } catch {} }
}
media.addEventListener('sourceopen', () => { try { buffer = media.addSourceBuffer(mime); buffer.addEventListener('updateend', flush); buffer.addEventListener('error', dispose); flush(); } catch { dispose(); } }, { once: true });
return { push(bytes) { if (stopped) return; queued += bytes.byteLength; if (queued > 8 * 1024 * 1024) { dispose(); return; } queue.push(bytes.slice(0)); flush(); }, end() { ended = true; flush(); }, close: dispose };
}
const api = { inspectBytes, inspect, create };
if (typeof module !== 'undefined') module.exports = api;
root.DirectStream = api;
}(globalThis));

View File

@@ -0,0 +1,12 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { inspectBytes } = require('./direct-stream.js');
function box(type, payload = Buffer.alloc(0)) { const b = Buffer.alloc(8 + payload.length); b.writeUInt32BE(b.length); b.write(type, 4); payload.copy(b, 8); return b; }
test('progressive playback only accepts a complete fragmented MP4 initialization', () => {
const codec = box('avcC', Buffer.from([1, 0x64, 0, 0x1f]));
const fragmented = Buffer.concat([box('ftyp'), box('moov', Buffer.concat([box('mvex'), codec, box('mp4a')]))]);
assert.equal(inspectBytes(fragmented), 'video/mp4; codecs="avc1.64001f, mp4a.40.2"');
assert.equal(inspectBytes(Buffer.concat([box('ftyp'), box('moov', codec)])), null);
assert.equal(inspectBytes(fragmented.subarray(0, fragmented.length - 1)), null);
assert.equal(inspectBytes(Buffer.alloc(0)), null);
});

View File

@@ -662,6 +662,7 @@
<script src="sw-update.js"></script>
<script src="settings-sections.js"></script>
<script src="direct-protocol.js"></script>
<script src="direct-stream.js"></script>
<script src="direct-media.js"></script>
<script src="download-errors.js"></script>
<script src="downloads.js"></script>

View File

@@ -84,6 +84,7 @@ const SHELL = [
'/direct-media.css',
'/direct-protocol.js',
'/direct-media.js',
'/direct-stream.js',
'/direct-recv-worker.js',
'/p2p-transfer.js',
'/p2p-recv-worker.js',