diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 625966ab3..e5b9b190b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -59,6 +59,37 @@ jobs: if-no-files-found: error retention-days: 30 + build-windows-store: + name: Windows Store package + runs-on: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: ./.github/actions/setup + + - name: Cache caption assets + uses: actions/cache@v4 + with: + path: caption-assets + key: caption-assets-${{ runner.os }}-${{ hashFiles('scripts/fetch-caption-model.mjs') }} + + # ponytail: STT is handled by the bundled whisper-stt-server (whisper.cpp + # with native DTW token timestamps). No separate VAD model is fetched + # here. See docs/engineering/stt-spec.md for the full architecture. + + - name: Build Windows Store package + run: npm run build:win:store -- --publish never + + - name: Upload Windows Store package + uses: actions/upload-artifact@v4 + with: + name: openscreen-windows-store + path: release/**/Openscreen.Setup.*.appx + if-no-files-found: error + retention-days: 30 + build-macos: name: macOS ${{ matrix.arch }} DMG runs-on: macos-latest diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index a592488c7..85b0f63af 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -75,34 +75,43 @@ jobs: NEXT: ${{ steps.version.outputs.next }} run: node .github/scripts/release-milestone-migrate.mjs - - name: Bump package.json to pre-release version - env: - PRERELEASE: ${{ steps.version.outputs.prerelease }} - run: | - set -euo pipefail - sed -i -E "s|(\"version\"[[:space:]]*:[[:space:]]*\")[^\"]*(\")|\1${PRERELEASE}\2|" package.json - echo "package.json version:" - grep '"version"' package.json - - - name: Commit package.json bump on a release branch + - name: Create or reuse the release branch and bump package.json env: TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} PRERELEASE: ${{ steps.version.outputs.prerelease }} + NEXT: ${{ steps.version.outputs.next }} run: | set -euo pipefail git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - # The release branch is FROZEN between RC cut and stable promotion. The bump - # commit lives on release/v${PRERELEASE} only; nothing is merged into main - # until promote.yml publishes the stable tag, so any features merged into - # main after this step are NOT in the RC build. - BRANCH="release/v${PRERELEASE}" - # Delete remote branch first so the push below is always fast-forward (idempotent on rerun). - git push "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" ":${BRANCH}" 2>/dev/null || true - git checkout -b "$BRANCH" + # ONE release branch per STABLE version (release/vX.Y.Z), created at rc.1 and + # FROZEN until promote publishes the stable tag. Later RCs (rc.2, rc.3, ...) are + # cut from this same branch, so they carry the rc.1 snapshot plus any + # cherry-picked bugfixes and NOT whatever has landed on main since. + # + # The name must stay in sync with promote.yml, which resolves + # release/v${STABLE_VERSION}. Naming this branch release/v${PRERELEASE} + # (with the -rc.N suffix) breaks promote and makes every re-cut a fresh + # branch off main, which silently defeats the freeze. + BRANCH="release/v${NEXT}" + REMOTE="https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then + # Re-cut (rc.2+): build on the frozen branch. Never delete it — it carries the + # cherry-picks that make this RC differ from the previous one. + echo "Reusing frozen release branch ${BRANCH}" + git fetch origin "$BRANCH" + git checkout -B "$BRANCH" "origin/${BRANCH}" + else + # First cut (rc.1): branch from the dispatched ref (main). + echo "Creating release branch ${BRANCH} from ${GITHUB_REF_NAME}" + git checkout -b "$BRANCH" + fi + sed -i -E "s|(\"version\"[[:space:]]*:[[:space:]]*\")[^\"]*(\")|\1${PRERELEASE}\2|" package.json + echo "package.json version:" + grep '"version"' package.json git add package.json - git commit -m "chore(release): bump to ${PRERELEASE} [skip ci]" - git push "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$BRANCH" + git commit -m "chore(release): bump to ${PRERELEASE} [skip ci]" || echo "(version already at ${PRERELEASE})" + git push "$REMOTE" "$BRANCH" - name: Push RC tag on the release branch env: @@ -111,10 +120,10 @@ jobs: # Note: GITHUB_TOKEN tag pushes do NOT trigger build.yml in this org's setup, # so we explicitly trigger it via gh workflow run right after. RC_TAG: ${{ steps.version.outputs.rc_tag }} - PRERELEASE: ${{ steps.version.outputs.prerelease }} + NEXT: ${{ steps.version.outputs.next }} run: | set -euo pipefail - BRANCH="release/v${PRERELEASE}" + BRANCH="release/v${NEXT}" git fetch origin "$BRANCH" git checkout "$BRANCH" git reset --hard "origin/${BRANCH}" @@ -133,7 +142,14 @@ jobs: # GITHUB_TOKEN tag pushes don't fire the build.yml trigger in this setup, # so dispatch it explicitly. The PAT ensures the build's release creation # propagates to Tier 3 (homebrew/winget/nix/aur) via release: published. + # + # --ref is REQUIRED: the RC version bump lives ONLY on the release branch / + # RC tag, never on the default branch. Without --ref the build runs on main + # (still the previous stable version), so build.yml's publish-release step + # fails its guard ("package.json version X does not match from tag"). + # Pin the build to the RC tag so checkout gets the bumped package.json + code. gh workflow run build.yml \ + --ref "${RC_TAG}" \ -f release_tag="${RC_TAG}" \ -f arch=both \ --repo "$GITHUB_REPOSITORY" diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index ac4cfc079..3e8214b9c 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -142,7 +142,13 @@ jobs: run: | set -euo pipefail # GITHUB_TOKEN tag pushes don't fire build.yml in this setup, dispatch it. + # + # --ref pins the build to the stable tag (the frozen release-branch tip). The + # prior main-merge step usually leaves main at the stable version already, but + # relying on that is fragile; building the tag guarantees checkout has the + # matching package.json + code and enables signing/notarization (tag has no '-'). gh workflow run build.yml \ + --ref "${STABLE_TAG}" \ -f release_tag="${STABLE_TAG}" \ -f arch=both \ --repo "$GITHUB_REPOSITORY" diff --git a/.gitignore b/.gitignore index ea760a81b..2aaec5370 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ playwright-report/ # Vitest browser mode screenshots __screenshots__/ +.vitest-attachments/ # shell files /shell.sh diff --git a/docs/engineering/main-to-ai-edition-port.md b/docs/engineering/main-to-ai-edition-port.md new file mode 100644 index 000000000..76db68ed7 --- /dev/null +++ b/docs/engineering/main-to-ai-edition-port.md @@ -0,0 +1,88 @@ +# Portage `main` → `feat/ai-edition` (juillet 2026) + +## Contexte + +`feat/ai-edition` diverge lourdement de `main` : **273 commits** d'un côté +(compositeur natif poc-d3d, éditeur V4, timeline clip-anchored, export v2 +multi-asset), **24 commits** de l'autre depuis le point de divergence +(`68d3a685`, 15 juil. 2026). Là où `main` a corrigé l'éditeur navigateur +legacy, `ai-edition` l'a **supprimé** — donc la plupart des correctifs `main` +patchent du code mort ici. + +Stratégie retenue (au lieu d'un rebase brut des 273 commits) : + +1. **Cherry-pick** ce qui est archi-compatible. +2. **Portage fonctionnel + revalidation** là où le même fichier a divergé mais + reste vivant (export audio). +3. **Recensement** des features liées au code supprimé → décider, par feature, + si c'est déjà couvert en V4/natif, obsolète, ou à ré-implémenter. + +Un rebase littéral aurait rejoué `ai-edition` par-dessus ces 24 commits ; le +contenu **fonctionnel** net qu'il aurait apporté est exactement les 11 commits +ci-dessous (les 13 autres = bumps de version, workflow Discord, docs RC du +legacy, et patches de l'éditeur supprimé — no-ops ou bruit). + +## 1. Cherry-pické (archi-compatible) — 11 commits + +| Commit | Feature | Résolution de conflit | +|---|---|---| +| `e129e408`, `49338719` | Infra release (pin dispatch, nommage branche RC) | clean (workflows non touchés par ai-edition) | +| `d75cb57e` | WGC : ne plus tenir le mutex pendant `WriteSample` (#115) | **fusion** : timing webcam horloge-réelle CFR d'ai-edition **+** split capture-sous-mutex / submit-hors-mutex de `main`. `writeBgraFrame`→`captureBgraSample`. | +| `21828bfd` | WGC : dims capture arrondies au pair | clean | +| `90d9bf2f` | WGC : garde-fou anti-hang (`cv.wait`→`wait_for(100ms)`) | clean | +| `cd088673` | HUD : le drag ne dérive plus du curseur | garde `hudAllocatedSizeRef` d'ai-edition **+** ajoute `isDraggingHudRef` | +| `c6cd9436` | Test de régression drag HUD | garde `shrink-0` d'ai-edition **+** `data-testid` | +| `42ab20dc` | Packaging Microsoft Store (MSIX/appx) | `build:win:store` reflète les étapes natives d'ai-edition (compositor + ffmpeg), sinon le build Store livrerait sans compositeur natif | +| `282a617e` | Désactiver Vulkan sur Wayland (import DMA-BUF PipeWire) | clean — ai-edition **a** le bloc Wayland ciblé | + +## 2. Portage fonctionnel — mixage audio multi-piste (`1c01a873`, `f65de972`) + +**Bug (macOS)** : les captures natives macOS écrivent système + micro en 2 pistes +AAC ; l'exporteur ne prenait que la 1re (souvent silencieuse) → export muet +malgré un micro enregistré. + +**Pourquoi c'était le point sensible** : `audioEncoder.ts` est **vivant** sur +ai-edition (l'export v2 `documentExporter`/`renderPlan`/`audioConcatPlan` +l'appelle encore) et `audioConcatPlan` est couplé à sa comptabilité +d'échantillons. + +**Ce qui a rendu l'intégration safe** : le changement d'ai-edition sur +`audioEncoder.ts` est **purement additif** (+72/-0 : une nouvelle méthode +`encodePcmToMuxer` pour la boucle segment v2) et **disjoint** du chemin de +décodage que `main` réécrit → `audioEncoder.ts` s'est appliqué **clean**. Le +mixage est réellement câblé : `streamingDecoder.loadMetadata()` renvoie +`audioStreamCount` → passé au blocker source-copy qui refuse le multi-piste → +le chemin plein mixe via `mixPlanarSources`. Pas inerte. + +Conflits résolus à la main dans `videoExporter.ts` / `.test.ts` en **conservant +les deux** familles de blockers (frame-rate/codec d'ai-edition + multi-piste +`#108` de `main`), `SourceCopyVideoInfo` enrichi de `audioStreamCount?`. + +**Revalidation** : `tsc` clean, **118/118 tests** (les tests de mixage de `main` ++ les 4 tests d'export v2 d'ai-edition passent ensemble), biome clean. Le test +navigateur `audioMixExport.browser.test.ts` (fixture dual-audio) n'a pas été +exécuté ici (nécessite `npm run test:browser:install`) — à lancer via +`npm run test:browser`. + +## 3. Recensement des features divergentes (code legacy supprimé) + +| Feature `main` | Verdict | Détail | +|---|---|---| +| `e4ef4768` perf playhead découplé du re-render ancêtre (#111) | ✅ **Déjà couvert** | `V4Timeline` a `PlayheadOverlay` memoïsé + `rafSeekRef`/`pendingSeekTimeRef` (coalescing rAF) + `playheadElRef` (DOM direct). Archi découplée équivalente/supérieure. | +| `574b685c` ref écrit en render (anti-pattern React, #120) | ✅ **Déjà couvert** | `PlayheadOverlay` V4 est pur/memoïsé, `pct` en prop, aucune écriture de ref en render. | +| `9844c782` zoom auto suit le curseur (`focusMode:"auto"`, #72) | ✅ **Déjà couvert** | `src/lib/ai-edition/store/zoomSuggestions.ts` met déjà `focusMode:"auto"` par défaut sur les zooms auto-suggérés (+ `zoomSuggestions.test.ts`). Exactement le fix #72. | +| `5cfdeb3c`/`0884032f` #2 durée capée après reload | ✅ **Déjà couvert** | ai-edition remplace la durée provisoire (placeholder 60s) par la vraie durée média au probe : `applyProbedDuration.ts`, gardes `useTimeline.ts`, commit `49602a14`. La race legacy (`resetDurationResolution` clobbered) n'existe pas dans ce modèle. | +| `5cfdeb3c`/`0884032f` #1 annotation texte vide + placeholder | ⚠️ **Décision produit** | ai-edition crée le texte avec un défaut *baked* `content:"New annotation"` (`useTimeline.ts:259`), pas vide+placeholder. Pas le bug legacy exact (« Enter text… »), mais même friction UX possible. À trancher : garder « New annotation » (défaut sensé) ou aligner sur vide+placeholder. | +| Docs RC e2e (`1e572232`, `dacea226`, `7013b8bd`) | ⛔ **Obsolète** | `rc-e2e-checklist.md` supprimé sur ai-edition ; écrit contre l'UI de l'éditeur legacy. | +| Bumps de version + workflow Discord | ⛔ **Skip** | Chore de release ; ai-edition gère son propre versioning. | + +## Seule décision ouverte + +Le défaut de contenu des annotations texte (⚠️ ci-dessus) — choix UX à valider. +Tout le reste des features divergentes est déjà ré-implémenté nativement dans +ai-edition. + +## Repères techniques + +- Ref de sécurité avant cherry-picks : tag `rebase-safety-before-cherrypick`. +- `node_modules` du worktree = junction vers le repo principal (pour tsc/vitest). diff --git a/electron-builder.json5 b/electron-builder.json5 index ecf9055f0..cbce12e3e 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -123,5 +123,18 @@ "nsis": { "oneClick": false, "allowToChangeInstallationDirectory": true + }, + // Microsoft Store packaging identity, from Partner Center's "Product identity" page + // (Applications et jeux > OpenScreen > Product identity). + "appx": { + "identityName": "EtienneLescot.OpenScreen", + "publisher": "CN=03B0FC8C-2067-45D9-BE82-9F15CE264B4A", + "publisherDisplayName": "Etienne Lescot", + "applicationId": "Openscreen", + "displayName": "OpenScreen", + "backgroundColor": "transparent", + "capabilities": ["runFullTrust", "microphone", "webcam"], + "languages": ["en-US", "fr-FR"], + "showNameOnTiles": true } } diff --git a/electron/main.ts b/electron/main.ts index bb56b24e9..199782072 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -50,6 +50,12 @@ if (process.platform === "linux") { app.commandLine.appendSwitch("ozone-platform", "wayland"); // Enable WebRTCPipeWireCapturer for screen capture on Wayland app.commandLine.appendSwitch("enable-features", "WaylandWindowDrag,WebRTCPipeWireCapturer"); + // Chromium's Wayland Ozone backend can't use Vulkan. When it tries, the WebRTC + // PipeWire capturer fails to import DMA-BUF frames into EGL (EGL_BAD_MATCH), the + // stream renegotiates, and screen recording yields no usable frames. Force the + // GL/EGL path so DMA-BUF import works. (Chromium itself logs this suggestion: + // "'--ozone-platform=wayland' is not compatible with Vulkan ... disabling Vulkan".) + app.commandLine.appendSwitch("disable-features", "Vulkan"); } } diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index 810cf667c..f8036c24a 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -624,8 +624,8 @@ int main(int argc, char* argv[]) { // SampleTime we attach (confirmed empirically -- varying, correctly // increasing input timestamps still produced perfectly even output // spacing). Since we cannot make the encoder respect real capture - // time, we instead make the encoder's assumption true: call - // writeBgraFrame() on a real-time-paced cadence (duplicating the + // time, we instead make the encoder's assumption true: feed the + // webcam encoder on a real-time-paced cadence (duplicating the // latest available camera frame when the camera hasn't produced a // newer one yet), so "sample N is at N/fps" is actually correct. int64_t nextWebcamWriteDueHns = 0; @@ -633,9 +633,14 @@ int main(int argc, char* argv[]) { static_cast(10'000'000ULL / std::max(1, webcamCapture.fps())); while (!control.stopRequested && !encodeFailed) { + Microsoft::WRL::ComPtr videoSample; + Microsoft::WRL::ComPtr webcamSample; + bool hasVideoSample = false; + bool hasWebcamSample = false; + { std::unique_lock lock(mutex); - control.cv.wait(lock, [&] { + control.cv.wait_for(lock, std::chrono::milliseconds(100), [&] { return control.stopRequested.load() || encodeFailed.load() || (!control.paused.load() && latestFrameTexture); @@ -691,7 +696,7 @@ int main(int argc, char* argv[]) { // sequentially at its configured nominal rate regardless of the // SampleTime attached to each input sample. So the only way to // keep the encoded webcam file in sync with real elapsed time is - // to call writeBgraFrame() *at* that nominal cadence, duplicating + // to feed the encoder *at* that nominal cadence, duplicating // the latest available camera frame when the camera hasn't // produced a newer one yet (VFR capture -> CFR encode resampling). if (targetElapsedHns >= nextWebcamWriteDueHns) { @@ -699,11 +704,16 @@ int main(int argc, char* argv[]) { if (lastWebcamTimestampHns >= 0 && webcamTimestampHns <= lastWebcamTimestampHns) { webcamTimestampHns = lastWebcamTimestampHns + nominalWebcamIntervalHns; } - if (!webcamEncoder.writeBgraFrame(webcamFrame, webcamTimestampHns)) { + // Capture the sample under `mutex` (the frame copy), but + // submit it to the sink writer OUTSIDE the mutex below + // (issue #115) so a slow WriteSample can't starve the main + // thread's stop-wait. + hasWebcamSample = webcamEncoder.captureBgraSample(webcamFrame, webcamTimestampHns, webcamSample); + if (!hasWebcamSample) { encodeFailed = true; control.stopRequested = true; control.cv.notify_all(); - return; + break; } lastWebcamTimestampHns = webcamTimestampHns; nextWebcamWriteDueHns += nominalWebcamIntervalHns; @@ -714,20 +724,49 @@ int main(int argc, char* argv[]) { } } } - if (latestFrameTexture && !encoder.writeFrame( + if (latestFrameTexture) { + // captureVideoSample performs the GPU readback + // (CopyResource/Map) from latestFrameTexture, which must + // stay serialized (via `mutex`) against the WGC + // frame-arrival callback above, which writes new data + // into the same texture on another thread. + hasVideoSample = encoder.captureVideoSample( latestFrameTexture.Get(), frameTimestampHns, - !writeSeparateWebcam && webcamFrame.data ? &webcamFrame : nullptr)) { - encodeFailed = true; - control.stopRequested = true; - control.cv.notify_all(); - return; - } - if (latestFrameTexture) { + !writeSeparateWebcam && webcamFrame.data ? &webcamFrame : nullptr, + videoSample); + if (!hasVideoSample) { + encodeFailed = true; + control.stopRequested = true; + control.cv.notify_all(); + break; + } lastEncodedVideoTimestampHns = frameTimestampHns; } } + // Submit the captured samples to their sink writers OUTSIDE + // `mutex`. IMFSinkWriter::WriteSample runs the H.264 encode + // synchronously and can be slow (especially the software encoder + // fallback used when preferSoftwareEncoder is set). Holding + // `mutex` across it would block the main thread's stop-wait + // (which locks the same mutex to check control.stopRequested) + // for as long as this thread keeps re-acquiring the lock faster + // than the main thread can, hanging the helper indefinitely + // after a stop request (issue #115). + if (hasWebcamSample && !webcamEncoder.submitVideoSample(webcamSample.Get())) { + encodeFailed = true; + control.stopRequested = true; + control.cv.notify_all(); + break; + } + if (hasVideoSample && !encoder.submitVideoSample(videoSample.Get())) { + encodeFailed = true; + control.stopRequested = true; + control.cv.notify_all(); + break; + } + frameIndex += 1; std::this_thread::sleep_for(frameDuration); } diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp index 42b708910..60f82e9f5 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -603,23 +603,38 @@ bool MFEncoder::copyBgraFrameToBuffer(const BgraFrameView& frame, BYTE* destinat return true; } -bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame) { - std::scoped_lock writerLock(writerMutex_); - if (!sinkWriter_ || finalized_) { - return false; - } +bool MFEncoder::captureVideoSample( + ID3D11Texture2D* texture, + int64_t timestampHns, + const BgraFrameView* webcamFrame, + Microsoft::WRL::ComPtr& outSample) { + outSample.Reset(); - if (firstTimestampHns_ < 0) { - firstTimestampHns_ = timestampHns; - } + const int64_t sampleDuration = 10'000'000LL / fps_; + int64_t sampleTime = 0; + { + std::scoped_lock writerLock(writerMutex_); + if (!sinkWriter_ || finalized_) { + return false; + } - int64_t sampleTime = timestampHns - firstTimestampHns_; - if (sampleTime <= lastTimestampHns_) { - sampleTime = lastTimestampHns_ + (10'000'000LL / fps_); + if (firstTimestampHns_ < 0) { + firstTimestampHns_ = timestampHns; + } + + sampleTime = timestampHns - firstTimestampHns_; + if (sampleTime <= lastTimestampHns_) { + sampleTime = lastTimestampHns_ + sampleDuration; + } + lastTimestampHns_ = sampleTime; } - const int64_t sampleDuration = 10'000'000LL / fps_; - lastTimestampHns_ = sampleTime; + // The GPU readback below (copyFrameToBuffer -> CopyResource/Map on + // `texture`) is not internally synchronized here. Callers must hold their + // own lock around this call that also serializes against whatever thread + // writes new frame data into `texture` (see main.cpp's writeVideoFrames, + // which holds the shared frame-state mutex across this call but not + // across submitVideoSample). Microsoft::WRL::ComPtr buffer; const DWORD frameBytes = static_cast(width_ * height_ * 4); if (!succeeded(MFCreateMemoryBuffer(frameBytes, &buffer), "MFCreateMemoryBuffer")) { @@ -648,25 +663,34 @@ bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const sample->SetSampleTime(sampleTime); sample->SetSampleDuration(sampleDuration); - return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample.Get()), "WriteSample"); + outSample = sample; + return true; } -bool MFEncoder::writeBgraFrame(const BgraFrameView& frame, int64_t timestampHns) { - std::scoped_lock writerLock(writerMutex_); - if (!sinkWriter_ || finalized_) { - return false; - } +bool MFEncoder::captureBgraSample( + const BgraFrameView& frame, + int64_t timestampHns, + Microsoft::WRL::ComPtr& outSample) { + outSample.Reset(); - if (firstTimestampHns_ < 0) { - firstTimestampHns_ = timestampHns; - } + const int64_t sampleDuration = 10'000'000LL / fps_; + int64_t sampleTime = 0; + { + std::scoped_lock writerLock(writerMutex_); + if (!sinkWriter_ || finalized_) { + return false; + } - int64_t sampleTime = timestampHns - firstTimestampHns_; - if (sampleTime <= lastTimestampHns_) { - sampleTime = lastTimestampHns_ + (10'000'000LL / fps_); + if (firstTimestampHns_ < 0) { + firstTimestampHns_ = timestampHns; + } + + sampleTime = timestampHns - firstTimestampHns_; + if (sampleTime <= lastTimestampHns_) { + sampleTime = lastTimestampHns_ + sampleDuration; + } + lastTimestampHns_ = sampleTime; } - const int64_t sampleDuration = 10'000'000LL / fps_; - lastTimestampHns_ = sampleTime; Microsoft::WRL::ComPtr buffer; const DWORD frameBytes = static_cast(width_ * height_ * 4); @@ -696,7 +720,25 @@ bool MFEncoder::writeBgraFrame(const BgraFrameView& frame, int64_t timestampHns) sample->SetSampleTime(sampleTime); sample->SetSampleDuration(sampleDuration); - return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample.Get()), "WriteSample(webcam)"); + outSample = sample; + return true; +} + +bool MFEncoder::submitVideoSample(IMFSample* sample) { + if (!sample) { + return false; + } + + // This is the potentially slow, blocking step (especially with the + // software H.264 encoder fallback): IMFSinkWriter::WriteSample runs the + // encode synchronously on the calling thread. Callers must NOT hold any + // lock shared with a thread that needs to make timely progress (e.g. a + // stop-request check) across this call. + std::scoped_lock writerLock(writerMutex_); + if (!sinkWriter_ || finalized_) { + return false; + } + return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample), "WriteSample"); } bool MFEncoder::writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) { diff --git a/electron/native/wgc-capture/src/mf_encoder.h b/electron/native/wgc-capture/src/mf_encoder.h index 8f5787481..e5fbd74c8 100644 --- a/electron/native/wgc-capture/src/mf_encoder.h +++ b/electron/native/wgc-capture/src/mf_encoder.h @@ -53,8 +53,25 @@ class MFEncoder { ID3D11DeviceContext* context, const AudioInputFormat* audioFormat = nullptr, MFEncoderOptions options = {}); - bool writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame = nullptr); - bool writeBgraFrame(const BgraFrameView& frame, int64_t timestampHns); + // Capturing a video/webcam sample (GPU readback + IMFSample creation) is + // split from submitting it to the sink writer (IMFSinkWriter::WriteSample) + // so callers that hold an external lock across the GPU-touching capture + // step (to serialize against a producer thread writing into the same + // texture) are not forced to also hold that lock across the potentially + // slow, blocking WriteSample call. See main.cpp's writeVideoFrames for why + // this split exists: holding the shared frame-state mutex across + // WriteSample let the software H.264 encoder path starve the main + // thread's stop-request check indefinitely (issue #115). + bool captureVideoSample( + ID3D11Texture2D* texture, + int64_t timestampHns, + const BgraFrameView* webcamFrame, + Microsoft::WRL::ComPtr& outSample); + bool captureBgraSample( + const BgraFrameView& frame, + int64_t timestampHns, + Microsoft::WRL::ComPtr& outSample); + bool submitVideoSample(IMFSample* sample); bool writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns); bool finalize(); const char* videoEncoderSelection() const; diff --git a/electron/native/wgc-capture/src/wgc_session.cpp b/electron/native/wgc-capture/src/wgc_session.cpp index e20096c4e..89f0b55fe 100644 --- a/electron/native/wgc-capture/src/wgc_session.cpp +++ b/electron/native/wgc-capture/src/wgc_session.cpp @@ -32,6 +32,28 @@ int64_t timeSpanToHns(wf::TimeSpan const& value) { return value.count(); } +// H.264 encoding (and the RGB32->NV12 conversion feeding it) requires even +// frame dimensions. Monitor resolutions are always even in practice, so +// CreateForMonitor items never hit this. Windows, however, frequently have +// odd client-area dimensions (arbitrary drag-resize, DPI rounding), and +// GraphicsCaptureItem::Size() reports the window's *actual* size verbatim. +// If we requested a Direct3D11CaptureFramePool sized to that odd value while +// the rest of the pipeline (main.cpp's bitrate calc, MFEncoder) rounds down +// to even, the frame pool's real DXGI textures end up one pixel wider/taller +// than the staging texture the encoder allocates. ID3D11DeviceContext:: +// CopyResource silently no-ops on a size mismatch (it only emits a debug- +// layer warning), so the staging texture never receives pixel data and the +// output is solid black for the entire recording -- or, if the mismatch +// trips up the video MFT's input negotiation, SetInputMediaType fails +// outright. Rounding up to the nearest even size here, and using that +// rounded size (not the raw item size) for both the frame pool and +// `captureWidth()`/`captureHeight()`, keeps every consumer of this session +// looking at the exact same dimensions as the real captured texture. +int roundUpToEven(int value) { + const int clamped = std::max(2, value); + return (clamped % 2 == 0) ? clamped : clamped + 1; +} + } // namespace WgcSession::~WgcSession() { @@ -135,8 +157,8 @@ bool WgcSession::createCaptureItem(HWND window) { item_ = item; const auto size = item_.Size(); - width_ = static_cast(size.Width); - height_ = static_cast(size.Height); + width_ = roundUpToEven(static_cast(size.Width)); + height_ = roundUpToEven(static_cast(size.Height)); return width_ > 0 && height_ > 0; } @@ -199,7 +221,7 @@ bool WgcSession::initialize(HMONITOR monitor, int fps, bool captureCursor) { winrtDevice_, wgdx::DirectXPixelFormat::B8G8R8A8UIntNormalized, 2, - item_.Size()); + winrt::Windows::Graphics::SizeInt32{width_, height_}); session_ = framePool_.CreateCaptureSession(item_); if (!applySessionOptions(captureCursor)) { @@ -223,7 +245,7 @@ bool WgcSession::initialize(HWND window, int fps, bool captureCursor) { winrtDevice_, wgdx::DirectXPixelFormat::B8G8R8A8UIntNormalized, 2, - item_.Size()); + winrt::Windows::Graphics::SizeInt32{width_, height_}); session_ = framePool_.CreateCaptureSession(item_); if (!applySessionOptions(captureCursor)) { diff --git a/package.json b/package.json index 0a144abc9..7c1614046 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "build:native:win": "node scripts/build-windows-wgc-helper.mjs", "build:native:compositor": "node scripts/build-windows-compositor-addon.mjs", "build:win": "npm run build:native:win && npm run build:native:compositor && npm run fetch:ffmpeg && tsc && vite build && electron-builder --win --config.npmRebuild=false", + "build:win:store": "npm run build:native:win && npm run build:native:compositor && npm run fetch:ffmpeg && tsc && vite build && electron-builder --win appx --config.npmRebuild=false", "build:linux": "tsc && vite build && electron-builder --linux AppImage deb pacman --config.npmRebuild=false", "build:whisper-binaries": "bash scripts/build-whisper-stt.sh", "test": "vitest --run", diff --git a/src/components/ai-edition/v4/FloatingInspector.tsx b/src/components/ai-edition/v4/FloatingInspector.tsx index 7fa773b22..d375e54ff 100644 --- a/src/components/ai-edition/v4/FloatingInspector.tsx +++ b/src/components/ai-edition/v4/FloatingInspector.tsx @@ -398,6 +398,7 @@ function SelectionPane({ tl, onClose }: { tl: TimelineApi; onClose: () => void }