fix(portal): make JPEG fallback latest-frame-wins
- commit
- 0c14205277dfde98ff0cd16c2a7958506b9ec9cc
- parent
- 35b7ce0b1eba2b93711559a9c005ce77a37aa99a
- parent
- 5c7f4372067b3f99dc38c46563b07a37a02caa01
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | portal/ | 7 | + |
| M | portal/ | 1 | + |
| M | portal/ | 6 | + |
| M | portal/ | 27 | ++++ |
| M | portal/ | 10 | +- |
| M | portal/ | 34 | +++++- |
| M | portal/ | 134 | ++++++++++++++++++-- |
| M | portal/ | 195 | +++++++++++++++++++++++++++++- |
| M | scripts/ | 16 | ++ |
| M | src-tauri/ | 126 | ++++++++++++++++++-- |
@@ -35,3 +35,10 @@ test('raw invitation bytes stay in native commands instead of the DOM', () => {
3535 assert.match(main, /portal_import_invitation_file/);3636 assert.doesNotMatch(main, /readAsText|FileReader/);3737 });38+39+test('forced JPEG development mode is visibly labeled without bypassing enrollment', () => {40+ assert.match(html, /id="dev-jpeg-badge"[^>]*>dev JPEG forced</);41+ assert.match(main, /if \(mode\.force_jpeg\)/);42+ assert.match(main, /getElementById\('dev-jpeg-badge'\)/);43+ assert.match(main, /if \(!mode\.enabled\) return;/);44+});@@ -135,6 +135,7 @@
135135 <div class="topbar">136136 <span class="brand">portal</span>137137 <span class="dev-badge hidden" id="dev-connect-badge" title="Skips passkey identity lookup. This is not client authorization.">dev direct-node · not auth</span>138+ <span class="dev-badge hidden" id="dev-jpeg-badge" title="Development JPEG compatibility mode is forced.">dev JPEG forced</span>138139 <span class="sep">·</span>139140 <div class="status-line" id="status-line">140141 <span class="dot" id="status-dot"></span>@@ -261,6 +261,12 @@ async function cancelInvitation() {
261261 async function checkDevelopmentConnectionMode() {262262 const mode = await invoke('development_connection_mode');263263 connectionState.setDevelopmentMode(mode.enabled);264+ if (mode.force_jpeg) {265+ const jpegBadge = document.getElementById('dev-jpeg-badge');266+ jpegBadge.classList.remove('hidden');267+ jpegBadge.title = mode.jpeg_warning;268+ console.warn('[development JPEG compatibility mode]', mode.jpeg_warning);269+ }264270 if (!mode.enabled) return;265271 266272 const badge = document.getElementById('dev-connect-badge');@@ -335,3 +335,30 @@ test('adaptive feedback preserves bounded queue fallbacks and pressure signals',
335335 resyncActive: true,336336 });337337 });338+339+test('adaptive feedback projects the bounded JPEG decode-plus-latest queue and overwrite counter', () => {340+ const { runtime, events } = subject();341+ runtime.prepareConnection();342+ const session = runtime.openFrameSession();343+ activate(runtime, session, 6);344+ events.length = 0;345+346+ assert.equal(runtime.publishAdaptiveFeedback(videoSnapshot({347+ presenterQueueDepth: 2,348+ presenterQueueCapacity: 2,349+ presentationDroppedFrames: 3,350+ })), true);351+ assert.equal(events[0].snapshot.presenterQueueDepth, 2);352+ assert.equal(events[0].snapshot.presenterQueueCapacity, 2);353+ assert.equal(events[0].snapshot.presenterDroppedTotal, 3);354+355+ events.length = 0;356+ assert.equal(runtime.publishAdaptiveFeedback(videoSnapshot({357+ presenterQueueDepth: 0,358+ presenterQueueCapacity: 2,359+ presentationDroppedFrames: 3,360+ })), true);361+ assert.equal(events[0].snapshot.presenterQueueDepth, 0);362+ assert.equal(events[0].snapshot.presenterQueueCapacity, 2);363+ assert.equal(events[0].snapshot.presenterDroppedTotal, 3);364+});@@ -38,8 +38,14 @@ export async function detectAndPublishVideoDeliveryMode({
3838 3939 const available = await probeH264WebCodecsSupport({ VideoDecoder, logger });4040 try {41- await invokeCommand('set_webcodecs_available', { available });42- return available;41+ const effectiveAvailable = await invokeCommand('set_webcodecs_available', { available });42+ if (typeof effectiveAvailable !== 'boolean') {43+ throw new TypeError('native video delivery publication returned an invalid mode');44+ }45+ if (available && !effectiveAvailable) {46+ logger.warn('development JPEG compatibility mode forced; WebCodecs probe passed but is not selected');47+ }48+ return effectiveAvailable;4349 } catch (error) {4450 logger.error('could not publish WebCodecs capability; using JPEG fallback:', error);4551 return false;@@ -61,7 +61,10 @@ test('awaits the probe before publishing the exact raw delivery mode', async ()
6161 }6262 const pending = detectAndPublishVideoDeliveryMode({6363 VideoDecoder: DelayedDecoder,64- invokeCommand: async (...args) => { calls.push(args); },64+ invokeCommand: async (...args) => {65+ calls.push(args);66+ return true;67+ },6568 });6669 6770 await Promise.resolve();@@ -74,6 +77,35 @@ test('awaits the probe before publishing the exact raw delivery mode', async ()
7477 ]);7578 });7679 80+test('keeps the real probe but obeys native forced-JPEG publication', async () => {81+ const calls = [];82+ const warnings = [];83+ class SupportedDecoder {84+ static async isConfigSupported(config) {85+ calls.push(['probe', config]);86+ return { supported: true, config };87+ }88+ }89+90+ assert.equal(await detectAndPublishVideoDeliveryMode({91+ VideoDecoder: SupportedDecoder,92+ invokeCommand: async (...args) => {93+ calls.push(args);94+ return false;95+ },96+ logger: { warn: (...args) => warnings.push(args), error() {} },97+ }), false);98+ assert.deepEqual(calls, [99+ ['probe', {100+ codec: 'avc1.64001f',101+ optimizeForLatency: true,102+ }],103+ ['set_webcodecs_available', { available: true }],104+ ]);105+ assert.equal(warnings.length, 1);106+ assert.match(warnings[0][0], /JPEG compatibility mode forced/);107+});108+77109 test('keeps frontend on JPEG when capability publication fails', async () => {78110 const errors = [];79111 class SupportedDecoder {@@ -23,6 +23,7 @@ import {
2323 export const MAX_DECODE_QUEUE_SIZE = 2;2424 const MAX_DECODE_TIMINGS = 8;2525 const PRESENTER_QUEUE_CAPACITY = 2;26+const JPEG_PRESENTER_QUEUE_CAPACITY = 2;2627 2728 export function createVideoPipelineSession({2829 hasWebCodecs,@@ -40,6 +41,7 @@ export function createVideoPipelineSession({
4041 createObjectUrl = (blob) => URL.createObjectURL(blob),4142 revokeObjectUrl = (url) => URL.revokeObjectURL(url),4243 createImage = () => new Image(),44+ abortImage = (image) => image.removeAttribute('src'),4345 requestFrame = (callback) => requestAnimationFrame(callback),4446 cancelFrame = (handle) => cancelAnimationFrame(handle),4547 setTimer = (callback, delayMs) => setTimeout(callback, delayMs),@@ -58,6 +60,9 @@ export function createVideoPipelineSession({
5860 let presentedFrames = 0;5961 let presentationDroppedFrames = 0;6062 let lastVideoDrawCompletedAtMs = null;63+ let jpegFrameSequence = 0;64+ let activeJpegDecode = null;65+ let queuedJpegFrame = null;6166 6267 const frontendDeliveryRate = new RollingRateWindow();6368 const decoderInputRate = new RollingRateWindow();@@ -113,8 +118,107 @@ export function createVideoPipelineSession({
113118 resetAudioSync(...args);114119 }115120 121+ function releaseActiveJpegDecode(frame, { abort = false } = {}) {122+ if (!frame || frame.released) return;123+ frame.released = true;124+ frame.image.onload = null;125+ frame.image.onerror = null;126+ if (abort) {127+ try {128+ abortImage(frame.image);129+ } catch (error) {130+ console.warn('could not abort stale JPEG decode:', error);131+ }132+ }133+ revokeObjectUrl(frame.url);134+ }135+136+ function startQueuedJpegDecode() {137+ if (activeJpegDecode || !queuedJpegFrame) return;138+ const pending = queuedJpegFrame;139+ queuedJpegFrame = null;140+141+ let url = null;142+ let image;143+ try {144+ url = createObjectUrl(pending.blob);145+ image = createImage();146+ } catch (error) {147+ if (url !== null) revokeObjectUrl(url);148+ droppedFrames++;149+ console.warn('could not start JPEG decode:', error);150+ startQueuedJpegDecode();151+ return;152+ }153+154+ const frame = {155+ ...pending,156+ image,157+ url,158+ released: false,159+ superseded: false,160+ };161+ activeJpegDecode = frame;162+ image.onload = () => {163+ if (activeJpegDecode !== frame) return;164+ activeJpegDecode = null;165+ try {166+ if (!frame.superseded && queuedJpegFrame === null) {167+ const drawStartedAt = now();168+ context.drawImage(image, 0, 0, canvas.width, canvas.height);169+ const presentedAt = now();170+ presentedFrames++;171+ presentationRate.record(presentedAt);172+ presentationCadence.record(presentedAt);173+ drawLatency.record(presentedAt - drawStartedAt, presentedAt);174+ clientPresentationLatency.record(175+ presentedAt - frame.receivedAt,176+ presentedAt,177+ );178+ }179+ } finally {180+ releaseActiveJpegDecode(frame);181+ startQueuedJpegDecode();182+ }183+ };184+ image.onerror = () => {185+ if (activeJpegDecode !== frame) return;186+ activeJpegDecode = null;187+ if (!frame.superseded && queuedJpegFrame === null) droppedFrames++;188+ releaseActiveJpegDecode(frame);189+ startQueuedJpegDecode();190+ };191+ try {192+ image.src = url;193+ } catch (error) {194+ if (activeJpegDecode === frame) activeJpegDecode = null;195+ droppedFrames++;196+ releaseActiveJpegDecode(frame, { abort: true });197+ console.warn('could not assign JPEG decode source:', error);198+ startQueuedJpegDecode();199+ }200+ }201+202+ function enqueueJpegFrame(frame) {203+ if (!activeJpegDecode) {204+ queuedJpegFrame = frame;205+ startQueuedJpegDecode();206+ return;207+ }208+ if (!activeJpegDecode.superseded) {209+ activeJpegDecode.superseded = true;210+ presentationDroppedFrames++;211+ }212+ if (queuedJpegFrame) presentationDroppedFrames++;213+ queuedJpegFrame = frame;214+ }215+116216 function teardown() {117217 resetAvSync();218+ queuedJpegFrame = null;219+ const active = activeJpegDecode;220+ activeJpegDecode = null;221+ releaseActiveJpegDecode(active, { abort: true });118222 framePresenter.clear();119223 decodeTimings.clear();120224 if (videoDecoder) {@@ -384,22 +488,16 @@ export function createVideoPipelineSession({
384488 epoch: activeVideoFormatEpoch,385489 });386490 }491+ // Keep exactly one browser decode active and one coalesced latest payload.492+ // Receipt order is authoritative: once a newer payload is queued, the493+ // active decode may finish to release its resources but can never draw.387494 const bytes = decodeBase64(data);388495 const blob = createBlob([bytes], { type: 'image/jpeg' });389- const url = createObjectUrl(blob);390- const image = createImage();391- image.onload = () => {392- const drawStartedAt = now();393- context.drawImage(image, 0, 0, canvas.width, canvas.height);394- revokeObjectUrl(url);395- const presentedAt = now();396- presentedFrames++;397- presentationRate.record(presentedAt);398- presentationCadence.record(presentedAt);399- drawLatency.record(presentedAt - drawStartedAt, presentedAt);400- clientPresentationLatency.record(presentedAt - receivedAt, presentedAt);401- };402- image.src = url;496+ enqueueJpegFrame({497+ sequence: ++jpegFrameSequence,498+ blob,499+ receivedAt,500+ });403501 }404502 }405503 @@ -429,8 +527,12 @@ export function createVideoPipelineSession({
429527 avSkewPercentiles: avSkew.summary(at),430528 decoderQueueDepth: videoDecoder?.decodeQueueSize ?? 0,431529 decoderQueueCapacity: MAX_DECODE_QUEUE_SIZE,432- presenterQueueDepth: framePresenter.depth,433- presenterQueueCapacity: PRESENTER_QUEUE_CAPACITY,530+ presenterQueueDepth: hasWebCodecs531+ ? framePresenter.depth532+ : Number(activeJpegDecode !== null) + Number(queuedJpegFrame !== null),533+ presenterQueueCapacity: hasWebCodecs534+ ? PRESENTER_QUEUE_CAPACITY535+ : JPEG_PRESENTER_QUEUE_CAPACITY,434536 recovering: decoderRecovery.recovering,435537 };436538 }@@ -28,6 +28,9 @@ function harness({ hasWebCodecs = true } = {}) {
2828 const draws = [];2929 const revokedUrls = [];3030 const images = [];31+ const imageStarts = [];32+ const abortedImages = [];33+ let nextObjectUrl = 0;3134 const canvas = { width: 0, height: 0 };3235 const context = {3336 drawImage: (...args) => draws.push(args),@@ -65,13 +68,26 @@ function harness({ hasWebCodecs = true } = {}) {
6568 },6669 decodeBase64: () => new Uint8Array([1, 2, 3]),6770 createBlob: (parts, options) => ({ parts, options }),68- createObjectUrl: () => 'blob:test-frame',71+ createObjectUrl: () => `blob:test-frame-${++nextObjectUrl}`,6972 revokeObjectUrl: (url) => revokedUrls.push(url),7073 createImage: () => {71- const image = { onload: null, src: null };74+ let src = null;75+ const image = {76+ onload: null,77+ onerror: null,78+ get src() { return src; },79+ set src(value) {80+ src = value;81+ imageStarts.push({ image: this, value });82+ },83+ };7284 images.push(image);7385 return image;7486 },87+ abortImage: (image) => {88+ abortedImages.push(image);89+ image.src = null;90+ },7591 requestFrame: () => 1,7692 cancelFrame: () => {},7793 setTimer: () => 2,@@ -89,6 +105,8 @@ function harness({ hasWebCodecs = true } = {}) {
89105 draws,90106 revokedUrls,91107 images,108+ imageStarts,109+ abortedImages,92110 advance(milliseconds = 1) { clock += milliseconds; },93111 };94112 }@@ -135,6 +153,8 @@ test('configured H.264 keyframes commit format only after decode enqueue', () =>
135153 assert.equal(stats.droppedFrames, 0);136154 assert.equal(stats.recovering, false);137155 assert.equal(stats.decoderQueueCapacity, MAX_DECODE_QUEUE_SIZE);156+ assert.equal(stats.presenterQueueDepth, 0);157+ assert.equal(stats.presenterQueueCapacity, 2);138158 });139159 140160 test('unsupported codecs are dropped before decoder construction', () => {@@ -218,15 +238,182 @@ test('JPEG fallback keeps its asynchronous base64 draw behavior', () => {
218238 assert.deepEqual(subject.formats, [subject.pipeline.format]);219239 assert.equal(subject.canvas.width, 640);220240 assert.equal(subject.canvas.height, 400);221- assert.equal(subject.images[0].src, 'blob:test-frame');241+ assert.equal(subject.images[0].src, 'blob:test-frame-1');222242 assert.deepEqual(subject.draws, []);223243 assert.deepEqual(subject.revokedUrls, []);244+ assert.equal(subject.pipeline.snapshot().presenterQueueDepth, 1);245+ assert.equal(subject.pipeline.snapshot().presenterQueueCapacity, 2);224246 225247 subject.advance();226248 subject.images[0].onload();227249 assert.equal(subject.draws.length, 1);228- assert.deepEqual(subject.revokedUrls, ['blob:test-frame']);250+ assert.deepEqual(subject.revokedUrls, ['blob:test-frame-1']);229251 assert.equal(subject.pipeline.snapshot().presentedFrames, 1);252+ assert.equal(subject.pipeline.snapshot().presenterQueueDepth, 0);253+ assert.equal(subject.pipeline.snapshot().presenterQueueCapacity, 2);254+});255+256+test('JPEG fallback suppresses an active stale decode before starting the newest frame', () => {257+ const subject = harness({ hasWebCodecs: false });258+ const payload = {259+ width: 640,260+ height: 400,261+ data: 'AQID',262+ keyframe: true,263+ };264+ subject.pipeline.processFramePayload(payload);265+ const staleLoad = subject.images[0].onload;266+ const staleError = subject.images[0].onerror;267+268+ subject.advance(10);269+ subject.pipeline.processFramePayload(payload);270+ assert.equal(subject.images.length, 1);271+ assert.deepEqual(subject.revokedUrls, []);272+ const afterOverwrite = subject.pipeline.snapshot();273+ assert.equal(afterOverwrite.presentationDroppedFrames, 1);274+ assert.equal(afterOverwrite.presenterQueueDepth, 2);275+ assert.equal(afterOverwrite.presenterQueueCapacity, 2);276+277+ subject.advance(5);278+ staleLoad();279+ assert.deepEqual(subject.draws, []);280+ assert.deepEqual(subject.revokedUrls, ['blob:test-frame-1']);281+ assert.equal(subject.images.length, 2);282+ assert.equal(subject.images[1].src, 'blob:test-frame-2');283+ const newestLoad = subject.images[1].onload;284+ assert.equal(subject.pipeline.snapshot().presenterQueueDepth, 1);285+286+ subject.advance(5);287+ newestLoad();288+ staleError(new Error('stale JPEG failure'));289+ const afterNewest = subject.pipeline.snapshot();290+ assert.deepEqual(subject.draws.map(([image]) => image), [subject.images[1]]);291+ assert.equal(afterNewest.presentedFrames, 1);292+ assert.equal(afterNewest.presentPercentiles.count, 1);293+ assert.equal(afterNewest.presentPercentiles.p50, 10);294+ assert.equal(afterNewest.presentationDroppedFrames, 1);295+ assert.equal(afterNewest.droppedFrames, 0);296+ assert.equal(afterNewest.presenterQueueDepth, 0);297+ assert.equal(afterNewest.presenterQueueCapacity, 2);298+ assert.deepEqual(subject.revokedUrls, [299+ 'blob:test-frame-1',300+ 'blob:test-frame-2',301+ ]);302+});303+304+test('JPEG fallback error callbacks affect only the current pending frame', () => {305+ const subject = harness({ hasWebCodecs: false });306+ const payload = {307+ width: 640,308+ height: 400,309+ data: 'AQID',310+ keyframe: true,311+ };312+ subject.pipeline.processFramePayload(payload);313+ const staleError = subject.images[0].onerror;314+315+ subject.pipeline.processFramePayload(payload);316+ staleError(new Error('stale JPEG failure'));317+ assert.equal(subject.pipeline.snapshot().droppedFrames, 0);318+ assert.equal(subject.images.length, 2);319+ const failedLoad = subject.images[1].onload;320+ const currentError = subject.images[1].onerror;321+322+ currentError(new Error('current JPEG failure'));323+ failedLoad();324+ const afterFailure = subject.pipeline.snapshot();325+ assert.equal(afterFailure.droppedFrames, 1);326+ assert.equal(afterFailure.presentedFrames, 0);327+ assert.equal(afterFailure.presentationDroppedFrames, 1);328+ assert.equal(afterFailure.presenterQueueDepth, 0);329+ assert.equal(afterFailure.presenterQueueCapacity, 2);330+ assert.deepEqual(subject.draws, []);331+ assert.deepEqual(subject.revokedUrls, [332+ 'blob:test-frame-1',333+ 'blob:test-frame-2',334+ ]);335+});336+337+test('JPEG fallback callbacks cannot survive a session reset', () => {338+ const subject = harness({ hasWebCodecs: false });339+ subject.pipeline.processFramePayload({340+ width: 640,341+ height: 400,342+ data: 'AQID',343+ keyframe: true,344+ });345+ const staleLoad = subject.images[0].onload;346+ const staleError = subject.images[0].onerror;347+348+ subject.pipeline.reset();349+ subject.advance();350+ staleLoad();351+ staleError(new Error('reset JPEG failure'));352+353+ const stats = subject.pipeline.snapshot();354+ assert.equal(stats.receivedFrames, 0);355+ assert.equal(stats.presentedFrames, 0);356+ assert.equal(stats.droppedFrames, 0);357+ assert.equal(stats.presentPercentiles.count, 0);358+ assert.equal(stats.presenterQueueDepth, 0);359+ assert.equal(stats.presenterQueueCapacity, 2);360+ assert.deepEqual(subject.draws, []);361+ assert.deepEqual(subject.revokedUrls, ['blob:test-frame-1']);362+ assert.deepEqual(subject.abortedImages, [subject.images[0]]);363+});364+365+test('JPEG fallback bounds delayed decoding to one active and one coalesced latest payload', () => {366+ const subject = harness({ hasWebCodecs: false });367+ const payload = {368+ width: 640,369+ height: 400,370+ data: 'AQID',371+ keyframe: true,372+ };373+ subject.pipeline.processFramePayload(payload);374+ const oldestLoad = subject.images[0].onload;375+376+ for (let frame = 2; frame <= 10; frame++) {377+ subject.advance();378+ subject.pipeline.processFramePayload(payload);379+ }380+381+ assert.equal(subject.images.length, 1);382+ assert.deepEqual(subject.imageStarts, [{383+ image: subject.images[0],384+ value: 'blob:test-frame-1',385+ }]);386+ assert.deepEqual(subject.revokedUrls, []);387+ let stats = subject.pipeline.snapshot();388+ assert.equal(stats.presentationDroppedFrames, 9);389+ assert.equal(stats.presenterQueueDepth, 2);390+ assert.equal(stats.presenterQueueCapacity, 2);391+392+ subject.advance();393+ oldestLoad();394+ assert.equal(subject.images.length, 2);395+ assert.deepEqual(subject.imageStarts, [396+ { image: subject.images[0], value: 'blob:test-frame-1' },397+ { image: subject.images[1], value: 'blob:test-frame-2' },398+ ]);399+ assert.deepEqual(subject.revokedUrls, ['blob:test-frame-1']);400+ stats = subject.pipeline.snapshot();401+ assert.equal(stats.presenterQueueDepth, 1);402+ assert.equal(stats.presenterQueueCapacity, 2);403+404+ subject.advance();405+ subject.images[1].onload();406+ stats = subject.pipeline.snapshot();407+ assert.deepEqual(subject.draws.map(([image]) => image), [subject.images[1]]);408+ assert.equal(stats.presentedFrames, 1);409+ assert.equal(stats.presentPercentiles.p50, 2);410+ assert.equal(stats.presentationDroppedFrames, 9);411+ assert.equal(stats.presenterQueueDepth, 0);412+ assert.equal(stats.presenterQueueCapacity, 2);413+ assert.deepEqual(subject.revokedUrls, [414+ 'blob:test-frame-1',415+ 'blob:test-frame-2',416+ ]);230417 });231418 232419 test('reset closes decoding state but preserves the last dimensions and codec quirk', () => {@@ -131,6 +131,22 @@ cargo test --locked -p portal --release \
131131 commands::state::tests::ordinary_release_excludes_direct_node_bypass \132132 -- --exact133133 134+cargo test --locked -p portal --release \135+ commands::state::tests::rejects_force_jpeg_when_debug_mode_is_disabled \136+ -- --exact137+138+cargo test --locked -p portal --release \139+ commands::state::tests::ordinary_release_excludes_force_jpeg_override \140+ -- --exact141+142+cargo test --locked -p portal --release \143+ commands::state::tests::app_state_accepts_force_jpeg_only_in_debug_builds \144+ -- --exact145+146+cargo test --locked -p portal --release --features demo-direct-node \147+ commands::state::tests::app_state_accepts_force_jpeg_only_in_debug_builds \148+ -- --exact149+134150 cargo test --locked -p portal --release --features demo-direct-node \135151 commands::state::tests::app_state_accepts_direct_node_only_in_debug_builds \136152 -- --exact@@ -282,12 +282,21 @@ pub const fn development_direct_node_available() -> bool {
282282 cfg!(any(debug_assertions, feature = "demo-direct-node"))283283 }284284 285+pub const fn development_force_jpeg_available() -> bool {286+ cfg!(debug_assertions)287+}288+285289 286290 pub struct LaunchOptions {287291 pub dev_connect_node_id: Option<iroh::PublicKey>,292+ pub dev_force_jpeg: bool,288293 }289294 290-pub fn parse_launch_options<I, S>(args: I, allow_dev_connect: bool) -> Result<LaunchOptions, String>295+pub fn parse_launch_options<I, S>(296+ args: I,297+ allow_dev_connect: bool,298+ allow_force_jpeg: bool,299+) -> Result<LaunchOptions, String>291300 where292301 I: IntoIterator<Item = S>,293302 S: Into<OsString>,@@ -309,6 +318,19 @@ where
309318 let arg = arg310319 .into_string()311320 .map_err(|_| "Portal command-line arguments must be valid UTF-8".to_string())?;321+ if arg == "--dev-force-jpeg" {322+ if !allow_force_jpeg {323+ return Err(324+ "--dev-force-jpeg requires a debug build; ordinary release builds use the normal WebCodecs capability probe"325+ .to_string(),326+ );327+ }328+ if options.dev_force_jpeg {329+ return Err("--dev-force-jpeg may be specified only once".to_string());330+ }331+ options.dev_force_jpeg = true;332+ continue;333+ }312334 let node_id = if arg == "--dev-connect" {313335 Some(314336 args.next()@@ -362,6 +384,7 @@ pub struct AppState {
362384 pub client_connection_active: Arc<AtomicBool>,363385 pub webcodecs: AtomicBool,364386 pub dev_connect_node_id: Option<iroh::PublicKey>,387+ pub dev_force_jpeg: bool,365388 }366389 367390 pub type MediaControlRequestSender =@@ -463,7 +486,12 @@ impl AppState {
463486 I: IntoIterator<Item = S>,464487 S: Into<OsString>,465488 {466- parse_launch_options(args, development_direct_node_available()).map(Self::new)489+ parse_launch_options(490+ args,491+ development_direct_node_available(),492+ development_force_jpeg_available(),493+ )494+ .map(Self::new)467495 }468496 469497 fn new(options: LaunchOptions) -> Self {@@ -484,6 +512,7 @@ impl AppState {
484512 client_connection_active: Arc::new(AtomicBool::new(false)),485513 webcodecs: AtomicBool::new(false),486514 dev_connect_node_id: options.dev_connect_node_id,515+ dev_force_jpeg: options.dev_force_jpeg,487516 }488517 }489518 }@@ -495,6 +524,8 @@ pub struct DevelopmentConnectionMode {
495524 pub enabled: bool,496525 pub host_node_id: Option<String>,497526 pub warning: &'static str,527+ pub force_jpeg: bool,528+ pub jpeg_warning: &'static str,498529 }499530 500531 @@ -503,6 +534,8 @@ pub fn development_connection_mode(state: tauri::State<'_, AppState>) -> Develop
503534 enabled: state.dev_connect_node_id.is_some(),504535 host_node_id: state.dev_connect_node_id.map(|node_id| node_id.to_string()),505536 warning: "Development direct-node routing skips passkey identity lookup; it is not client authorization.",537+ force_jpeg: state.dev_force_jpeg,538+ jpeg_warning: "Development JPEG compatibility mode is forced; WebCodecs remains probed but is not selected.",506539 }507540 }508541 @@ -587,9 +620,15 @@ pub fn set_client_cursor_grab(window: tauri::WebviewWindow, grab: bool) -> Resul
587620 .map(|()| !cfg!(target_os = "macos"))588621 }589622 623+const fn effective_webcodecs_available(available: bool, force_jpeg: bool) -> bool {624+ available && !force_jpeg625+}626+590627 591-pub fn set_webcodecs_available(state: tauri::State<'_, AppState>, available: bool) {592- state.webcodecs.store(available, Ordering::SeqCst);628+pub fn set_webcodecs_available(state: tauri::State<'_, AppState>, available: bool) -> bool {629+ let effective = effective_webcodecs_available(available, state.dev_force_jpeg);630+ state.webcodecs.store(effective, Ordering::SeqCst);631+ effective593632 }594633 595634 @@ -711,14 +750,41 @@ mod tests {
711750 712751 fn parses_debug_direct_node_flag() {713752 let node_id = test_node_id();714- let options = parse_launch_options(["portal", "--dev-connect", &node_id], true).unwrap();753+ let options =754+ parse_launch_options(["portal", "--dev-connect", &node_id], true, true).unwrap();715755 716756 assert_eq!(options.dev_connect_node_id.unwrap().to_string(), node_id);717757 }718758 759+ 760+ fn parses_debug_force_jpeg_flag() {761+ let options = parse_launch_options(["portal", "--dev-force-jpeg"], true, true).unwrap();762+763+ assert!(options.dev_force_jpeg);764+ }765+766+ 767+ fn parses_combined_debug_hardware_proof_flags() {768+ let node_id = test_node_id();769+ let options = parse_launch_options(770+ [771+ "portal",772+ "--dev-connect",773+ node_id.as_str(),774+ "--dev-force-jpeg",775+ ],776+ true,777+ true,778+ )779+ .unwrap();780+781+ assert_eq!(options.dev_connect_node_id.unwrap().to_string(), node_id);782+ assert!(options.dev_force_jpeg);783+ }784+719785 720786 fn rejects_legacy_daemon_mode() {721- let error = parse_launch_options(["portal", "--daemon"], true).unwrap_err();787+ let error = parse_launch_options(["portal", "--daemon"], true, true).unwrap_err();722788 assert!(error.contains("separate sigil executable"));723789 }724790 @@ -726,7 +792,7 @@ mod tests {
726792 fn parses_equals_form() {727793 let node_id = test_node_id();728794 let flag = format!("--dev-connect={node_id}");729- let options = parse_launch_options(["portal", &flag], true).unwrap();795+ let options = parse_launch_options(["portal", &flag], true, true).unwrap();730796 731797 assert_eq!(options.dev_connect_node_id.unwrap().to_string(), node_id);732798 }@@ -734,11 +800,19 @@ mod tests {
734800 735801 fn rejects_direct_node_when_debug_mode_is_disabled() {736802 let node_id = test_node_id();737- let error = parse_launch_options(["portal", "--dev-connect", &node_id], false).unwrap_err();803+ let error =804+ parse_launch_options(["portal", "--dev-connect", &node_id], false, true).unwrap_err();738805 739806 assert!(error.contains("debug build or the explicit demo-direct-node feature"));740807 }741808 809+ 810+ fn rejects_force_jpeg_when_debug_mode_is_disabled() {811+ let error = parse_launch_options(["portal", "--dev-force-jpeg"], true, false).unwrap_err();812+813+ assert!(error.contains("requires a debug build"));814+ }815+742816 743817 fn ordinary_release_excludes_direct_node_bypass() {744818 if cfg!(not(debug_assertions)) && cfg!(not(feature = "demo-direct-node")) {@@ -754,6 +828,28 @@ mod tests {
754828 assert_eq!(result.is_ok(), development_direct_node_available());755829 }756830 831+ 832+ fn ordinary_release_excludes_force_jpeg_override() {833+ if cfg!(not(debug_assertions)) {834+ assert!(!development_force_jpeg_available());835+ }836+ }837+838+ 839+ fn app_state_accepts_force_jpeg_only_in_debug_builds() {840+ let result = AppState::from_args(["portal", "--dev-force-jpeg"]);841+842+ assert_eq!(result.is_ok(), development_force_jpeg_available());843+ }844+845+ 846+ fn forced_jpeg_publication_keeps_native_and_frontend_mode_aligned() {847+ assert!(effective_webcodecs_available(true, false));848+ assert!(!effective_webcodecs_available(true, true));849+ assert!(!effective_webcodecs_available(false, false));850+ assert!(!effective_webcodecs_available(false, true));851+ }852+757853 758854 fn app_state_starts_without_owned_transport_connections() {759855 let state = AppState::default();@@ -768,12 +864,12 @@ mod tests {
768864 769865 fn rejects_missing_invalid_and_duplicate_node_ids() {770866 assert!(771- parse_launch_options(["portal", "--dev-connect"], true)867+ parse_launch_options(["portal", "--dev-connect"], true, true)772868 .unwrap_err()773869 .contains("requires an iroh node ID")774870 );775871 assert!(776- parse_launch_options(["portal", "--dev-connect", "not-a-node-id"], true)872+ parse_launch_options(["portal", "--dev-connect", "not-a-node-id"], true, true)777873 .unwrap_err()778874 .contains("Invalid iroh node ID")779875 );@@ -789,6 +885,16 @@ mod tests {
789885 &node_id,790886 ],791887 true,888+ true,889+ )890+ .unwrap_err()891+ .contains("only once")892+ );893+ assert!(894+ parse_launch_options(895+ ["portal", "--dev-force-jpeg", "--dev-force-jpeg"],896+ true,897+ true,792898 )793899 .unwrap_err()794900 .contains("only once")