diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 8596dfaf..0f20dd56 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1353,6 +1353,12 @@ function readNativeWindowsEncoderSelection(output: string) { // which is what `salvageNativeWindowsFragmentedCapture` asks. container?: string; preferSoftwareEncoder?: boolean; + // Whether BeginWriting() actually landed on a hardware H.264 MFT, as + // opposed to `video` above, which only says which configuration path + // was tried. "default" plus a software runtime means the machine never + // got hardware acceleration in the first place -- see + // kVideoEncoderRuntime* in mf_encoder.h. + videoEncoderRuntime?: string; }; } catch { return null; @@ -2531,6 +2537,7 @@ export function registerIpcHandlers( path: outputPath, helperPath, videoEncoderSelection: encoderSelection?.video ?? null, + videoEncoderRuntime: encoderSelection?.videoEncoderRuntime ?? null, webcamUnavailable, microphoneDefaulted, }; diff --git a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift index 75163dee..5add8074 100644 --- a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift +++ b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift @@ -413,6 +413,9 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { let size = captureSize( for: filter, fallbackPointSize: window.frame.size, + // Unrelated to `initializeCoreGraphicsWindowServerConnection()`: that call + // exists purely for its side effect at process startup, this one wants the + // actual display ID as a fallback when no display intersects the window. fallbackDisplayId: candidateDisplay?.displayID ?? CGMainDisplayID() ) return CaptureTarget( @@ -800,8 +803,21 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { @main struct OpenScreenScreenCaptureKitHelper { + // This helper is a plain command-line executable, so nothing has connected it to the + // window server yet. `SCContentFilter(desktopIndependentWindow:)` reaches into SkyLight + // (`SLSGetDisplaysWithRect`) to find the display a window sits on, and SkyLight aborts + // with `CGS_REQUIRE_INIT` when CoreGraphics was never initialised in the process — so + // every window capture crashed before it produced a frame, while display capture (which + // never resolves a rect) worked fine. Touching any CoreGraphics display API first + // performs that initialisation. + private static func initializeCoreGraphicsWindowServerConnection() { + _ = CGMainDisplayID() + } + static func main() async { do { + initializeCoreGraphicsWindowServerConnection() + guard CommandLine.arguments.count == 2 else { throw HelperError.invalidArguments } diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index 1479bc84..c64d7970 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -865,7 +865,14 @@ int main(int argc, char* argv[]) { << "\",\"container\":\"" << encoder.containerFormat() << "\",\"preferSoftwareEncoder\":" << (config.preferSoftwareEncoder ? "true" : "false") - << "}" << std::endl; + // What BeginWriting() actually landed on, not what the "video" + // field above asked for -- see kVideoEncoderRuntime* in + // mf_encoder.h. "default" plus "software" here means the machine + // never got a hardware encoder in the first place, which is a + // different bug report than "default" plus "hardware" stalling + // on stop. + << ",\"videoEncoderRuntime\":\"" << encoder.videoEncoderRuntime() + << "\"}" << std::endl; MFEncoder webcamEncoder; if (writeSeparateWebcam) { MFEncoderOptions webcamEncoderOptions = encoderOptions; diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp index 4058ca24..c1e5c2bb 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -140,7 +140,7 @@ enum class SinkWriterCreateStage { SoftwareEncoderRegistration, CreateAttributes, DisableHardwareTransforms, - ConfigureDxgiManager, + EnableHardwareTransforms, CreateFile, CreateFragmentedMediaSink, CreateSinkWriter, @@ -248,10 +248,30 @@ HRESULT createSinkWriter( failedStage = SinkWriterCreateStage::DisableHardwareTransforms; return hr; } - } else if (dxgiDeviceManager != nullptr) { - HRESULT hr = MFCreateAttributes(&attributes, 3); + } else { + // Ask for hardware transforms whenever software is not forced -- + // whether or not a DXGI device manager came with the request. + // MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS defaults to FALSE, and + // leaving it unset (the old behaviour on the plain CPU-readback path) + // meant the sink writer never considered a hardware H.264 MFT even + // when one was registered and working: every "default" recording + // landed on the same software encoder forceSoftwareEncoder asks for + // explicitly, on any machine that had not separately opted into + // OPENSCREEN_WGC_ENABLE_DXGI_INPUT (getopenscreen/openscreen#460, + // confirmed by videoEncoderRuntime on real hardware: "default" read + // back "software" until the DXGI path was turned on, on a machine + // whose encoder is hardware-capable either way). + // + // A hardware MFT does not require the D3D manager to accept samples: + // without one it manages its own device and takes system-memory + // samples the same way the software encoder does, which is exactly + // the CPU-readback path this branch also serves. So the attribute is + // set unconditionally here; only the manager itself stays behind the + // null check, since supplying a manager the caller does not have would + // be undefined rather than merely declined. + HRESULT hr = MFCreateAttributes(&attributes, dxgiDeviceManager != nullptr ? 3 : 1); if (FAILED(hr)) { - std::cerr << "ERROR: MFCreateAttributes(DXGI sink writer) failed (hr=0x" + std::cerr << "ERROR: MFCreateAttributes(sink writer) failed (hr=0x" << std::hex << hr << std::dec << ")" << std::endl; failedStage = SinkWriterCreateStage::CreateAttributes; return hr; @@ -260,15 +280,17 @@ HRESULT createSinkWriter( if (FAILED(hr)) { std::cerr << "ERROR: Set MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS(TRUE) failed (hr=0x" << std::hex << hr << std::dec << ")" << std::endl; - failedStage = SinkWriterCreateStage::ConfigureDxgiManager; + failedStage = SinkWriterCreateStage::EnableHardwareTransforms; return hr; } - hr = attributes->SetUnknown(MF_SINK_WRITER_D3D_MANAGER, dxgiDeviceManager); - if (FAILED(hr)) { - std::cerr << "ERROR: Set MF_SINK_WRITER_D3D_MANAGER failed (hr=0x" - << std::hex << hr << std::dec << ")" << std::endl; - failedStage = SinkWriterCreateStage::ConfigureDxgiManager; - return hr; + if (dxgiDeviceManager != nullptr) { + hr = attributes->SetUnknown(MF_SINK_WRITER_D3D_MANAGER, dxgiDeviceManager); + if (FAILED(hr)) { + std::cerr << "ERROR: Set MF_SINK_WRITER_D3D_MANAGER failed (hr=0x" + << std::hex << hr << std::dec << ")" << std::endl; + failedStage = SinkWriterCreateStage::EnableHardwareTransforms; + return hr; + } } } @@ -382,6 +404,68 @@ bool resolveStreamSinkIndex(IMFMediaSink* mediaSink, const GUID& majorType, DWOR return false; } +// Did the video stream's encoder MFT actually land on hardware? +// +// BeginWriting() succeeding says nothing about this: on the "default" path +// (see kVideoEncoderRuntime* in mf_encoder.h) no attribute asked for hardware +// transforms, so Media Foundation is free to hand the sink writer a software +// MFT even when a hardware one is registered and would have worked. The only +// way to know which one it actually picked is to ask the pipeline it built, +// after the fact -- IMFSinkWriterEx::GetTransformForStream walks the MFTs the +// sink writer inserted for a stream, and a hardware MFT instance is required +// to expose MFT_ENUM_HARDWARE_URL_Attribute on its own attribute store (not +// just on the IMFActivate MFTEnumEx returns), which is what distinguishes it +// from a software one at this point. +// +// Every failure path here returns "unknown" rather than guessing: this runs +// after the sink writer is already committed to, so it must never be able to +// fail configureSinkWriterAttempt, and a wrong hardware/software guess in a +// bug report would be worse than an admitted "could not tell." +const char* detectVideoEncoderRuntime(IMFSinkWriter* sinkWriter, DWORD videoStreamIndex) { + Microsoft::WRL::ComPtr sinkWriterEx; + if (FAILED(sinkWriter->QueryInterface(IID_PPV_ARGS(&sinkWriterEx)))) { + return kVideoEncoderRuntimeUnknown; + } + + for (DWORD mftIndex = 0;; mftIndex += 1) { + GUID category{}; + Microsoft::WRL::ComPtr transform; + const HRESULT hr = + sinkWriterEx->GetTransformForStream(videoStreamIndex, mftIndex, &category, &transform); + if (hr == MF_E_INVALIDINDEX) { + // Walked the whole pipeline (converters, the encoder, anything + // else the topology loader inserted) without finding an encoder + // node. Should not happen -- an H.264 stream has to have one -- + // but this is diagnostics code, not the recording path, so an + // unexpected shape is "unknown", not a crash. + return kVideoEncoderRuntimeUnknown; + } + if (FAILED(hr)) { + return kVideoEncoderRuntimeUnknown; + } + if (category != MFT_CATEGORY_VIDEO_ENCODER) { + // A colour converter or similar the sink writer inserted ahead of + // the encoder. Keep walking; the encoder is further down. + continue; + } + + Microsoft::WRL::ComPtr transformAttributes; + if (FAILED(transform->GetAttributes(&transformAttributes))) { + return kVideoEncoderRuntimeUnknown; + } + UINT32 hardwareUrlLength = 0; + const HRESULT hardwareUrlHr = + transformAttributes->GetStringLength(MFT_ENUM_HARDWARE_URL_Attribute, &hardwareUrlLength); + if (SUCCEEDED(hardwareUrlHr)) { + return kVideoEncoderRuntimeHardware; + } + if (hardwareUrlHr == MF_E_ATTRIBUTENOTFOUND) { + return kVideoEncoderRuntimeSoftware; + } + return kVideoEncoderRuntimeUnknown; + } +} + void logSinkWriterCreateFailure( HRESULT sinkWriterHr, const char* createCall, @@ -513,6 +597,10 @@ const char* MFEncoder::videoEncoderSelection() const { return videoEncoderSelection_; } +const char* MFEncoder::videoEncoderRuntime() const { + return videoEncoderRuntime_; +} + const char* MFEncoder::containerFormat() const { return containerFormat_; } @@ -600,6 +688,7 @@ bool MFEncoder::initialize( // encoder, never reaching the software encoder the knob is aimed at. useDxgiInput_ = options.useDxgiInput && !options.injectDefaultSinkWriterFailureOnce; videoEncoderSelection_ = kVideoEncoderSelectionDefault; + videoEncoderRuntime_ = kVideoEncoderRuntimeUnknown; if (!succeeded(MFStartup(MF_VERSION), "MFStartup")) { return false; @@ -689,6 +778,7 @@ bool MFEncoder::initialize( audioStreamIndex_ = 0; hasAudioStream_ = false; videoEncoderSelection_ = kVideoEncoderSelectionDefault; + videoEncoderRuntime_ = kVideoEncoderRuntimeUnknown; containerFormat_ = kContainerFormatMp4; }; @@ -780,7 +870,7 @@ bool MFEncoder::initialize( "SetInputMediaType")) { return false; } - if (useDxgiInput_) { + if (!forceSoftwareEncoder) { applyHardwareRateControl(std::max(1, bitrate)); } if (!succeeded(sinkWriter_->BeginWriting(), "BeginWriting")) { @@ -788,6 +878,7 @@ bool MFEncoder::initialize( } videoEncoderSelection_ = selection; + videoEncoderRuntime_ = detectVideoEncoderRuntime(sinkWriter_.Get(), videoStreamIndex_); containerFormat_ = fragmented ? kContainerFormatFragmentedMp4 : kContainerFormatMp4; return true; }; @@ -1204,12 +1295,17 @@ bool MFEncoder::initializeVideoProcessor() { } void MFEncoder::applyHardwareRateControl(int bitrate) { - // The D3D manager switches the sink writer onto a hardware MFT, and those - // default to constant bitrate: a static desktop then spends the full - // configured budget doing nothing, 16.9 Mbps measured against the 1.95 the - // software encoder the CPU path lands on produced for the same screen. Same - // budget, opposite reading of it. Ask for VBR so the GPU path spends what - // the picture costs, which is what users have been getting all along. + // Enabling MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS can hand the sink + // writer a hardware MFT, and those default to constant bitrate: a static + // desktop then spends the full configured budget doing nothing, 16.9 Mbps + // measured against the 1.95 the software encoder produced for the same + // screen. Same budget, opposite reading of it. Ask for VBR so a hardware + // encoder spends what the picture costs, which is what the software + // encoder was already doing. Called whenever hardware transforms were + // requested, DXGI device manager or not (getopenscreen/openscreen#460) -- + // whether the sink writer actually landed on hardware is not knowable + // until after BeginWriting() (see MFEncoder::videoEncoderRuntime()), and + // this call is a no-op on a software MFT that ignores or lacks the knob. // // Best effort on purpose. An encoder that exposes neither knob still // produces a valid recording, and a bitrate we could not pin down is not diff --git a/electron/native/wgc-capture/src/mf_encoder.h b/electron/native/wgc-capture/src/mf_encoder.h index f8370874..19fac700 100644 --- a/electron/native/wgc-capture/src/mf_encoder.h +++ b/electron/native/wgc-capture/src/mf_encoder.h @@ -42,6 +42,28 @@ constexpr const char* kVideoEncoderSelectionDefault = "default"; constexpr const char* kVideoEncoderSelectionSoftwarePreferred = "software-preferred"; constexpr const char* kVideoEncoderSelectionSoftwareFallback = "software-fallback"; +// Whether BeginWriting() actually landed on a hardware-accelerated H.264 MFT. +// +// videoEncoderSelection() above says which *path* initialize() took -- +// whether the DXGI GPU pipeline was asked for, or software was forced -- but +// none of those labels says what Media Foundation itself picked, and that +// matters even now that createSinkWriter asks for hardware transforms on +// every path but the forced-software one: MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS +// asks, it does not guarantee -- a machine with no hardware H.264 MFT +// registered, or one whose driver refuses it, still lands on software. That +// gap is exactly what this exists to close for a bug report: "default" alone +// cannot tell a real hardware encode apart from software Media Foundation +// picked anyway, which was the whole ambiguity behind a slow-CPU stop timeout +// (getopenscreen/openscreen#460) before this field existed. +constexpr const char* kVideoEncoderRuntimeHardware = "hardware"; +constexpr const char* kVideoEncoderRuntimeSoftware = "software"; +// Introspection itself failed (no IMFSinkWriterEx, no encoder node found in +// the resolved topology, GetAttributes refused). Reported as its own value +// rather than guessed into hardware or software, because a bug report that +// cannot tell "we checked and it's software" from "we couldn't check" would +// draw the wrong conclusion either way. +constexpr const char* kVideoEncoderRuntimeUnknown = "unknown"; + // Which MP4 flavour the recording was actually written in. The fragmented sink // writes a self-describing moof+mdat pair roughly every second, so a helper the // shutdown watchdog force-exits leaves a file that plays up to the last @@ -97,6 +119,9 @@ class MFEncoder { bool writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns); bool finalize(); const char* videoEncoderSelection() const; + // Best-effort, read only after initialize() returns true. See the + // kVideoEncoderRuntime* constants above for what each value means. + const char* videoEncoderRuntime() const; // Which container initialize() settled on, which is not necessarily the one // it asked for: the fragmented sink degrades to the plain one rather than // failing a recording. A bug report that cannot tell the two apart cannot @@ -202,5 +227,6 @@ class MFEncoder { bool finalized_ = false; bool useDxgiInput_ = false; const char* videoEncoderSelection_ = kVideoEncoderSelectionDefault; + const char* videoEncoderRuntime_ = kVideoEncoderRuntimeUnknown; const char* containerFormat_ = kContainerFormatMp4; }; diff --git a/scripts/test-windows-wgc-helper.mjs b/scripts/test-windows-wgc-helper.mjs index e1dc4814..1e513c72 100644 --- a/scripts/test-windows-wgc-helper.mjs +++ b/scripts/test-windows-wgc-helper.mjs @@ -554,6 +554,34 @@ if ( `WGC helper encoder selection was ${JSON.stringify(encoderSelection)}, expected ${expectedEncoderSelection} with preferSoftwareEncoder=${WITH_SOFTWARE_ENCODER}: ${result.stdout}`, ); } +// videoEncoderRuntime is separate from `video` above: it is what +// GetTransformForStream found in the sink writer's own resolved pipeline +// after BeginWriting(), not which configuration path was tried. "unknown" +// here on a run that otherwise passed means the introspection itself is +// broken (wrong COM call, wrong category, wrong attribute), not a real +// ambiguity -- a healthy sink writer always has exactly one encoder node. +if (!["hardware", "software", "unknown"].includes(encoderSelection.videoEncoderRuntime)) { + throw new Error( + `WGC helper reported an unrecognised videoEncoderRuntime: ${JSON.stringify(encoderSelection)}`, + ); +} +if (encoderSelection.videoEncoderRuntime === "unknown") { + throw new Error( + `WGC helper could not introspect its own sink writer for videoEncoderRuntime: ${JSON.stringify(encoderSelection)}`, + ); +} +// forceSoftwareEncoder disables hardware transforms explicitly +// (MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS=FALSE), so this is deterministic +// regardless of what the test machine has registered -- unlike the "default" +// path, whose runtime legitimately depends on the machine. +if ( + (WITH_SOFTWARE_ENCODER || WITH_SOFTWARE_FALLBACK) && + encoderSelection.videoEncoderRuntime !== "software" +) { + throw new Error( + `WGC helper forced the software encoder but videoEncoderRuntime was ${encoderSelection.videoEncoderRuntime}, expected software: ${JSON.stringify(encoderSelection)}`, + ); +} // Every fallback path has to stay fragmented, not just the nominal one. The // helper degrades to the plain container rather than failing a recording, so // without this the fix could quietly stop applying and every other assertion diff --git a/src/lib/nativeWindowsRecording.ts b/src/lib/nativeWindowsRecording.ts index e9e8c0c5..5d5d92b3 100644 --- a/src/lib/nativeWindowsRecording.ts +++ b/src/lib/nativeWindowsRecording.ts @@ -47,6 +47,13 @@ export type NativeWindowsRecordingStartResult = { error?: string; /** Helper-reported encoder selection: "default", "software-preferred", or "software-fallback". */ videoEncoderSelection?: string | null; + /** + * Whether the helper actually landed on a hardware H.264 encoder MFT, as + * opposed to `videoEncoderSelection` above, which only says which + * configuration path was tried: "hardware", "software", or "unknown" when + * the helper could not introspect its own sink writer. + */ + videoEncoderRuntime?: string | null; /** * A camera was asked for and the helper could not open it, so this take is * screen and audio only. Still a success — the recording is worth keeping — diff --git a/technical-documentation/architecture/recording.md b/technical-documentation/architecture/recording.md index 1dc63837..bb6e143a 100644 --- a/technical-documentation/architecture/recording.md +++ b/technical-documentation/architecture/recording.md @@ -81,8 +81,9 @@ Cursor samples are persisted as cursor telemetry rather than baked into editable - A window with odd client dimensions can produce black video: H.264 encoding requires even dimensions (`electron/native/wgc-capture/src/wgc_session.cpp:38`). - The Windows helper's frame lock (`electron/native/wgc-capture/src/main.cpp`) is still held across blocking, uninterruptible D3D11 work: the WGC callback's `CopyResource`, and whatever the video writer does with the frame. A driver that stalls inside either one still costs the recording. What no longer happens is a hang: stop detection runs on `CaptureControl::stopMutex`, which no frame thread ever touches, and a shutdown watchdog force-exits the helper when a step overruns its budget, naming the step it died in. Each step gets `OPENSCREEN_WGC_STEP_BUDGET_MS` (8s by default) and that is the bound which normally fires; the whole shutdown is capped by `OPENSCREEN_WGC_STOP_BUDGET_MS` (50s by default), which the encoder-finalize step alone is allowed to spend in full because a long software-encoder finalize legitimately takes seconds (issue #34). Picking the D3D adapter that actually drives the captured monitor instead of adapter 0 is still outstanding. -- The video writer has two ways to get a frame to the encoder. Which one it uses is a setting first and a per-machine outcome second: the GPU path has to be asked for, and is then kept only if the machine supports it. The GPU path (`videoInput: "dxgi-nv12"`) copies the frame across a keyed-mutex bridge to a second D3D11 device, converts BGRA to NV12 with the D3D11 video processor, and hands the hardware H.264 encoder a DXGI sample; it never touches system memory. The CPU path (`videoInput: "cpu-rgb32"`) is the original staging-texture `Map(D3D11_MAP_READ)` readback, and is what a `Map`/`Unmap` that never returns wedges (issue #252: Windows 10, WDDM 2.7, multi-adapter). The GPU path is OFF by default; `OPENSCREEN_WGC_ENABLE_DXGI_INPUT=1` turns it on. Once asked for, it degrades to the CPU path at every check made **during initialization** — no hardware encoder, no NV12 video-processor output, no shared keyed-mutex texture, no DXGI sample allocator — so a machine it does not fit records exactly as it did before it existed. That fallback ends when the first frame arrives: a `captureDxgiSample()` failure after encoding has started stops the recording, because the sink writer is configured for NV12 by then and there is no path left to take. That gap is why the default is off, and it is what cost the reporter in #336 their recording. It is skipped outright for `preferSoftwareEncoder` and for inline webcam PiP, both of which need the frame in system memory, The two paths land on different encoders, so the GPU one asks for VBR explicitly through `ICodecAPI`: hardware MFTs default to constant bitrate and would spend the full configured budget on a static screen (measured 16.9 Mbps against 1.95 for the same desktop). +- The video writer has two ways to get a frame to the encoder. Which one it uses is a setting first and a per-machine outcome second: the GPU path has to be asked for, and is then kept only if the machine supports it. The GPU path (`videoInput: "dxgi-nv12"`) copies the frame across a keyed-mutex bridge to a second D3D11 device, converts BGRA to NV12 with the D3D11 video processor, and hands the hardware H.264 encoder a DXGI sample; it never touches system memory. The CPU path (`videoInput: "cpu-rgb32"`) is the original staging-texture `Map(D3D11_MAP_READ)` readback, and is what a `Map`/`Unmap` that never returns wedges (issue #252: Windows 10, WDDM 2.7, multi-adapter). The GPU path is OFF by default; `OPENSCREEN_WGC_ENABLE_DXGI_INPUT=1` turns it on. Once asked for, it degrades to the CPU path at every check made **during initialization** — no hardware encoder, no NV12 video-processor output, no shared keyed-mutex texture, no DXGI sample allocator — so a machine it does not fit records exactly as it did before it existed. That fallback ends when the first frame arrives: a `captureDxgiSample()` failure after encoding has started stops the recording, because the sink writer is configured for NV12 by then and there is no path left to take. That gap is why the default is off, and it is what cost the reporter in #336 their recording. It is skipped outright for `preferSoftwareEncoder` and for inline webcam PiP, both of which need the frame in system memory. Whichever path lands on a hardware MFT asks for VBR explicitly through `ICodecAPI` (`MFEncoder::applyHardwareRateControl`, called whenever hardware transforms were requested — see the `videoEncoderRuntime` gap below): hardware MFTs default to constant bitrate and would spend the full configured budget on a static screen (measured 16.9 Mbps against 1.95 for the same desktop on the GPU path). VBR keeps that in check but does not make a hardware encoder's output match a software one byte-for-byte — measured back-to-back on the same idle desktop, this machine's hardware MFT still produced roughly 5x the bytes of the software encoder even with VBR correctly engaged (8.7 Mbps vs 1.7 Mbps), which looks like a genuine rate-distortion difference between the two encoder implementations rather than a rate-control bug. Accepted as the cost of the fix below rather than tuned further, since disk is cheap and a stuck recording losing the whole take is not. - Linux/Wayland can produce no usable frames on the `getDisplayMedia` fallback because Chromium initializes Vulkan against the Ozone Wayland backend. The PipeWire helper path is unaffected. - On Linux the compositor's source picker appears on every recording. That is deliberate — see "Why Linux sends no source identity" — but it is an interruption, and there is currently no way to reuse a previous choice without also making it impossible to change. - Holding a portal session across the countdown means the compositor's "screen is being shared" indicator is up before recording begins. That is honest — access really has been granted — but the user can click it to revoke, or close the window they picked. The helper's exit surfaces as a rejected `waitUntilSourceSelected`; the session is not yet subscribed to the portal's `Session::Closed` signal, so a revocation is reported as a failed start rather than a specific message. - `preferSoftwareEncoder` is read when recording starts. The recorder has no UI for setting it; Windows also accepts `OPENSCREEN_WGC_PREFER_SOFTWARE_ENCODER=true` in the helper request path. +- The `encoder-selection` event's `video` field ("default", "software-preferred", "software-fallback") names which *configuration path* `MFEncoder::initialize` took, not which encoder Media Foundation actually picked; `videoEncoderRuntime` ("hardware", "software", or "unknown") answers that, by walking the sink writer's own transform pipeline after `BeginWriting()` for a node that self-identifies as hardware (`MFT_ENUM_HARDWARE_URL_Attribute`). It exists because of what it found (getopenscreen/openscreen#460): `MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS` defaults to FALSE, and until this was fixed, leaving it unset on the "default" (non-DXGI) path meant the sink writer only ever considered software MFTs there, even on a machine with a working hardware H.264 encoder — "default" recording was, in practice, software-encoded on every machine that had not separately opted into the DXGI path above. `createSinkWriter` now sets that attribute to TRUE whenever `forceSoftwareEncoder` is false, DXGI device manager or not, so a hardware encoder is used on the "default" path when one is registered and working — which is most of the point, since it is weak-CPU machines running the software encoder that blow the stop-timeout budget in the first place. `videoEncoderRuntime` stays in the event to catch the cases that don't fit that story: a machine with no real hardware encoder falling back to software regardless, or a hardware encoder that itself turns out to be the slow or unstable one. diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index daa574ae..478da7f9 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -1,453 +1,455 @@ -# Manual end-to-end checklist - -This checklist covers the real desktop capture-to-export path: the parts that unit, browser, and Playwright tests cannot exercise, including real screen capture, a physical webcam, the system tray, the native compositor, and export. Run it before promoting a release candidate and after any change to native capture, preview, or export. - -**"Manual" is about the input, not the operator.** These checks need real OS mouse and keyboard events, not a human hand — so an agent with the computer-use MCP runs them, on demand, and a request for one section after a targeted change is as much a run as the whole file before a promote. Availability check and the rule on partial runs: [AGENTS.md](../../AGENTS.md#desktop-e2e-testing-with-computer-use). - -Sections marked **v1.8.0** cover what this release changed: chat-driven editing through the agent tool set, clip-anchored modifiers, local transcription, the macOS Metal compositor, and the new effect controls. Run the whole file for a release candidate; the v1.8.0 sections are the ones with no prior release to fall back on. - -## How to run this - -1. Drive the real Electron app with computer-use — real OS mouse and keyboard events. Start a dev build with `npm run dev`, or launch the packaged build under test. - - "Manual" here usually means an agent holding the mouse, so the tempting shortcut is not a browser shim: it is driving the real app through CDP instead. **Do not.** Playwright's `.click()`, `javascript_tool`-dispatched pointer events and anything else synthesised into the renderer arrive *below* the OS hit-test. On Windows and macOS the HUD is input-transparent until a real cursor move lifts it, so an injected click fires the DOM handler and comes back green while the path a user actually takes was never exercised at all. - - That trap is specific to the HUD and the countdown overlay — they are the only click-through windows; the editor is an ordinary one, and an injected click there does reach the handler a user would reach. The reason not to inject in the editor either is the first line of this file: this checklist covers what unit, browser and **Playwright** tests cannot. Drive it the way those tests already drive it and you have re-run the coverage you had, then written "passed" beside the parts nothing checked. -2. The app is single-instance per `userData` path. If a leftover Electron/OpenScreen process still holds the lock, stop that process before relaunching; a second launch can exit successfully without opening a window. The lock is held by the OS and is released when the process dies, so there is nothing to delete on disk. -3. From a worktree, link or junction `node_modules` to the main checkout and provide the prebuilt native capture binaries for the platform before starting the dev build. **Date those binaries against the change you came to test.** Nothing rebuilds them, so an older helper runs the old code path in silence: the recording succeeds and the thing you were checking for is simply absent. See AGENTS.md for how to check one and how to rebuild it; when you cannot, test the CI-built artifact, because a dev build cannot answer a native question. -4. Grant computer-use access to the process name that actually owns the window: `electron.exe` or `Electron.app` for a dev build, and `Openscreen.exe` or `Openscreen.app` for a packaged build. Do not grant access only to the installed app name when testing a dev build — it resolves to the *installed* executable and reports success while the dev window stays masked. This is step 4 and not step 1 for a reason: a dev build is not an installed app, so the resolver cannot find it until it is running and owns a window, and one unresolvable name voids the whole request. - - **Ask for everything in ONE call — after the launches above, before the first check.** `request_access` takes a list, and once a grant is in place the rest of the pass runs without a single further prompt: a full capture-to-export run is dozens of clicks and none of them ask again. So the only thing keeping a human at the keyboard is *how many* dialogs you raise and *when*. Raise one, before the first check, and the operator can walk away for the rest of the run; discover a fourth app you need an hour in and they cannot. That is also why this cannot move earlier — the resolver needs the app running, and one unresolvable name voids the batch. Beyond the app under test, ask for: - - - the desktop shell — the tray icon and the native save dialogs live there, and the tray is the only reliable way back to the HUD; - - the OS settings app — needed to change display scaling, which is how DPI checks are run (see AGENTS.md; "the machine is at 100%" is not a reason to skip them). - - **Name them the way the Start menu does, in the system's own language.** The resolver matches installed-app display names, not executables: on a French Windows the shell is `Explorateur de fichiers` and `explorer.exe` fails outright — `notInstalled`, with a nonsense suggestion attached — which then voids every other name in the same call. On an English install it is `File Explorer`. When unsure, ask rather than guess; the tool lists the installed names it knows. - - There is no way to pre-approve any of this in config: the request has to be answered live. That is upstream ([claude-code#46907](https://github.com/anthropics/claude-code/issues/46907), closed stale), and `bypassPermissions` does not cover it either ([#43172](https://github.com/anthropics/claude-code/issues/43172)). Batching is the whole mitigation. -5. Read [AGENTS.md](../../AGENTS.md) for the computer-use mechanics, screenshot permissions, tray interaction, and cleanup procedure. Read one check, perform it, observe the result, then continue; close each modal or popover with `Esc` before the next check. -6. The recording HUD is protected from capture by default and is invisible in screenshots. For this session only, launch with `OPENSCREEN_DISABLE_CONTENT_PROTECTION=1`; this is the environment variable checked before `setContentProtection(true)`. Unset it before making any recording whose HUD must not appear in the video. -7. A preview screenshot is downscaled. Settle every pixel-level question by exporting a frame and measuring the exported frame, not by judging fine edges, corners, shadows, or alignment from the preview screenshot. -8. Keep the first real recording or imported project available for the editor sections. Log crashes, hangs, data loss, security issues, and reproducible visual failures as soon as they occur. -9. Several v1.8.0 sections need a configured AI provider (chat editing, caption translation) or a built native compositor addon (preview, export). A dev build from a worktree needs the compositor addon installed for its platform, not only the capture binaries. When a prerequisite is missing, record the section as skipped with the reason; do not mark it passed. -10. Prefer a project with at least two clips from the same asset for the modifier sections. A single-clip project cannot exercise anchoring, reorder, or cross-boundary splitting at all, which is where the v1.8.0 timeline model changed. - -## Launch and HUD - -- [ ] Start the app and confirm one launch window appears without a startup crash. -- [ ] Confirm the launch window remains usable after the first device enumeration completes. -- [ ] Confirm the HUD is visible when content protection is disabled for the test session. -- [ ] Activate `[data-testid="launch-tray-layout-button"]` and confirm the tray changes between horizontal and vertical layouts. -- [ ] Confirm the chosen tray layout remains coherent when the HUD grows to show recording controls. -- [ ] Activate `[data-testid="hud-drag-handle"]`, drag the HUD across most of the primary display, and confirm it follows the pointer without drift. -- [ ] Release the drag and confirm the HUD stays at the dropped position instead of jumping. -- [ ] Activate the language button by its visible language code and confirm a menu of locale choices opens. -- [ ] Press `Esc` with the language menu open and confirm it closes without changing the locale. -- [ ] Activate the minimize control and confirm the HUD hides without quitting the app. -- [ ] Refocus the app from its system-tray icon and confirm the HUD returns to the foreground. -- [ ] Activate the close control while idle and confirm the HUD closes cleanly. -- [ ] Relaunch the app after closing it and confirm the single-instance behavior does not leave a duplicate HUD. - -## Source selection and recording - -- [ ] Activate `[data-testid="launch-source-selector-button"]` and confirm the source selector opens. -- [ ] Select a screen or application card with `data-testid="source-selector-card"`, activate `[data-testid="source-selector-share-button"]`, and confirm the selector closes with the source name on the HUD. -- [ ] Confirm `[data-testid="launch-record-button"]` is disabled until a source is selected, then activate it and confirm recording starts with a red stop state and an increasing elapsed timer. -- [ ] Confirm the configured system-audio, microphone, webcam, and cursor states remain visible while recording. -- [ ] Activate the recording control's pause action and confirm the timer stops advancing, then resume and confirm it advances again. -- [ ] Activate the restart action while recording and confirm the current recording is discarded and a fresh recording begins. -- [ ] Activate the cancel action while recording and confirm recording ends without opening an editor for the canceled take. -- [ ] Confirm stopping opens the editor with the recorded screen asset loaded. -- [ ] On Windows, stop once with system audio, microphone, webcam, and cursor all disabled and confirm the editor opens within a few seconds. -- [ ] Record once with microphone only and confirm the resulting playback contains audible microphone audio. -- [ ] Record once with system audio only and confirm the resulting playback contains audible system audio. -- [ ] Record with microphone and system audio enabled and confirm both sources are audible and reasonably balanced. - -## Editor opens and loads the project - -- [ ] Confirm the editor opens after a successful stop with the expected project title and asset. -- [ ] Confirm `[data-testid="preview"]` is present and its current-time value starts at the beginning of the project. -- [ ] Confirm the loaded video is visible in the preview rather than an empty state or broken-video state. -- [ ] Confirm the timeline contains a clip for the recorded or imported asset. -- [ ] Activate the project rename control by its `aria-label`, enter a new non-empty title, and confirm the title changes. -- [ ] Confirm the top bar shows an unsaved state after changing the project title. -- [ ] Switch among the Media, Edit, and Rec editor modes and confirm each selected tab visibly changes state. -- [ ] Confirm the editor's preview, timeline, and inspector remain usable after switching modes. -- [ ] Activate the left-panel toggle by its `aria-label` and confirm the chat/media panel opens or closes without changing the project. -- [ ] Resize the chat panel by its visible divider and confirm the preview area resizes without moving the timeline content. -- [ ] Resize the timeline by its visible top divider and confirm the timeline height changes without a layout crash. - -## Transport and preview - -- [ ] Activate the playback control with the `aria-label` for play/pause and confirm `[data-testid="preview"]` changes `data-is-playing` from `false` to `true`. -- [ ] Activate play/pause again and confirm playback stops and the preview reports `data-is-playing="false"`. -- [ ] Confirm the transport time readout advances while playback is running. -- [ ] Confirm the playhead advances with the video instead of remaining at its starting position. -- [ ] Drag the transport seek range control with the `aria-label` for seeking and confirm `[data-testid="preview"]` reports the new current time. -- [ ] Seek while paused and confirm the preview frame changes to the selected time. -- [ ] Seek while playing and confirm playback continues from the new time without a visible stuck frame. -- [ ] Activate the loop control and confirm its pressed state changes. -- [ ] Play through the end with looping enabled and confirm playback returns to the loop start. -- [ ] Activate the fullscreen control and confirm the preview enters fullscreen presentation. -- [ ] Exit fullscreen and confirm the normal editor layout returns. -- [ ] With a webcam recording, confirm the webcam picture-in-picture appears aligned with the screen content. -- [ ] Add a full-camera segment, scrub into it, and confirm the webcam grows to fullscreen then returns at the segment end. -- [ ] Confirm the preview's webcam, cursor, background, and region effects remain synchronized while scrubbing. - -## Timeline navigation (pan, zoom, scrub) - -- [ ] Confirm the timeline ruler displays time labels from the project start through its duration. -- [ ] Click a position on the ruler and confirm the playhead and preview seek to that time. -- [ ] Drag across the ruler or timeline track and confirm the playhead follows the pointer. -- [ ] Hold `Ctrl` while scrolling over the timeline and confirm the timeline zooms around the pointer position. -- [ ] Hold `Shift` while scrolling over the timeline and confirm the visible time range pans without changing the project. -- [ ] Drag the timeline with the middle mouse button and confirm the visible time range pans. -- [ ] Confirm the playhead remains aligned with the ruler and clip positions after zooming and panning. -- [ ] Drag the navigator window and confirm the main timeline follows its visible range. -- [ ] Drag a navigator handle and confirm the visible range narrows or widens without changing clip data. -- [ ] Confirm an empty-area click clears any selected region and closes its selection inspector. -- [ ] Confirm the reworked ruler keeps readable labels at the narrowest and widest zoom levels rather than colliding or disappearing. -- [ ] Confirm the playhead stays exactly on the time it reports after zooming, panning, and resizing the timeline. -- [ ] Change the project title, save, and toggle the export control's availability, and confirm the top bar keeps its layout instead of reflowing on each state change. - -## Clip operations - -- [ ] Open the Media panel and confirm the project asset is listed with its source name. -- [ ] Drag a listed media asset into the timeline clip area and confirm a new clip appears. -- [ ] Click a clip and confirm it receives a selected visual state. -- [ ] Drag a selected clip before another clip and confirm the clip order changes. -- [ ] Double-click a clip and confirm the Edit Clip dialog opens. -- [ ] Change the clip start in-point in the dialog and confirm the clip duration changes. -- [ ] Change the clip end in-point in the dialog and confirm the clip duration changes. -- [ ] Confirm the clip's crop or in/out changes affect the preview after closing the dialog. -- [ ] Select a clip and activate the delete control with the `aria-label` for deleting a clip; confirm only that clip is removed. -- [ ] Select a clip, use the configured copy and paste shortcuts, and confirm a duplicate clip appears. -- [ ] Select more than one clip when supported and confirm the edit control offers a clip picker rather than editing an unspecified clip. - -## Regions (trim/skip, zoom, speed, annotation) - -- [ ] Drag a trim region's left edge and confirm its start time changes. -- [ ] Drag a trim region's right edge and confirm its end time changes. -- [ ] Scrub across a trim region and confirm the preview skips the marked interval during playback. -- [ ] Delete the selected trim region from its inspector and confirm the interval is restored. -- [ ] Activate the timeline tool with the visible zoom label and confirm a zoom region appears. -- [ ] Select the zoom region and cycle its level through multiple available depths; confirm the preview scale changes. -- [ ] Drag the zoom focus point in the preview and confirm the zoom follows the new focus. -- [ ] Change the zoom rotation preset among none, iso, left, and right and confirm the preview orientation changes. -- [ ] Set a zoom region to automatic focus and confirm its focus follows cursor telemetry across the whole region. -- [ ] Use the automatic-zooms menu and confirm it adds suggested zoom regions when cursor telemetry supports suggestions. -- [ ] Select a zoom region and delete it from the selection inspector; confirm it disappears from the lane. -- [ ] Activate the timeline tool with the visible speed label and confirm a speed region appears. -- [ ] Change the speed region through its preset selector and confirm the lane label and preview timing change. -- [ ] Enter a custom speed in the speed field, commit it, and confirm the custom value remains selected. -- [ ] Play across a speed region and confirm the preview reflects the region's speed. -- [ ] Select a speed region and delete it from its inspector; confirm normal speed returns. -- [ ] Activate the timeline tool with the visible annotation or comment label and confirm an annotation region appears. -- [ ] Select a text annotation, replace its text, and confirm the new text appears in the preview. -- [ ] Change the text color and toggle its background; confirm both changes are visible in the preview. -- [ ] Change the text animation using the control with the `aria-label` for selecting text animation and confirm the animation runs when the playhead enters the region. -- [ ] Convert an annotation to an image, upload a supported image, and confirm the image appears in the preview. -- [ ] Convert an annotation to a figure, change its arrow direction and stroke width, and confirm the figure changes. -- [ ] Convert an annotation to blur, change its blur type and shape, and confirm the selected area is obscured. -- [ ] Drag an annotation in the preview and confirm its position persists when the playhead leaves and returns. -- [ ] Select an annotation and delete it from its inspector; confirm it disappears from the preview and lane. -- [ ] Use undo and redo after adding, editing, and deleting at least one region and confirm each operation restores the prior state. - -## Modifiers are anchored to clips — v1.8.0 - -Zoom, speed, annotation, and full-camera regions are stored against a clip in that clip's own source time, not at an absolute ruler position. See [timeline-model.md](../architecture/timeline-model.md). These checks exist because the failure mode is silent: the pill stays where it was drawn while the effect fires somewhere else. - -- [ ] Draw a zoom wholly inside one clip, reorder that clip to another position, and confirm the zoom travels with the clip and keeps its length. -- [ ] Confirm the moved zoom still fires over the same picture content, not at the ruler position it originally occupied. -- [ ] Draw a region across a boundary between two clips, move one of those clips away, and confirm the region splits into one pill per clip instead of remaining one pill at the old position. -- [ ] Put the two clips back side by side and confirm the fragments render as a single pill again. -- [ ] Confirm two regions of the same kind with identical properties that touch display as one pill. -- [ ] Change one of the two merged regions and confirm the pill separates into two. -- [ ] Drag a zoom pill into a neighbouring zoom with a different level and confirm it clamps at the neighbour's edge and the neighbour does not move. -- [ ] Confirm the same repel behaviour for two speed regions with different speeds. -- [ ] Add a trim inside a clip that a zoom already covers and confirm the covered part is hidden without shifting any later region on the ruler. -- [ ] Confirm the ruler still shows the trimmed span occupying its place while playback skips it. -- [ ] Delete a clip and confirm modifiers anchored only to that clip disappear while modifiers on other clips are untouched. -- [ ] Duplicate a clip and confirm its modifiers are duplicated with the copy. -- [ ] Change a clip's in and out points in the Edit Clip dialog and confirm anchored modifiers clamp to the new range rather than drifting past it. -- [ ] Select a zoom, copy its attributes with the configured copy shortcut, select another zoom, paste, and confirm the copied toast appears and the target adopts level, rotation, and focus without changing its own span. -- [ ] Repeat the attribute copy and paste for a speed region and for a text annotation. -- [ ] Trigger copy with nothing selected and confirm the "select a region" message rather than a silent no-op. -- [ ] Trigger paste before anything was copied and confirm the "nothing copied yet" message. -- [ ] Save, reopen the project, and confirm every modifier is still on the same clip content after the reorder performed above. -- [ ] Zoom and pan the timeline and confirm each pill's span still matches the time at which its effect fires in the preview. -- [ ] Export a short range that covers a reordered clip and a trim, and confirm the exported frames agree with the preview about where each modifier fires. - -## Transcript and captions - -- [ ] With no transcript, confirm the pane offers a transcribe action instead of showing an empty editor. -- [ ] Start transcription for the loaded asset and confirm a visible in-progress state appears. -- [ ] Confirm a completed transcription displays words in timeline clip order. -- [ ] Click a transcript word and confirm the playhead seeks to that word's start. -- [ ] Play the project and confirm the current word receives the cue highlight as playback advances. -- [ ] Place the caret in the transcript and press `Backspace` or `Delete`; confirm the affected word becomes marked as skipped rather than disappearing from the transcript. -- [ ] Hover a skipped word and activate its restore control by the `aria-label` for restoring that word; confirm the word is kept again. -- [ ] Open the inspector facet with the visible Captions label and confirm caption controls appear. -- [ ] Toggle caption visibility and confirm captions appear or disappear in the preview. -- [ ] Change caption font, alignment, position, size, color, and background controls and confirm each committed change is visible. -- [ ] Select a caption translation language, run translation with a configured provider, and confirm translated captions appear. -- [ ] Switch the caption language back to Original and confirm the source transcript returns. - -### Local transcription and captions — v1.8.0 - -- [ ] Confirm the transcript pane states that transcription runs locally and that no upload occurs when it is started. -- [ ] With the Whisper helper binary absent, activate the transcribe action and confirm the UI reports why nothing happened, and that the main-process log carries exactly one matching `[stt]` line. The failure now reaches a toast (`transcriptionStore.ts`) and the log (`whisperServer.ts`), but the sentence shown is one the app writes for itself — `whisper-stt-server binary not found; build it via scripts/build-whisper-stt.sh`, produced before any helper process starts — so it points a packaged-build user at a script they do not have. Helper stderr is a separate source, and only ever for a helper that did start. Verify against a build whose helper was deliberately not packaged, not only against a working one. -- [ ] Run transcription in the packaged build and confirm the model is fetched or reused without an error about a missing cache directory. -- [ ] Confirm a second transcription reuses the cached model instead of downloading it again. -- [ ] Confirm the completed transcript reports the detected language on the media asset card. -- [ ] Choose an explicit language on the asset card, regenerate, and confirm the new transcript replaces the old one with its own word timings. -- [ ] Confirm word timings are monotonic: click several words in order and confirm each seek lands later than the previous one. -- [ ] Confirm silent stretches appear as a silence span with its duration rather than as missing text. -- [ ] Activate a silence span's trim control and confirm a trim appears on the timeline covering that interval. -- [ ] Restore that silence from the transcript and confirm the trim is removed. -- [ ] Confirm transcription is unavailable with a clear message rather than a crash when the app runs outside Electron. -- [ ] Translate captions, then delete the translation, and confirm the original transcript text and timings are unchanged. -- [ ] Confirm a project carrying caption annotations from the old feature reports them and offers to remove them. -- [ ] Play across a zoom region with captions on and confirm the captions stay in the frame instead of scaling and drifting with the zoom. -- [ ] Export that range and confirm the exported frames show the same caption placement as the preview. - -## AI chat and providers — requires a configured provider - -- [ ] Open the chat panel with the top-bar control identified by its `aria-label` and confirm the chat surface appears. -- [ ] Confirm the chat header shows controls for AI settings, history, and a new conversation. -- [ ] Send a short request and confirm the user message appears in the conversation. -- [ ] Confirm the provider returns an assistant response without an unhandled error. -- [ ] Open the model picker and confirm the active model is visibly selected. -- [ ] Change the reasoning effort when the configured provider supports it and confirm the chosen value remains selected. -- [ ] Run an edit request that creates a supported timeline change and confirm the applied operation is visible in the conversation. -- [ ] Use the conversation rewind control by its `aria-label` and confirm the rewind confirmation surface appears. -- [ ] Confirm a rejected or canceled rewind leaves the timeline unchanged. -- [ ] Open AI settings and confirm the provider list, connection status, and configuration form load. -- [ ] For an API-key provider, enter a key and confirm the provider becomes connected without displaying the raw key afterward. -- [ ] For a device-flow provider, confirm the challenge panel shows a user code and an Open login page action. -- [ ] Open conversation history and confirm the current conversation is listed. -- [ ] Start a new conversation, switch back to the prior one, and confirm each conversation retains its own messages. -- [ ] Rename a conversation with its visible rename control and confirm the new title appears. -- [ ] Delete a conversation with its visible delete control and confirmation prompt, then confirm it no longer appears. - -## Chat-driven editing — v1.8.0, requires a configured provider - -The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-agent.md); it never writes the document freehand. These checks are about the edit actually landing on the timeline, the turn being one undo unit, and a failed turn leaving the project intact. - -- [ ] With no provider connected, open the chat and confirm the "bring your own AI" welcome view appears with the composer disabled instead of an error. -- [ ] Connect a provider and confirm the same panel becomes a usable conversation without restarting the app. -- [ ] Ask the agent to cut the silences and confirm the result appears as trim regions on the timeline rather than as rewritten clips. -- [ ] Confirm the seekable duration after that edit still reaches the full recording, so the trims are reversible. -- [ ] Ask for a zoom on a described moment and confirm a zoom pill appears at approximately the requested time and the preview scales there. -- [ ] Ask for a speed change over a described range and confirm a speed region appears with the requested factor. -- [ ] Ask for a text annotation and confirm it appears in the preview with the requested text. -- [ ] Ask for a full-camera segment and confirm the region appears and the camera fills the frame while it plays. -- [ ] Confirm each applied operation is summarized in the conversation and that the number of summarized operations matches what the timeline gained. -- [ ] Ask the agent to remove one of the modifiers it created and confirm that modifier alone disappears. -- [ ] Ask the agent to restore the full timeline and confirm the trims it added are gone. -- [ ] Confirm modifiers created by the agent are anchored like hand-drawn ones: reorder a clip and confirm they travel with it. -- [ ] After a turn that applied several operations, undo once and confirm the whole turn reverts as a single unit rather than one tool call at a time. -- [ ] Redo and confirm the whole turn returns. -- [ ] Ask for something outside the tool set and confirm the agent explains rather than silently doing nothing or leaving an invalid document. -- [ ] Send a request while the project has no asset and confirm a clear response instead of an unhandled error. -- [ ] Use the rewind control on an earlier user message, confirm in the dialog, and confirm the timeline, the conversation tail, and the later checkpoints all roll back together. -- [ ] Cancel a rewind at the confirmation dialog and confirm both the timeline and the conversation are untouched. -- [ ] Confirm the context badge shows a percentage and that its tooltip reports used and budget tokens. -- [ ] Activate Compact context on a conversation with enough history and confirm an earlier-context summary message appears and the percentage drops. -- [ ] Activate Compact context on a short conversation and confirm the "not enough history" message rather than a failure. -- [ ] Confirm a compaction failure leaves the conversation history unchanged. -- [ ] Use the copy control on an assistant message and confirm the message text reaches the clipboard. -- [ ] Open the timeline toolbar's auto-enhance menu, choose the AI option, and confirm the chat panel opens with the prompt prefilled and sent through the normal send path. -- [ ] Confirm the edit produced by that auto-enhance request can be rewound like any other turn. -- [ ] Choose the AI auto-enhance option with no provider connected and confirm the setup view appears instead of a failed send. -- [ ] Choose the cursor-based automatic zooms option and confirm it adds zooms without involving the provider. -- [ ] Restart the app and confirm conversations are gone while the provider configuration persists; this is a known gap, not a defect to file. -- [ ] Confirm the provider API key is never displayed in the settings form after it is saved. - -## Native compositor preview and export — v1.8.0 - -- [ ] Confirm `[data-testid="native-compositor-mount"]` shows a live composited preview rather than an empty surface. -- [ ] Confirm `[data-testid="native-compositor-error"]` is absent during a normal run. -- [ ] Scrub back and forth across a clip boundary several times and confirm the preview keeps up without a stall on each crossing. -- [ ] Seek to the very end of the project and confirm the last frame is shown instead of a blank or stuck frame. -- [ ] Confirm no loading overlay remains on top of an already valid preview frame. -- [ ] On a machine with no compatible GPU, confirm `[data-testid="native-compositor-cpu-notice"]` appears, the export dialog shows its CPU warning, and the export still completes. -- [ ] Export the same project on macOS and on Windows and compare frames at identical timestamps for background, blur, shadow, roundness, padding, cursor, and text. -- [ ] On macOS, export an MP4 from a project with audio and confirm the output has audio. -- [ ] On macOS, export a frame containing a text annotation and confirm the text is upright, centred in its box, and that its background plate fits the text. -- [ ] On macOS, export a frame containing a blur annotation and confirm the area is actually obscured. -- [ ] On macOS, export a range with the cursor visible and confirm the cursor and its trail are rendered. -- [ ] On macOS, export frames with each 3D rotation preset and confirm the tilt matches the Windows render. -- [ ] On macOS, export a frame with background blur enabled and confirm it matches the Windows render. -- [ ] Export a range containing a zoom with an annotation and captions on screen, and confirm neither follows the zoom in the exported frames. -- [ ] Confirm the packaged macOS app refuses to start or reports clearly when the compositor addon is missing, rather than failing at first render. - -## Export - -- [ ] Confirm the top-bar export control is disabled when the project has no asset. -- [ ] With a loaded project, activate the export control by its `aria-label` and confirm the export dialog opens. -- [ ] Confirm the dialog initially offers MP4 and GIF format choices. -- [ ] Select MP4 and confirm quality choices include a lower tier, a balanced tier, and Source. -- [ ] Select each available MP4 quality and confirm the displayed output dimensions update. -- [ ] Select 24, 30, and 60 FPS and confirm the selected frame rate remains visible. -- [ ] Select H.264 and H.265 and confirm the selected codec remains visible. -- [ ] Select GIF and confirm GIF frame-rate, size, and loop controls appear. -- [ ] Change GIF frame rate and size, toggle looping, and confirm the summary reflects the choices. -- [ ] Start an MP4 export and confirm the native rendering progress reports advancing frames or percentage. -- [ ] Confirm the export dialog reports a saved output path after MP4 completes. -- [ ] Open the exported MP4 outside the app and confirm it plays through the expected duration with audio when the source has audio. -- [ ] Start a GIF export and confirm frame rendering and file writing complete without an unhandled error. -- [ ] Open the exported GIF outside the app and confirm it contains the expected motion and loop behavior. -- [ ] Export a GIF from colour-rich footage long enough to exhaust the encoder's first code widths, and confirm no frame degrades into corrupted stripes or shifted colours partway through. -- [ ] Open that GIF in a second viewer and confirm both decoders agree, since a code-width defect can decode differently per viewer. -- [ ] Export a project containing audio, a trim, a speed region, a zoom, an annotation, captions, and webcam layout changes when available. -- [ ] Compare that exported result with the preview for timing, skipped intervals, audio, webcam, captions, and effects. -- [ ] For every pixel-level comparison, export a frame and measure it with an image tool rather than relying on a preview screenshot. - -## Settings, shortcuts, themes, i18n - -- [ ] Change one shortcut, save it, use the new key in the editor, and confirm it triggers the configured action. -- [ ] Confirm `Ctrl/Cmd+S` saves the current project. -- [ ] Confirm `Ctrl/Cmd+O` opens the project dialog. -- [ ] Open the Background facet and switch among image, color, and gradient tabs. -- [ ] Select a built-in wallpaper and confirm the preview background changes. -- [ ] Choose a color swatch or enter a valid hex color and confirm the background changes. -- [ ] Choose a gradient preset and confirm the preview background changes. -- [ ] Open the Effects facet and toggle background blur, motion blur, shadow, roundness, and padding; confirm each changes the preview. -- [ ] Open the Layout facet and choose each available webcam layout; confirm the preview arrangement changes. -- [ ] Change webcam mirror, reactive zoom when supported, shape, and size; confirm each change is visible. -- [ ] Open the Cursor facet and toggle cursor visibility and clip-to-bounds; confirm the preview changes. -- [ ] Change cursor theme, size, smoothing, motion blur, and click bounce; confirm each committed value remains visible. -- [ ] Toggle the theme control by its `aria-label` and confirm the editor switches between dark and light themes. -- [ ] Open the top-bar language control by its `aria-label`, choose a non-English locale, and confirm visible UI strings change. -- [ ] Switch back to English and confirm the top bar, transport, inspector, and export labels return to English. -- [ ] Select a different aspect ratio from the timeline aspect-ratio menu and confirm the preview frame changes shape. -- [ ] Press `Esc` or click outside an open menu, popover, or dialog and confirm it closes. - -### App menu, About, and updates - -- [ ] On macOS, open the application menu and confirm About OpenScreen is followed by Check for Updates. -- [ ] On Windows and Linux, right-click the tray icon and confirm it lists Check for Updates and About OpenScreen. Outside the editor the tray is the only surface reachable by default there: the HUD is frameless and the editor and notes windows auto-hide their menu bar, so the Help menu appears only while Alt is held over one of those two windows. -- [ ] On Windows and Linux, open the editor, hold Alt, and confirm the Help menu lists Check for Updates and About OpenScreen. -- [ ] In the editor, click the OpenScreen wordmark in the top bar and confirm it opens a menu listing Keyboard Shortcuts, AI settings, Check for Updates and About OpenScreen. This is the discoverable path on Windows and Linux, where the two above are not. -- [ ] Confirm the About row in that menu shows the running version, and that it matches what the About box then reports. -- [ ] Open the wordmark menu and pick Keyboard Shortcuts; confirm the shortcuts configuration dialog opens and that only one dialog appears. -- [ ] Open the wordmark menu and pick AI settings; confirm it opens the same provider dialog the AI panel's gear does, and that only one dialog appears. -- [ ] Repeat that in Media mode, in Rec mode, and in Edit mode with the chat panel collapsed — the three states in which the dialog had no owner before, and the reason the row must not be Edit-only. -- [ ] Connect or disconnect a provider from the menu's dialog while the chat panel is open behind it, close the dialog, and confirm the composer and the model pill follow without reopening the panel. -- [ ] Open the wordmark menu, then press Escape, click elsewhere in the top bar, and click the wordmark again — confirm each closes it and that the window does not start dragging instead of registering the click. -- [ ] With the wordmark menu open, walk it with the Down and Up arrows and confirm focus wraps at both ends. -- [ ] Switch the app language and confirm the wordmark menu's four labels follow — the first two matching the dialogs they open, the last two the wording the macOS app menu and the tray use. -- [ ] Open About and confirm it names the running version, the Electron/Chromium/Node versions, and the install channel. -- [ ] Confirm the About box opens in front of the HUD rather than behind it. -- [ ] On Windows and Linux, press Copy in the About box and confirm the clipboard holds that same block. -- [ ] Open the HUD's device-settings panel and confirm its About row reports the same version. -- [ ] Run Check for Updates from the menu, from the tray, and from the HUD panel, and confirm each reaches the same result dialog. -- [ ] Start a second check while one is still running and confirm the HUD button stays disabled and reads "Checking…" until the first check's dialogs are done, rather than re-enabling into a click that does nothing. -- [ ] Start a recording, then confirm Check for Updates is gone from the app menu, the Help menu and the tray for as long as the take runs, and returns when it stops. -- [ ] Open the HUD's device-settings panel, start a recording with it still open, and confirm the update button disappears while the version stays. Then stop the take, reopen the panel, and confirm the button is back — a HUD that mounts mid-take must not lose it permanently. -- [ ] On a Microsoft Store, Flathub, Snap, or Nix install, confirm no update affordance appears in the menu, the tray, or the HUD panel, while the version still shows. - -### New effects and controls — v1.8.0 - -- [ ] Set a zoom's custom scale beyond the preset levels, commit it, and confirm the preview scale and the retained value both follow. -- [ ] Activate the timeline's global auto-focus toggle and confirm every zoom switches to automatic focus. -- [ ] With the global toggle on, open a zoom's focus-mode control and confirm it reports being controlled globally instead of silently ignoring a per-zoom change. -- [ ] Turn the global toggle off and confirm per-zoom focus mode becomes settable again. -- [ ] Set a speed above the native playback limit and confirm the preview reports that it is frame-stepped and muted. -- [ ] Export that range and confirm the exported timing is correct despite the frame-stepped preview. -- [ ] Enter a speed above the maximum and confirm the limit message rather than a silently clamped value. -- [ ] Enable the webcam's shrink-on-zoom option and confirm the camera shrinks while a zoom plays and returns afterwards. -- [ ] Choose each webcam layout preset, including vertical stack and dual frame, and confirm the preview arrangement changes. -- [ ] Choose each webcam shape and confirm the mask changes in the preview. -- [ ] Turn the cursor's clip-to-canvas option off, zoom in, and confirm the cursor may extend past the frame edge; turn it on and confirm it is kept inside. -- [ ] Apply each text animation in turn and confirm the animation runs when the playhead enters the region. -- [ ] Toggle an annotation's background off and back on and confirm the previously chosen colour returns instead of black. -- [ ] Switch a blur annotation between gaussian and mosaic and confirm intensity and block-size controls follow the chosen type. -- [ ] Set a blur shape to oval and confirm the obscured area is elliptical in the preview. -- [ ] Draw a freehand blur shape and confirm the preview follows the drawn outline. -- [ ] Export a frame containing that freehand blur and confirm the export covers its bounding box, which over-covers rather than under-covers, as the inspector states. -- [ ] Add a Google font through the custom-font dialog and confirm it appears in the font selector and renders in the preview. -- [ ] Enter an invalid font URL and confirm the error message rather than a stuck adding state. -- [ ] Open the crop dialog, change the ratio with aspect lock on and off, apply, and confirm the preview reframes. -- [ ] Confirm a cropped project exports with the cropped framing rather than the original. - -## Persistence (save, reopen, reload) - -- [ ] Make a project change and confirm the top bar shows an unsaved indicator. -- [ ] Activate the top-bar save control by its `aria-label` and confirm the indicator changes to the saved state. -- [ ] Close and reopen the project from the Open Project dialog and confirm the asset and project title match before closing. -- [ ] Confirm clip order and each clip's in/out and crop settings survive reopen. -- [ ] Confirm trim, zoom, speed, annotation, and full-camera regions survive reopen with their positions and values. -- [ ] Confirm background, effects, layout, webcam, cursor, aspect-ratio, and caption settings survive reopen. -- [ ] Confirm the transcript and skipped-word ranges survive reopen. -- [ ] Confirm the seekable duration after reopen reaches the recording duration, not merely the end of the last region. -- [ ] Make a change, attempt to open another project, choose Cancel in the unsaved-changes prompt, and confirm the current project remains loaded. -- [ ] Make a change, choose Save in the unsaved-changes prompt, and confirm the next project opens after saving. -- [ ] Make a change, choose Discard in the unsaved-changes prompt, and confirm the next project opens without the discarded change. -- [ ] Open a project saved by a previous release and confirm it loads without a schema error. -- [ ] Confirm every modifier in that migrated project sits on the clip content it covered before, not at a shifted ruler position. -- [ ] Confirm a migrated project that had a region straddling two clips still renders it as one pill while the clips remain adjacent. -- [ ] Save the migrated project, reopen it, and confirm nothing shifted on the second round-trip. - -## Platform-specific - -### Windows - -- [ ] Run the complete capture-to-export flow on real Windows with the packaged build. -- [ ] Confirm a screen source and a single-window source both produce non-black video. -- [ ] Confirm the system tray icon appears and changes to a recording state while recording. -- [ ] Right-click the tray icon while recording, choose Stop Recording, and confirm the editor opens. -- [ ] Confirm the HUD and notes window are excluded from captured video when content protection is enabled. -- [ ] Disable hardware H.264 if the test machine supports that diagnostic path and confirm the software-encoder notice is clear and non-blocking. -- [ ] Switch the recording HUD between displays and confirm it remains positioned on the intended display. -- [ ] Switch the desktop to an odd-pixel window size and confirm the recorded frame dimensions remain valid. -- [ ] Open Settings diagnostics when available and confirm a diagnostic bundle can be written. - -### macOS - -- [ ] Run the complete capture-to-export flow on real macOS with the packaged build. -- [ ] Grant screen-recording, microphone, and camera permissions and confirm the app reflects the granted devices. -- [ ] Record while switching Spaces with the HUD visible and confirm recording continues. -- [ ] Stop a recording and confirm the editor opens without a crash during native recorder shutdown. -- [ ] Confirm the tray or menu-bar item can refocus the HUD after it is hidden. -- [ ] Confirm the HUD and notes window are excluded from captured video when content protection is enabled. -- [ ] Confirm a physical webcam picture-in-picture records and plays back with the selected layout. -- [ ] Export MP4 and GIF and confirm both files open in a native macOS media viewer. -- [ ] Confirm closing and relaunching the packaged app does not leave an orphaned capture or editor window. -- [ ] On the newest supported macOS, confirm the HUD and notes windows are visible on screen rather than blanked by content protection. -- [ ] Confirm the HUD opens without waiting for the microphone permission prompt to be answered. -- [ ] Confirm local transcription reports the device it actually ran on and completes on a Metal-capable machine. -- [ ] Confirm the packaged `.app` contains the compositor addon and that the addon carries no build-machine path. -- [ ] Confirm the packaged `.app` bundles its ffmpeg libraries and runs on a machine with no developer toolchain installed. -- [ ] **On a Retina/HiDPI display**, record the screen and confirm the recorded frame is filled edge to edge — not the desktop drawn small in one corner of a black rectangle. Then do the same for a single window. Issue #418 shipped exactly this, invisible on every 1× display because a point size and a pixel size are the same number there; `SCStreamConfiguration` does not scale a frame up to fill an oversized buffer, so the surplus stays background black. Check the frame, not just the file's dimensions — the reporter's `.mp4` was 3024×1898 as expected and still wrong inside. -- [ ] **With a second display attached at a different scale factor**, record each display in turn and confirm both fill their frame. A machine whose displays all share one scale factor cannot catch a units mix-up. - -### Linux - -- [ ] Run the complete editor-to-export flow on real Linux with the supported packaged or development build. -- [ ] Confirm the HUD remains interactive on the supported Linux window manager. -- [ ] Select a screen source in the compositor's portal picker and confirm the resulting recording is not black. -- [ ] **Select a single WINDOW in the portal picker and confirm the recording contains only that window, at the window's dimensions — not the whole screen.** Check the pixel size, not just the look of it: `ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 ` should report the window's size, never the monitor's. This is the case that shipped broken in 1.8.0. -- [ ] Record twice in a row and confirm the portal picker appears BOTH times, and that choosing a different source the second time actually changes what is recorded. -- [ ] Confirm the HUD shows no in-app source button on Linux, and that the record button starts a recording directly instead of opening a picker. -- [ ] Confirm the portal picker appears BEFORE the 3-2-1 countdown, not during or after it. -- [ ] Start the same flow from the editor's Rec stage ("Start recording") and confirm it behaves identically to the HUD — no source row, picker first, then countdown. -- [ ] Cancel the countdown after answering the picker and confirm the compositor's "screen is being shared" indicator goes away rather than lingering. -- [ ] Confirm the system tray or supported desktop indicator can refocus the HUD when it is hidden. -- [ ] Confirm microphone capture works with a physical device and the chosen device is audible in playback. -- [ ] Confirm the webcam toggle reflects the available physical camera or clearly reports that no camera is available. -- [ ] Confirm the native compositor preview loads without a blank surface or renderer crash. -- [ ] Export MP4 and GIF and confirm the files open in a system media player. -- [ ] Close and relaunch the app and confirm a saved project can be reopened without data loss. - -## Results log - -| Date | Build / tag | Platform | Pass/fail | Notes | -|------|-------------|----------|-----------|-------| -| 2026-07-31 | dev build, `claude/e2e-tests-v1-8-0-474894` (e9578f09) | macOS 26.5, M1 | Partial — 1 defect | Ran launch/HUD, media, modifier anchoring, and export. **Defect: a dangling asset blanks the preview.** Modifier anchoring across a reorder verified in preview and in the exported frames. macOS export produced 1280×720 h264 + AAC at ~2× realtime. Chat sections skipped: no AI provider configured. HUD drag not runnable under computer-use (drop point is the desktop). | -| 2026-08-13 | installed `v1.9.5-rc.1` | Windows 11 26200, 1920×1080 @ 100% | Partial — 1 defect | Ran launch/HUD, source selection, recording, stop, editor open. Fragmented MP4 confirmed on the shipped artifact: 48 `moof`+`mdat` pairs over 47.6s, `mvex` present, `mfra` on clean stop. **Defect: a recording that survives a helper kill is thrown away by the app** — killing `wgc-capture.exe` mid-recording leaves a fully decodable 41s file (2460 packets, `ffmpeg -f null -` exit 0) with no `.session.json` and no `.cursor.json`, and stop answers "The recording could not be saved". Fixed in #363, re-verified end to end. Truncation ablation at 60%: plain MP4 unreadable, fragmented plays 29s. **A dev build cannot test any of this** — the prebuilt worktree helper predated the change and silently ran the old path. Editor/export/chat sections not run. | -| 2026-08-14 | `release/v1.9.5` @ `b1b81de5` (rc.2 candidate: dev TS + the CI-built rc.1 native payload, which is byte-identical since no native source changed) | Windows 11 26200, 1920×1080 @ 100% | Pass — no defect | Regression net across the 65 commits since **v1.9.2**, not just the rc.2 delta. Four recordings. Every one a fragmented MP4 (`mvex` + ~1 `moof`/s, `mfra` only on a clean stop). GPU DXGI path still correctly opt-in (`videoInput: cpu-rgb32`) — the #336 regression has not crept back. No capture-pacing drift: HUD `00:59` → 60.067s at 60/1. Waveform correct in both directions: absent with no audio track, rendered with one. Audio muxes into the fragmented container (AAC 48k stereo) with 15 ms A/V drift, under one frame. Compositor renders and exports with no camera declared. **#366**: reopening returns to the saved project with its settings (Blur BG on, padding 9%) and mints no second project — 167→168 across a whole new recording. **#363**: helper killed mid-recording → editor opens on the recovered take (46 `moof`, no `mfra`), all three sidecars written, imported once. Export MP4 1080p60 **from that recovered take**: 46.0s / 2760 packets, decodes clean, duration matches the source exactly. Tray refocus works. NOT covered: DPI scaling — **not re-run here, already validated when `60bb6d7c` / `71cc88d6` landed**; note that the display scale is a setting, so "this machine is at 100%" is never a reason a DPI bug cannot be tested (flip it to 150%, ~2 min). Also not covered: webcam PiP and the export webcam fixes, microphone, GIF, macOS/Linux, AI sections, packaging. | -| 2026-08-14 | installed `v1.9.5-rc.1`, macOS Apple Silicon DMG (CI-built, Developer ID signed). **rc.2 is not published** — only rc.1 exists on Releases; no native source changed between `v1.9.5-rc.1` and `origin/release/v1.9.5`, so this artifact already carries the rc.2 native payload, but #366 (cross-platform TS) is absent from it | macOS 26.5 (25F71), M1, 1920×1080 @ 2× | **Fail — 1 blocker** | **The plan's assertion-1 criterion does not hold on macOS, in both directions.** On a clean stop `AVAssetWriter.finishWriting()` collapses the fragments into a normal movie: `ftyp mdat moov`, `mvex` ABSENT, 0 `moof`, no `mfra` (45 s / 44.4 MB run). That is exactly the shape the plan calls the headline failure — and the pre-`a6795d23` control recording (2026-08-10) has the *same* shape — so **a clean-stop box walk cannot distinguish fragmented from plain on macOS; only the kill test can.** Fragmenting *is* active: the takes whose writer died mid-fragment retain `mvex` + ~1 `moof` per second of media (shipped-build writer-failure samples: 35 `moof`/36.0 s, 14/15.0 s, 3/4.0 s; plus 18 on a surviving-helper kill). The one kill on the shipped build is the exception that proves the scope — capture had already stalled ~12 s before the kill, so it carries `mvex` but **0 `moof`** and only 1.0 s. No macOS file, clean or killed, ever carried `mfra`. **Blocker: every app-driven recording truncates, then the app discards it.** (Root cause and fix reported in #375 — the fragments carry a negative composition offset in a version 0 `trun`, where ISO/IEC 14496-12 8.8.8.2 defines the field as unsigned, because frame reordering was left on; `AVVideoAllowFrameReorderingKey: false` clears it and restores the crash-resilience the fragmenting was for. Verified at helper level there; **this rc.1 run only reproduced the failure and validated nothing about the fix**. Re-run this section against a CI build carrying #375 before rc.2 ships.) 3/3 takes stopped writing early while the HUD kept counting — media 4.0 s / 36.0 s / 15.0 s against HUD `02:02` / `01:30` / `01:04`. Helper emits `{"event":"error","code":"writer-failed"}`; main log `AVFoundationErrorDomain Code=-11800 … (-16341)`. Stop then hangs ~30 s on "Saving…" and drops the take: no `.session.json`, no `.cursor.json`, no editor. The app *does* surface the raw error in a toast (confirmed by hand on the same machine at 13:28–13:35 — my automated runs screenshotted after it auto-dismissed, so an earlier draft of this row wrongly said there was none). 44,561,966 / 328,337,979 / 139,631,607 / 17,187,009 bytes decodable and thrown away (147 GB free — not disk). Reproduced standalone with the shipped helper at 1080p30/8 Mbps, 2/2 (~9 s, ~5 s), so it is not confined to the app's 4K60 path — but do not read that as load-independent: append rate demonstrably modulates how reliably it bites (#375 measures it reliable at ~57 fps and intermittent at 30 fps). **Reproduced by hand, no automation involved**, on six takes recording a YouTube page — and those six separate the trigger cleanly: **system audio ON → 3/3 died at ~1.0 s and minted 0 projects; system audio OFF → 3/3 survived (3.3 s, 7.4 s, 25.0 s) and minted 1 project each.** **Audio is not the condition, only an accelerant** — a controlled run with system audio off *and not one screenshot taken during the capture* (the screenshot layer hides non-allowlisted windows, so it was the last confound worth eliminating) died the same way: 8.008 s of video, 79,004,330 bytes then flat for 76 s with the helper still alive, 7 `moof`, 0 sidecars, 0 projects, same `-11800`/`-16341`. What audio changes is the window: with a track it is ~1 s, without one ~4–40 s. That reconciles the by-hand takes with mine — a take short enough to stop before the writer dies is clean, which is why 3.3 s and 7.4 s survived and 8.0 s did not, and why the 25.0 s one minted a project while still carrying `mvex` (never cleanly finalised). **Turning audio off is therefore not a safe workaround.** Untested here: microphone — this Mac has no input device, and whether a mic track triggers the same path is an inference, not a measurement. **Helper A/B narrows the with-audio path to the fragmentation line**: helper built twice from source identical to the rc.1 tag, differing only by `writer.movieFragmentInterval` (701 vs 700 lines) — with system audio at 1080p30, WITH the line `writer-failed` 2/2 (2.0 s, 1.0 s), WITHOUT it clean `recording-stopped` 3/3 (40.6 s, 37.9 s, 37.6 s). **Read those counts as a sample, not a law**: a later rebuild of the with-the-line arm survived 22.2 s at the same settings, so the failure is probabilistic and rate-dependent, and the byte-level evidence in #375 is what actually carries the case. The video-only local-vs-shipped gap (local survived 45 s, shipped failed 5/5) is explained by the same variable rather than by the released artifact — the shipped runs encoded at 56.6 fps against 29 fps locally. **Kill test** is confounded on the shipped build (capture already dead before the kill): 17.19 MB → only 1.0 s / 56 packets, 0 `moof`. On a helper that does not fail, a mid-write kill leaves 18 `moof`, decodes clean (`ffmpeg -v error -f null -` exit 0, 1373 packets) and no `mfra` — the shape the plan expects. **#363 gap confirmed, and on macOS it fires with no kill at all**: `writer-failed` alone loses the take; there is no app-side recovery. **Audio**: AAC 48 kHz stereo muxes into the fragmented container, video start `0.000000` vs audio `0.014479` → 14.5 ms drift, under one frame at 30 fps (measured on the 2.0 s written before the writer died). **Compositor + export pass**: preview renders with no camera declared; export MP4 1080p60 H.264+AAC via `h264_videotoolbox (zero-copy VT)`, 5,726,865 bytes, 318 packets, decodes clean, duration matches to within 7 ms — source 26.713 s minus trims 19.910 + 1.513 = 5.290 s expected vs 5.283 s measured, under one frame at 60 fps. **#366 not runnable as specified** (absent from rc.1, rc.2 unpublished, and record→editor never completes); adjacent behaviour measured on an existing project — close+reopen kept 19→19 projects, exactly ONE project references the recording, and Blur BG / padding survived (`showBlur=true`, `padding=16`). NOT covered: Windows-only DPI and wgc-capture, GIF, AI sections, packaging (per plan); webcam PiP and microphone — this Mac has neither (Device settings reports "No microphone found" / "No camera found"). | -| | | | | | -| | | | | | +# Manual end-to-end checklist + +This checklist covers the real desktop capture-to-export path: the parts that unit, browser, and Playwright tests cannot exercise, including real screen capture, a physical webcam, the system tray, the native compositor, and export. Run it before promoting a release candidate and after any change to native capture, preview, or export. + +**"Manual" is about the input, not the operator.** These checks need real OS mouse and keyboard events, not a human hand — so an agent with the computer-use MCP runs them, on demand, and a request for one section after a targeted change is as much a run as the whole file before a promote. Availability check and the rule on partial runs: [AGENTS.md](../../AGENTS.md#desktop-e2e-testing-with-computer-use). + +Sections marked **v1.8.0** cover what this release changed: chat-driven editing through the agent tool set, clip-anchored modifiers, local transcription, the macOS Metal compositor, and the new effect controls. Run the whole file for a release candidate; the v1.8.0 sections are the ones with no prior release to fall back on. + +## How to run this + +1. Drive the real Electron app with computer-use — real OS mouse and keyboard events. Start a dev build with `npm run dev`, or launch the packaged build under test. + + "Manual" here usually means an agent holding the mouse, so the tempting shortcut is not a browser shim: it is driving the real app through CDP instead. **Do not.** Playwright's `.click()`, `javascript_tool`-dispatched pointer events and anything else synthesised into the renderer arrive *below* the OS hit-test. On Windows and macOS the HUD is input-transparent until a real cursor move lifts it, so an injected click fires the DOM handler and comes back green while the path a user actually takes was never exercised at all. + + That trap is specific to the HUD and the countdown overlay — they are the only click-through windows; the editor is an ordinary one, and an injected click there does reach the handler a user would reach. The reason not to inject in the editor either is the first line of this file: this checklist covers what unit, browser and **Playwright** tests cannot. Drive it the way those tests already drive it and you have re-run the coverage you had, then written "passed" beside the parts nothing checked. +2. The app is single-instance per `userData` path. If a leftover Electron/OpenScreen process still holds the lock, stop that process before relaunching; a second launch can exit successfully without opening a window. The lock is held by the OS and is released when the process dies, so there is nothing to delete on disk. +3. From a worktree, link or junction `node_modules` to the main checkout and provide the prebuilt native capture binaries for the platform before starting the dev build. **Date those binaries against the change you came to test.** Nothing rebuilds them, so an older helper runs the old code path in silence: the recording succeeds and the thing you were checking for is simply absent. See AGENTS.md for how to check one and how to rebuild it; when you cannot, test the CI-built artifact, because a dev build cannot answer a native question. +4. Grant computer-use access to the process name that actually owns the window: `electron.exe` or `Electron.app` for a dev build, and `Openscreen.exe` or `Openscreen.app` for a packaged build. Do not grant access only to the installed app name when testing a dev build — it resolves to the *installed* executable and reports success while the dev window stays masked. This is step 4 and not step 1 for a reason: a dev build is not an installed app, so the resolver cannot find it until it is running and owns a window, and one unresolvable name voids the whole request. + + **Ask for everything in ONE call — after the launches above, before the first check.** `request_access` takes a list, and once a grant is in place the rest of the pass runs without a single further prompt: a full capture-to-export run is dozens of clicks and none of them ask again. So the only thing keeping a human at the keyboard is *how many* dialogs you raise and *when*. Raise one, before the first check, and the operator can walk away for the rest of the run; discover a fourth app you need an hour in and they cannot. That is also why this cannot move earlier — the resolver needs the app running, and one unresolvable name voids the batch. Beyond the app under test, ask for: + + - the desktop shell — the tray icon and the native save dialogs live there, and the tray is the only reliable way back to the HUD; + - the OS settings app — needed to change display scaling, which is how DPI checks are run (see AGENTS.md; "the machine is at 100%" is not a reason to skip them). + + **Name them the way the Start menu does, in the system's own language.** The resolver matches installed-app display names, not executables: on a French Windows the shell is `Explorateur de fichiers` and `explorer.exe` fails outright — `notInstalled`, with a nonsense suggestion attached — which then voids every other name in the same call. On an English install it is `File Explorer`. When unsure, ask rather than guess; the tool lists the installed names it knows. + + There is no way to pre-approve any of this in config: the request has to be answered live. That is upstream ([claude-code#46907](https://github.com/anthropics/claude-code/issues/46907), closed stale), and `bypassPermissions` does not cover it either ([#43172](https://github.com/anthropics/claude-code/issues/43172)). Batching is the whole mitigation. +5. Read [AGENTS.md](../../AGENTS.md) for the computer-use mechanics, screenshot permissions, tray interaction, and cleanup procedure. Read one check, perform it, observe the result, then continue; close each modal or popover with `Esc` before the next check. +6. The recording HUD is protected from capture by default and is invisible in screenshots. For this session only, launch with `OPENSCREEN_DISABLE_CONTENT_PROTECTION=1`; this is the environment variable checked before `setContentProtection(true)`. Unset it before making any recording whose HUD must not appear in the video. +7. A preview screenshot is downscaled. Settle every pixel-level question by exporting a frame and measuring the exported frame, not by judging fine edges, corners, shadows, or alignment from the preview screenshot. +8. Keep the first real recording or imported project available for the editor sections. Log crashes, hangs, data loss, security issues, and reproducible visual failures as soon as they occur. +9. Several v1.8.0 sections need a configured AI provider (chat editing, caption translation) or a built native compositor addon (preview, export). A dev build from a worktree needs the compositor addon installed for its platform, not only the capture binaries. When a prerequisite is missing, record the section as skipped with the reason; do not mark it passed. +10. Prefer a project with at least two clips from the same asset for the modifier sections. A single-clip project cannot exercise anchoring, reorder, or cross-boundary splitting at all, which is where the v1.8.0 timeline model changed. + +## Launch and HUD + +- [ ] Start the app and confirm one launch window appears without a startup crash. +- [ ] Confirm the launch window remains usable after the first device enumeration completes. +- [ ] Confirm the HUD is visible when content protection is disabled for the test session. +- [ ] Activate `[data-testid="launch-tray-layout-button"]` and confirm the tray changes between horizontal and vertical layouts. +- [ ] Confirm the chosen tray layout remains coherent when the HUD grows to show recording controls. +- [ ] Activate `[data-testid="hud-drag-handle"]`, drag the HUD across most of the primary display, and confirm it follows the pointer without drift. +- [ ] Release the drag and confirm the HUD stays at the dropped position instead of jumping. +- [ ] Activate the language button by its visible language code and confirm a menu of locale choices opens. +- [ ] Press `Esc` with the language menu open and confirm it closes without changing the locale. +- [ ] Activate the minimize control and confirm the HUD hides without quitting the app. +- [ ] Refocus the app from its system-tray icon and confirm the HUD returns to the foreground. +- [ ] Activate the close control while idle and confirm the HUD closes cleanly. +- [ ] Relaunch the app after closing it and confirm the single-instance behavior does not leave a duplicate HUD. + +## Source selection and recording + +- [ ] Activate `[data-testid="launch-source-selector-button"]` and confirm the source selector opens. +- [ ] Select a screen or application card with `data-testid="source-selector-card"`, activate `[data-testid="source-selector-share-button"]`, and confirm the selector closes with the source name on the HUD. +- [ ] Confirm `[data-testid="launch-record-button"]` is disabled until a source is selected, then activate it and confirm recording starts with a red stop state and an increasing elapsed timer. +- [ ] Confirm the configured system-audio, microphone, webcam, and cursor states remain visible while recording. +- [ ] Activate the recording control's pause action and confirm the timer stops advancing, then resume and confirm it advances again. +- [ ] Activate the restart action while recording and confirm the current recording is discarded and a fresh recording begins. +- [ ] Activate the cancel action while recording and confirm recording ends without opening an editor for the canceled take. +- [ ] Confirm stopping opens the editor with the recorded screen asset loaded. +- [ ] On Windows, stop once with system audio, microphone, webcam, and cursor all disabled and confirm the editor opens within a few seconds. +- [ ] Record once with microphone only and confirm the resulting playback contains audible microphone audio. +- [ ] Record once with system audio only and confirm the resulting playback contains audible system audio. +- [ ] Record with microphone and system audio enabled and confirm both sources are audible and reasonably balanced. + +## Editor opens and loads the project + +- [ ] Confirm the editor opens after a successful stop with the expected project title and asset. +- [ ] Confirm `[data-testid="preview"]` is present and its current-time value starts at the beginning of the project. +- [ ] Confirm the loaded video is visible in the preview rather than an empty state or broken-video state. +- [ ] Confirm the timeline contains a clip for the recorded or imported asset. +- [ ] Activate the project rename control by its `aria-label`, enter a new non-empty title, and confirm the title changes. +- [ ] Confirm the top bar shows an unsaved state after changing the project title. +- [ ] Switch among the Media, Edit, and Rec editor modes and confirm each selected tab visibly changes state. +- [ ] Confirm the editor's preview, timeline, and inspector remain usable after switching modes. +- [ ] Activate the left-panel toggle by its `aria-label` and confirm the chat/media panel opens or closes without changing the project. +- [ ] Resize the chat panel by its visible divider and confirm the preview area resizes without moving the timeline content. +- [ ] Resize the timeline by its visible top divider and confirm the timeline height changes without a layout crash. + +## Transport and preview + +- [ ] Activate the playback control with the `aria-label` for play/pause and confirm `[data-testid="preview"]` changes `data-is-playing` from `false` to `true`. +- [ ] Activate play/pause again and confirm playback stops and the preview reports `data-is-playing="false"`. +- [ ] Confirm the transport time readout advances while playback is running. +- [ ] Confirm the playhead advances with the video instead of remaining at its starting position. +- [ ] Drag the transport seek range control with the `aria-label` for seeking and confirm `[data-testid="preview"]` reports the new current time. +- [ ] Seek while paused and confirm the preview frame changes to the selected time. +- [ ] Seek while playing and confirm playback continues from the new time without a visible stuck frame. +- [ ] Activate the loop control and confirm its pressed state changes. +- [ ] Play through the end with looping enabled and confirm playback returns to the loop start. +- [ ] Activate the fullscreen control and confirm the preview enters fullscreen presentation. +- [ ] Exit fullscreen and confirm the normal editor layout returns. +- [ ] With a webcam recording, confirm the webcam picture-in-picture appears aligned with the screen content. +- [ ] Add a full-camera segment, scrub into it, and confirm the webcam grows to fullscreen then returns at the segment end. +- [ ] Confirm the preview's webcam, cursor, background, and region effects remain synchronized while scrubbing. + +## Timeline navigation (pan, zoom, scrub) + +- [ ] Confirm the timeline ruler displays time labels from the project start through its duration. +- [ ] Click a position on the ruler and confirm the playhead and preview seek to that time. +- [ ] Drag across the ruler or timeline track and confirm the playhead follows the pointer. +- [ ] Hold `Ctrl` while scrolling over the timeline and confirm the timeline zooms around the pointer position. +- [ ] Hold `Shift` while scrolling over the timeline and confirm the visible time range pans without changing the project. +- [ ] Drag the timeline with the middle mouse button and confirm the visible time range pans. +- [ ] Confirm the playhead remains aligned with the ruler and clip positions after zooming and panning. +- [ ] Drag the navigator window and confirm the main timeline follows its visible range. +- [ ] Drag a navigator handle and confirm the visible range narrows or widens without changing clip data. +- [ ] Confirm an empty-area click clears any selected region and closes its selection inspector. +- [ ] Confirm the reworked ruler keeps readable labels at the narrowest and widest zoom levels rather than colliding or disappearing. +- [ ] Confirm the playhead stays exactly on the time it reports after zooming, panning, and resizing the timeline. +- [ ] Change the project title, save, and toggle the export control's availability, and confirm the top bar keeps its layout instead of reflowing on each state change. + +## Clip operations + +- [ ] Open the Media panel and confirm the project asset is listed with its source name. +- [ ] Drag a listed media asset into the timeline clip area and confirm a new clip appears. +- [ ] Click a clip and confirm it receives a selected visual state. +- [ ] Drag a selected clip before another clip and confirm the clip order changes. +- [ ] Double-click a clip and confirm the Edit Clip dialog opens. +- [ ] Change the clip start in-point in the dialog and confirm the clip duration changes. +- [ ] Change the clip end in-point in the dialog and confirm the clip duration changes. +- [ ] Confirm the clip's crop or in/out changes affect the preview after closing the dialog. +- [ ] Select a clip and activate the delete control with the `aria-label` for deleting a clip; confirm only that clip is removed. +- [ ] Select a clip, use the configured copy and paste shortcuts, and confirm a duplicate clip appears. +- [ ] Select more than one clip when supported and confirm the edit control offers a clip picker rather than editing an unspecified clip. + +## Regions (trim/skip, zoom, speed, annotation) + +- [ ] Drag a trim region's left edge and confirm its start time changes. +- [ ] Drag a trim region's right edge and confirm its end time changes. +- [ ] Scrub across a trim region and confirm the preview skips the marked interval during playback. +- [ ] Delete the selected trim region from its inspector and confirm the interval is restored. +- [ ] Activate the timeline tool with the visible zoom label and confirm a zoom region appears. +- [ ] Select the zoom region and cycle its level through multiple available depths; confirm the preview scale changes. +- [ ] Drag the zoom focus point in the preview and confirm the zoom follows the new focus. +- [ ] Change the zoom rotation preset among none, iso, left, and right and confirm the preview orientation changes. +- [ ] Set a zoom region to automatic focus and confirm its focus follows cursor telemetry across the whole region. +- [ ] Use the automatic-zooms menu and confirm it adds suggested zoom regions when cursor telemetry supports suggestions. +- [ ] Select a zoom region and delete it from the selection inspector; confirm it disappears from the lane. +- [ ] Activate the timeline tool with the visible speed label and confirm a speed region appears. +- [ ] Change the speed region through its preset selector and confirm the lane label and preview timing change. +- [ ] Enter a custom speed in the speed field, commit it, and confirm the custom value remains selected. +- [ ] Play across a speed region and confirm the preview reflects the region's speed. +- [ ] Select a speed region and delete it from its inspector; confirm normal speed returns. +- [ ] Activate the timeline tool with the visible annotation or comment label and confirm an annotation region appears. +- [ ] Select a text annotation, replace its text, and confirm the new text appears in the preview. +- [ ] Change the text color and toggle its background; confirm both changes are visible in the preview. +- [ ] Change the text animation using the control with the `aria-label` for selecting text animation and confirm the animation runs when the playhead enters the region. +- [ ] Convert an annotation to an image, upload a supported image, and confirm the image appears in the preview. +- [ ] Convert an annotation to a figure, change its arrow direction and stroke width, and confirm the figure changes. +- [ ] Convert an annotation to blur, change its blur type and shape, and confirm the selected area is obscured. +- [ ] Drag an annotation in the preview and confirm its position persists when the playhead leaves and returns. +- [ ] Select an annotation and delete it from its inspector; confirm it disappears from the preview and lane. +- [ ] Use undo and redo after adding, editing, and deleting at least one region and confirm each operation restores the prior state. + +## Modifiers are anchored to clips — v1.8.0 + +Zoom, speed, annotation, and full-camera regions are stored against a clip in that clip's own source time, not at an absolute ruler position. See [timeline-model.md](../architecture/timeline-model.md). These checks exist because the failure mode is silent: the pill stays where it was drawn while the effect fires somewhere else. + +- [ ] Draw a zoom wholly inside one clip, reorder that clip to another position, and confirm the zoom travels with the clip and keeps its length. +- [ ] Confirm the moved zoom still fires over the same picture content, not at the ruler position it originally occupied. +- [ ] Draw a region across a boundary between two clips, move one of those clips away, and confirm the region splits into one pill per clip instead of remaining one pill at the old position. +- [ ] Put the two clips back side by side and confirm the fragments render as a single pill again. +- [ ] Confirm two regions of the same kind with identical properties that touch display as one pill. +- [ ] Change one of the two merged regions and confirm the pill separates into two. +- [ ] Drag a zoom pill into a neighbouring zoom with a different level and confirm it clamps at the neighbour's edge and the neighbour does not move. +- [ ] Confirm the same repel behaviour for two speed regions with different speeds. +- [ ] Add a trim inside a clip that a zoom already covers and confirm the covered part is hidden without shifting any later region on the ruler. +- [ ] Confirm the ruler still shows the trimmed span occupying its place while playback skips it. +- [ ] Delete a clip and confirm modifiers anchored only to that clip disappear while modifiers on other clips are untouched. +- [ ] Duplicate a clip and confirm its modifiers are duplicated with the copy. +- [ ] Change a clip's in and out points in the Edit Clip dialog and confirm anchored modifiers clamp to the new range rather than drifting past it. +- [ ] Select a zoom, copy its attributes with the configured copy shortcut, select another zoom, paste, and confirm the copied toast appears and the target adopts level, rotation, and focus without changing its own span. +- [ ] Repeat the attribute copy and paste for a speed region and for a text annotation. +- [ ] Trigger copy with nothing selected and confirm the "select a region" message rather than a silent no-op. +- [ ] Trigger paste before anything was copied and confirm the "nothing copied yet" message. +- [ ] Save, reopen the project, and confirm every modifier is still on the same clip content after the reorder performed above. +- [ ] Zoom and pan the timeline and confirm each pill's span still matches the time at which its effect fires in the preview. +- [ ] Export a short range that covers a reordered clip and a trim, and confirm the exported frames agree with the preview about where each modifier fires. + +## Transcript and captions + +- [ ] With no transcript, confirm the pane offers a transcribe action instead of showing an empty editor. +- [ ] Start transcription for the loaded asset and confirm a visible in-progress state appears. +- [ ] Confirm a completed transcription displays words in timeline clip order. +- [ ] Click a transcript word and confirm the playhead seeks to that word's start. +- [ ] Play the project and confirm the current word receives the cue highlight as playback advances. +- [ ] Place the caret in the transcript and press `Backspace` or `Delete`; confirm the affected word becomes marked as skipped rather than disappearing from the transcript. +- [ ] Hover a skipped word and activate its restore control by the `aria-label` for restoring that word; confirm the word is kept again. +- [ ] Open the inspector facet with the visible Captions label and confirm caption controls appear. +- [ ] Toggle caption visibility and confirm captions appear or disappear in the preview. +- [ ] Change caption font, alignment, position, size, color, and background controls and confirm each committed change is visible. +- [ ] Select a caption translation language, run translation with a configured provider, and confirm translated captions appear. +- [ ] Switch the caption language back to Original and confirm the source transcript returns. + +### Local transcription and captions — v1.8.0 + +- [ ] Confirm the transcript pane states that transcription runs locally and that no upload occurs when it is started. +- [ ] With the Whisper helper binary absent, activate the transcribe action and confirm the UI reports why nothing happened, and that the main-process log carries exactly one matching `[stt]` line. The failure now reaches a toast (`transcriptionStore.ts`) and the log (`whisperServer.ts`), but the sentence shown is one the app writes for itself — `whisper-stt-server binary not found; build it via scripts/build-whisper-stt.sh`, produced before any helper process starts — so it points a packaged-build user at a script they do not have. Helper stderr is a separate source, and only ever for a helper that did start. Verify against a build whose helper was deliberately not packaged, not only against a working one. +- [ ] Run transcription in the packaged build and confirm the model is fetched or reused without an error about a missing cache directory. +- [ ] Confirm a second transcription reuses the cached model instead of downloading it again. +- [ ] Confirm the completed transcript reports the detected language on the media asset card. +- [ ] Choose an explicit language on the asset card, regenerate, and confirm the new transcript replaces the old one with its own word timings. +- [ ] Confirm word timings are monotonic: click several words in order and confirm each seek lands later than the previous one. +- [ ] Confirm silent stretches appear as a silence span with its duration rather than as missing text. +- [ ] Activate a silence span's trim control and confirm a trim appears on the timeline covering that interval. +- [ ] Restore that silence from the transcript and confirm the trim is removed. +- [ ] Confirm transcription is unavailable with a clear message rather than a crash when the app runs outside Electron. +- [ ] Translate captions, then delete the translation, and confirm the original transcript text and timings are unchanged. +- [ ] Confirm a project carrying caption annotations from the old feature reports them and offers to remove them. +- [ ] Play across a zoom region with captions on and confirm the captions stay in the frame instead of scaling and drifting with the zoom. +- [ ] Export that range and confirm the exported frames show the same caption placement as the preview. + +## AI chat and providers — requires a configured provider + +- [ ] Open the chat panel with the top-bar control identified by its `aria-label` and confirm the chat surface appears. +- [ ] Confirm the chat header shows controls for AI settings, history, and a new conversation. +- [ ] Send a short request and confirm the user message appears in the conversation. +- [ ] Confirm the provider returns an assistant response without an unhandled error. +- [ ] Open the model picker and confirm the active model is visibly selected. +- [ ] Change the reasoning effort when the configured provider supports it and confirm the chosen value remains selected. +- [ ] Run an edit request that creates a supported timeline change and confirm the applied operation is visible in the conversation. +- [ ] Use the conversation rewind control by its `aria-label` and confirm the rewind confirmation surface appears. +- [ ] Confirm a rejected or canceled rewind leaves the timeline unchanged. +- [ ] Open AI settings and confirm the provider list, connection status, and configuration form load. +- [ ] For an API-key provider, enter a key and confirm the provider becomes connected without displaying the raw key afterward. +- [ ] For a device-flow provider, confirm the challenge panel shows a user code and an Open login page action. +- [ ] Open conversation history and confirm the current conversation is listed. +- [ ] Start a new conversation, switch back to the prior one, and confirm each conversation retains its own messages. +- [ ] Rename a conversation with its visible rename control and confirm the new title appears. +- [ ] Delete a conversation with its visible delete control and confirmation prompt, then confirm it no longer appears. + +## Chat-driven editing — v1.8.0, requires a configured provider + +The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-agent.md); it never writes the document freehand. These checks are about the edit actually landing on the timeline, the turn being one undo unit, and a failed turn leaving the project intact. + +- [ ] With no provider connected, open the chat and confirm the "bring your own AI" welcome view appears with the composer disabled instead of an error. +- [ ] Connect a provider and confirm the same panel becomes a usable conversation without restarting the app. +- [ ] Ask the agent to cut the silences and confirm the result appears as trim regions on the timeline rather than as rewritten clips. +- [ ] Confirm the seekable duration after that edit still reaches the full recording, so the trims are reversible. +- [ ] Ask for a zoom on a described moment and confirm a zoom pill appears at approximately the requested time and the preview scales there. +- [ ] Ask for a speed change over a described range and confirm a speed region appears with the requested factor. +- [ ] Ask for a text annotation and confirm it appears in the preview with the requested text. +- [ ] Ask for a full-camera segment and confirm the region appears and the camera fills the frame while it plays. +- [ ] Confirm each applied operation is summarized in the conversation and that the number of summarized operations matches what the timeline gained. +- [ ] Ask the agent to remove one of the modifiers it created and confirm that modifier alone disappears. +- [ ] Ask the agent to restore the full timeline and confirm the trims it added are gone. +- [ ] Confirm modifiers created by the agent are anchored like hand-drawn ones: reorder a clip and confirm they travel with it. +- [ ] After a turn that applied several operations, undo once and confirm the whole turn reverts as a single unit rather than one tool call at a time. +- [ ] Redo and confirm the whole turn returns. +- [ ] Ask for something outside the tool set and confirm the agent explains rather than silently doing nothing or leaving an invalid document. +- [ ] Send a request while the project has no asset and confirm a clear response instead of an unhandled error. +- [ ] Use the rewind control on an earlier user message, confirm in the dialog, and confirm the timeline, the conversation tail, and the later checkpoints all roll back together. +- [ ] Cancel a rewind at the confirmation dialog and confirm both the timeline and the conversation are untouched. +- [ ] Confirm the context badge shows a percentage and that its tooltip reports used and budget tokens. +- [ ] Activate Compact context on a conversation with enough history and confirm an earlier-context summary message appears and the percentage drops. +- [ ] Activate Compact context on a short conversation and confirm the "not enough history" message rather than a failure. +- [ ] Confirm a compaction failure leaves the conversation history unchanged. +- [ ] Use the copy control on an assistant message and confirm the message text reaches the clipboard. +- [ ] Open the timeline toolbar's auto-enhance menu, choose the AI option, and confirm the chat panel opens with the prompt prefilled and sent through the normal send path. +- [ ] Confirm the edit produced by that auto-enhance request can be rewound like any other turn. +- [ ] Choose the AI auto-enhance option with no provider connected and confirm the setup view appears instead of a failed send. +- [ ] Choose the cursor-based automatic zooms option and confirm it adds zooms without involving the provider. +- [ ] Restart the app and confirm conversations are gone while the provider configuration persists; this is a known gap, not a defect to file. +- [ ] Confirm the provider API key is never displayed in the settings form after it is saved. + +## Native compositor preview and export — v1.8.0 + +- [ ] Confirm `[data-testid="native-compositor-mount"]` shows a live composited preview rather than an empty surface. +- [ ] Confirm `[data-testid="native-compositor-error"]` is absent during a normal run. +- [ ] Scrub back and forth across a clip boundary several times and confirm the preview keeps up without a stall on each crossing. +- [ ] Seek to the very end of the project and confirm the last frame is shown instead of a blank or stuck frame. +- [ ] Confirm no loading overlay remains on top of an already valid preview frame. +- [ ] On a machine with no compatible GPU, confirm `[data-testid="native-compositor-cpu-notice"]` appears, the export dialog shows its CPU warning, and the export still completes. +- [ ] Export the same project on macOS and on Windows and compare frames at identical timestamps for background, blur, shadow, roundness, padding, cursor, and text. +- [ ] On macOS, export an MP4 from a project with audio and confirm the output has audio. +- [ ] On macOS, export a frame containing a text annotation and confirm the text is upright, centred in its box, and that its background plate fits the text. +- [ ] On macOS, export a frame containing a blur annotation and confirm the area is actually obscured. +- [ ] On macOS, export a range with the cursor visible and confirm the cursor and its trail are rendered. +- [ ] On macOS, export frames with each 3D rotation preset and confirm the tilt matches the Windows render. +- [ ] On macOS, export a frame with background blur enabled and confirm it matches the Windows render. +- [ ] Export a range containing a zoom with an annotation and captions on screen, and confirm neither follows the zoom in the exported frames. +- [ ] Confirm the packaged macOS app refuses to start or reports clearly when the compositor addon is missing, rather than failing at first render. + +## Export + +- [ ] Confirm the top-bar export control is disabled when the project has no asset. +- [ ] With a loaded project, activate the export control by its `aria-label` and confirm the export dialog opens. +- [ ] Confirm the dialog initially offers MP4 and GIF format choices. +- [ ] Select MP4 and confirm quality choices include a lower tier, a balanced tier, and Source. +- [ ] Select each available MP4 quality and confirm the displayed output dimensions update. +- [ ] Select 24, 30, and 60 FPS and confirm the selected frame rate remains visible. +- [ ] Select H.264 and H.265 and confirm the selected codec remains visible. +- [ ] Select GIF and confirm GIF frame-rate, size, and loop controls appear. +- [ ] Change GIF frame rate and size, toggle looping, and confirm the summary reflects the choices. +- [ ] Start an MP4 export and confirm the native rendering progress reports advancing frames or percentage. +- [ ] Confirm the export dialog reports a saved output path after MP4 completes. +- [ ] Open the exported MP4 outside the app and confirm it plays through the expected duration with audio when the source has audio. +- [ ] Start a GIF export and confirm frame rendering and file writing complete without an unhandled error. +- [ ] Open the exported GIF outside the app and confirm it contains the expected motion and loop behavior. +- [ ] Export a GIF from colour-rich footage long enough to exhaust the encoder's first code widths, and confirm no frame degrades into corrupted stripes or shifted colours partway through. +- [ ] Open that GIF in a second viewer and confirm both decoders agree, since a code-width defect can decode differently per viewer. +- [ ] Export a project containing audio, a trim, a speed region, a zoom, an annotation, captions, and webcam layout changes when available. +- [ ] Compare that exported result with the preview for timing, skipped intervals, audio, webcam, captions, and effects. +- [ ] For every pixel-level comparison, export a frame and measure it with an image tool rather than relying on a preview screenshot. + +## Settings, shortcuts, themes, i18n + +- [ ] Change one shortcut, save it, use the new key in the editor, and confirm it triggers the configured action. +- [ ] Confirm `Ctrl/Cmd+S` saves the current project. +- [ ] Confirm `Ctrl/Cmd+O` opens the project dialog. +- [ ] Open the Background facet and switch among image, color, and gradient tabs. +- [ ] Select a built-in wallpaper and confirm the preview background changes. +- [ ] Choose a color swatch or enter a valid hex color and confirm the background changes. +- [ ] Choose a gradient preset and confirm the preview background changes. +- [ ] Open the Effects facet and toggle background blur, motion blur, shadow, roundness, and padding; confirm each changes the preview. +- [ ] Open the Layout facet and choose each available webcam layout; confirm the preview arrangement changes. +- [ ] Change webcam mirror, reactive zoom when supported, shape, and size; confirm each change is visible. +- [ ] Open the Cursor facet and toggle cursor visibility and clip-to-bounds; confirm the preview changes. +- [ ] Change cursor theme, size, smoothing, motion blur, and click bounce; confirm each committed value remains visible. +- [ ] Toggle the theme control by its `aria-label` and confirm the editor switches between dark and light themes. +- [ ] Open the top-bar language control by its `aria-label`, choose a non-English locale, and confirm visible UI strings change. +- [ ] Switch back to English and confirm the top bar, transport, inspector, and export labels return to English. +- [ ] Select a different aspect ratio from the timeline aspect-ratio menu and confirm the preview frame changes shape. +- [ ] Press `Esc` or click outside an open menu, popover, or dialog and confirm it closes. + +### App menu, About, and updates + +- [ ] On macOS, open the application menu and confirm About OpenScreen is followed by Check for Updates. +- [ ] On Windows and Linux, right-click the tray icon and confirm it lists Check for Updates and About OpenScreen. Outside the editor the tray is the only surface reachable by default there: the HUD is frameless and the editor and notes windows auto-hide their menu bar, so the Help menu appears only while Alt is held over one of those two windows. +- [ ] On Windows and Linux, open the editor, hold Alt, and confirm the Help menu lists Check for Updates and About OpenScreen. +- [ ] In the editor, click the OpenScreen wordmark in the top bar and confirm it opens a menu listing Keyboard Shortcuts, AI settings, Check for Updates and About OpenScreen. This is the discoverable path on Windows and Linux, where the two above are not. +- [ ] Confirm the About row in that menu shows the running version, and that it matches what the About box then reports. +- [ ] Open the wordmark menu and pick Keyboard Shortcuts; confirm the shortcuts configuration dialog opens and that only one dialog appears. +- [ ] Open the wordmark menu and pick AI settings; confirm it opens the same provider dialog the AI panel's gear does, and that only one dialog appears. +- [ ] Repeat that in Media mode, in Rec mode, and in Edit mode with the chat panel collapsed — the three states in which the dialog had no owner before, and the reason the row must not be Edit-only. +- [ ] Connect or disconnect a provider from the menu's dialog while the chat panel is open behind it, close the dialog, and confirm the composer and the model pill follow without reopening the panel. +- [ ] Open the wordmark menu, then press Escape, click elsewhere in the top bar, and click the wordmark again — confirm each closes it and that the window does not start dragging instead of registering the click. +- [ ] With the wordmark menu open, walk it with the Down and Up arrows and confirm focus wraps at both ends. +- [ ] Switch the app language and confirm the wordmark menu's four labels follow — the first two matching the dialogs they open, the last two the wording the macOS app menu and the tray use. +- [ ] Open About and confirm it names the running version, the Electron/Chromium/Node versions, and the install channel. +- [ ] Confirm the About box opens in front of the HUD rather than behind it. +- [ ] On Windows and Linux, press Copy in the About box and confirm the clipboard holds that same block. +- [ ] Open the HUD's device-settings panel and confirm its About row reports the same version. +- [ ] Run Check for Updates from the menu, from the tray, and from the HUD panel, and confirm each reaches the same result dialog. +- [ ] Start a second check while one is still running and confirm the HUD button stays disabled and reads "Checking…" until the first check's dialogs are done, rather than re-enabling into a click that does nothing. +- [ ] Start a recording, then confirm Check for Updates is gone from the app menu, the Help menu and the tray for as long as the take runs, and returns when it stops. +- [ ] Open the HUD's device-settings panel, start a recording with it still open, and confirm the update button disappears while the version stays. Then stop the take, reopen the panel, and confirm the button is back — a HUD that mounts mid-take must not lose it permanently. +- [ ] On a Microsoft Store, Flathub, Snap, or Nix install, confirm no update affordance appears in the menu, the tray, or the HUD panel, while the version still shows. + +### New effects and controls — v1.8.0 + +- [ ] Set a zoom's custom scale beyond the preset levels, commit it, and confirm the preview scale and the retained value both follow. +- [ ] Activate the timeline's global auto-focus toggle and confirm every zoom switches to automatic focus. +- [ ] With the global toggle on, open a zoom's focus-mode control and confirm it reports being controlled globally instead of silently ignoring a per-zoom change. +- [ ] Turn the global toggle off and confirm per-zoom focus mode becomes settable again. +- [ ] Set a speed above the native playback limit and confirm the preview reports that it is frame-stepped and muted. +- [ ] Export that range and confirm the exported timing is correct despite the frame-stepped preview. +- [ ] Enter a speed above the maximum and confirm the limit message rather than a silently clamped value. +- [ ] Enable the webcam's shrink-on-zoom option and confirm the camera shrinks while a zoom plays and returns afterwards. +- [ ] Choose each webcam layout preset, including vertical stack and dual frame, and confirm the preview arrangement changes. +- [ ] Choose each webcam shape and confirm the mask changes in the preview. +- [ ] Turn the cursor's clip-to-canvas option off, zoom in, and confirm the cursor may extend past the frame edge; turn it on and confirm it is kept inside. +- [ ] Apply each text animation in turn and confirm the animation runs when the playhead enters the region. +- [ ] Toggle an annotation's background off and back on and confirm the previously chosen colour returns instead of black. +- [ ] Switch a blur annotation between gaussian and mosaic and confirm intensity and block-size controls follow the chosen type. +- [ ] Set a blur shape to oval and confirm the obscured area is elliptical in the preview. +- [ ] Draw a freehand blur shape and confirm the preview follows the drawn outline. +- [ ] Export a frame containing that freehand blur and confirm the export covers its bounding box, which over-covers rather than under-covers, as the inspector states. +- [ ] Add a Google font through the custom-font dialog and confirm it appears in the font selector and renders in the preview. +- [ ] Enter an invalid font URL and confirm the error message rather than a stuck adding state. +- [ ] Open the crop dialog, change the ratio with aspect lock on and off, apply, and confirm the preview reframes. +- [ ] Confirm a cropped project exports with the cropped framing rather than the original. + +## Persistence (save, reopen, reload) + +- [ ] Make a project change and confirm the top bar shows an unsaved indicator. +- [ ] Activate the top-bar save control by its `aria-label` and confirm the indicator changes to the saved state. +- [ ] Close and reopen the project from the Open Project dialog and confirm the asset and project title match before closing. +- [ ] Confirm clip order and each clip's in/out and crop settings survive reopen. +- [ ] Confirm trim, zoom, speed, annotation, and full-camera regions survive reopen with their positions and values. +- [ ] Confirm background, effects, layout, webcam, cursor, aspect-ratio, and caption settings survive reopen. +- [ ] Confirm the transcript and skipped-word ranges survive reopen. +- [ ] Confirm the seekable duration after reopen reaches the recording duration, not merely the end of the last region. +- [ ] Make a change, attempt to open another project, choose Cancel in the unsaved-changes prompt, and confirm the current project remains loaded. +- [ ] Make a change, choose Save in the unsaved-changes prompt, and confirm the next project opens after saving. +- [ ] Make a change, choose Discard in the unsaved-changes prompt, and confirm the next project opens without the discarded change. +- [ ] Open a project saved by a previous release and confirm it loads without a schema error. +- [ ] Confirm every modifier in that migrated project sits on the clip content it covered before, not at a shifted ruler position. +- [ ] Confirm a migrated project that had a region straddling two clips still renders it as one pill while the clips remain adjacent. +- [ ] Save the migrated project, reopen it, and confirm nothing shifted on the second round-trip. + +## Platform-specific + +### Windows + +- [ ] Run the complete capture-to-export flow on real Windows with the packaged build. +- [ ] Confirm a screen source and a single-window source both produce non-black video. +- [ ] Confirm the system tray icon appears and changes to a recording state while recording. +- [ ] Right-click the tray icon while recording, choose Stop Recording, and confirm the editor opens. +- [ ] Confirm the HUD and notes window are excluded from captured video when content protection is enabled. +- [ ] Disable hardware H.264 if the test machine supports that diagnostic path and confirm the software-encoder notice is clear and non-blocking. +- [ ] Switch the recording HUD between displays and confirm it remains positioned on the intended display. +- [ ] Switch the desktop to an odd-pixel window size and confirm the recorded frame dimensions remain valid. +- [ ] Open Settings diagnostics when available and confirm a diagnostic bundle can be written. + +### macOS + +- [ ] Run the complete capture-to-export flow on real macOS with the packaged build. +- [ ] Grant screen-recording, microphone, and camera permissions and confirm the app reflects the granted devices. +- [ ] Record while switching Spaces with the HUD visible and confirm recording continues. +- [ ] Stop a recording and confirm the editor opens without a crash during native recorder shutdown. +- [ ] Confirm the tray or menu-bar item can refocus the HUD after it is hidden. +- [ ] Confirm the HUD and notes window are excluded from captured video when content protection is enabled. +- [ ] Confirm a physical webcam picture-in-picture records and plays back with the selected layout. +- [ ] Export MP4 and GIF and confirm both files open in a native macOS media viewer. +- [ ] Confirm closing and relaunching the packaged app does not leave an orphaned capture or editor window. +- [ ] On the newest supported macOS, confirm the HUD and notes windows are visible on screen rather than blanked by content protection. +- [ ] Confirm the HUD opens without waiting for the microphone permission prompt to be answered. +- [ ] Confirm local transcription reports the device it actually ran on and completes on a Metal-capable machine. +- [ ] Confirm the packaged `.app` contains the compositor addon and that the addon carries no build-machine path. +- [ ] Confirm the packaged `.app` bundles its ffmpeg libraries and runs on a machine with no developer toolchain installed. +- [ ] **On a Retina/HiDPI display**, record the screen and confirm the recorded frame is filled edge to edge — not the desktop drawn small in one corner of a black rectangle. Then do the same for a single window. Issue #418 shipped exactly this, invisible on every 1× display because a point size and a pixel size are the same number there; `SCStreamConfiguration` does not scale a frame up to fill an oversized buffer, so the surplus stays background black. Check the frame, not just the file's dimensions — the reporter's `.mp4` was 3024×1898 as expected and still wrong inside. +- [ ] **With a second display attached at a different scale factor**, record each display in turn and confirm both fill their frame. A machine whose displays all share one scale factor cannot catch a units mix-up. + +### Linux + +- [ ] Run the complete editor-to-export flow on real Linux with the supported packaged or development build. +- [ ] Confirm the HUD remains interactive on the supported Linux window manager. +- [ ] Select a screen source in the compositor's portal picker and confirm the resulting recording is not black. +- [ ] **Select a single WINDOW in the portal picker and confirm the recording contains only that window, at the window's dimensions — not the whole screen.** Check the pixel size, not just the look of it: `ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 ` should report the window's size, never the monitor's. This is the case that shipped broken in 1.8.0. +- [ ] Record twice in a row and confirm the portal picker appears BOTH times, and that choosing a different source the second time actually changes what is recorded. +- [ ] Confirm the HUD shows no in-app source button on Linux, and that the record button starts a recording directly instead of opening a picker. +- [ ] Confirm the portal picker appears BEFORE the 3-2-1 countdown, not during or after it. +- [ ] Start the same flow from the editor's Rec stage ("Start recording") and confirm it behaves identically to the HUD — no source row, picker first, then countdown. +- [ ] Cancel the countdown after answering the picker and confirm the compositor's "screen is being shared" indicator goes away rather than lingering. +- [ ] Confirm the system tray or supported desktop indicator can refocus the HUD when it is hidden. +- [ ] Confirm microphone capture works with a physical device and the chosen device is audible in playback. +- [ ] Confirm the webcam toggle reflects the available physical camera or clearly reports that no camera is available. +- [ ] Confirm the native compositor preview loads without a blank surface or renderer crash. +- [ ] Export MP4 and GIF and confirm the files open in a system media player. +- [ ] Close and relaunch the app and confirm a saved project can be reopened without data loss. + +## Results log + +| Date | Build / tag | Platform | Pass/fail | Notes | +|------|-------------|----------|-----------|-------| +| 2026-07-31 | dev build, `claude/e2e-tests-v1-8-0-474894` (e9578f09) | macOS 26.5, M1 | Partial — 1 defect | Ran launch/HUD, media, modifier anchoring, and export. **Defect: a dangling asset blanks the preview.** Modifier anchoring across a reorder verified in preview and in the exported frames. macOS export produced 1280×720 h264 + AAC at ~2× realtime. Chat sections skipped: no AI provider configured. HUD drag not runnable under computer-use (drop point is the desktop). | +| 2026-08-13 | installed `v1.9.5-rc.1` | Windows 11 26200, 1920×1080 @ 100% | Partial — 1 defect | Ran launch/HUD, source selection, recording, stop, editor open. Fragmented MP4 confirmed on the shipped artifact: 48 `moof`+`mdat` pairs over 47.6s, `mvex` present, `mfra` on clean stop. **Defect: a recording that survives a helper kill is thrown away by the app** — killing `wgc-capture.exe` mid-recording leaves a fully decodable 41s file (2460 packets, `ffmpeg -f null -` exit 0) with no `.session.json` and no `.cursor.json`, and stop answers "The recording could not be saved". Fixed in #363, re-verified end to end. Truncation ablation at 60%: plain MP4 unreadable, fragmented plays 29s. **A dev build cannot test any of this** — the prebuilt worktree helper predated the change and silently ran the old path. Editor/export/chat sections not run. | +| 2026-08-14 | `release/v1.9.5` @ `b1b81de5` (rc.2 candidate: dev TS + the CI-built rc.1 native payload, which is byte-identical since no native source changed) | Windows 11 26200, 1920×1080 @ 100% | Pass — no defect | Regression net across the 65 commits since **v1.9.2**, not just the rc.2 delta. Four recordings. Every one a fragmented MP4 (`mvex` + ~1 `moof`/s, `mfra` only on a clean stop). GPU DXGI path still correctly opt-in (`videoInput: cpu-rgb32`) — the #336 regression has not crept back. No capture-pacing drift: HUD `00:59` → 60.067s at 60/1. Waveform correct in both directions: absent with no audio track, rendered with one. Audio muxes into the fragmented container (AAC 48k stereo) with 15 ms A/V drift, under one frame. Compositor renders and exports with no camera declared. **#366**: reopening returns to the saved project with its settings (Blur BG on, padding 9%) and mints no second project — 167→168 across a whole new recording. **#363**: helper killed mid-recording → editor opens on the recovered take (46 `moof`, no `mfra`), all three sidecars written, imported once. Export MP4 1080p60 **from that recovered take**: 46.0s / 2760 packets, decodes clean, duration matches the source exactly. Tray refocus works. NOT covered: DPI scaling — **not re-run here, already validated when `60bb6d7c` / `71cc88d6` landed**; note that the display scale is a setting, so "this machine is at 100%" is never a reason a DPI bug cannot be tested (flip it to 150%, ~2 min). Also not covered: webcam PiP and the export webcam fixes, microphone, GIF, macOS/Linux, AI sections, packaging. | +| 2026-08-14 | installed `v1.9.5-rc.1`, macOS Apple Silicon DMG (CI-built, Developer ID signed). **rc.2 is not published** — only rc.1 exists on Releases; no native source changed between `v1.9.5-rc.1` and `origin/release/v1.9.5`, so this artifact already carries the rc.2 native payload, but #366 (cross-platform TS) is absent from it | macOS 26.5 (25F71), M1, 1920×1080 @ 2× | **Fail — 1 blocker** | **The plan's assertion-1 criterion does not hold on macOS, in both directions.** On a clean stop `AVAssetWriter.finishWriting()` collapses the fragments into a normal movie: `ftyp mdat moov`, `mvex` ABSENT, 0 `moof`, no `mfra` (45 s / 44.4 MB run). That is exactly the shape the plan calls the headline failure — and the pre-`a6795d23` control recording (2026-08-10) has the *same* shape — so **a clean-stop box walk cannot distinguish fragmented from plain on macOS; only the kill test can.** Fragmenting *is* active: the takes whose writer died mid-fragment retain `mvex` + ~1 `moof` per second of media (shipped-build writer-failure samples: 35 `moof`/36.0 s, 14/15.0 s, 3/4.0 s; plus 18 on a surviving-helper kill). The one kill on the shipped build is the exception that proves the scope — capture had already stalled ~12 s before the kill, so it carries `mvex` but **0 `moof`** and only 1.0 s. No macOS file, clean or killed, ever carried `mfra`. **Blocker: every app-driven recording truncates, then the app discards it.** (Root cause and fix reported in #375 — the fragments carry a negative composition offset in a version 0 `trun`, where ISO/IEC 14496-12 8.8.8.2 defines the field as unsigned, because frame reordering was left on; `AVVideoAllowFrameReorderingKey: false` clears it and restores the crash-resilience the fragmenting was for. Verified at helper level there; **this rc.1 run only reproduced the failure and validated nothing about the fix**. Re-run this section against a CI build carrying #375 before rc.2 ships.) 3/3 takes stopped writing early while the HUD kept counting — media 4.0 s / 36.0 s / 15.0 s against HUD `02:02` / `01:30` / `01:04`. Helper emits `{"event":"error","code":"writer-failed"}`; main log `AVFoundationErrorDomain Code=-11800 … (-16341)`. Stop then hangs ~30 s on "Saving…" and drops the take: no `.session.json`, no `.cursor.json`, no editor. The app *does* surface the raw error in a toast (confirmed by hand on the same machine at 13:28–13:35 — my automated runs screenshotted after it auto-dismissed, so an earlier draft of this row wrongly said there was none). 44,561,966 / 328,337,979 / 139,631,607 / 17,187,009 bytes decodable and thrown away (147 GB free — not disk). Reproduced standalone with the shipped helper at 1080p30/8 Mbps, 2/2 (~9 s, ~5 s), so it is not confined to the app's 4K60 path — but do not read that as load-independent: append rate demonstrably modulates how reliably it bites (#375 measures it reliable at ~57 fps and intermittent at 30 fps). **Reproduced by hand, no automation involved**, on six takes recording a YouTube page — and those six separate the trigger cleanly: **system audio ON → 3/3 died at ~1.0 s and minted 0 projects; system audio OFF → 3/3 survived (3.3 s, 7.4 s, 25.0 s) and minted 1 project each.** **Audio is not the condition, only an accelerant** — a controlled run with system audio off *and not one screenshot taken during the capture* (the screenshot layer hides non-allowlisted windows, so it was the last confound worth eliminating) died the same way: 8.008 s of video, 79,004,330 bytes then flat for 76 s with the helper still alive, 7 `moof`, 0 sidecars, 0 projects, same `-11800`/`-16341`. What audio changes is the window: with a track it is ~1 s, without one ~4–40 s. That reconciles the by-hand takes with mine — a take short enough to stop before the writer dies is clean, which is why 3.3 s and 7.4 s survived and 8.0 s did not, and why the 25.0 s one minted a project while still carrying `mvex` (never cleanly finalised). **Turning audio off is therefore not a safe workaround.** Untested here: microphone — this Mac has no input device, and whether a mic track triggers the same path is an inference, not a measurement. **Helper A/B narrows the with-audio path to the fragmentation line**: helper built twice from source identical to the rc.1 tag, differing only by `writer.movieFragmentInterval` (701 vs 700 lines) — with system audio at 1080p30, WITH the line `writer-failed` 2/2 (2.0 s, 1.0 s), WITHOUT it clean `recording-stopped` 3/3 (40.6 s, 37.9 s, 37.6 s). **Read those counts as a sample, not a law**: a later rebuild of the with-the-line arm survived 22.2 s at the same settings, so the failure is probabilistic and rate-dependent, and the byte-level evidence in #375 is what actually carries the case. The video-only local-vs-shipped gap (local survived 45 s, shipped failed 5/5) is explained by the same variable rather than by the released artifact — the shipped runs encoded at 56.6 fps against 29 fps locally. **Kill test** is confounded on the shipped build (capture already dead before the kill): 17.19 MB → only 1.0 s / 56 packets, 0 `moof`. On a helper that does not fail, a mid-write kill leaves 18 `moof`, decodes clean (`ffmpeg -v error -f null -` exit 0, 1373 packets) and no `mfra` — the shape the plan expects. **#363 gap confirmed, and on macOS it fires with no kill at all**: `writer-failed` alone loses the take; there is no app-side recovery. **Audio**: AAC 48 kHz stereo muxes into the fragmented container, video start `0.000000` vs audio `0.014479` → 14.5 ms drift, under one frame at 30 fps (measured on the 2.0 s written before the writer died). **Compositor + export pass**: preview renders with no camera declared; export MP4 1080p60 H.264+AAC via `h264_videotoolbox (zero-copy VT)`, 5,726,865 bytes, 318 packets, decodes clean, duration matches to within 7 ms — source 26.713 s minus trims 19.910 + 1.513 = 5.290 s expected vs 5.283 s measured, under one frame at 60 fps. **#366 not runnable as specified** (absent from rc.1, rc.2 unpublished, and record→editor never completes); adjacent behaviour measured on an existing project — close+reopen kept 19→19 projects, exactly ONE project references the recording, and Blur BG / padding survived (`showBlur=true`, `padding=16`). NOT covered: Windows-only DPI and wgc-capture, GIF, AI sections, packaging (per plan); webcam PiP and microphone — this Mac has neither (Device settings reports "No microphone found" / "No camera found"). | +| 2026-08-22 | installed `v1.10.0-rc.3` — CI-built NSIS artifact from build run 32582966489 (`openscreen-windows`), App menu → About reports `1.10.0-rc.3`, native payload complete and uniformly stamped (19 files in `resources/electron/native/bin/win32-x64`, all `17:59:10`, so helper + compositor addon + av\* DLLs are one matched CI set) | Windows 11 26200, 1920×1080 @ 100% | **Pass — 2 minor defects** | **Pause works, and the measurement that says so is the wall clock.** `createdAt` 20:25:52.208 against a file finalised at 20:30:56.754 is 304.55 s elapsed for a **286.333 s** file — **18.21 s shorter, exactly the paused interval**, so capture was genuinely suspended. The HUD timer froze at `03:58` across two reads 7 s apart with the indicator amber, and resume was clean (`04:01` → `04:08` over 7 s, no time lost). An earlier draft of this row called this a blocking defect, on the strength of comparing the file duration against a timer read *before* the stop click; with tool round-trips of ~20 s that comparison is worthless, and the packet count offered as corroboration proves nothing either — a file is continuous 60 fps whether or not capture was ever suspended. Written down because the wrong version of this measurement is easy to repeat: compare against wall-clock elapsed, never against the last timer you happened to screenshot. **Capture is otherwise sound, on two takes.** 15.8 s: fragmented (`ftyp uuid pdin moov` then 16 `moof`/`mdat`, `mvex` present), `mfra` on the clean stop, 1920×1080 @ 60/1, 948 packets = 15.8 × 60, `ffmpeg -v error -f null -` exit 0, both sidecars written. 286.3 s: 287 `moof`, `mfra` present, 17,180 packets, decodes clean, `.cursor.json` 1.3 MB. No pacing drift and no dropped frames over 4 min 46. **Export passes and honours its settings**: 720p/30 requested from a 1080p60 source gave 1280×720, `avg_frame_rate` 85900/2863 = 30.004, 8590 packets matching the frame count the progress UI itself reported, duration 286.333 s identical to source, decodes clean, 124.5 MB, written to the path chosen in the native save dialog and reported back as "Saved to …". Composition verified by extracting a frame and reading it at full resolution (not from a preview screenshot): gradient background, content inset as a rounded card with a drop shadow, content aspect ≈1.76 against the 16:9 target, synthetic cursor drawn. Note the exporter adds a silent **AAC 48 kHz stereo** track even though no audio source was enabled. **Retracted: "the HUD language menu ignores `Escape`".** It does not — the maintainer confirms the key works by hand. **Claude Desktop swallows `Escape` before it reaches the app under test**, so a synthesised press proves nothing about the app, and `GetForegroundWindow()` returning the HUD does not rescue the inference: the key never left the driver. The companion observation (an outside click on the HUD's own drag handle did not dismiss the menu) is withdrawn with it, since the HUD's own chrome is not "outside" the popover in any meaningful sense. What *is* established is that the blur path shipped in this RC works: `54e12706 fix(hud): dismiss the HUD popovers when the window loses focus` dismissed the menu on a click to the desktop. **Rule for anyone driving keyboard checks from computer-use: `Escape` is unusable as evidence, and any negative keyboard result needs a by-hand confirmation before it goes in this table.** **Behaviour vs doc**: the record button is not disabled without a source — it opens the source selector. No recording starts, so the check's intent holds, but AGENTS.md still describes a disabled button with a "Please select a source to record" tooltip, and that is why no tooltip appears. **Passed**: single launch window, no startup crash; HUD visible under `OPENSCREEN_DISABLE_CONTENT_PROTECTION=1`; tray layout toggles horizontal↔vertical both ways; HUD drag follows the pointer without drift and stays at the drop point; language menu opens with its locale list; minimize hides the HUD without quitting (6 processes still alive); relaunching routes through the single-instance lock, restores the window and mints no duplicate; source selector opens, selecting a card enables Share, and the HUD label becomes the picked source (`Tout l'écran`); record → stop opens the editor with the asset, a timeline clip and a rendered preview; About reports the RC version. **Local transcription works, on GPU** — an earlier draft of this row reported it broken, which was wrong. Relaunching with stdout/stderr captured and importing a 15 s asset that carries an audio track settles it: `[whisper-stt] boot: model=…\whisper-ggml\ggml-small-q8_0.bin host=127.0.0.1 port=64720 threads=16`, `ggml_vulkan: 0 = NVIDIA GeForce RTX 4070 Ti`, `model loaded; backend=whispercpp-vulkan`, then `[stt] done on whispercpp-vulkan: 1 chunk(s), 15.0s audio in 0.1s (0.01 rtf, 106.8x real-time)`. The pane switched to "1 caption lines, derived live from the transcript". **The real (minor) defect is the error message**: on an asset with *no audio track* the captions pane says **"Failed to fetch"**, which reads as a network failure and sent this run hunting a broken STT server that was never involved — the pipeline simply has no audio to extract. It should say so. **Second minor find, from the same stderr**: `listProjects` cannot read three saved projects — one `ZodError` (`transcript.segments[0].endSec must be greater than or equal to startSec`, repeated across `segments`, `words` and `transcripts[0]`) and two `SyntaxError: Unexpected non-whitespace character after JSON`, i.e. truncated or double-written project files. They are skipped silently in the UI. **Caption anchoring — the rc.2→rc.3 delta — is present but its rendering was not measured.** The Position section carries exactly the model those commits describe: `Bottom`/`Top`, the note "Long captions grow upward — the bottom edge stays put", `Distance from bottom` defaulting to **1.5 %**, and Left/Center/Right. What could not be checked is where a caption actually lands, because the only transcript obtainable here came from a 300 Hz sine and yielded one line that never surfaced at any scrubbed position. **Closed out of band: the maintainer ran the caption sections by hand on a real spoken-audio recording and reports them correct**, which is the coverage this automated run could not supply and the last gap standing between this RC and a promote. Also confirmed from stderr: `[content-protection] OFF for the HUD window (OPENSCREEN_DISABLE_CONTENT_PROTECTION=1)`, so the flag does log its effect, and with the flag unset the HUD is correctly invisible to screenshots. **The consequence matters more than the cause: the eight caption anchoring/margin/inset cherry-picks that are the entire delta from rc.2 to rc.3 are NOT covered by this run.** **Not run**: restart and cancel actions; audio capture of any kind; webcam PiP; GIF; DPI scaling; HUD/notes exclusion from captured video with content protection ON (the whole session ran with it off, and the exported frame confirms the HUD *is* captured when it is off); regions, modifiers, timeline navigation, clip operations, persistence; macOS and Linux. **Environment limits that shaped this run, worth knowing before the next one.** `parsecd.exe` runs **elevated** and holds an invisible always-foreground window (`ParsecMinFrameRate16`); the moment OpenScreen loses focus every computer-use click is refused, and because the process is elevated UIPI makes granting Parsec useless — **tray-icon refocus could therefore not be tested at all**. Relaunching the app (single-instance raises it) is the way back. Dragging the HUD only works while every intermediate pointer position stays inside the HUD's own 904×698 mostly-transparent window; as soon as one lands on the desktop, the tier-"click" shell gate refuses the drag mid-gesture and leaves the button down — release it explicitly. Finally, the Microsoft Store package (`EtienneLescot.OpenScreen`, 1.9.6) **shadows the NSIS install in `request_access`**: every grant resolved to the Store bundle and the RC window stayed masked in screenshots while reporting success, until the Store package was removed. Screenshots do **not** interrupt a recording — that hypothesis was raised and disproved by running a 90 s capture with none taken and then taking one mid-capture with the helper surviving. | +| 2026-08-22 | installed `v1.10.0-rc.3`, macOS Apple Silicon DMG downloaded via Safari from the GitHub release (`Openscreen-macOS-Apple-Silicon-1.10.0-rc.3.dmg`), CI-built, notarized Developer ID (`spctl` → `accepted / source=Notarized Developer ID`, `Etienne Lescot (M4LK7C6S84)`), bundle `com.etiennelescot.openscreen` confirmed to be the only copy of that bundle on the machine (the sole other `Openscreen.app` on disk is an unrelated 1.8.0-rc.7 dev build in a different worktree, never touched) | macOS 26.5 (Darwin 25.5), M1, single 1920×1080 display @ 1× | **Pass — 1 finding retracted** | **Retracted: "clean Stop silently discards the recording."** It does not — I was clicking the wrong button. An earlier draft of this row reported 5/5 reproductions of a Stop-produces-nothing blocker; the maintainer could not reproduce it and asked directly whether I had the wrong control, which sent me to the source instead of back to the HUD. `src/components/launch/HudControls.tsx` and `useScreenRecorder.ts` (traced via a research pass) settle it: the row of icons shown while recording is `[record button/timer] [pause] [restart] [cancel] [notes]`, and the icon I was clicking — the X-in-a-circle, tooltip `"Cancel recording"` — calls `cancelRecording()` → `finalizeNativeMacRecording(true)` with `discard=true`. That is **by design** a no-save discard: no file, no sidecars, no error, because there is nothing wrong to report. The real Stop-and-save control is the record button/timer itself, toggled a second time; its accessible name while recording is the source name or `"Recording"`, never the word "Stop", which is exactly why five straight attempts at the visually-plausible "stop icon" never once hit it. Re-tested against the actual control immediately after: `recording-1787474633337.mp4` (227.4 MB, 27.1 s) saved with both sidecars, minted a project, and opened in the editor with a **"Recording added to a new project"** toast — the ordinary success path, working exactly as the maintainer said. Its box structure while at it: `ftyp mdat moov`, 0 `moof`, no `mvex`/`mfra` — a plain (non-fragmented) container on a clean stop, matching the shape already established for macOS on 2026-08-14 (fragmenting is real but a clean `finishWriting()` collapses it, so only a kill test can tell fragmented from plain here) rather than indicating anything new. **Lesson for next time, stated so it doesn't repeat**: five reproductions across audio configs and two launch methods felt like rigor, but every one shared the same unverified premise — that the icon I was clicking was actually "Stop" — so the repetition just multiplied one wrong assumption instead of testing anything. A screenshot-identified icon is not a confirmed control; check the accessible name or the source before trusting five reproductions of a click you never verified. One environmental note that stands independent of this mistake: a stray `UserNotificationCenter` process (once a 17-day-old zombie stuck in a hide/show loop, later a fresh instance respawning every few seconds around each record/stop click) intermittently grabbed OS-level "frontmost" status with nothing on screen to show for it and blocked every synthetic click until killed — a computer-use/host artifact, not an Openscreen bug; killing the offending PID before each click was the workaround used throughout this run. **Captions were verified for the cases pixel-measured below — default-distance Top/Bottom anchoring, wrapping, and Left/Center alignment.** Right alignment and user-adjusted top/bottom distances are not covered by this row; see the caveats further down. Measured via an imported clip rather than a fresh live capture — this predates the Stop mistake above and was not a workaround for it: `say -v Samantha` generated ~52 s of real, multi-sentence English speech (about caption placement itself), muxed with a plain video track via a from-source LGPL ffmpeg build into an MP4, then imported through File → New Project → Import media, which exercises the same transcription/caption/export code a live recording would. Auto-transcription fired on import (`Detected language: English`, 26 caption lines from 54.4 s, transcript accurate verbatim bar one mishearing, "Openscreen" → "Append screen") — confirms local Whisper STT works end to end on this Mac for this RC. Every measurement below comes from pixel-sampling **exported** frames via ffprobe/ffmpeg (`crop=1:1:X:Y` → rawvideo → RGB), never from the preview, per this checklist's own instruction. **Bottom anchor, default 1.5 %**: plate bottom edge at y=1063→1064 on a 1080-tall export — 16 px / 1080 = 1.48 %, matching the UI's displayed "1.5 %" — and that boundary is pixel-**identical** across two different single-line captions of very different width (741 px and 999 px wide, both centered at x≈959.5–960, i.e. exact frame center for `Center` alignment). **Top anchor, default 1.5 %**: switching Format to 9:16 (which also forced genuine multi-line wrapping — at 16:9 these ~5–7-word phrase-level captions never once wrapped, since `Max words per line` is 7 and the segmenter already keeps phrases under that) gave a plate top edge at y=28→29 on a 1920-tall export — 29 px / 1920 = 1.51 %, again matching "1.5 %", and again pixel-identical between a genuine 2-line and a genuine 3-line caption. **Long captions grow away from the anchored edge, confirmed quantitatively, not just by reading the UI's own helper text**: with Top anchored, the 2-line caption's plate spans y=29–251 (222 px tall) and the 3-line one spans y=29–353 (324 px tall) — same top edge to the pixel, bottom edge pushed down by very close to one text-line's worth per added line. **Left alignment**: plate left edge at a consistent x≈108 on the 1080-wide export, for both the 2-line and 3-line captions (a ~10 % left inset with no dedicated margin control in the UI, unlike top/bottom). **Right alignment**: exercised in the UI (selectable, no rendering glitch) but not pixel-measured in an export — not covered, for time, and low-risk given Left and Center both landed correct to sub-pixel precision off what is presumably the same layout code. **Distance-from-top/bottom was only pixel-checked at its 1.5 % default** — a user-adjusted value was not re-measured. This closes the gap the Windows rc.3 row left open ("the maintainer ran the caption sections by hand … the last gap standing between this RC and a promote") with an independent, automated, pixel-level measurement on the platform the feature actually shipped for. **#418 Retina/HiDPI frame-fill: skipped, hardware limitation.** This Mac has exactly one 1920×1080 display at native scale factor 1 (point size == pixel size, confirmed previously via `CGDisplayCopyDisplayMode` and consistent with everything observed this run), so any point-vs-pixel mixup — the exact bug class #418 was — is structurally invisible here; there is no second or HiDPI display to attach. **General capture-to-export pass**: app launch with stdout/stderr captured from the first run; HUD interactions (source picker, system-audio toggle, record/pause/restart/cancel/stop controls, Notes panel); editor (New Project, Rec tab, Media import, Composition/Cursor/Captions side panels, timeline); aspect-ratio switching 16:9 ↔ 9:16; a real screen recording, correctly stopped, saved with both sidecars and minted a project (`recording-1787474633337.mp4`, 227.4 MB / 27.1 s, `ftyp mdat moov`, decodes clean); MP4 export at both resolutions and 60 fps H.264 (a 54.4 s clip rendered in roughly 65–90 s); project "Saved" state and the projects directory growing correctly on both import and recording. **Not covered**: webcam PiP and microphone (this Mac has neither, per prior sessions); GIF export; AI chat/agent captioning shortcuts (no provider configured); Spaces-switch-while-recording; content-protection-visible-HUD-during-a-recording; deep persistence checks (migrated-project schema, modifier positions across reopen); whether the app's spontaneous restart of capture after a helper SIGKILL (observed once during the mistaken-blocker investigation — it silently began a new recording on its own, without a click) is itself worth a follow-up — seen once, not isolated or reproduced, not stated as a finding. | +| 2026-08-23 | installed `v1.10.0-rc.3` (Developer ID, unmodified) run with `OPENSCREEN_SCK_CAPTURE_EXE` pointed at a helper built from this branch | macOS 26.6.2 (25G83), M1, 1728×1117 @ 2× | Pass — fixes a blocker | **Window capture section only.** Before: selecting any window in the source picker kills the helper the instant `start()` builds its filter — `Assertion failed: (did_initialize), function CGS_REQUIRE_INIT, file CGInitialization.c, line 44`, SIGABRT, `-[SCContentFilter initWithDesktopIndependentWindow:]` → `SLSGetDisplaysWithRect`. 6/6 attempts on the shipped rc.3, no file, no error surfaced in the UI (the HUD returns to idle as if nothing happened). Display capture is unaffected and always worked, which is why this went unnoticed: the two paths diverge at `makeCaptureTarget`, and only the window branch resolves a rect through SkyLight. After: record → 25.2s → stop → **editor opened on the take**, `recording-1787475175449.mp4` 12,559,123 bytes / 25.18s / 2674×1684, the MP4 and both sidecars written (`.cursor.json`, `.session.json`), one project minted, zero crash reports. Helper-level A/B on an identical request JSON isolates the change: shipped signed helper → assertion, no file; this branch's helper → `recording-started`/`recording-stopped`, 4.49s / 1336×840 decodable MP4. NOT covered: webcam PiP, microphone, system audio (all off for these runs), export, GIF, AI/transcript sections, Windows, Linux. Not covered by unit tests either — `Package.swift` scopes the Swift test target to what runs without a screen, a display server or a TCC grant, and this crash needs all three. | +| | | | | |