Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 96 additions & 12 deletions crates/compositor/src/live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ fn webcam_seek_time(screen_source_time_sec: f64, webcam_offset_sec: f64) -> f64
struct PrefetchedClip {
sdec: Decoder,
wdec: Decoder,
/// `wdec` est-il la VRAIE caméra, ou le remplaçant écran (pas de caméra déclarée, ou
/// déclarée et illisible) ? Voyage avec la paire de décodeurs parce que c'est d'ELLE que
/// la réponse dépend, pas de la scène : deux clips de la même timeline peuvent avoir l'un
/// une caméra qui s'ouvre et l'autre un fichier mort, et le pool (`PooledClip`) réactive
/// des paires ouvertes plusieurs bascules plus tôt. Cf. `open_webcam_or_stand_in`.
webcam_decoder_is_real: bool,
webcam_offset_sec: f64,
idx: u32,
/// Piste curseur du clip à venir, préchargée ici pour la même raison que les décodeurs :
Expand Down Expand Up @@ -89,8 +95,15 @@ struct PrefetchedClip {
/// valide plutôt qu'un `Option` à dérouler sur tout le chemin chaud, et rien ne le dessine
/// puisque la composition ne pose une vignette que si le document déclare une caméra.
///
/// Avec une caméra déclarée dont le fichier ne s'ouvre pas, c'est l'inverse : la vignette
/// EST dessinée et affiche l'écran. Ce cas-là méritait une trace, et n'en avait aucune.
/// Avec une caméra déclarée dont le fichier ne s'ouvre pas, le chemin, lui, reste parfaitement
/// plausible : `webcam_is_real` répond vrai, la vignette était donc dessinée — sur le
/// remplaçant, c'est-à-dire l'enregistrement d'écran dupliqué dans son propre coin. Vu en vrai
/// avec un `.mp4` webcam de 0 octet, laissé non finalisé par le helper de capture natif.
///
/// D'où le `bool` rendu à côté du décodeur : « ce que je te rends est-il VRAIMENT la caméra ? ».
/// Seule l'ouverture peut répondre — aucune inspection du chemin ne sait qu'un fichier est mort
/// — et c'est cette réponse, et pas le chemin, qui décide de dessiner la vignette (voir
/// `should_draw_webcam`).
///
/// ponytail: on garde le remplaçant plutôt que de passer `wdec` en `Option<Decoder>`, ce qui
/// toucherait 22 sites dont le pool de décodeurs et la boucle de composition `unsafe`. À faire
Expand All @@ -100,21 +113,35 @@ unsafe fn open_webcam_or_stand_in(
screen_path: &str,
webcam_path: &str,
gpu: &Gpu,
) -> Result<Decoder> {
) -> Result<(Decoder, bool)> {
if !webcam_is_real(webcam_path, screen_path) {
return Decoder::open(screen_path, gpu);
return Ok((Decoder::open(screen_path, gpu)?, false));
}
match Decoder::open(webcam_path, gpu) {
Ok(d) => Ok(d),
Ok(d) => Ok((d, true)),
Err(e) => {
eprintln!(
"WARNING: caméra déclarée mais illisible ({webcam_path}) : {e}. La vignette caméra affichera l'enregistrement d'écran ; le média est à relier."
"WARNING: caméra déclarée mais illisible ({webcam_path}) : {e}. La vignette caméra ne sera pas dessinée ; le média est à relier."
);
Decoder::open(screen_path, gpu)
Ok((Decoder::open(screen_path, gpu)?, false))
}
}
}

/// Faut-il dessiner la vignette caméra ? Il faut les DEUX moitiés :
///
/// - le document déclare une caméra — `webcam_is_real`, un test de chemins ;
/// - et son décodeur s'est vraiment ouvert — `decoder_is_real`, ce que seul
/// `open_webcam_or_stand_in` sait, transporté jusqu'ici par `PrefetchedClip`/`Player`.
///
/// Le test de chemins seul ne suffit pas : il a répondu « vraie caméra » pour un `.mp4` webcam
/// de 0 octet (fichier non finalisé par le helper de capture), la vignette a été dessinée, et
/// le décodeur derrière elle était le remplaçant écran — l'utilisateur voyait son propre
/// enregistrement d'écran répliqué dans le petit rectangle caméra.
fn should_draw_webcam(webcam_path: &str, screen_path: &str, decoder_is_real: bool) -> bool {
webcam_is_real(webcam_path, screen_path) && decoder_is_real
}

unsafe fn open_and_seek_clip(
screen_path: &str,
webcam_path: &str,
Expand All @@ -124,7 +151,7 @@ unsafe fn open_and_seek_clip(
) -> Result<PrefetchedClip> {
let source_time_sec = source_time_sec.max(0.0);
let mut sdec = Decoder::open(screen_path, gpu)?;
let mut wdec = open_webcam_or_stand_in(screen_path, webcam_path, gpu)?;
let (mut wdec, webcam_decoder_is_real) = open_webcam_or_stand_in(screen_path, webcam_path, gpu)?;
let sf = sdec.seek_to(source_time_sec)?;
let mut wf = wdec.seek_to(webcam_seek_time(source_time_sec, webcam_offset_sec))?;
if wf.is_null() {
Expand All @@ -135,7 +162,7 @@ unsafe fn open_and_seek_clip(
}
let idx = (source_time_sec * sdec.fps()).round().max(0.0) as u32;
let cursor_track = CursorTrack::load(&format!("{screen_path}.cursor.json"), 0.0, 24.0 * 3600.0).ok();
Ok(PrefetchedClip { sdec, wdec, webcam_offset_sec, idx, cursor_track })
Ok(PrefetchedClip { sdec, wdec, webcam_decoder_is_real, webcam_offset_sec, idx, cursor_track })
}

/// Nombre de paires de décodeurs INACTIVES gardées ouvertes en plus de la paire active.
Expand Down Expand Up @@ -253,6 +280,10 @@ pub struct Player {
sdec: Decoder,
wdec: Decoder,
gpu: Gpu,
/// Même question que `PrefetchedClip::webcam_decoder_is_real`, pour la paire ACTIVE :
/// `wdec` est-il la caméra ou le remplaçant écran ? Mis à jour à chaque bascule de clip
/// (`swap_active`), lu par la boucle de rendu pour décider de dessiner la vignette.
webcam_decoder_is_real: bool,
webcam_offset_sec: f64,
has_current_frame: bool,
use_current_on_next_step: bool,
Expand All @@ -261,7 +292,7 @@ pub struct Player {

impl Player {
pub unsafe fn open(screen: &str, webcam: &str, gpu: &Gpu) -> Result<Player> {
let wdec = open_webcam_or_stand_in(screen, webcam, gpu)?;
let (wdec, webcam_decoder_is_real) = open_webcam_or_stand_in(screen, webcam, gpu)?;
Ok(Player {
sdec: Decoder::open(screen, gpu)?,
wdec,
Expand All @@ -271,6 +302,7 @@ impl Player {
feature_level: gpu.feature_level,
backend: gpu.backend,
},
webcam_decoder_is_real,
webcam_offset_sec: 0.0,
has_current_frame: false,
use_current_on_next_step: false,
Expand Down Expand Up @@ -348,11 +380,16 @@ impl Player {
let outgoing = PrefetchedClip {
sdec: std::mem::replace(&mut self.sdec, incoming.sdec),
wdec: std::mem::replace(&mut self.wdec, incoming.wdec),
// Suit son décodeur dans les deux sens : la paire sortante emporte sa réponse vers
// le pool (elle sera réactivée sans réouverture, donc sans personne pour la
// recalculer), l'entrante impose la sienne au player.
webcam_decoder_is_real: self.webcam_decoder_is_real,
webcam_offset_sec: self.webcam_offset_sec,
idx: self.idx,
// Le curseur est re-dérivé du chemin à la réactivation ; inutile de le trimballer.
cursor_track: None,
};
self.webcam_decoder_is_real = incoming.webcam_decoder_is_real;
self.webcam_offset_sec = incoming.webcam_offset_sec;
self.idx = incoming.idx;
self.has_current_frame = true;
Expand All @@ -372,6 +409,14 @@ impl Player {
open_and_seek_clip(screen, webcam, webcam_offset_sec, source_time_sec, &self.gpu)
}

/// Le décodeur webcam ACTIF est-il la vraie caméra ? `false` quand c'est le remplaçant
/// écran — aucune caméra déclarée, ou une caméra déclarée dont le fichier ne s'ouvre pas.
/// La boucle de rendu en a besoin parce que la seule autre source d'information dont elle
/// dispose, le chemin webcam du clip, ment dans le second cas (cf. `should_draw_webcam`).
pub fn webcam_decoder_is_real(&self) -> bool {
self.webcam_decoder_is_real
}

/// Temps source courant du décodeur écran — utilisé par `render_thread` pour détecter le
/// franchissement de la fin de fenêtre du clip actif pendant la lecture libre, et pour
/// calculer la cible de `step` en lecture libre. `pub` (pas `pub(crate)`) : le harnais
Expand Down Expand Up @@ -1424,8 +1469,16 @@ unsafe fn render_thread(
cfg.mblur_n = ip.mblur_taps;
cfg.cursor = ip.cursor_show;
// A clip with no camera must not draw the PiP box — the decoder behind it is the
// screen video, so drawing it duplicates the recording into its own corner.
let has_real_webcam = webcam_is_real(&active_webcam_path, &active_screen_path);
// screen video, so drawing it duplicates the recording into its own corner. The
// paths alone cannot answer that: a declared camera whose file will not open (the
// 0-byte MP4 an unfinalized capture leaves behind) keeps a perfectly plausible
// path, and the decoder behind its box is the screen fallback just the same. So
// ask the player what it actually opened.
let has_real_webcam = should_draw_webcam(
&active_webcam_path,
&active_screen_path,
player.webcam_decoder_is_real(),
);
comp.set_live_params(LiveParams {
bg_color: ip.bg_color,
shadow_scale: ip.shadow_scale,
Expand Down Expand Up @@ -1930,6 +1983,37 @@ mod tests {
assert!(webcam_is_real("/rec/recording-1-webcam.webm", "/rec/recording-1.mp4"));
}

#[test]
fn a_camera_whose_file_would_not_open_draws_nothing() {
// Reproduced on a real machine: the native capture helper left a 0-byte webcam
// MP4, so `Decoder::open` failed and the webcam decoder fell back to the SCREEN
// file — while the path stayed as plausible as any other, which is why the string
// test still says "real camera" on the very same input.
assert!(webcam_is_real("/rec/recording-1-webcam.mp4", "/rec/recording-1.mp4"));
assert!(!should_draw_webcam(
"/rec/recording-1-webcam.mp4",
"/rec/recording-1.mp4",
false,
));
}

#[test]
fn a_camera_that_did_open_is_drawn() {
assert!(should_draw_webcam(
"/rec/recording-1-webcam.webm",
"/rec/recording-1.mp4",
true,
));
}

#[test]
fn a_clip_without_a_camera_draws_nothing_however_well_its_decoder_opened() {
// The stand-in decoder always opens — it IS the screen file — so the path test
// remains the half of the answer that catches "this clip has no camera at all".
assert!(!should_draw_webcam("", "/rec/recording-1.mp4", true));
assert!(!should_draw_webcam("/rec/recording-1.mp4", "/rec/recording-1.mp4", true));
}

// --- transport handed to an export and back -------------------------------
// A real `LiveView` needs a D3D device and a decoder; the transport is the only
// part an export touches, so these exercise it through `PreviewTransport` alone.
Expand Down
5 changes: 5 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,11 @@ interface Window {
message?: string;
discarded?: boolean;
error?: string;
/**
* A camera was recorded but produced nothing usable, so the session was
* saved without it. Still a success — the screen video is intact.
*/
webcamDropped?: boolean;
}>;
pauseNativeWindowsRecording: () => Promise<{
success: boolean;
Expand Down
66 changes: 44 additions & 22 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ import { toHelperRect } from "../native-bridge/helperCoordinates";
import {
isSalvageableFragmentedCapture,
NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES,
readWebcamFormat,
readWebcamUnavailable,
terminateNativeWindowsCapture,
waitForNativeWindowsCaptureStop,
} from "../recording/nativeWindowsCaptureStop";
Expand Down Expand Up @@ -1324,25 +1326,6 @@ function sendNativeWindowsStopCommand(proc: ChildProcessWithoutNullStreams) {
return true;
}

function readNativeWindowsWebcamFormat(output: string) {
const lines = output.split(/\r?\n/).filter((line) => line.includes('"event":"webcam-format"'));
const lastLine = lines.at(-1);
if (!lastLine) {
return null;
}

try {
return JSON.parse(lastLine) as {
width?: number;
height?: number;
fps?: number;
deviceName?: string;
};
} catch {
return null;
}
}

function readNativeWindowsEncoderSelection(output: string) {
const lines = output
.split(/\r?\n/)
Expand Down Expand Up @@ -2449,7 +2432,7 @@ export function registerIpcHandlers(
cursorCaptureMode === "editable-overlay"
? Math.max(0, captureStartedAtMs - cursorStartTimeMs)
: 0;
const webcamFormat = readNativeWindowsWebcamFormat(nativeWindowsCaptureOutput);
const webcamFormat = readWebcamFormat(nativeWindowsCaptureOutput);
const encoderSelection = readNativeWindowsEncoderSelection(nativeWindowsCaptureOutput);
// Captured now because stop may have no helper left to ask. A helper
// killed mid-recording is exactly the case where this matters most.
Expand All @@ -2466,12 +2449,30 @@ export function registerIpcHandlers(
onRecordingStateChange(true, source.name);
}

// Reported at start, not at stop: the helper decides the camera is a
// lost cause during its own init — before it announces "Recording
// started", so the warning is already in the buffer here — and telling
// the user now, while the take is still worth restarting, beats telling
// them at the end. Keyed on the helper's own event rather than on a
// missing `webcamFormat`: absence of the format line also means "the
// line could not be parsed", which would put a red toast on a recording
// whose camera is working perfectly.
const webcamUnavailable =
request.webcam.enabled && readWebcamUnavailable(nativeWindowsCaptureOutput);
if (webcamUnavailable) {
console.warn("[native-wgc] recording without a camera; the helper could not open it", {
deviceId: request.webcam.deviceId,
deviceName: request.webcam.deviceName,
});
}

return {
success: true,
recordingId,
path: outputPath,
helperPath,
videoEncoderSelection: encoderSelection?.video ?? null,
webcamUnavailable,
};
} catch (error) {
console.error("Failed to start native Windows recording:", error);
Expand Down Expand Up @@ -2863,8 +2864,21 @@ export function registerIpcHandlers(
let webcamVideoPath: string | undefined;
if (preferredWebcamPath) {
try {
await fs.access(preferredWebcamPath, fsConstants.R_OK);
webcamVideoPath = preferredWebcamPath;
// Size, not just existence. A camera that opened but delivered no
// frame still gets a file created for it, and its `Finalize()` then
// fails, leaving nought bytes on disk. Admitting that file put a
// camera track in the document pointing at something no demuxer can
// read, and the preview compositor answers an unreadable camera by
// drawing the SCREEN recording inside the little camera rectangle —
// which is how a webcam that never recorded showed up as the desktop
// duplicated into its own corner (getopenscreen/openscreen#387).
const webcamStat = await fs.stat(preferredWebcamPath);
webcamVideoPath = webcamStat.size > 0 ? preferredWebcamPath : undefined;
if (!webcamVideoPath) {
console.warn("[native-wgc] the webcam file is empty; saving without a camera", {
path: preferredWebcamPath,
});
}
} catch {
webcamVideoPath = undefined;
}
Expand All @@ -2887,6 +2901,14 @@ export function registerIpcHandlers(
path: screenVideoPath,
session,
recovered,
// `preferredWebcamPath` is non-null only for a take that asked for a
// camera, so the pair means "a camera was requested and none survived".
// This is the second, quieter way to lose one: the helper opened the
// device happily and then never got a frame out of it, so it reports no
// `webcam-unavailable` and the start-time notice stays silent. Left
// unreported, the user would find out in the editor — which is exactly
// the silence this change exists to end.
webcamDropped: Boolean(preferredWebcamPath) && !webcamVideoPath,
message: recovered
? "Native Windows recording recovered from a failed stop"
: "Native Windows recording session stored successfully",
Expand Down
Loading
Loading