cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

Merge pull request #116 from FelineStateMachine/feat/jpeg-latest-frame-wins

FelineStateMachine committed 0c14205
fix(portal): make JPEG fallback latest-frame-wins
commit
0c14205277dfde98ff0cd16c2a7958506b9ec9cc
parent
35b7ce0b1eba2b93711559a9c005ce77a37aa99a
parent
5c7f4372067b3f99dc38c46563b07a37a02caa01
author
FelineStateMachine <dami.etoile@gmail.com>
10 files changed, +523 -33
Mportal/enrollment-ui.test.mjs7+
Mportal/index.html1+
Mportal/main.js6+
Mportal/stream-runtime.test.mjs27++++
Mportal/video-capability.mjs10+-
Mportal/video-capability.test.mjs34+++++-
Mportal/video-pipeline.mjs134++++++++++++++++++--
Mportal/video-pipeline.test.mjs195+++++++++++++++++++++++++++++-
Mscripts/verify-demo-build.sh16++
Msrc-tauri/src/commands/state.rs126++++++++++++++++++--
M portal/enrollment-ui.test.mjs +7 -0
M portal/index.html +1 -0
M portal/main.js +6 -0
M portal/stream-runtime.test.mjs +27 -0
M portal/video-capability.mjs +8 -2
M portal/video-capability.test.mjs +33 -1
M portal/video-pipeline.mjs +118 -16
@@ -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 }
M portal/video-pipeline.test.mjs +191 -4
@@ -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', () => {
M scripts/verify-demo-build.sh +16 -0
M src-tauri/src/commands/state.rs +116 -10
@@ -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 #[derive(Clone, Debug, Default, PartialEq, Eq)]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 #[tauri::command]
@@ -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 #[tauri::command]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 #[tauri::command]
@@ -711,14 +750,41 @@ mod tests {
711750 #[test]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+ #[test]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+ #[test]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 #[test]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 #[test]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+ #[test]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 #[test]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+ #[test]832+ fn ordinary_release_excludes_force_jpeg_override() {833+ if cfg!(not(debug_assertions)) {834+ assert!(!development_force_jpeg_available());835+ }836+ }837+838+ #[test]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+ #[test]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 #[test]758854 fn app_state_starts_without_owned_transport_connections() {759855 let state = AppState::default();
@@ -768,12 +864,12 @@ mod tests {
768864 #[test]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")