Preview compatible fragmented MP4 transfers while saving the verified copy
This commit is contained in:
@@ -16,7 +16,7 @@
|
|||||||
if (!file) throw Error('Save this song on this device before sending it');
|
if (!file) throw Error('Save this song on this device before sending it');
|
||||||
const hash = root.Sha256.create();
|
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()));
|
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');
|
if (!claim) throw Error('Unsupported file metadata');
|
||||||
pending.set(`${key}:${to}:${claim.id}`, { file, claim });
|
pending.set(`${key}:${to}:${claim.id}`, { file, claim });
|
||||||
send(key, { type: 'direct', action: 'invite', to, file: claim });
|
send(key, { type: 'direct', action: 'invite', to, file: claim });
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
function finish(s, error) {
|
function finish(s, error) {
|
||||||
if (s.done) return; s.done = true; clearTimeout(s.timer); clearTimeout(s.idle);
|
if (s.done) return; s.done = true; clearTimeout(s.timer); clearTimeout(s.idle);
|
||||||
s.pc?.close(); transfers.delete(s.token);
|
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 (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))); }
|
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 {
|
try {
|
||||||
if (receiver) {
|
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'); }
|
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 {
|
} else {
|
||||||
if (typeof e.data !== 'string' || s.started) throw Error('Unexpected file request');
|
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');
|
const m = JSON.parse(e.data); if (m.t !== 'get') throw Error('Unexpected file request');
|
||||||
@@ -69,6 +70,7 @@
|
|||||||
}
|
}
|
||||||
async function accept(key, m) {
|
async function accept(key, m) {
|
||||||
const s = { key, token: m.token, claim: m.file, peer: m.from, receiver: true }; transfers.set(m.token, s);
|
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);
|
connection(s, true);
|
||||||
s.worker = new Worker('/direct-recv-worker.js');
|
s.worker = new Worker('/direct-recv-worker.js');
|
||||||
s.worker.onmessage = async e => {
|
s.worker.onmessage = async e => {
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
function file(v) {
|
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 (!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;
|
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) {
|
function parse(value) {
|
||||||
if (typeof value === 'string') { if (value.length > 65536) return null; try { value = JSON.parse(value); } catch { return null; } }
|
if (typeof value === 'string') { if (value.length > 65536) return null; try { value = JSON.parse(value); } catch { return null; } }
|
||||||
|
|||||||
@@ -27,11 +27,13 @@ async function run(m) {
|
|||||||
else if (m.op === 'finish') {
|
else if (m.op === 'finish') {
|
||||||
await close();
|
await close();
|
||||||
if (pos !== claim.size || hash.hex() !== claim.cid) { await dir.removeEntry(name); throw Error('File verification failed; retry starts from zero'); }
|
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.
|
// Copy only after verification; existing saved playback remains intact until then.
|
||||||
const out = await (await dir.getFileHandle(final, { create: true })).createSyncAccessHandle();
|
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(); }
|
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);
|
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 });
|
self.postMessage({ op: 'done', file: claim });
|
||||||
}
|
}
|
||||||
} catch (e) { await close(); self.postMessage({ op: 'error', error: e.message }); }
|
} catch (e) { await close(); self.postMessage({ op: 'error', error: e.message }); }
|
||||||
|
|||||||
44
frontend/direct-stream.js
Normal file
44
frontend/direct-stream.js
Normal 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));
|
||||||
12
frontend/direct-stream.test.js
Normal file
12
frontend/direct-stream.test.js
Normal 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);
|
||||||
|
});
|
||||||
@@ -662,6 +662,7 @@
|
|||||||
<script src="sw-update.js"></script>
|
<script src="sw-update.js"></script>
|
||||||
<script src="settings-sections.js"></script>
|
<script src="settings-sections.js"></script>
|
||||||
<script src="direct-protocol.js"></script>
|
<script src="direct-protocol.js"></script>
|
||||||
|
<script src="direct-stream.js"></script>
|
||||||
<script src="direct-media.js"></script>
|
<script src="direct-media.js"></script>
|
||||||
<script src="download-errors.js"></script>
|
<script src="download-errors.js"></script>
|
||||||
<script src="downloads.js"></script>
|
<script src="downloads.js"></script>
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ const SHELL = [
|
|||||||
'/direct-media.css',
|
'/direct-media.css',
|
||||||
'/direct-protocol.js',
|
'/direct-protocol.js',
|
||||||
'/direct-media.js',
|
'/direct-media.js',
|
||||||
|
'/direct-stream.js',
|
||||||
'/direct-recv-worker.js',
|
'/direct-recv-worker.js',
|
||||||
'/p2p-transfer.js',
|
'/p2p-transfer.js',
|
||||||
'/p2p-recv-worker.js',
|
'/p2p-recv-worker.js',
|
||||||
|
|||||||
Reference in New Issue
Block a user