Browse / github.com/storytold/filmcraft / patch
web: Keep WebCodecs enabled after media read errors
web: Keep WebCodecs enabled after media read errors ## What and why A failed MP4/MOV sample read currently takes the same path as a WebCodecs decoder failure. This permanently switches the source to FilmCraft's software decoder, including subsequent requests for readable parts of the file. Return a media I/O error for source-read failures without disabling WebCodecs. Pending reads still retry, and actual decoder failures retain the existing software fallback. This does not retry failed reads or hide unavailable data. ## How it was tested - Added `apps/filmcraft-web/tests/webcodecs-read-errors.html`, a standalone browser regression using a l
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- michael-holzheu (imported from pull request #3422, open)
- Base
8fda2f232198c4c79ff9cfe5b6a347bb1375fa9e- Size
- +101 −1 in 2 files
- Review
- pending
- Shared
- 2026-10-28
- Id
9140bd8bbc1cadc9453fc0a9
How it went
No other operator has reported using it yet.
Diff
raw · apps/filmcraft-web/src/webcodecs.rs apps/filmcraft-web/tests/webcodecs-read-errors.html
@@ -448,6 +448,8 @@ pub struct WcSource { enum Drive { /// Work is under way (or bytes are loading): retry later. Waiting,+ /// Missing source bytes cannot be repaired by switching decoders.+ ReadError(std::io::Error), /// The decoder failed: use our own. Failed, }@@ -512,7 +514,7 @@ impl WcSource { Ok(()) => {} // the bytes are being fetched (pending is marked): feed on the next try Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => return Drive::Waiting,- Err(_) => return Drive::Failed,+ Err(e) => return Drive::ReadError(e), } let Some(chunk_ctor) = ctor("EncodedVideoChunk") else { return Drive::Failed }; let init = obj(&[@@ -604,6 +606,7 @@ impl MediaSource for WcSource { return Ok(f.clone()); } match self.drive(i, want) {+ Drive::ReadError(e) => Err(MediaError::Io(format!("{}: {e}", self.info.name))), Drive::Waiting => { self.collect(&mut c, want); if let Some(f) = c.frames.get(&want) {new file mode 100644@@ -0,0 +1,97 @@+<!doctype html>+<meta charset="utf-8">+<title>WebCodecs source-read regression</title>+<!--+ Serve this file beside the built web app and open it in a WebCodecs-capable browser.+ Generate the test fixture locally (do not commit media):+ ffmpeg -f lavfi -i testsrc2=size=640x360:rate=30 -t 12 -an -c:v libx264 \+ -preset ultrafast -g 30 -b:v 12M -minrate 12M -maxrate 12M -bufsize 12M \+ -x264-params nal-hrd=cbr -pix_fmt yuv420p -movflags +faststart read-test.mp4+ Optional query parameters: ?app=index.html&media=read-test.mp4+ Uses only the upstream File import API; no embedding-host API is required.+-->+<button id="run">Run source-read regression</button>+<pre id="result" role="status">Ready</pre>+<iframe id="app" title="Isolated FilmCraft test" style="width:100%;height:700px;border:0"></iframe>+<script type="module">+const params = new URLSearchParams(location.search);+const frame = document.querySelector('#app');+frame.src = (params.get('app') || 'index.html') + '?empty&norecover';+const result = document.querySelector('#result');+const wait = ms => new Promise(resolve => setTimeout(resolve, ms));+async function until(predicate, message, timeout = 30000) {+ const deadline = Date.now() + timeout;+ while (!await predicate()) {+ if (Date.now() >= deadline) throw new Error(message);+ await wait(50);+ }+}+document.querySelector('#run').onclick = async () => {+ document.querySelector('#run').disabled = true;+ const report = {ok: false};+ let restoreDecoder;+ try {+ result.textContent = 'Loading…';+ await until(() => frame.contentWindow.filmcraftLoad?.readyMs, 'App did not load');+ const win = frame.contentWindow, api = win.filmcraft;+ if (!win.VideoDecoder) throw new Error('WebCodecs is required');+ await api.execute('file.newProject', {name: 'WebCodecs read error'});+ await api.request('ui.set', {timeline: {thumbnails: 'off'}});+ const response = await fetch(params.get('media') || 'read-test.mp4');+ if (!response.ok) throw new Error('Fixture fetch failed');+ const bytes = await response.arrayBuffer();+ if (bytes.byteLength < 16 * 1024 * 1024) throw new Error('Generate the documented 12-second CBR fixture');+ let rejected = 0, decodeCalls = 0, decoderFault = false;+ const originalDecode = win.VideoDecoder.prototype.decode;+ win.VideoDecoder.prototype.decode = function (...args) {+ decodeCalls++;+ if (decoderFault) throw new win.DOMException('Injected decoder error', 'EncodingError');+ return originalDecode.apply(this, args);+ };+ restoreDecoder = () => {win.VideoDecoder.prototype.decode = originalDecode;};+ // Fail one region, while other parts of the same File remain readable.+ const file = new win.File([bytes], 'read-test.mp4', {type: 'video/mp4'});+ const slice = file.slice.bind(file);+ file.slice = (start, end, type) => {+ const blob = slice(start, end, type);+ if (start < 10 * 1024 * 1024 && end > 8 * 1024 * 1024) {+ blob.arrayBuffer = async () => {rejected++; throw new Error('Injected source read error');};+ }+ return blob;+ };+ const imported = await api.importFiles([file]);+ if (!imported.items?.length) throw new Error('Fixture import failed: ' + JSON.stringify(imported));+ const sequence = await api.execute('file.newSequence', {fromItem: imported.items[0], name: 'Read error'});+ await api.execute('sequence.open', {item: sequence.sequence});+ await until(() => api.info().webcodecsStats.framesDecoded > 0, 'No initial WebCodecs frame');+ await until(() => api.info().fetchesInFlight === 0, 'Initial reads did not finish');+ const baseline = api.info().webcodecsStats;+ result.textContent = 'Injecting a media read error…';+ await api.execute('playhead.set', {seconds: 6});+ await until(() => rejected > 0, 'Fault region was not read');+ // Give the frame server time to observe the rejected asynchronous read.+ await wait(1500);+ report.rejectedReads = rejected;+ report.afterReadError = api.info().webcodecsStats;+ if (report.afterReadError.fallbacks !== baseline.fallbacks) throw new Error('Source read error disabled WebCodecs');+ const beforeRecovery = api.info().webcodecsStats.framesDecoded;+ const callsBeforeRecovery = decodeCalls;+ await api.execute('playhead.set', {seconds: 10});+ await until(() => decodeCalls > callsBeforeRecovery && api.info().webcodecsStats.framesDecoded > beforeRecovery,+ 'A readable region did not decode through WebCodecs after the read error');+ report.afterRecovery = api.info().webcodecsStats;+ if (report.afterRecovery.fallbacks !== baseline.fallbacks) throw new Error('Recovery used the software fallback');+ // A genuine decoder exception must still take the established fallback path.+ decoderFault = true;+ await api.execute('playhead.set', {seconds: 2});+ await until(() => api.info().webcodecsStats.fallbacks > baseline.fallbacks, 'Decoder error did not trigger fallback');+ report.afterDecoderError = api.info().webcodecsStats;+ report.ok = true;+ } catch (error) {+ report.error = String(error.stack || error);+ } finally {+ restoreDecoder?.();+ result.textContent = JSON.stringify(report, null, 2);+ }+};+</script>