diff --git a/Services/Encoder/FfmpegEncoder.cs b/Services/Encoder/FfmpegEncoder.cs index 03dbee4..7352df8 100644 --- a/Services/Encoder/FfmpegEncoder.cs +++ b/Services/Encoder/FfmpegEncoder.cs @@ -230,14 +230,35 @@ public sealed class FfmpegEncoder : IFfmpegEncoder OnStderrLine(process, line); } - var code = process.ExitCode; - var stillRunning = false; - lock (_gate) stillRunning = IsRunning; - if (stillRunning && !_stopRequested && code != 0) + // Read the exit code defensively: the stop path disposes the process + // while this loop may still be waking (2026-09-01 "No process is + // associated with this object" — a cosmetic race that poisoned the log). + int exitCode; + try { - AppLog.Write($"FFmpeg encoder: subprocess exited unexpectedly ({code})"); - _health.LastError = $"FFmpeg exited with code {code}"; - ProcessFailed?.Invoke(this, $"FFmpeg exited with code {code}"); + exitCode = process.ExitCode; + } + catch (Exception) + { + exitCode = -1; + } + + var stillRunning = false; + var stopping = false; + lock (_gate) + { + stillRunning = IsRunning; + stopping = _stopRequested; + } + if (stillRunning && !stopping) + { + // ANY exit we did not ask for is a failure — including code 0. + // The old `code != 0` gate let ffmpeg's clean early exit vanish + // without a word while the pump kept feeding a corpse + // (2026-09-01 first real recording: died at ~0.9s, zero logs). + AppLog.Write($"FFmpeg encoder: subprocess exited unexpectedly (code {exitCode})"); + _health.LastError = $"FFmpeg exited with code {exitCode}"; + ProcessFailed?.Invoke(this, $"FFmpeg exited with code {exitCode}"); } } catch (Exception ex) @@ -250,7 +271,17 @@ public sealed class FfmpegEncoder : IFfmpegEncoder private void OnStderrLine(IEncoderProcess process, string line) { var progress = FfmpegProgressParser.TryParse(line); - if (progress == null) return; + if (progress == null) + { + // THE EYES: everything ffmpeg says that isn't a progress line — startup + // config, warnings, the real reason it quit. Was silently discarded + // (2026-09-01: encoder died at ~0.9s, log silent). Truncated so a + // \r-choked stats blob can't flood; noise now beats blindness. + var text = line.Trim('\r', ' ', '\t'); + if (text.Length > 0) + AppLog.Write($"ffmpeg: {(text.Length > 400 ? text[..400] + "…" : text)}"); + return; + } var dropped = Math.Max(0, (long)Math.Round(progress.Value.Fps * progress.Value.Duration.TotalSeconds) - progress.Value.Frame); diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index f1ed14f..bb0664d 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -459,6 +459,26 @@ public partial class MainViewModel : ViewModelBase // preview: webcam by DeviceId, live captures (the background) by CaptureKey, // images/background by AssetId. A null frame leaves the element transparent. private VideoFrame? ResolveOutputFrame(SceneElement element) + { + var frame = ResolveOutputFrameCore(element); + // Output-side eyes (2026-09-01): preview showed the webcam but the recording + // didn't — a null resolver frame silently DROPS the layer. Log once per 5s + // per kind so the repro run tells acquire-mismatch from compose-skip. + if (frame == null && element is WebcamSceneConfig webcam) + { + var now = DateTime.UtcNow; + if (now - _lastWebcamNullLog >= TimeSpan.FromSeconds(5)) + { + _lastWebcamNullLog = now; + AppLog.Write($"Output resolver: webcam '{webcam.WebcamId}' frame is null — layer skipped in composite"); + } + } + return frame; + } + + private DateTime _lastWebcamNullLog = DateTime.MinValue; + + private VideoFrame? ResolveOutputFrameCore(SceneElement element) { return element switch {