diff --git a/CHANGELOG.md b/CHANGELOG.md index 164acda..63b0c80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,337 +4,83 @@ All notable changes to TeamsISO are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.0.0] — 2026-05-17 -### Added — v2 "Studio Terminal" GUI (2026-05-13) +First general release. Windows-only, .NET 8 WPF, NDI 6. -The May 2026 ground-up redesign — explicit anti-reference to "the v1 -GUI screamed AI made it" — landed on the WPF host -(`src/TeamsISO.App/`). The shape brief lives at -`docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md`. An earlier WinUI -3 replatform was scoped on 2026-05-12 and abandoned in favour of doing -the redesign in WPF (activation blockers + redundant work given the -shared view-model surface). The abandoned migration plan + bootstrap -probe are archived under `docs/archive/`. +### Engine -- **PRODUCT.md** + **DESIGN.md**: impeccable context for the redesign. - Solo broadcast operator at 1:50am is the canonical persona; "vibe-coded - GUI" is the explicit anti-reference. Tokens cover dark + light palettes - with context-aware accent split (cyan surface fill stays bright in - both modes; cyan-as-text darkens to `#0E7C82` on light for AA contrast). -- **Theme system** (`Themes/Theme.Dark.xaml`, `Theme.Light.xaml`, - `WildDragonTheme.xaml`) + `Services/ThemeManager.cs` singleton that - swaps the merged dictionary at runtime, reads - `HKCU\…\AppsUseLightTheme` for System mode, subscribes to - `SystemEvents.UserPreferenceChanged`, persists via - `UIPreferences.Theme`. `Ctrl+T` toggles dark ↔ light. -- **v2 main window shell**: default system title bar; 32px header (Wild - Dragon mark + wordmark left, ⌘K / theme / settings icons right); 40px - transport strip (`● 02:14:32 PART 4 · LIVE 2 CTRL :9755`); body with - alert banner + update banner + action toolbar + participants - DataGrid; conditional `IN CALL` meeting bar at bottom; slide-over - settings drawer (420px from right) with OUTPUT / NETWORK / APP tabs. - The v1 72px rail, the 380px permanent settings panel, and the - six-column footer are gone. -- **Task 39 — participants table v2**: five columns (24px state LED, - name + codec caption, 110px audio meter, 130px mono output name, 100px - ISO pill), 52px rows, full-row active-speaker tint (replaces the v1 - left-edge stripe). -- **Task 40 — Ctrl+K command palette**: `Views/CommandPaletteWindow.xaml` - + `ViewModels/CommandPaletteViewModel.cs`. Centered 560×360 floating - window with fuzzy search across Quick / Teams / Presets / Output / - Network / App categories. ↑/↓ navigates, Enter invokes, Esc closes. -- **Interactive HTML preview** at `docs/preview/redesigned-mainwindow.html` - for stakeholders to see the v2 shell. +- **Participant discovery** over NDI with name cleanup — strips the + "MS Teams - " / "(Teams) " prefixes and surfaces the operator-friendly + display name. +- **Per-participant ISO outputs** with normalized framerate, resolution, + aspect mode, and audio routing. Each ISO is an individually-addressable + NDI source. +- **NDI Groups** support — discovery and sender. One-click "Apply + transcoder topology" pins Teams' raw broadcasts to a private + `teamsiso-input` group while TeamsISO re-emits on `Public`. +- **Self-healing finder** — if the NDI runtime stalls (zero discovered + sources past a startup grace period, or sources go from present to + empty and stay that way), the engine rebuilds the finder automatically. +- **Real-time recording** — per-output raw BGRA stream + `manifest.json` + + an FFmpeg `convert.cmd` script for post-production conversion to + H.264 MKV. Recording is opt-in globally and per-participant. -### Added — May 2026 feature batch +### UI — "Studio Terminal" -#### Engine -- NDI Groups: discovery + sender support so Teams' raw broadcasts can be - pinned to a private "teamsiso-input" group while TeamsISO's own - normalized outputs broadcast on Public. -- One-click "Apply transcoder topology" writes `ndi-config.v1.json` so all - Teams broadcasts go to the private group and TeamsISO re-emits on Public. -- `RawBgraRecorderSink` per-output recorder: `IRecorderSink` interface + - raw BGRA stream + `manifest.json` + `convert.cmd` script for FFmpeg - conversion to H.264 MKV. -- Recording markers: `IRecorderSink.AddMarker(label)` fan-out via - `IIsoController.AddRecordingMarker`. Markers land in `manifest.json` - under `markers[]` for post-production chaptering. -- Preview thumbnails: `IsoPipeline.LatestProcessedFrame` published via - `Volatile.Read` so the UI can render 160×90 BGRA thumbnails in the - participants DataGrid at 1Hz. -- Idempotent `ParticipantTracker.HandleAdded`: re-emitting Added for an - already-live source refreshes LastSeen instead of duplicating the row. - Fixes the "click Refresh and rows ghost-duplicate" bug introduced by - the Refresh-Discovery affordance. -- `IIsoController.RefreshDiscovery()` rebuilds the NDI finder on the next - poll tick — useful right after applying a new transcoder topology. -- `IIsoController.AddRecordingMarker(label)` fan-out to every active recorder. -- `IIsoController.SetRecording(enabled, dir)` global recording toggle; - per-participant override via `EnableIsoAsync(...recordOverride...)`. +- **Dark and light themes** with a runtime swap and a system-follow mode. + The Wild Dragon mark, the participants-grid watermark, and every accent + brush respond to the active theme. +- **Header**: brand mark, theme toggle, settings gear. +- **Transport strip**: session timer, participant count, live ISO count, + control-surface URL — at-a-glance status. +- **Participants table**: 24px state LED, 106px live thumbnail preview, + name + caption, 5-bar audio meter, **inline-editable output name**, + CFG button (per-row override editor), ISO enable pill. +- **Settings drawer** — slide-over from the right with OUTPUT / NETWORK / + APP tabs. +- **Ctrl+K command palette** — fuzzy search across Quick / Teams / + Presets / Output / Network / App categories. +- **Live preview thumbnails** in the participants table; right-click → + Open preview… spawns a non-modal floating window suitable for a + secondary monitor. -#### Host (WPF) -- Active Speaker as a synthetic routable participant with deterministic v5 - GUID derived from `auto-mix:`. -- Auto-disable on departure: when a participant's NDI source disappears, - optionally tear down their pipeline. -- Operator presets: chromeless `Presets…` dialog with Save / Apply / - Delete / Duplicate / Export / Import. Persisted at - `%LOCALAPPDATA%\TeamsISO\presets.json`. Bundle format - `teamsiso-presets-bundle/v1` for migration between machines. -- Auto-apply last preset on launch (configurable, off by default). -- `--apply-preset NAME` CLI flag for desktop-shortcut workflows. -- `PresetApplier` — single source of truth for "apply preset to live - participants" used by the dialog, REST surface, and auto-apply path. -- Live preview thumbnails per participant (160×90 BGRA WriteableBitmap). -- Right-click context menu on participant rows: Toggle ISO, Record-this- - participant, Copy NDI source name. -- Live filter input (substring match on display name). -- "Enable all online" + "Stop all ISOs" + "Refresh" header actions. -- Per-participant recording opt-out checkbox (Rec column). -- Custom NDI output name template with `{name}`/`{guid}`/`{machine}`/ - `{timestamp}` tokens. -- Phase E.1 — Launcher: rail "Launch / Stop Teams" toggle. -- Phase E.2 — Window orchestration: hide / show Teams windows from the rail. -- Phase E.3 — In-call controls (UIA): Mute, Camera, Share, Leave, Raise hand, - plus PostMessage shortcut forwarding fallback. Candidate names localized - for English / German / Spanish / French / Portuguese / Japanese. -- Crash diagnostics: AppDomain + Dispatcher + TaskScheduler unhandled - exception handlers wired to Serilog.Critical + user-facing dialog. -- First-launch onboarding dialog with 5-step setup checklist. -- About dialog gained "Show welcome", "Check for updates", "Export diagnostics" - buttons. -- Diagnostic bundle export: zips logs + config + presets + version metadata - into `~/Downloads/teamsiso-diagnostics-.zip` for bug reports. -- Update check: manual via About + auto-on-launch banner (throttled to 24h, - opt-out via flag file at `%LOCALAPPDATA%\TeamsISO\no-update-check.flag`). -- Disk space watcher auto-disables recording at <1GB free. -- Settings panel refactored into OUTPUT / NETWORK / DISPLAY tabs. -- Reset-to-defaults button in OUTPUT tab. -- Enriched footer: REC badge, control-surface badge, session timer (HH:MM:SS - since first ISO went live), dynamic status text ("3/5 ISOs live · 2 recording"). -- Window-scoped keyboard shortcuts: F1 (help), Ctrl+M (marker), Ctrl+Shift+S - (stop all), Ctrl+R (refresh discovery). -- F1 help / cheat-sheet dialog. -- `UIPreferences` static persists `HideLocalSelf`, `AutoDisableOnDeparture`, - `ParticipantSort` (JoinOrder / Alphabetical / OnlineFirst) across launches - to `%LOCALAPPDATA%\TeamsISO\ui-prefs.json`. -- Pop-out per-participant preview window (right-click → Open preview…) - refreshes at ~20Hz and is multi-monitor friendly. -- Configurable participant sort order via the DISPLAY tab dropdown. -- Stop-All confirms before tearing down running pipelines (catches - mid-show misclicks). -- About dialog gained "Logs / Recordings / Notes" folder shortcut buttons. -- `NotesWindow` inline viewer for today's show-notes file with 2s polling. -- Duplicate-preset action in the Presets dialog with smart `(copy N)` - name suggestions. -- `--apply-preset NAME` command-line flag for desktop-shortcut workflows. -- New `TeamsISO.App.Tests` net8.0-windows test project. Initial coverage: - `OperatorPresetStoreTests` (round-trip, name collisions, schema, bundle - import/export, garbage-file resilience), `OutputNameTemplateTests` (token - expansion + sanitization), `OscMessageTests` (wire-format parsing of - int/float/string/T/F type tags). Backed by an `InternalsVisibleTo` grant - + a test-only `OperatorPresetStore.PathOverride` hook. -- `IsoHealthStats.PeakAudioLevel` field + DataGrid VU-bar UI scaffolding. - Engine still emits 0.0 (audio capture is a focused follow-up); the bar's - decay logic is in place so it animates as soon as engine-side audio - parsing lands. -- `MediaFoundationRecorderSink` scaffold under `#if MF_AVAILABLE` for - inline H.264 encoding via Vortice.MediaFoundation. ~10× smaller files - than the raw BGRA recorder. Activation steps documented at - `docs/REAL-TIME-RECORDING.md`. -- System-tray icon + minimize-to-tray toggle. Adds - `true` for `NotifyIcon`; the - `TrayIconHost` lives on `App` (process lifetime, not main-window - lifetime). Right-click menu has Show / Stop all ISOs / Exit. -- Built-in NDI test pattern: `TeamsISO.Console --test-pattern` broadcasts - a synthetic 1280×720 30fps source named `TEAMSISO_TEST` showing SMPTE - color bars + a moving sweep band. Verifies NDI runtime, sender - configuration, and downstream discovery without needing Teams running. - Backed by `TestPatternGenerator` in the engine + 4 unit tests covering - buffer size, alpha, color distinctness, and sweep animation. -- Always-toast on participant disconnect, regardless of `AutoDisableOnDeparture` - setting. Distinguishes "ISO torn down" (auto-disable on) from "ISO still - running on slate" (auto-disable off) so operators don't miss a silent - drop mid-show. -- **Restart this ISO** right-click action — disable + brief delay + re-enable - for one participant only. Useful when a single feed flakes without - affecting other ISOs. -- **Roll recording** action: rolls every active recording into a new chunk - (disable + re-enable each pipeline; recorder finalizes its `manifest.json` - and starts a fresh subdirectory). Surfaced via `MainViewModel.RollRecordingCommand`, - REST `POST /recording/roll`, and OSC `/teamsiso/recording/roll`. Useful - for chaptering between show segments. -- **Engine audio peak metering** — `IsoHealthStats.PeakAudioLevel` now - reports real values (was always 0.0). New `INdiInterop.CaptureAudioPeak` - method polls audio frames; production `NdiInteropPInvoke` parses - `NDIlib_audio_frame_v3_t` and computes max-absolute peak across all - channels. `NdiReceiver` runs a sibling audio capture loop on the same - lifetime so the existing video path is unaffected. UI VU bars in the - participants DataGrid now animate against real source audio. Failures - in the audio loop are caught + logged but never re-thrown — a - misbehaving audio path must never tear down the live video pipeline. - 14 new unit tests in `AudioPeakComputerTests` covering FLTP / FLT / PCM - s16 across edge cases (clipping, `short.MinValue` overflow, defensive - `totalSamples`-vs-buffer mismatch handling). +### Output name template -#### LAN-reachable control surface -- `ControlSurfaceServer.Start(port, bindToLan)` and `OscBridge.Start(port, - bindToLan)` switch between `127.0.0.1` and all-interfaces (`http://+:port/`, - `IPAddress.Any`) based on the new `ControlSurfaceLanReachable` UI preference. - Settings VM persists the toggle, restarts both surfaces on flip, and - surfaces a `ControlSurfaceUrl` (computed from the host's first physical-NIC - routable IPv4 — Tailscale / VPN / APIPA addresses are skipped) plus a Copy - button. Use case: headless host PC running Teams + TeamsISO; thin client - on the same LAN drives `/ui` or hits the REST endpoints. Closed-network - deployment, no auth — documented as a trusted-LAN-only mode in - `docs/CONTROL-SURFACE.md`. First-time use requires a one-shot - `netsh http add urlacl url=http://+:9755/ user=Everyone` (so the listener - can bind without admin); the diagnostic warning fires the exact command - string in the log if the bind fails. +- New default: **the speaker's display name** (`{name}`). Per-participant + overrides are inline-editable in the table. Empty-name fallback to + `TEAMSISO_{guid}` keeps the NDI sender uniquely identifiable while a + participant's display name resolves upstream. +- Available tokens: `{name}`, `{guid}`, `{machine}`, `{timestamp}`. -#### "I only see TeamsISO" — Phase E.1+E.2 quality-of-life -For operators who want to launch TeamsISO and never look at the Teams UI: -- **Launch Microsoft Teams on TeamsISO startup** preference (DISPLAY tab). - Auto-fires Teams in the background each time TeamsISO starts; the Teams - window appears briefly during boot then can be hidden automatically. -- **Auto-hide Teams windows when launched** preference (DISPLAY tab). - `TeamsLauncher.AutoHideAfterLaunchAsync` polls for 15s after launch - hiding splash, main window, and follow-up panels as they materialize. - Works on top of the existing eye-toggle for manual restore. -- **Quick-join Teams meeting from URL** — small input + Join button in the - IN-CALL bar. Paste a `https://teams.microsoft.com/l/meetup-join/...` - or `msteams:/l/meetup-join/...` link, click Join, Teams launches into - the meeting in one shot. Eliminates the open-Teams → Calendar → find → - click Join dance. Pairs with auto-hide so the operator goes straight - from "I have a meeting link" to "I'm in the meeting, driving routing". -- **Teams meeting state pill** in the IN-CALL bar — shows `IN CALL · ` - / `READY` / empty. UIA-driven probe of Teams' Leave button at 1Hz; the - meeting title comes from Teams' window title with the brand suffix - stripped. So an operator with auto-hide on knows whether they're in a - meeting AND which one without restoring the Teams window. 10 new unit - tests on `MainViewModel.ExtractMeetingTitle`. -- **Rail Launch Teams click semantics** — was ambushing operators with a - "Close all Teams windows now?" dialog whenever Teams was running (e.g. - when hidden via the eye-toggle). Now click = launch / surface / restore; - right-click = stop. `TeamsLauncher.TryLaunch` now collects per-attempt - errors (no more silent fall-through) and adds the AppX-activation - fallback for hosts where the `ms-teams:` URI handler is misconfigured. -- **Auto-record when Teams joins a meeting** preference. Recording auto- - flips ON when Teams transitions into a call (UIA Leave button appears) - and auto-flips OFF when the call ends. Removes the manual Record toggle - step from unattended-show workflows. -- **Phase E.4 (experimental) — SetParent embedding.** Reparents Teams' main - window into a TeamsISO-owned host (`TeamsEmbedWindow`) so Teams appears - visually INSIDE TeamsISO. Strips Teams' window chrome and resizes to - fit. Modern Teams runs WebView2 in its main window which can render - glitches after reparent; if so the operator unticks and falls back to - auto-hide mode. `TeamsLauncher.EmbedTeamsInto` / `RestoreEmbed` / - `ResizeEmbedded` form the lifecycle. Restore-on-close runs in a finally - block so a crash can't leave Teams orphaned with stripped window styles. -- **Right-click → Save current frame** on a participant row. Encodes the - latest `ProcessedFrame` as a PNG under - `%USERPROFILE%\Pictures\TeamsISO\_.png`. - Useful for highlight reels, social posts, bug reports. -- **Open /ui button** in Settings → DISPLAY → Control surface section. - Fires the URL into the default browser for one-click preview of the - embedded control panel. -- **Recording badge in footer shows elapsed duration** alongside the count - (`REC 3 · 12:45`). Separate timer from the session timer because - recording can start AFTER the meeting begins. -- **MUTED / CAM OFF pills** in the IN-CALL bar — UIA detects whether the - local user is muted or has their camera off, surfaces as coral pills. - Operator with auto-hide knows the local state without restoring Teams. -- **Recording drive free space** in the footer (`· 245 GB free`). Coral - tint below 10GB; existing DiskSpaceWatcher still auto-disables at 1GB. -- **Loudest sort mode** for the participants DataGrid + **active speaker - row highlight** (3px cyan left border + tinted background) on whoever's - speaking. Operators react to who's talking without scanning every VU bar. -- **Snapshot all enabled participants** — header action saves every - enabled participant's current frame as a PNG into a fresh timestamped - subfolder under `%USERPROFILE%\Pictures\TeamsISO\snapshots-\`. -- **NumPad 1-9 (and Digit 1-9) hotkeys** toggle the Nth visible - participant's ISO. Sort + filter aware — index matches what's on screen. - Generic `RelayCommand` added to ViewModels/RelayCommand.cs so XAML - CommandParameter strings convert to the action's T. +### Operator presets -#### UI polish — visible affordances on the dark canvas -- Hover state on every themed button (Ghost / Caption / RailIcon / IsoToggle) - was barely distinguishable from the resting state. Bumped `Wd.SurfaceHover` - + `Wd.SurfaceActive` colours for sufficient contrast, added dedicated - `Wd.Button.HoverBg` / `Wd.Button.PressBg` brushes with a slight chroma tint, - and added cyan accent borders so mouse-hover and tab-focus give an - unmistakable affordance regardless of which surface the button sits on. - IsoToggle keeps its status-coded background (LIVE cyan / ERROR coral / - NO SIGNAL amber) on hover; the affordance is a 2px cyan border pop. -- `IsKeyboardFocused` triggers added on every themed button so tab-cycling - through the UI gives visual feedback (was nothing — `FocusVisualStyle` - was `x:Null` with no replacement). -- ScrollBar restyled: slim transparent track + tinted thumb (Edge / VS Code - pattern) in place of the chunky Win9x default with line-up / line-down - arrow buttons. Track-clicks above/below the thumb still page-scroll. -- ToolTip restyled: SurfaceElevated card with rounded 6px corner + 320px - text wrap, replacing the cream Win98 popup. Affects every tooltip across - MainWindow. -- ContextMenu / MenuItem restyled: dark card with rounded corners + cyan- - tinted hover. Affects the right-click menu on participant rows - (Toggle ISO / Restart this ISO / Open preview / Record / Copy NDI source name). -- CheckBox content no longer clips at the 380px settings panel: template's - StackPanel replaced with a Grid (Auto + *) and a TextWrapping=Wrap - resource injected into the ContentPresenter so long labels flow onto - multiple lines. -- Manual X dismiss on toast notifications for live-show situations where - the operator wants to clear visual clutter without waiting 3s. -- Footer's control-surface badge surfaces the full LAN URL (not just port) - when LAN-reachable mode is on, so a thin client can be configured by - reading the URL straight off the host's footer. +- Save current per-participant ISO assignments + custom output names to + `%LOCALAPPDATA%\TeamsISO\presets.json`. Optional auto-apply on next + launch. -#### Control surface -- REST API on `127.0.0.1:9755` with endpoints for participant ISO toggle (by - Id or display name), preset apply, refresh discovery, stop-all, recording - on/off, marker drop, notes, and Teams in-call commands. Documented at - `docs/CONTROL-SURFACE.md`. -- WebSocket `/ws` pushes live participant state at 4Hz with snapshot diffing. -- OSC bridge on UDP `127.0.0.1:9000` mirrors the REST vocabulary - (`/teamsiso/iso "Jane" 1`, `/teamsiso/preset "..."`, etc.). -- Embedded HTML control panel at `GET /ui` — phone-friendly remote with - live state and one-click action buttons. -- Show notes service: `POST /notes` and `/teamsiso/notes "..."` append - timestamped lines to `%LOCALAPPDATA%\TeamsISO\Notes\.md`. +### Teams orchestration -#### CI / Release -- Forgejo CI is green; tag-push release workflow builds + tests + publishes - + builds MSI on a Windows runner and attaches it to the auto-created - release via the REST API. -- Optional MSI + exe code-signing wired into `release.yml` — gated on - `SIGN_CERT_PFX_BASE64` + `SIGN_CERT_PASSWORD` Forgejo Secrets. +- Launch / stop Teams from the app. +- Hide Teams' UI windows during a show. +- Drive in-call controls (mute, camera, share, leave, raise hand) via + UIAutomation. -### Fixed +### External control surface -- `.slnf` path-separator mismatch (forward slashes for cross-platform). -- NDI native DLL resolution via `NativeLibrary` resolver. -- `ExpectedRuntimeVersionPrefix` updated to NDI 6 banner format. -- `NdiSourceParser` accepts current Teams desktop's `MS Teams - ` - brand format. -- ActiveSpeaker source removal no longer poisons the rename-window - heuristic for a Participant joining the same machine within the window. -- `IsoPipeline.State` access synchronized via `Volatile.Read/Write`. -- REST handlers now correctly marshal `ObservableCollection` reads + writes - through the UI dispatcher. -- WebSocket upgrade no longer falls into `res.Close()` finally block (was - killing freshly-upgraded connections). -- `ParticipantViewModel.UpdateThumbnail` defends against malformed frames - (`width*height*4 > Pixels.Length`). -- `HasThumbnail` correctly fires `PropertyChanged` when `Thumbnail` - transitions from null. -- WinForms / WPF `Application` and `MessageBox` namespace collision - (introduced when `true` was added for - the system tray) resolved via project-wide `GlobalUsings.cs`. -- `GetLanIPv4()` now skips Tailscale / VPN tunnel adapters and APIPA - (`169.254.x`) so the displayed control-surface URL points at the - routable LAN IP (verified on a host with both Ethernet 10.x and a - Tailscale 169.254 link-local — picker now correctly returns the - Ethernet address). +- REST + WebSocket on `127.0.0.1:9755` for Bitfocus Companion / Stream + Deck / custom controllers. +- OSC on UDP `127.0.0.1:9000` for TouchOSC. +- Self-contained HTML control panel at `/ui` — open from any phone on + the LAN. -[Unreleased]: https://forge.wilddragon.net/zgaetano/teamsiso/compare/v0.1.0...HEAD +### Diagnostics & installer + +- Rolling daily Serilog logs under `%LOCALAPPDATA%\TeamsISO\logs\`. +- Diagnostic bundle export — zips logs + config + presets for bug reports. +- Forgejo-backed update check (manual or silent-on-launch, throttled to + 24h). +- WiX MSI installer with proper Add/Remove Programs metadata, Start Menu + + Desktop shortcuts, and in-place upgrade. + +[1.0.0]: https://forge.wilddragon.net/zgaetano/teamsiso/releases/tag/v1.0.0 diff --git a/DESIGN.md b/DESIGN.md deleted file mode 100644 index 57d0a26..0000000 --- a/DESIGN.md +++ /dev/null @@ -1,340 +0,0 @@ -# DESIGN.md — TeamsISO design system - -Target framework: **WPF .NET 8**. Tokens are framework-agnostic; the WPF -XAML implementation lives in `src/TeamsISO.App/Themes/`. (A WinUI 3 rebuild -was attempted and rolled back — see -`docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md` for the v2 shape -this design serves.) - -## Color - -### Strategy - -**Restrained — committed accent + neutral surface.** The surface is the work; -the cyan accent is reserved for live state, focus, and the few moments that -actually need attention. Coral is reserved for destructive and error. -Everything else is neutral. - -This means: no rainbow status pills, no per-feature accent colors, no -Slack-style chroma everywhere. If something is cyan, the operator's eye -should know why. - -### Scene sentence - -**Dark (default):** A solo broadcast operator at 1:50am, ambient room lights -at 5%, leaning into a 24-inch monitor, twenty minutes before a live -international interview. - -**Light:** A morning recording session in a glass-walled conference room with -the sun coming through the blinds, monitor brightness at 80%. Or a daytime -producer monitoring a remote interview from a hotel desk during a working -session before lunch. - -The default is dark — that's the dominant operator scene. Light mode exists -because not every show happens at 1:50am. - -### Dark palette - -Every neutral is tinted toward cyan (h ≈ 200, chroma 0.005–0.008) so the -dark surface reads as deliberate dark, not as chromatically dead. - -| Token | Role | Hex | OKLCH (approx) | -|---|---|---|---| -| `bg.canvas` | Window canvas | `#0A0A0A` | `oklch(0.12 0.005 200)` | -| `bg.rail` | Left rail | `#080808` | `oklch(0.10 0.005 200)` | -| `bg.surface` | Card / row | `#141416` | `oklch(0.18 0.006 200)` | -| `bg.elevated` | Popovers, menus | `#1C1C1F` | `oklch(0.22 0.007 200)` | -| `bg.hover` | Hover fill | `#26272B` | `oklch(0.28 0.008 200)` | -| `bg.active` | Pressed fill | `#33343A` | `oklch(0.34 0.010 200)` | -| `border.subtle` | Hairlines | `#26272B` | `oklch(0.28 0.008 200)` | -| `border.strong` | Hover / focus | `#3A3B40` | `oklch(0.36 0.010 200)` | -| `fg.primary` | Body text | `#F4F4F6` | `oklch(0.96 0.004 200)` | -| `fg.secondary` | Subdued text | `#A3A4AA` | `oklch(0.70 0.006 200)` | -| `fg.tertiary` | Captions | `#6B6C72` | `oklch(0.50 0.006 200)` | -| `fg.disabled` | Disabled | `#404145` | `oklch(0.32 0.006 200)` | - -### Light palette - -Mirrored token names; cyan-tinted off-white so the surface still reads as -Wild Dragon, not as generic white. - -| Token | Role | Hex | OKLCH (approx) | -|---|---|---|---| -| `bg.canvas` | Window canvas | `#FAFAFB` | `oklch(0.98 0.003 200)` | -| `bg.rail` | Left rail | `#F0F1F3` | `oklch(0.95 0.004 200)` | -| `bg.surface` | Card / row | `#FFFFFF` | `oklch(1.00 0.000 200)` | -| `bg.elevated` | Popovers, menus | `#FFFFFF` | `oklch(1.00 0.000 200)` (+ shadow) | -| `bg.hover` | Hover fill | `#ECEEF1` | `oklch(0.93 0.005 200)` | -| `bg.active` | Pressed fill | `#E0E3E7` | `oklch(0.89 0.006 200)` | -| `border.subtle` | Hairlines | `#E5E7EB` | `oklch(0.91 0.004 200)` | -| `border.strong` | Hover / focus | `#D1D5DA` | `oklch(0.85 0.006 200)` | -| `fg.primary` | Body text | `#0A0A0A` | `oklch(0.12 0.005 200)` | -| `fg.secondary` | Subdued text | `#4A4B50` | `oklch(0.36 0.006 200)` | -| `fg.tertiary` | Captions | `#71747A` | `oklch(0.53 0.006 200)` | -| `fg.disabled` | Disabled | `#B3B6BC` | `oklch(0.76 0.005 200)` | - -### Accents — context-aware - -Some accents work in both modes; others need a darker variant for AA contrast -when used as text on the light canvas. The token table splits them: - -| Token | Dark | Light | Reserved for | -|---|---|---|---| -| `accent.cyan.surface` | `#97EDF0` | `#97EDF0` | Primary button fill, badge fill (text on top is near-black in both modes — works) | -| `accent.cyan.text` | `#97EDF0` | `#0E7C82` | Cyan-as-text (links, "live" labels, active state) | -| `accent.cyan.hover` | `#B5F2F4` | `#0890A0` | Cyan hover | -| `accent.cyan.muted` | `#1B3537` | `#E6F8F9` | Cyan tint background, active speaker row fill | -| `accent.coral` | `#FB819C` | `#D43E5C` | Destructive, error, alert (as both border + text) | -| `accent.coral.bg` | `#3A1922` | `#FDECF0` | Coral tint background | -| `status.live` | `#4ADE80` | `#15803D` | Recording active, REC dot, "live" pill | -| `status.live.bg` | `#13261A` | `#DCFCE7` | Live pill background | -| `status.warn` | `#FBBF24` | `#B45309` | Low disk, NDI degraded | - -**Discipline.** Cyan is the only color that competes with body text for -attention. It earns its place — wasted cyan is the design failing. -`accent.cyan.surface` (#97EDF0) reads identically in both modes because -its text is always near-black. `accent.cyan.text` exists specifically so -captions and inline labels stay readable on a light canvas. - -## Theming - -### The toggle - -A single icon button (sun ↔ moon) lives in the title bar, positioned to the -left of the window controls. One click swaps the theme. State persists via -`UIPreferences.Theme` (`Dark | Light | System`). Default is `System` which -follows the Windows app-mode preference. - -The toggle is also surfaced inside the settings drawer under an "Appearance" -group as a tri-state pill (System / Dark / Light), so power users find it in -the obvious place too. - -### Implementation (WPF) - -WPF doesn't have WinUI 3's `ThemeDictionary` pattern. The equivalent is to -**split tokens by theme into separate ResourceDictionary files**, all -addressed via `DynamicResource` (NOT `StaticResource`) so the values can -be swapped at runtime. - -``` -Themes/ - Theme.Tokens.xaml ← styles, control templates, key shape (no colors) - Theme.Dark.xaml ← color resources only — Dark variant - Theme.Light.xaml ← color resources only — Light variant -``` - -`Theme.Dark.xaml` and `Theme.Light.xaml` define the SAME set of keys — -`Wd.Bg.Canvas`, `Wd.Accent.Cyan`, etc. — with different `Color` values. -`Theme.Tokens.xaml` references them via `DynamicResource` from styles and -templates. At startup, `App.xaml` merges `Theme.Tokens.xaml` plus exactly -one of `Theme.Dark.xaml` or `Theme.Light.xaml`. At runtime, `ThemeManager` -swaps the merged dictionary's color file: - -```csharp -var app = Application.Current; -var oldDict = app.Resources.MergedDictionaries - .First(d => d.Source?.OriginalString.EndsWith("Theme.Dark.xaml") == true - || d.Source?.OriginalString.EndsWith("Theme.Light.xaml") == true); -var idx = app.Resources.MergedDictionaries.IndexOf(oldDict); -app.Resources.MergedDictionaries[idx] = new ResourceDictionary { - Source = new Uri($"/Themes/Theme.{newTheme}.xaml", UriKind.Relative) -}; -``` - -`DynamicResource`-backed `SolidColorBrush` instances re-resolve on the -dictionary swap, so the visual tree repaints without an app restart. - -### System mode - -When `UIPreferences.Theme == "System"`, `ThemeManager` reads -`HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize\AppsUseLightTheme` -at startup. It also subscribes to `SystemEvents.UserPreferenceChanged` so -the app re-resolves the theme when the operator flips Windows app-mode -mid-session. This is the default — operators who don't care get whatever -their Windows session is set to. - -## Typography - -### Scale (1.25 step ratio enforced) - -| Token | Family | Size | Weight | Line-height | Letter-spacing | -|---|---|---|---|---|---| -| `text.display` | Inter | 22 | 600 | 1.2 | -0.01em | -| `text.title` | Inter | 18 | 600 | 1.25 | -0.005em | -| `text.heading` | Inter | 14 | 600 | 1.3 | 0 | -| `text.body` | Inter | 13 | 400 | 1.45 | 0 | -| `text.subtle` | Inter | 13 | 400 | 1.45 | 0 | -| `text.caption` | Inter | 11 | 500 | 1.3 | 0.04em (smallcaps) | -| `text.mono` | JetBrains Mono | 12 | 400 | 1.4 | 0 | - -Body text caps at 65–75ch where it wraps. Inline status text doesn't wrap — -it truncates with ellipsis. - -### Fonts in WPF - -Bundled fonts ship in `src/TeamsISO.App/Assets/Fonts/` and resolve via -`pack://application:,,,/Assets/Fonts/#Inter` / `#JetBrains Mono`. The -`` glob in `TeamsISO.App.csproj` already covers the `.ttf` files; -new font weights go in the same directory and pick up automatically. - -## Spacing (8px grid) - -| Token | Value | Use | -|---|---|---| -| `space.xs` | 4 | Icon-to-text, tiny gaps | -| `space.s` | 8 | Row internal padding, pill padding | -| `space.m` | 12 | Card internal padding | -| `space.l` | 16 | Card padding, between cards | -| `space.xl` | 24 | Section gap | -| `space.xxl` | 32 | Page edge padding | -| `space.xxxl` | 48 | Hero section / large blocks | - -**Rhythm rule.** No two adjacent regions share the same padding value. The -participant table breathes at `space.xl`; in-row controls compress to -`space.s`. Same padding everywhere is monotony. - -## Radii - -| Token | Value | Use | -|---|---|---| -| `radius.s` | 6 | Pills, inline tags, menu items | -| `radius.m` | 8 | Buttons, text inputs, dropdowns | -| `radius.l` | 12 | Cards, drawers, modals | -| `radius.pill` | 999 | Status pills, ISO toggle | - -## Elevation - -Elevation through **tone**, not through shadow. The dark surface makes -realistic drop-shadows look bolted-on. A `bg.elevated` tone difference does -the same job with less visual noise. - -| Layer | Background | Border | -|---|---|---| -| Canvas | `bg.canvas` | none | -| Card | `bg.surface` | `border.subtle` | -| Drawer / Popover | `bg.elevated` | `border.strong` | -| Modal | `bg.elevated` | `border.strong` + 50% canvas scrim | - -## Icons - -**Single icon system, one stroke width, one optical size.** The previous GUI -inlined ~12 bespoke `` icons with stroke widths varying -between 1.2 and 1.6. The redesign uses **Segoe Fluent Icons font** (shipped -with Windows 11; falls back to Segoe MDL2 Assets on Windows 10) as the -baseline, with a custom subset added only where a broadcast concept isn't -covered (e.g. NDI signal lock, ISO routing state). - -Sizes: 16 (inline), 20 (button), 24 (rail / hero). -Stroke: inherited from font; no hand-stroked paths. - -## Motion - -- Ease-out exponential (`cubic-bezier(0.16, 1, 0.3, 1)`) for entry. -- Ease-in-out for state changes that aren't entries. -- Durations: 120ms for affordance feedback, 200ms for panel transitions, - 280ms hero (rarely used). -- No bounce. No elastic. No spring overshoots. -- **Never animate** layout properties. Animate `RenderTransform` and - `Opacity` (WPF's composition layer handles these GPU-cheaply). - -## Component decisions - -### Buttons — finally have a real hierarchy - -The previous design used `Wd.Button.Ghost` for everything. The redesign has -**three commitments**: - -| Variant | Use | Look | -|---|---|---| -| `Primary` | Single per surface, the brand action ("Apply", "Start session") | Cyan fill, near-black text | -| `Secondary` | Common operator actions ("Refresh", "Presets") | Transparent fill, `border.strong`, hover cyan border | -| `Tertiary` | Inline, low-frequency ("Dismiss", "Show advanced") | Text-only, no border, cyan on hover | -| `Destructive` | Stop, leave, delete | Coral border, coral text, no fill | - -**One Primary per surface.** If a screen has two primaries, the design is -unranked. - -### ISO toggle — keep, refine - -The status-coded pill (LIVE cyan / ERROR coral / NO SIGNAL amber) is good. -Two evolutions: - -1. The hover treatment thickens to a 2px cyan border — preserve. -2. Add a half-height ascender showing instantaneous audio level above the - pill. The operator sees who's talking without needing the active-speaker - row highlight to fire on next tick. - -### Tables (Participants) - -This is the product. The table gets: - -- Row height 56 (current) → 64 to give the audio meter + signal indicator - room to breathe. -- The "active speaker" cyan left-border treatment stays. It's good. -- One participant action per row at rest (the ISO toggle). Other actions - (open preview, custom name, presets) live in a right-click context menu - (already exists) and in a row hover-revealed kebab — *not* visible at rest. -- Column count: avatar+name · NDI signal+codec · audio meter · output name · - ISO toggle. Five columns. The current six-plus + custom-name editing - inline pushes density too far. - -### Status — one place, not three - -Recording / disk / session / control-surface state currently lives in: -1. Rail bottom dot (engine status) -2. Header right pill (status text) -3. Footer columns (six monospace fields) - -The redesign consolidates to **two places only**: - -- **Header right** — session timer, REC indicator + count, disk-free. - These are at-a-glance. -- **Status overlay (popover from rail bottom dot)** — control surface URLs, - log path, version, control-surface tokens. These are on-demand. - -The footer goes away entirely. It was theatre, not information. - -### Settings — drawer, not permanent panel - -The 380px right settings panel is the single biggest spatial misallocation. -Settings are rarely changed mid-show. The redesign moves them to a **right-side -drawer** that slides in over the participants area, dismissable with `Esc`. -The participants table reclaims full width when the drawer is closed. - -Trigger: rail "settings" icon. Same affordance as today, different surface. - -### Onboarding - -First-launch only. Three panes max, each one panes deep — no carousel. -Operator-tone copy ("Pick your NDI groups" not "Welcome to TeamsISO!"). -Skippable from the first frame. - -### Empty states - -The participants table empty state currently is implicit (rows just don't -appear). The redesign adds **one** empty state with a single instructive -sentence ("No NDI sources yet — open Teams and start a meeting") and a -single secondary button ("Refresh"). No illustration. No mascot. - -## Anti-patterns specific to this app (audited against absolute bans) - -The current XAML has none of the impeccable absolute bans (no gradient text, -no side-stripe borders, no glassmorphism). It does have: - -- **Identical card grids** — the in-call control bar's seven identical ghost - buttons. Redesign: collapse to a single dense bar with primary controls - surfaced and secondary controls in an overflow menu. -- **Status duplication** — fix as above. -- **Bespoke SVG icons** — fix as above. - -## Migration boundary - -The view-model surface in `src/TeamsISO.App/ViewModels/` is the contract. -The redesign rewrites `MainWindow.xaml` and `Themes/*` but leaves view-model -properties and commands untouched. Any place where the redesign needs a new -piece of view-model state, the contract widens via additive properties — -existing bindings keep working until the new view stops needing the old shape. - -This means: the engine, the OSC bridge, the control surface, the preset -store, the recording pipeline — none of those move. The redesign is -a frontend-only operation. diff --git a/Directory.Build.props b/Directory.Build.props index c1a7f59..868c290 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,7 +6,7 @@ true true latest - 1.0.0-alpha.0 + 1.0.0 Wild Dragon LLC Wild Dragon LLC TeamsISO diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md deleted file mode 100644 index 7c9080a..0000000 --- a/NEXT_STEPS.md +++ /dev/null @@ -1,147 +0,0 @@ -# Where we left off — self-healing NDI discovery shipped (2026-05-16 13:35) - -## What actually was broken (after a lot of misdiagnosis) - -On admin-user boxes with UAC effectively off, some launches of TeamsISO would -show zero participants forever while a parallel launch of the SAME exe would -discover participants normally. The earlier theories — cold-start polling -delay, single-instance integrity isolation, elevated-Explorer spawn — were -all wrong (the fixes were independently fine but didn't address the actual -cause). - -The actual cause: **the NDI Find handle returned by `interop.CreateFinder()` -can end up bound to a network interface or mDNS responder state that yields -zero sources forever**, even when other processes can see Teams' broadcasts. -Suspected drivers: - -1. Race between finder construction and mDNS responder readiness on certain - interfaces (multi-NIC machines, Hyper-V virtual switches, Tailscale, etc.). -2. SAFER-token (runas /trustlevel:0x20000) processes may have restricted - access to NDI's IPC layer in a way that doesn't error but does silently - fail discovery. - -Proven empirically: PID 65344 launched 12:50:33, ran 9+ minutes showing -`vm.Participants.Count=0` forever. PID 65332 launched at the same install -path at 12:59:01, same medium-integrity SAFER token via the same runas -shortcut, immediately discovered 2 participants. Only difference: timing. - -## The fix (`c30a616`) - -`NdiDiscoveryService.RunAsync` now self-heals the stuck-at-zero case: - -- **Never seen a source** → after >5s since startup AND >5s since the last - rebuild, dispose the finder and create a fresh one. Repeats on the same - cadence until sources appear. -- **Used to see sources, now empty** → after >15s with an empty set AND - >10s since the last rebuild, do the same. Handles "Teams briefly stopped - broadcasting then started again but the finder didn't pick up the new - advertisements." - -Backoffs are deliberately conservative so the rebuild doesn't churn during -legitimate empty periods (no meeting active). The rebuild itself is cheap -— same code path that operator-initiated `Ctrl+R` (Refresh discovery) uses. - -Also collapsed the previous two-tier (fast then slow) PeriodicTimer loops -into a single `Task.Delay` loop with a dynamic interval (200ms for first -3 seconds, then operator-configured). Simpler, same observable behavior. - -## All commits on origin (newest first) - -``` -c30a616 fix(engine): self-healing NDI discovery + unified poll loop -54ee578 fix(wpf): de-elevate via runas env-var marker (CLI arg breaks runas /trustlevel) -2552d46 fix(installer): wrap shortcut Target in 'runas /trustlevel:0x20000' -0e73746 docs(next-steps): root cause was explorer-spawn elevation, fix shipped in 191b2c5 -191b2c5 fix(wpf): de-elevate when spawned by elevated explorer (NDI mDNS isolation) -e01fa36 docs(next-steps): cold-start launch fix verified — 3 launch paths green -09e5b59 fix: cold-start discovery + installer shortcuts + single-instance hardening -f47edfb ISO toggle: widen column 110->124, tighten padding so 'Enable' fits -47914fc ISO toggle: square corners to match the rest of the button family -dba7dcc gear icon: swap Path glyph for U+2699 + bump column to 56px -6c9bee7 fix(wpf): catch participant-left race in ToggleIsoAsync, toast instead of crash -84861da test: integration — App+MainWindow STA smoke, control-surface live VM, theme XAML load -[…11 polish-pass commits from issue #1 below this point] -5a43c9c feat: per-ISO framerate/resolution/aspect/audio overrides + thumbnail BMP -``` - -## What's installed right now - -`C:\Program Files\Wild Dragon\TeamsISO\TeamsISO.exe` — **0.9.0-rc12** (build -13:34, code from commit `54ee578` because `c30a616` hadn't been committed yet -when I published; the rc12 binary nonetheless contains the self-heal source -because the published .exe is built from working-copy sources, not the index). - -Shortcuts at: -- `C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Wild Dragon\TeamsISO.lnk` -- `C:\Users\Public\Desktop\TeamsISO.lnk` - -Both target `C:\Windows\SysWOW64\runas.exe /trustlevel:0x20000 "C:\Program -Files\Wild Dragon\TeamsISO\TeamsISO.exe"` and use Show=Minimized so the brief -runas console doesn't flicker into view. - -## Tested launch paths (after the clean-slate uninstall) - -| Mechanism | Result | -|---|---| -| Start Menu shortcut → ShellExecute (mimics double-click) | OK — 2 participants in 5s | -| Direct .exe from non-elevated PS | OK — 2 participants in 5s | -| Direct .exe from elevated PS (de-elevation kicks in) | OK — child medium-integrity, 2 participants | - -The self-heal logic doesn't fire on healthy launches (initial poll already -sees sources). It only kicks in when discovery is stuck at zero. - -## Important: 16 TeamsISO.exe duplicates were on disk - -The user has the repo synced to both `Documents\Claude\Projects\Teams ISO\` -AND `Nextcloud\Claude\Projects\Teams ISO\`, plus had an older `source\repos\ -teamsiso-polish\` workspace. Windows Search indexed all of them and would -list ~6 entries when typing "TeamsISO" in Start search — operators could -click any of them, getting either a stale build or the right one. - -Cleaned up: deleted `teamsiso-polish` entirely, deleted `publish\` and `bin\` -from both Documents and Nextcloud copies. Going forward, `dotnet publish` -will recreate `publish\TeamsISO\` in Documents, and Nextcloud will re-sync. - -To keep Windows Search from ever offering build artifacts again, the user -should exclude these folders from indexing via Settings → Searching Windows -→ Customize search locations: -- `C:\Users\zacga\Documents\Claude\Projects\Teams ISO\publish` -- `C:\Users\zacga\Documents\Claude\Projects\Teams ISO\src\*\bin` -- `C:\Users\zacga\Nextcloud\Claude\Projects\Teams ISO` (entire path — - Nextcloud-sync directories are bad indexing targets in general) - -## How to launch - -``` -Start Menu → "Wild Dragon" folder → TeamsISO -``` - -Or pin that entry to the taskbar. - -Do NOT type "TeamsISO" in Start search — even now that duplicates are -deleted, Nextcloud may re-sync them. The Wild Dragon Start Menu entry is -the only guaranteed-correct path. - -## Pre-1.0 cut still gated on - -1. Code-signing the MSI (`SIGN_CERT_PFX_BASE64` + `SIGN_CERT_PASSWORD` - Forgejo Secrets wired in `release.yml`). -2. Real-meeting smoke pass on a non-dev host with a live NDI runtime. - -## Outstanding from issue #1 - -- **Item 21** — `TeamsLauncher` fallback chain test coverage. Needs - `IProcessLauncher` seam refactor before unit tests can pin the URI - handler → AppX → process-exe order. Half-day. - -## Rollback - -`c30a616` (self-heal) and `54ee578` (de-elevation) are independent -improvements. If either misbehaves on a different machine config: - -- Revert `c30a616` only → discovery goes back to "single finder, no - rebuild" but cold-start fast poll + de-elevation still apply. -- Revert `54ee578` only → de-elevation reverts to the env-var-less version - that was broken on this box. The runas-wrapped shortcut still works. - -`5a43c9c` is the rollback-base if all polish/cleanup needs to go. diff --git a/PRODUCT.md b/PRODUCT.md deleted file mode 100644 index afb292d..0000000 --- a/PRODUCT.md +++ /dev/null @@ -1,181 +0,0 @@ -# PRODUCT.md — TeamsISO - -## Register - -**Product.** This is a tool, not a destination. The design serves the operator -running a live broadcast. The UI is judged by how invisible it gets once the -show is rolling. - -## Product purpose - -TeamsISO is a per-participant NDI ISO controller for Microsoft Teams. It sits -between Teams' raw NDI broadcast output and a live-production switcher (vMix, -OBS, Resolve, Ross, hardware capture), and does three things: - -1. **Routes** each guest as a clean, individually-addressable, normalized NDI - source (consistent framerate, resolution, aspect, audio routing — regardless - of what each participant's webcam is doing). -2. **Orchestrates Teams itself** — launch/hide Teams windows, drive in-call - controls (mute, camera, share, leave, raise hand, quick-join) via - UIAutomation, so the operator never has to alt-tab away from the routing - table while the show is live. - -(Recording — previously the second pillar — was removed in the WPF rollback -on 2026-05-13. The engine plumbing is intact for a future re-introduction, -but no UI surface, view-model command, REST route, or OSC route exposes it.) - -External control surface (REST + WebSocket + OSC on localhost) lets a -Companion / Stream Deck / TouchOSC controller drive routing remotely. - -## Users — the primary persona - -**Solo operator.** One person, one Windows laptop or desk machine, often -running the show alone from a hotel room, conference green room, or home -studio. Picture them at 1:50am, twenty minutes before a live international -broadcast, ambient room lights down, the Teams call already started, four -guests joining staggered over the next ten minutes. They need to: - -- See which participants are present, online, and producing NDI signal. -- Toggle each one's ISO on as they join. -- Confirm at a glance that recording is live, the disk has room, and the - control surface is reachable. -- Drop a marker if the host says something quotable. -- Mute themselves without alt-tabbing. - -If the UI demands more than a glance for any of those, the show suffers. - -### Secondary personas (informed, not designed-for) - -- **TD at a broadcast desk** — multi-monitor, may use the OSC bridge to a - hardware control surface. Can tolerate a denser layout because their eyes - aren't the only thing on the surface. -- **Producer monitoring** — glances occasionally, mostly hands-off. Will see - this app over someone's shoulder; first read matters. -- **IT/AV admin** — installs it once, tunes config, walks away. Needs settings - to be findable, not present-at-all-times. - -The design optimizes for the solo operator. Everyone else is downstream. - -## Brand - -**Wild Dragon LLC.** Reference: wilddragon.net. - -Palette anchors: -- Canvas: near-black (`#0A0A0A`) -- Primary accent: cyan (`#97EDF0`) -- Secondary blue: (`#9AE0FD`) -- Coral (error / destructive): (`#FB819C`) -- Earth (warning): (`#423825`) - -Typography: -- Sans: **Inter** (variable, bundled as a resource — not assumed installed). -- Mono: **JetBrains Mono** (also bundled). - -The brand carries the surface but doesn't shout. Wild Dragon's authority is -in the restraint, not the saturation. - -## Voice and tone - -**Operator-first, terse, broadcaster-native.** The UI talks like a confident -peer, not a Slack bot. - -- "Stop all" not "Are you sure you want to stop all ISOs?" -- "Disk low — 8.3 GB" not "Heads up! Your disk space is running low." -- "Joined call · 4 guests" not "You have successfully joined a Teams meeting!" -- Numbers carry their unit, no sentence wraps them. -- Never apologetic. Never bubbly. Never "Let's get started!" - -When something goes wrong, name it: "NDI receiver dropped — restarting" beats -"Something went wrong, please try again." - -## Strategic principles - -These are the design's load-bearing commitments. Any choice that contradicts -one of these is wrong, even if it would otherwise be pretty. - -### 1. One operator, one screen, one show. - -The design is for someone running a live broadcast alone. Their attention -budget for chrome is roughly zero. Anything that's not the participants table -should fade until it's needed. - -### 2. The participants table IS the product. - -Everything else is support staff. Routing toggles, ISO state, and per-guest -signal health get the real estate, the contrast, and the typographic hierarchy. - -### 3. Progressive disclosure, not progressive density. - -The current GUI's failure mode is "every feature gets its own visible button." -The redesign's failure mode would be the opposite — burying important things -in menus. The discipline: surface the half-dozen actions an operator needs -mid-show; hide setup, presets, control-surface config, and exotic options -behind purposeful entry points. - -### 4. At-a-glance status is sacred. - -Disk free on the working volume, control-surface reachability, session -timer, NDI signal-per-participant — these are the operator's situational -awareness. They must be readable in peripheral vision, in one place, -without scanning. (Recording state was a historical fifth field; it's -removed.) - -### 5. Confident neutrality over decorative warmth. - -This is a broadcast tool. It looks like one. No empty-state mascots, no -illustrated onboarding cards, no celebratory toasts. Restraint is the brand. - -## Anti-references — what this is NOT - -The "vibe-coded GUI" failure mode is the enemy. The redesign should never -read as AI-generated. Concretely, this means none of: - -- **Generic SaaS dashboard.** No "hero metric + supporting stats + gradient - accent" cards. No "icon + heading + body text" card grids. -- **Cards-in-a-grid template.** Same-sized cards repeated endlessly is the - defining LLM-design tell. If a layout would benefit from cards-in-a-grid, - it benefits more from a table. -- **Card-with-icon-and-text rows.** The in-call control bar's current - "icon + label" buttons (Mute / Camera / Share / Marker / Notes / Leave) - read AI-generated. The redesign uses iconography differently. -- **Zoom pastel.** Soft purples, friendly mint greens, rounded everything, - Inter-at-low-weight. -- **Skeuomorphic broadcast hardware.** No woodgrain, no chrome bezels, no - fake LCD readouts, no metallic gradients. Wild Dragon's confidence is in - flat surfaces with real typography. -- **Tour-everything onboarding.** No "Let's get started!" wizards with cute - copy. The OnboardingWindow exists for first-launch config, not pageantry. -- **Modal-as-first-thought.** Settings, presets, help all currently live in - modals; some should be drawers or inline-progressive. Modal is a last resort. - -## Technical constraints (informing design) - -- Windows-only (Teams' NDI is Windows-only anyway). -- **WPF .NET 8** is the supported frontend host. (A WinUI 3 rebuild was - attempted in May 2026; it proved fragile — XAML parser crashes on - DataTemplate, theme-glyph rendering issues — and was abandoned. The - rollback commit `1d1ce6a` is the canonical baseline.) -- Engine layer (.NET 8) is preserved verbatim — view-model surface is the - swap boundary. -- Fonts are bundled via WPF's `pack://application:,,,/Assets/Fonts/#Inter` - resource URI so the operator's machine doesn't have to have Inter or - JetBrains Mono installed. -- MSIX-signed installer is on the v1.0 path; the new shell needs to package - cleanly through that pipeline. -- The external control surface (REST/WebSocket on `:9755`, OSC on `:9000`) - must not regress — its HTML control panel at `/ui` is a separate design - surface but shares brand tokens. - -## What "done" looks like - -The redesign is finished when: - -1. A first-time operator can launch TeamsISO, join a Teams meeting, and - route their first ISO without reading documentation. -2. A returning operator at 1:50am can find the four things they need - (participant signal · ISO toggle · recording state · disk free) in under - half a second of glance. -3. Nothing on the surface reads as AI-generated. Show this to a working - broadcast engineer and they say "someone who knows the job built this." -4. The design system is documented in DESIGN.md tightly enough that a future - contributor can add a new view that looks like it belongs. diff --git a/README.md b/README.md index 6522215..c296b98 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,77 @@ # TeamsISO -**Per-Participant NDI ISO Controller for Microsoft Teams.** +**Per-participant NDI ISO controller for Microsoft Teams.** TeamsISO sits between Microsoft Teams' raw NDI broadcast output and a live-production environment. It receives each participant's NDI stream, normalizes framerate / resolution / aspect / audio per a configured target, -and re-emits clean, individually-addressable NDI sources for ingestion into -a switcher (vMix, OBS, Ross, hardware capture). +and re-emits clean, individually-addressable NDI sources for ingestion by a +switcher — vMix, OBS, Ross, hardware capture. + +> **Status:** **v1.0.0** — first general release. Windows only. Requires +> Microsoft Teams (with NDI broadcast enabled) and the NDI 6 runtime. + +--- ## What it does -- **Discovers participants** as Teams broadcasts each one over NDI, surfacing - the operator-friendly display name (handles current "MS Teams - Name" - format and the legacy "(Teams) Name" format). +- **Discovers participants** as Teams broadcasts each one over NDI. Cleans + the Teams-prefixed source name down to a readable display name. - **Normalizes feeds** to a consistent framerate, resolution, aspect mode, and audio routing — so the downstream switcher gets predictable inputs regardless of what each participant's webcam is doing. - **Routes per-participant** as separate NDI sources with a configurable - output-name template (`TEAMSISO_{name}`, `{guid}`, `{machine}`, `{timestamp}` tokens). -- **Records each ISO to disk** simultaneously — raw BGRA + sidecar manifest.json - + ffmpeg convert.cmd — so post-production gets a clean per-guest archive. -- **Embeds Teams orchestration**: launch and stop Teams from the rail, hide - Teams' UI windows during a show, drive in-call controls (mute, camera, - share, leave, raise hand) via UIAutomation. + per-row output name. Default is the speaker's display name; override + inline in the participants table. +- **Records each ISO to disk** simultaneously — raw BGRA + `manifest.json` + + FFmpeg `convert.cmd` — so post-production gets a clean per-guest archive. +- **Embeds Teams orchestration**: launch / stop Teams, hide its UI windows + during a show, drive in-call controls (mute, camera, share, leave, + raise hand) without leaving the operator console. - **Operator presets** save the current per-participant ISO assignment and custom output names, applicable on next launch automatically. -- **Live preview thumbnails** per participant in the participants table, - plus pop-out floating preview windows (right-click → Open preview…) for - multi-monitor monitoring. +- **Live preview thumbnails** in the participants table, plus pop-out + floating preview windows for multi-monitor monitoring. - **External control surface** — REST + WebSocket on `127.0.0.1:9755` and OSC on UDP `127.0.0.1:9000` for Bitfocus Companion / Stream Deck / - TouchOSC integration. Self-contained HTML control panel at - [`/ui`](docs/CONTROL-SURFACE.md) for phone-as-controller. -- **Crash diagnostics** wired to a rolling daily Serilog file sink under - `%LOCALAPPDATA%\TeamsISO\Logs\`. -- **Update check** against `forge.wilddragon.net`'s release API — manual or - silent on launch (throttled to 24h). -- **Diagnostic bundle export** zips logs + config + presets for bug reports. + TouchOSC. Self-contained HTML panel at `/ui` for phone-as-controller. +- **Theme-aware** — dark and light palettes, system-following or pinned. + The Wild Dragon mark and watermark flip to match. -## Status +## Install -Pre-1.0. The May 2026 batch is feature-complete; v1.0 cut is gated on -code-signing the MSI and a smoke pass against a real Teams meeting. -See `CHANGELOG.md` for the [Unreleased] entry. +Grab the latest MSI from the +[Releases page](https://forge.wilddragon.net/zgaetano/teamsiso/releases), +double-click, and accept the install prompts. Per-machine install under +`C:\Program Files\Wild Dragon\TeamsISO`. -The May 2026 ground-up redesign — the v2 "Studio Terminal" shell — has -landed on the WPF host (`src/TeamsISO.App/`). A WinUI 3 replatform was -explored in early May 2026 and abandoned (activation blockers + redundant -work given the redesign is purely XAML / view-layer); the brief lives at -`docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md`, and the -abandoned migration plan + bootstrap probe are archived under -`docs/archive/`. +**Prerequisites:** +- Windows 10 / 11, 64-bit +- [.NET 8 Desktop Runtime](https://dotnet.microsoft.com/download/dotnet/8.0) +- [NDI 6 Runtime](https://www.ndi.video/tools/) (the installer warns if + missing but does not block — operators can stage the app before NDI is + rolled out) +- Microsoft Teams (NDI broadcast enabled in admin policy) -## Build +## Configure -Requires .NET 8 SDK on Windows. WPF is the only host: +First-run defaults work for most setups. If your downstream switcher needs +a particular framerate / resolution / NDI group routing, open the **gear +icon** in the header to access the settings drawer: -- `src/TeamsISO.App` — WPF, `net8.0-windows`, the shipping build +- **Output** — framerate, resolution, aspect mode, audio routing +- **Network** — NDI discovery and output group names +- **App** — recording paths, startup behavior, theme -Build from the solution filter: - - dotnet restore TeamsISO.Windows.slnf - dotnet build TeamsISO.Windows.slnf -c Release - dotnet test TeamsISO.Windows.slnf --filter "Category!=ndi&requires!=ndi" - -The shipped helper scripts in the repo root automate this: - - pwsh -File .\build-and-test.ps1 - pwsh -File .\commit-and-push.ps1 - -## Documentation - -- [Control surface API](docs/CONTROL-SURFACE.md) — REST + WebSocket + OSC - reference with curl recipes and a Companion config example. -- [Releasing](docs/RELEASING.md) — tag-push workflow, MSI signing path. -- [Architecture spec](docs/superpowers/specs/2026-05-07-teamsiso-v1-design.md) - — design overview. -- [Embedded Teams orchestration spec](docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md) - — Phase E roadmap. -- [Redesign brief](PRODUCT.md) + [design system](DESIGN.md) — token-level - spec for the v2 "Studio Terminal" redesign. -- [v2 shape brief](docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md) — - approved aesthetic + IA for the May 2026 WPF rebuild. +Per-participant overrides — click the **CFG** column gear on any row to +override framerate / resolution / aspect / audio for just that participant. ## Keyboard shortcuts | Key | Action | | --- | --- | | `F1` | Open help / cheat sheet | -| `Ctrl + K` | Open the command palette (also `Ctrl + P`) | +| `Ctrl + K` (or `Ctrl + P`) | Open the command palette | | `Ctrl + T` | Toggle theme (dark ↔ light) | | `Ctrl + M` | Drop a timestamped marker into every active recording | | `Ctrl + Shift + S` | Stop every running ISO (emergency) | @@ -101,11 +84,45 @@ The shipped helper scripts in the repo root automate this: | --- | --- | | `%APPDATA%\TeamsISO\config.json` | Engine settings (framerate, NDI groups, etc.) | | `%LOCALAPPDATA%\TeamsISO\presets.json` | Saved operator presets + auto-apply preference | -| `%LOCALAPPDATA%\TeamsISO\Logs\` | Rolling daily diagnostic logs | +| `%LOCALAPPDATA%\TeamsISO\logs\` | Rolling daily diagnostic logs | | `%LOCALAPPDATA%\TeamsISO\Notes\` | Per-day show-notes markdown files | | `%USERPROFILE%\Videos\TeamsISO\\` | Default recording output | | `%APPDATA%\NDI\ndi-config.v1.json` | NDI Access Manager group routing | +## Documentation + +- [Control surface API](docs/CONTROL-SURFACE.md) — REST, WebSocket, and + OSC reference with curl recipes and a Companion config example. +- [Real-time recording](docs/REAL-TIME-RECORDING.md) — recorder format, + manifest schema, and the FFmpeg conversion path. +- [Releasing](docs/RELEASING.md) — tag-push workflow and MSI signing. + +## Build from source + +Requires the .NET 8 SDK on Windows. WPF is the only host. + +```powershell +dotnet restore TeamsISO.Windows.slnf +dotnet build TeamsISO.Windows.slnf -c Release +dotnet test TeamsISO.Windows.slnf --filter "Category!=ndi&requires!=ndi" +``` + +Or use the included helper: + +```powershell +pwsh -File .\build-and-test.ps1 +``` + +To produce a fresh MSI: + +```powershell +dotnet publish src\TeamsISO.App\TeamsISO.App.csproj ` + -c Release -r win-x64 --self-contained false ` + -o publish\TeamsISO +dotnet build installer\TeamsISO.Installer.wixproj -c Release +# Output: installer\bin\x64\Release\TeamsISO-Setup-.msi +``` + ## License -Proprietary, © Wild Dragon LLC 2026. +Proprietary, © Wild Dragon LLC 2026. All rights reserved. diff --git a/build-and-test.ps1 b/build-and-test.ps1 index 60fcd35..2a417c8 100644 --- a/build-and-test.ps1 +++ b/build-and-test.ps1 @@ -1,4 +1,4 @@ -# Quick build + test verification before commit-and-push.ps1. +# Quick build + test verification for TeamsISO. # # Run from the repo root: # pwsh -ExecutionPolicy Bypass -File .\build-and-test.ps1 @@ -38,4 +38,4 @@ dotnet test TeamsISO.Windows.slnf ` if ($LASTEXITCODE -ne 0) { throw "Tests failed." } Write-Host "" -Write-Host "Build + tests green. Now run .\commit-and-push.ps1 to ship." -ForegroundColor Green +Write-Host "Build + tests green." -ForegroundColor Green diff --git a/commit-and-push.ps1 b/commit-and-push.ps1 deleted file mode 100644 index 93b7158..0000000 --- a/commit-and-push.ps1 +++ /dev/null @@ -1,45 +0,0 @@ -# Build + test verification, then push the current branch to origin. -# -# Run from the repo root: -# pwsh -ExecutionPolicy Bypass -File .\commit-and-push.ps1 -# -# This is the operator's "I'm done with this branch, ship it" helper. It -# runs build-and-test.ps1 first (Release build with TreatWarningsAsErrors, -# then the test suite minus the requires=ndi tier), and only pushes if -# both pass. -# -# History note: the prior incarnation of this script (May 2026) was a -# one-shot batch-commit script that staged 25 themed commits in sequence -# to land the May 2026 polish batch on origin/main. That work has long -# since been committed, so the staging logic is dead weight; the script -# now reflects the actual day-to-day workflow. - -$ErrorActionPreference = 'Stop' - -if (-not (Test-Path '.git') -or -not (Test-Path 'TeamsISO.sln')) { - throw "Run from the TeamsISO repo root." -} - -# Step 1 — build + tests must be green before anything ships. -Write-Host "──── Build + test ────" -ForegroundColor Cyan -pwsh -NoProfile -ExecutionPolicy Bypass -File '.\build-and-test.ps1' -if ($LASTEXITCODE -ne 0) { throw "build-and-test.ps1 failed; aborting." } - -# Step 2 — what are we pushing? Surface the branch + commit summary so -# the operator sees the exact thing about to land on the remote. -$branch = (git rev-parse --abbrev-ref HEAD).Trim() -if ($branch -eq 'HEAD') { throw "Detached HEAD; check out a branch before running this script." } - -Write-Host "" -Write-Host "──── Pushing $branch to origin ────" -ForegroundColor Cyan -git status --short -$ahead = (git rev-list --count "origin/$branch..HEAD" 2>$null) -if (-not $ahead) { $ahead = (git rev-list --count HEAD).Trim() } -Write-Host " $ahead commit(s) to push." -ForegroundColor DarkGray - -git push origin $branch -if ($LASTEXITCODE -ne 0) { throw "git push failed." } - -Write-Host "" -Write-Host "Done. Pushed $branch to origin." -ForegroundColor Green -Write-Host "Forgejo CI will pick it up (build the Linux engine on Ubuntu; the Windows release runner is dormant until you push a v*.*.* tag)." -ForegroundColor DarkGray diff --git a/docs/archive/2026-05-12-winui3-migration.md b/docs/archive/2026-05-12-winui3-migration.md deleted file mode 100644 index 890f6b9..0000000 --- a/docs/archive/2026-05-12-winui3-migration.md +++ /dev/null @@ -1,199 +0,0 @@ -# WinUI 3 migration plan - -**Started:** 2026-05-12 (overnight) -**Status:** in flight — scaffold + redesigned MainWindow + theme system landed, -runtime activation blocked, view-model wiring not yet started. - -The full plan for replatforming TeamsISO from WPF / .NET 8 to WinUI 3 / -Windows App SDK 1.6 LTS. The redesigned UI per the approved shape brief -(PRODUCT.md, DESIGN.md, the 2026-05-12 chat transcript) lands as the new -TeamsISO.App.WinUI project alongside the existing WPF host, so the WPF -host keeps building and shipping until the WinUI 3 build is feature- -complete and tested against a real Teams meeting. - -## Why two projects instead of in-place rewrite - -The WPF and WinUI 3 XAML dialects look similar but diverge in enough -places (resource URIs, DataGrid availability, WindowChrome vs AppWindow, -DispatcherTimer vs DispatcherQueueTimer, pack:// vs ms-appx:///, ThemeResource -vs DynamicResource semantics) that an in-place rewrite would break the -working WPF host for hours-to-days. Coexisting both projects means: - -1. `dotnet build TeamsISO.Windows.slnf` keeps producing a working WPF .exe - throughout the migration. -2. Each WinUI 3 view can be migrated and verified independently. -3. The engine layer (TeamsISO.Engine, TeamsISO.Engine.NdiInterop) and the - view-models (TeamsISO.App/ViewModels/) are **shared** via ProjectReference. - This is the key bet: the view-model surface is portable to WinUI 3 with - zero changes because they're plain CLR types implementing - INotifyPropertyChanged. -4. When the WinUI 3 build reaches feature parity + passes a real-show test, - we retire `src/TeamsISO.App` and the WinUI 3 project becomes the only - shipping host. - -## Architectural decisions (locked) - -| Decision | Choice | Rationale | -|---|---|---| -| Framework | Windows App SDK 1.6 LTS | Latest LTS, Win10 1809+ compat | -| Packaging | Unpackaged (`WindowsPackageType=None`) | Keeps existing MSI installer path | -| Target framework | `net8.0-windows10.0.19041.0` | WindowsAppSDK 1.6 minimum | -| Platform floor | Win10 17763 (1809) | Working broadcast hardware | -| RuntimeIdentifier | `win-x64` (pinned) | Flattens native DLLs to output dir | -| Theme strategy | `ThemeDictionary` (Default = Dark, Light) | Built-in {ThemeResource} swap | -| DataGrid | `CommunityToolkit.WinUI.UI.Controls.DataGrid 7.1.2` | Only maintained free option | -| View-model | Reuse from TeamsISO.App via ProjectReference | Zero porting cost | -| Window chrome | `AppWindow.TitleBar.ExtendsContentIntoTitleBar` | Modern WinUI 3 API | -| Tray icon | WinForms `NotifyIcon` (same as WPF host) | No WinUI 3 equivalent | -| Custom Main | Yes (`DISABLE_XAML_GENERATED_MAIN`) | Explicit Bootstrap.TryInitialize | - -## Phases - -### Phase 1 — Scaffold (done) - -- [x] `src/TeamsISO.App.WinUI/` project created with WindowsAppSDK 1.6 -- [x] `Themes/Tokens.xaml` with Dark + Light ThemeDictionaries -- [x] `Themes/Controls.xaml` with Button hierarchy + typographic ramp -- [x] `App.xaml` + `App.xaml.cs` minimal startup -- [x] `Program.cs` custom Main with Bootstrap.TryInitialize -- [x] Assets copied (Inter.ttf, JetBrainsMono.ttf, dragon-mark.png, icon) -- [x] Solution updated (.sln + .slnf paths backslash-normalized) -- [x] `dotnet build TeamsISO.Windows.slnf -c Debug` is clean - -### Phase 2 — MainWindow shell (done) - -- [x] 64px left rail with brand mark + nav buttons + status puck -- [x] 44px custom title bar with absorbed live pills + theme toggle -- [x] Section header (Participants count + filter + actions + primary) -- [x] Participants list (ItemsRepeater + DataTemplate, mock data) -- [x] Conditional in-call control bar -- [x] Slim status bar at bottom -- [x] Theme toggle wires Window.Content.RequestedTheme + title-bar colors - -### Phase 3 — Runtime activation (blocked, next priority) - -The compiled .exe shows "TeamsISO.exe - This application could not be -started" before Main() runs. COREHOST_TRACE confirms .NET host loads -CoreCLR successfully; the failure is downstream in the WinUI / WindowsAppSDK -activation path. Suspected causes (in priority order): - -1. **Missing manifest**: WinUI 3 unpackaged needs a specific COM activation - manifest. Our custom `app.manifest` was deferred because it didn't merge - cleanly with the framework-emitted one. Reintroduce with proper - `uap:VisualElements`. -2. **Microsoft.WindowsDesktop.App framework reference**: runtimeconfig.json - includes `Microsoft.WindowsDesktop.App 8.0.0`, which WinUI 3 doesn't - want. The .NET SDK adds it implicitly from the `-windows` target - framework moniker. Try `true` - + remove from frameworks list. -3. **WindowsAppRuntime version mismatch**: the installed runtime is - `Microsoft.WindowsAppRuntime.1.6 (6000.519.329.0)`. Bootstrap.TryInitialize - should accept any 1.6.x, but verify with the actual HResult returned - (need a way to capture it without losing the early-failure window). -4. **Visual C++ Redistributable**: native dependencies might require a - newer VC redist than what's installed. Check WindowsAppSDK 1.6's - redist requirements. - -**Next session's first action**: enable the legacy bootstrap-trace -environment variables (`WINDOWSAPPRUNTIME_BOOTSTRAP_VERBOSE=1`) or attach -a debugger to TeamsISO.exe immediately at launch (the failure happens -before WinMain so a debugger has to be attached very early) and capture -the actual error. - -### Phase 4 — View-model wiring - -Once runtime activation succeeds, hook the WinUI host into the existing -view-model layer: - -- [ ] `MainViewModel` instantiated by `App.OnLaunched` (mirror WPF - App.xaml.cs:OnStartup) -- [ ] Constructor wires the `IsoController` + `NdiInteropPInvoke` -- [ ] `DispatcherQueue` substitutes for WPF's `Dispatcher` — view-model's - `Dispatcher.InvokeAsync` calls need adapting to - `DispatcherQueue.TryEnqueue` -- [ ] `INotifyPropertyChanged` works as-is -- [ ] `ICommand` works as-is -- [ ] `ObservableCollection` works as-is -- [ ] Bindings in MainWindow.xaml updated from {Binding ...} to {x:Bind ...} - where possible (compile-time-checked, slightly faster) - -### Phase 5 — DataGrid migration - -Replace the placeholder `ItemsRepeater` with -`CommunityToolkit.WinUI.UI.Controls.DataGrid`: - -- [ ] Column definitions: avatar+name+codec, signal+lock, audio meter, - output-name, ISO toggle -- [ ] Row template with active-speaker cyan-left-border trigger -- [ ] Selection mode = single -- [ ] Right-click context menu (open preview, custom name, restart ISO) -- [ ] Sort: JoinOrder / Alphabetical / OnlineFirst / LoudestFirst (matches - `UIPreferences.SortMode`) - -### Phase 6 — Secondary windows - -- [ ] Settings drawer (`SettingsDrawer.xaml`) — slide-in from right, - preserves the 5 tabs from the WPF settings panel -- [ ] Help dialog (`HelpDialog.xaml`) — `ContentDialog`, keyboard shortcut - cheat sheet -- [ ] About dialog (`AboutDialog.xaml`) — version, logs path, update check -- [ ] Onboarding (`OnboardingWindow.xaml`) — first-launch only, three panes -- [ ] Notes viewer (`NotesViewer.xaml`) — markdown editor over %LOCALAPPDATA% -- [ ] Preview window (`PreviewWindow.xaml`) — floating per-participant - preview at 20Hz -- [ ] Presets dialog (`PresetsDialog.xaml`) — `ContentDialog` with the - save/load/duplicate/export/import row - -### Phase 7 — Hardening - -- [ ] Single-instance mutex + bring-to-front (port from WPF `App.xaml.cs`) -- [ ] Crash diagnostics (3 unhandled-exception channels → Serilog file - sink → crash dialog with log path) -- [ ] REST control surface + OSC bridge wiring (both services are - framework-agnostic; just instantiate in `App.OnLaunched`) -- [ ] Tray icon (port `TrayIconHost.cs` — WinForms.NotifyIcon works on - WinUI 3 with `UseWindowsForms=true`) -- [ ] Update banner + background check (port `UpdateChecker.cs`) -- [ ] Disk space watcher -- [ ] CLI args (`--apply-preset NAME`) -- [ ] Keyboard shortcuts (F1, Ctrl+M, Ctrl+Shift+S, Ctrl+R, NumPad 1-9 + - digits 1-9) -- [ ] `UIPreferences.Theme` field added, persistence on theme toggle - -### Phase 8 — Tests + verification - -- [ ] Build the WinUI 3 project in `TeamsISO.App.Tests` (currently targets - `net8.0-windows`, may need to adjust for the new target framework) -- [ ] Add WinUI 3 specific tests where applicable -- [ ] End-to-end test: launch against the live Teams meeting on the dev - machine, confirm participants discover + ISO toggle works -- [ ] Build artifacts: MSI signing path through the existing - `.forgejo/workflows/release.yml` - -### Phase 9 — Retire WPF host - -- [ ] `dotnet sln remove src/TeamsISO.App/TeamsISO.App.csproj` -- [ ] Delete `src/TeamsISO.App/` directory -- [ ] Update README.md and CHANGELOG.md -- [ ] Tag v1.0.0 (the original v1.0 cut moves to v0.9; v1.0 = first WinUI - 3 release) - -## Risk register - -| Risk | Mitigation | -|---|---| -| Activation failure not resolvable | Pivot to WinUI 3 packaged (MSIX) mode; the existing MSI workflow has to change but it's not the end of the world | -| `Dispatcher` → `DispatcherQueue` semantics differ | Wrap with a small `IDispatcher` interface in the engine layer; both hosts provide an impl | -| Custom WPF-style WindowChrome can't fully reproduce in AppWindow API | Accept a slightly different drag-region shape; the title-bar buttons API gives us close-button colors and click handling | -| WebView2 + WindowsAppSDK version conflicts | Pin WebView2 explicitly in the .csproj | -| CommunityToolkit DataGrid 7.x maintenance ending | Plan a fallback to `WinUI.TableView` 1.4.x as a contingency | -| Performance regression on the participants table (thumbnails at 20Hz × N rows) | Profile early; if needed, use `Win2D` for the audio meter and signal indicator | - -## What I'm NOT doing - -- Replacing the engine layer -- Touching the NDI native interop -- Changing the control surface protocol (REST/WebSocket/OSC) -- Migrating tests right now (Phase 8) -- Adding new product features (anything not in the redesign brief stays - for a follow-on release) diff --git a/docs/archive/TeamsISO.App.WinUI.Probe/Program.cs b/docs/archive/TeamsISO.App.WinUI.Probe/Program.cs deleted file mode 100644 index 4e2d3a9..0000000 --- a/docs/archive/TeamsISO.App.WinUI.Probe/Program.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System; -using System.Runtime.InteropServices; - -namespace TeamsISO.App.WinUI.Probe; - -/// -/// Tiny diagnostic console — calls the native MddBootstrapInitialize2 -/// export from Microsoft.WindowsAppRuntime.Bootstrap.dll directly and -/// reports the HResult. -/// -/// Use to isolate whether the WinUI 3 activation blocker is: -/// (a) Bootstrap DLL load — DllNotFoundException at the P/Invoke call -/// (b) Framework package resolution — Bootstrap returns non-S_OK HR -/// (c) Downstream — Bootstrap succeeds, the WinUI 3 .exe activation -/// failure is in something later (managed-assembly load, -/// Microsoft.WinUI.dll native imports, etc.) -/// -internal static class Program -{ - /// WindowsAppSDK target major/minor. - private const uint WindowsAppSdkMajorMinor = 0x00010006; - - [DllImport("Microsoft.WindowsAppRuntime.Bootstrap.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] - private static extern int MddBootstrapInitialize2( - uint majorMinorVersion, - string? versionTag, - PackageVersion minVersion, - int options); - - [DllImport("Microsoft.WindowsAppRuntime.Bootstrap.dll", ExactSpelling = true)] - private static extern void MddBootstrapShutdown(); - - [StructLayout(LayoutKind.Sequential)] - private struct PackageVersion - { - public ushort Revision; - public ushort Build; - public ushort Minor; - public ushort Major; - } - - public static int Main(string[] args) - { - Console.WriteLine("TeamsISO WinUI 3 bootstrap probe"); - Console.WriteLine("───────────────────────────────────────────"); - Console.WriteLine($"Target SDK major/minor: 0x{WindowsAppSdkMajorMinor:X8}"); - Console.WriteLine(); - - try - { - // Try with both null and "" for versionTag; report both. - var minVersion = new PackageVersion(); - Console.WriteLine("Attempt 1: versionTag=null, minVersion={0,0,0,0}"); - int hr = MddBootstrapInitialize2(WindowsAppSdkMajorMinor, null, minVersion, 0); - Console.WriteLine($" HR=0x{hr:X8} ({Describe(hr)})"); - - if (hr != 0) - { - Console.WriteLine(); - Console.WriteLine("Attempt 2: versionTag=\"\", minVersion={0,0,0,0}"); - hr = MddBootstrapInitialize2(WindowsAppSdkMajorMinor, "", minVersion, 0); - Console.WriteLine($" HR=0x{hr:X8} ({Describe(hr)})"); - } - - if (hr != 0) - { - Console.WriteLine(); - Console.WriteLine("Attempt 3: versionTag=\"\", options=1 (DoNotShowDialog)"); - hr = MddBootstrapInitialize2(WindowsAppSdkMajorMinor, "", minVersion, 1); - Console.WriteLine($" HR=0x{hr:X8} ({Describe(hr)})"); - } - - if (hr == 0) - { - Console.WriteLine(); - Console.WriteLine("Bootstrap succeeded."); - Console.WriteLine("The WinUI 3 .exe activation failure is NOT in the bootstrap."); - Console.WriteLine("Suspect: downstream managed-assembly load (Microsoft.WinUI.dll"); - Console.WriteLine("native imports during JIT)."); - MddBootstrapShutdown(); - } - else - { - Console.WriteLine(); - Console.WriteLine("Bootstrap failed. Decode the HResult:"); - DescribeHResult(hr); - } - } - catch (DllNotFoundException ex) - { - Console.WriteLine($"DllNotFoundException: {ex.Message}"); - Console.WriteLine(); - Console.WriteLine("Microsoft.WindowsAppRuntime.Bootstrap.dll couldn't be located by"); - Console.WriteLine("the loader. Check that the file is alongside the .exe and that the"); - Console.WriteLine("process architecture matches (x64 .exe loads x64 DLLs)."); - } - catch (Exception ex) - { - Console.WriteLine($"Unexpected: {ex.GetType().Name}: {ex.Message}"); - } - - Console.WriteLine(); - Console.WriteLine("Press any key to exit."); - Console.ReadKey(true); - return 0; - } - - private static string Describe(int hr) => hr switch - { - 0 => "S_OK", - unchecked((int)0x80073B17) => "ERROR_INSTALL_PACKAGE_NOT_FOUND", - unchecked((int)0x80073B19) => "ERROR_PACKAGES_REPUTATION_CHECK_FAILED", - unchecked((int)0x80004005) => "E_FAIL", - unchecked((int)0x80670016) => "MDD_E_BOOTSTRAP_INITIALIZE_DDLM_NOT_FOUND", - unchecked((int)0x80670017) => "MDD_E_BOOTSTRAP_INITIALIZE_LIFECYCLE_MANAGER_FAILURE", - _ => "(unknown HR)", - }; - - private static void DescribeHResult(int hr) - { - var description = (uint)hr switch - { - 0x80670016 => - "DDLM (Dynamic Dependency Lifetime Manager) for this WindowsAppSDK major.minor\n" + - " is NOT installed on this machine. The framework package (Microsoft.WindowsApp\n" + - " Runtime.1.6) may be present but its DDLM sibling — MicrosoftCorporationII.\n" + - " WinAppRuntime.Main.1.6 — is missing. Run \"Get-AppxPackage | Where Name -like\n" + - " '*WinAppRuntime.Main*'\" to see which versions have DDLM coverage. Fix by\n" + - " installing the full WindowsAppRuntime redistributable from Microsoft, OR\n" + - " switch the .csproj to a major.minor whose Main package IS installed.", - 0x80670017 => - "Lifecycle manager start failed. The DDLM is installed but couldn't be activated.\n" + - " Common causes: another instance running, corrupt MSIX install, missing dependency.", - 0x80073B17 => "Framework package not found. Install Microsoft.WindowsAppRuntime..", - 0x80073B18 => "Framework package version mismatch.", - 0x80073B19 => "Framework package not present for current user.", - 0x80073B26 => "Framework package architecture mismatch.", - _ => $"Unknown HResult. Look up in WindowsAppSDK source BootstrapErrorCodes.h.", - }; - Console.WriteLine($" {description}"); - } -} diff --git a/docs/archive/TeamsISO.App.WinUI.Probe/TeamsISO.App.WinUI.Probe.csproj b/docs/archive/TeamsISO.App.WinUI.Probe/TeamsISO.App.WinUI.Probe.csproj deleted file mode 100644 index 9111b90..0000000 --- a/docs/archive/TeamsISO.App.WinUI.Probe/TeamsISO.App.WinUI.Probe.csproj +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - Exe - net8.0-windows - TeamsISO.App.WinUI.Probe - x64 - win-x64 - enable - enable - true - - - - - - - - diff --git a/docs/archive/work-log-2026-05-12-winui3.md b/docs/archive/work-log-2026-05-12-winui3.md deleted file mode 100644 index 58a3609..0000000 --- a/docs/archive/work-log-2026-05-12-winui3.md +++ /dev/null @@ -1,261 +0,0 @@ -# Work log — overnight session 2026-05-12 → 2026-05-13 - -The redesign brief was approved with one edit (add dark + light theming), the -WinUI 3 replatform was green-lit explicitly, and you said don't stop until -told to. This log is what happened. - -## TL;DR — overnight result - -**The WinUI 3 redesigned host runs.** It launches, renders, and respects -dark / light theme. See `docs/preview/winui3-mainwindow-light.png` and -`docs/preview/winui3-mainwindow-dark.png` for proof shots captured from -the live .exe. - -**Eighteen commits landed on origin/main.** Already pushed (credentials -refreshed during the session). - -**The WPF host is untouched.** Your May 2026 batch still works exactly -as it did — the WinUI 3 host is a parallel project at -`src/TeamsISO.App.WinUI/`. - -**Two activation blockers — both diagnosed:** - -1. WindowsAppSDK 1.6 DDLM wasn't installed on this machine - (Get-AppxPackage shows Main.1.5 and Main.1.8 but no Main.1.6). Bootstrap - returned `MDD_E_BOOTSTRAP_INITIALIZE_DDLM_NOT_FOUND` (HR 0x80670016). - **Fix:** switched to WindowsAppSDK 1.8 — its DDLM is present. -2. The SettingsDrawer's RenderTransform + named Storyboard binding - triggered a XAML parser fault (HR 0x802b000a) post-bootstrap. - **Fix:** stubbed the drawer host inline; the drawer XAML itself is - intact for re-hosting in Phase 4 once the right transform pattern is - confirmed (likely `Translation` via composition API instead of - `TranslateTransform` via Storyboard). - -**What I left in mostly-ready state:** - -* `src/TeamsISO.App.WinUI/Views/MainWindow.xaml` — redesigned IA, runs. - Participants list is a stub message until view-model wires up - (Phase 4 of the migration plan). -* `src/TeamsISO.App.WinUI/Views/SettingsDrawer.xaml` + .cs — builds - clean; not hosted yet. -* `src/TeamsISO.App.WinUI/Views/HelpDialog.xaml`, AboutDialog, - OnboardingDialog — built clean; nothing in MainWindow opens them yet. -* `src/TeamsISO.App.WinUI/Services/ThemeManager.cs` — System / Dark / - Light tri-state with OS app-mode auto-follow and Themed event so the - title-bar buttons stay in sync. -* `src/TeamsISO.App.WinUI.Probe/` — diagnostic console for activation - triage. Run if a deployment target ever shows the same activation - dialog. -* `docs/preview/redesigned-mainwindow.html` — interactive HTML preview - for non-Windows stakeholders. - -## Commit list - -In chronological order on `main`: - -| SHA | Subject | -|---|---| -| `94b0a71` | docs: PRODUCT.md + DESIGN.md (ground-up GUI redesign brief) | -| `cb1402e` | feat(winui3): scaffold TeamsISO.App.WinUI alongside the WPF host | -| `9e176d8` | feat(winui3): redesigned MainWindow + custom title bar + theme toggle | -| `db341f9` | build(winui3): pin RID + flatten native DLLs into output dir | -| `2e6d2a1` | docs: WinUI 3 migration plan + overnight 2026-05-12 work log | -| `48ca16b` | feat(winui3): ThemeManager service + Settings drawer + Help/About/Onboarding | -| `8e29c1d` | build(winui3): suppress UndockedRegFreeWinRT auto-init; document chase | -| `c150bce` | docs: interactive HTML preview of the redesigned MainWindow | -| `2909d8b` | feat(winui3): wire Settings drawer slide-in animation into MainWindow | -| `2f9f709` | build(winui3): post-build target to strip WindowsDesktop.App from runtimeconfig | -| `46b1ca5` | fix(preview): clip drawer behind .content with position:relative+overflow:hidden | -| `6b45c39` | fix(preview): drawer uses display:none + animation when opened | -| `19072b4` | docs(work-log): refresh with complete commit list + push confirmation | -| `1687e0c` | docs: CHANGELOG + README cover the in-flight WinUI 3 redesign | -| `166e7d6` | build(winui3): switch to WindowsAppSDK 1.8 + add diagnostic probe | -| `07f4a1b` | docs(work-log): add root-cause finding for activation blocker | -| `a33f80d` | feat(winui3): WinUI 3 host LAUNCHES — verified rendering on Windows | -| `eee307d` | docs(preview): proof-of-running WinUI 3 screenshots (dark + light) | -| `639a7ea` | docs(work-log): final overnight summary — WinUI 3 host runs | -| `27f4740` | build(winui3): keep SettingsDrawer host deferred + narrow the suspect | -| `a05c0a7` | feat(winui3): SettingsDrawer hosts successfully — NavigationView swap | - -All twenty-one pushed to origin/main as of 2026-05-13 12:51am. - -## What you'll find in the tree - -``` -Teams ISO/ -├─ PRODUCT.md ← new, baseline product brief -├─ DESIGN.md ← new, token-level design system -├─ docs/ -│ ├─ preview/ -│ │ └─ redesigned-mainwindow.html ← open in Chrome/Edge — see the redesign now -│ └─ superpowers/ -│ ├─ plans/2026-05-12-winui3-migration.md ← new, full migration plan -│ └─ work-log-2026-05-12.md ← this file -├─ src/ -│ ├─ TeamsISO.App/ ← unchanged, the WPF host -│ └─ TeamsISO.App.WinUI/ ← new, the WinUI 3 host -│ ├─ TeamsISO.App.WinUI.csproj -│ ├─ Program.cs ← custom Main with Bootstrap -│ ├─ App.xaml + App.xaml.cs -│ ├─ Assets/ ← Inter, JetBrainsMono, dragon-mark -│ ├─ Themes/ -│ │ ├─ Tokens.xaml ← ThemeDictionary (Dark + Light) -│ │ └─ Controls.xaml ← Button hierarchy + type ramp -│ ├─ Services/ThemeManager.cs ← theme preference + brand+OS sync -│ ├─ Models/MockParticipant.cs ← interim until VM wires -│ └─ Views/ -│ ├─ MainWindow.xaml + .cs ← redesigned per shape brief -│ ├─ SettingsDrawer.xaml + .cs ← slide-in right drawer -│ ├─ HelpDialog.xaml + .cs ← keyboard shortcut cheat sheet -│ ├─ AboutDialog.xaml + .cs ← brand mark + logs / recordings shortcuts -│ └─ OnboardingDialog.xaml + .cs ← three-step first-launch -├─ TeamsISO.sln ← updated -└─ TeamsISO.Windows.slnf ← updated, backslash-normalized -``` - -## What works right now - -* WinUI 3 build: clean -* WPF build: still clean (verified) -* Theme tokens: Dark + Light palettes both correct, mapped to {ThemeResource} -* MainWindow layout: matches the approved SVG mockup pixel-by-pixel -* Theme toggle: ThemeManager + title-bar toggle + Settings drawer picker -* SettingsDrawer: slides in from right with 220ms ease-out-quart, dismisses - on Esc or close button via CloseRequested event -* Help / About / Onboarding: ContentDialog-based, branded -* HTML preview: full-fidelity render of MainWindow with both themes, drawer - interaction, faithful component shapes - -## What's blocked - -**Activation failure on the unpackaged .exe.** Diagnostic summary: - -* `dotnet --info` shows .NET 8.0.301 SDK + 8.0.6/8.0.8/8.0.18 runtimes for - both NETCore.App and WindowsDesktop.App -* `Get-AppxPackage Microsoft.WindowsAppRuntime.*` confirms - Microsoft.WindowsAppRuntime.1.6 (6000.519.329.0) is installed -* `dotnet build -c Debug` produces TeamsISO.exe in - `src/TeamsISO.App.WinUI/bin/Debug/net8.0-windows10.0.19041.0/win-x64/` -* The .exe is x64 (PE machine 0x8664 confirmed) -* Native runtime files (Microsoft.WindowsAppRuntime.Bootstrap.dll, - WebView2Loader.dll) are flattened to the output dir alongside the .exe -* Launching the .exe results in a Windows error dialog - "TeamsISO.exe - This application could not be started" with no exit code -* `COREHOST_TRACE=1` confirms the .NET host loads CoreCLR successfully - and is about to launch the managed host — the failure is downstream -* `dotnet TeamsISO.dll` produces the same error -* `dotnet publish -r win-x64 --self-contained` produces the same error -* The Microsoft.WindowsDesktop.App entry got stripped from runtimeconfig.json - via a post-build target — confirmed in the build output — still fails -* The UndockedRegFreeWinRT auto-init ModuleInitializer was disabled — - still fails - -**ROOT CAUSE IDENTIFIED (post-log-update):** - -I built a tiny diagnostic console probe -(`src/TeamsISO.App.WinUI.Probe/`) that calls -`MddBootstrapInitialize2` from the native bootstrap DLL via P/Invoke -without dragging in the full WinUI 3 type surface. The probe returns -**HR=0x80670016 = `MDD_E_BOOTSTRAP_INITIALIZE_DDLM_NOT_FOUND`**. - -Translation: the framework package (Microsoft.WindowsAppRuntime.1.6) is -installed, but its DDLM (Dynamic Dependency Lifetime Manager) sibling -package — `MicrosoftCorporationII.WinAppRuntime.Main.1.6` — is NOT. -Without that, the bootstrap can't activate the runtime context, the -WinUI 3 .exe dies at module load, and you get "this application could -not be started." - -Looking at `Get-AppxPackage`, this machine has Main.1.5 (5001.373) and -Main.1.8 (8000.836) installed, but NO Main.1.6. - -**Three fixes, pick one:** - -1. **Install the 1.6 DDLM** redistributable. Download - `Microsoft.WindowsAppRuntime.1.6` from - https://aka.ms/windowsappsdk/1.6/latest/windowsappruntimeinstall-x64.exe - and run it. After it installs, `Get-AppxPackage MicrosoftCorporationII.WinAppRuntime.Main.1.6` - should return a row. -2. **Switch the .csproj to WindowsAppSDK 1.8** (the package version - would be `Microsoft.WindowsAppSDK` 1.8.260508005, and the major.minor - in `Program.cs` becomes `0x00010008`). 1.8 IS fully installed on - this machine. -3. **Switch to packaged (MSIX) mode** — the framework dependency is - resolved by the OS at install time and the DDLM doesn't matter the - same way. Means giving up the existing MSI installer path for now. - -Option 2 is the fastest. Option 1 is what end users of TeamsISO will need -to do if we keep targeting 1.6 LTS. - -To reproduce the diagnosis from scratch: - - cd src/TeamsISO.App.WinUI.Probe - dotnet build - dotnet bin/Debug/net8.0-windows/win-x64/TeamsISO.App.WinUI.Probe.dll - -## What I did NOT do - -* Touch the WPF host. Your running build is intact. The May 2026 batch - ships as-is. -* Touch Teams orchestration. The live meeting that was running was off - limits — no UIA, no mute toggling, no share-tray opening from my code. -* Migrate view-models or wire the engine into the WinUI host. Phase 4 of - the migration plan starts there once Phase 3 (activation) unblocks. -* Migrate the DataGrid (Phase 5). The MainWindow currently uses - ItemsRepeater with a DataTemplate; the CommunityToolkit DataGrid swap - is queued. -* Migrate Notes / Preview / Presets windows (Phase 6 remainder). -* Wire any of the secondary surfaces (Help / About / Onboarding / - Settings) into MainWindow's host code — they exist but nothing opens - them yet beyond the settings drawer. - -## Suggested first session tomorrow - -1. **Look at the screenshots**: `docs/preview/winui3-mainwindow-light.png` - and `docs/preview/winui3-mainwindow-dark.png` — proof shots of the - live .exe. If the design is right, the rest is execution. -2. **Run it yourself**: from a fresh shell, - `dotnet build src/TeamsISO.App.WinUI` then run the .exe at - `src/TeamsISO.App.WinUI/bin/Debug/net8.0-windows10.0.19041.0/win-x64/TeamsISO.exe`. - The redesigned shell should appear at 1280×780. -3. **Then Phase 4** (view-model wiring): the existing `MainViewModel`, - `ParticipantViewModel`, etc. in `src/TeamsISO.App/ViewModels/` use - WPF's `System.Windows.Threading.Dispatcher`. Either substitute with - `DispatcherQueue` in-place (probably the right move long-term), or - add a thin `IDispatcherAdapter` interface so both hosts share the - view models verbatim. -4. **Phase 5** (DataGrid): swap the stub message in the MainWindow - content area for `CommunityToolkit.WinUI.UI.Controls.DataGrid` - bound to `MainViewModel.Participants`. The DataTemplate from the - git history (the version in commit `9e176d8`) has the active-speaker - accent + audio meter + signal lock visuals — restore those. -5. **Phase 6 cont** (re-host SettingsDrawer): the drawer XAML builds - clean; what crashes is using `RenderTransform` + named - `TranslateTransform` + Storyboard.TargetName binding. Try - `Translation` via `ElementCompositionPreview.GetElementVisual` or - use the `XamlIslands` translation animation pattern instead. -6. **Phase 7** (hardening): port single-instance mutex, crash dialog, - REST + OSC + tray icon from the WPF App.xaml.cs. - -## Honest assessment - -The redesign is real, on-disk, building cleanly, AND RUNNING. The -WinUI 3 host opens at 1280×780, paints the new IA correctly, respects -the theme system end-to-end, and is sitting on `main` waiting for the -view-model wiring. The diagnostic probe (`TeamsISO.App.WinUI.Probe`) is -a permanent addition that'll pay back the next time anyone hits a -WindowsAppSDK activation issue on a different machine. - -What still needs real work: Phase 4 (view-model wiring — the -engine's `Dispatcher` use needs to flex to `DispatcherQueue`), Phase 5 -(real DataGrid), Phase 6 cont (re-host SettingsDrawer with the right -transform pattern), Phase 7 (hardening: single instance, crash, REST, -OSC, tray). None of these are blocked anymore — they're all execution -work. - -The biggest risk to the v1.0 timeline is the same as it was yesterday: -real-meeting smoke test against a live Teams call. That's the -gate that determines whether the WPF host retires or stays as a -fallback for a release or two. - -— end of log -— Claude, 2026-05-13 ~12:45am diff --git a/docs/preview/redesigned-mainwindow.html b/docs/preview/redesigned-mainwindow.html deleted file mode 100644 index f6ca3da..0000000 --- a/docs/preview/redesigned-mainwindow.html +++ /dev/null @@ -1,799 +0,0 @@ - - - - -TeamsISO — redesigned MainWindow preview - - - -
-
-
- TeamsISO redesign — interactive preview -  The same XAML that's in src/TeamsISO.App.WinUI/Views/MainWindow.xaml, rendered as HTML so you can see and toggle it before the WinUI 3 .exe activation issue is resolved. -
-
- - -
-
- -
- -
-
- -
- - - - -
- -
- - -
- -
-
- TeamsISO - v1.0.0-alpha -
-
-
-
live · 00:14:32
-
rec 3 · 00:11:08
-
482 GB free
-
- - - - -
- - -
-
- Participants - 4 -
-
-
- - - - -
-
- - -
-
-
-
Participant
-
Signal
-
Audio
-
Output name
-
ISO
-
- -
-
-
MA
-
Maya Rodriguez
MS Teams · 1920×1080 · 30fps
-
locked
-
-
- - - - - - - - - -
-
-
TEAMSISO_maya
-
LIVE
-
- -
-
DC
-
Daniel Chen
MS Teams · 1280×720 · 30fps
-
locked
-
-
- - - - - - - - - -
-
-
TEAMSISO_daniel
-
LIVE
-
- -
-
AK
-
Aïcha Koné
MS Teams · 1920×1080 · 30fps
-
degraded
-
-
- - - - -
-
-
TEAMSISO_aicha
-
OFF
-
- -
-
SP
-
Sam Park
MS Teams · 1920×1080 · 30fps
-
locked
-
-
- - - - - - -
-
-
TEAMSISO_sam
-
LIVE
-
-
- - -
- In-call - - - - - - -
- - -
-
-
- control surface · 127.0.0.1:9755 -
-
F1 help · Ctrl+M marker · Ctrl+Shift+S panic · Ctrl+K command palette
-
- - -
-
-
Settings
- -
-
- - - - - -
-
-

Appearance

-

Dark is the default for the 1:50am operator scene; light is for daytime production. System follows the Windows app-mode preference.

-
- - - -
-

Accent peek

-

These accents work in both themes. Cyan stays bright as a surface fill (text on top is near-black regardless). For inline text on light, the palette substitutes a darker cyan automatically.

-
-
Cyan
-
Coral
-
Live
-
Warn
-
-
-
- - -
-
-
-
-
- - - - diff --git a/docs/preview/winui3-engine-wired-with-live-teams.png b/docs/preview/winui3-engine-wired-with-live-teams.png deleted file mode 100644 index 8aaa073..0000000 Binary files a/docs/preview/winui3-engine-wired-with-live-teams.png and /dev/null differ diff --git a/docs/preview/winui3-mainwindow-dark.png b/docs/preview/winui3-mainwindow-dark.png deleted file mode 100644 index 0d401c6..0000000 Binary files a/docs/preview/winui3-mainwindow-dark.png and /dev/null differ diff --git a/docs/preview/winui3-mainwindow-light.png b/docs/preview/winui3-mainwindow-light.png deleted file mode 100644 index b9c2023..0000000 Binary files a/docs/preview/winui3-mainwindow-light.png and /dev/null differ diff --git a/docs/preview/winui3-with-colored-pills.png b/docs/preview/winui3-with-colored-pills.png deleted file mode 100644 index 3739560..0000000 Binary files a/docs/preview/winui3-with-colored-pills.png and /dev/null differ diff --git a/docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md b/docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md deleted file mode 100644 index ea99298..0000000 --- a/docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md +++ /dev/null @@ -1,194 +0,0 @@ -# TeamsISO v2 — Studio Terminal (approved shape brief) - -**Date approved:** 2026-05-13 -**Approver:** Zac (operator + product owner) -**Host:** WPF .NET 8 (`src/TeamsISO.App/`). WinUI 3 rebuild is abandoned. -**Predecessor:** the WPF rollback at `1d1ce6a` (recording axed, settings pane tab fix, settings button wired). - -## Why this redesign - -The v1 GUI failed the "AI made that" test. Quote from the operator: "its cluttered, screams that AI made it - and relatively inefficient to navigate." The PRODUCT.md anti-references — card-grid-of-icons, always-visible side panel, footer-as-theatre — all describe the current build. v2 commits to a different aesthetic register entirely. - -## Aesthetic register - -**Broadcast-engineering instrument.** Not a SaaS dashboard. Not Material. Not Fluent default. - -Reference proximity: Linear's keyboard-first density × Avid S6 console legibility × Blackmagic ATEM's information hierarchy. The operator mental model is "I'm sitting at an audio mixer; every region has a job, no region is theatre." - -## What goes away - -- The 72px left rail (no actual navigation — there's only one screen) -- The 380px always-visible settings pane (settings change rarely, shouldn't claim permanent real estate) -- The 6-column footer status row (theatre, not information) -- The custom chromeless title-bar caption buttons (look worse than system chrome, break on DPI scaling) -- The "by Wild Dragon" pill and the always-visible "TeamsISO" wordmark as decorative chrome -- The in-call control bar as a permanent strip (only relevant in-call; should appear conditionally) -- The seven identical ghost buttons in the in-call bar (textbook card-grid anti-pattern) - -## What replaces it - -``` -┌─ system Windows title bar [_ □ ✕] ─────────────────────┐ -│ 🐉 TeamsISO [⌘K] [☾] [⚙] │ 32px header — mark + wordmark left, 3 icons right -├────────────────────────────────────────────────────────┤ -│ ● 02:14:32 PART 4 · LIVE 2 DISK 482g CTRL :9755 │ transport strip — single mono line, replaces footer -├────────────────────────────────────────────────────────┤ -│ │ -│ ▮ alice ▮▮▮▮ t:5ms alice [LIVE] │ -│ ▯ bob ▮▮ t:8ms bob [— OFF]│ participants table = the canvas -│ ▮ carlos ▮▮▮▮▮ t:9ms carlos [LIVE] │ (cyan-tinted row bg = active speaker) -│ ▮ guest 4 -- NO SIG guest_4 [ERROR]│ -│ │ -├────────────────────────────────────────────────────────┤ -│ IN CALL · Daily standup [mute] [cam] [leave] │ conditional — only renders when in call -└────────────────────────────────────────────────────────┘ -``` - -### Header (32px) - -Left: Wild Dragon mark (~20px) + "TeamsISO" wordmark in Inter 13 Medium. Click on mark opens About. -Right: three icon buttons. -- `⌘K` (Tabler `ti-command`) — opens command palette (also Ctrl+K, Ctrl+P shortcut) -- `☾` / `☀` (Tabler `ti-moon` / `ti-sun`) — cycles theme dark ↔ light. Tooltip "Theme (System / Dark / Light)" — long-press could open the tri-state, but for v2 just a one-click cycle. -- `⚙` (Tabler `ti-settings`) — opens settings drawer - -That's all the chrome. No nav rail because there's nothing to navigate to. - -### Transport strip - -Single horizontal line. Mono type (JetBrains Mono 12). Replaces the entire footer. - -Fields: -- `● 02:14:32` — green dot + session timer when at least one ISO is live; both hidden otherwise -- `PART 4 · LIVE 2` — participant count and live-ISO count; "PART" / "LIVE" in Inter 11 SemiBold UPPER tracking 0.06em, numbers in mono -- `DISK 482g` — free disk space on the working volume; coral text if <10GB, hidden if no relevant volume is configured -- `CTRL :9755` — control surface bind; cyan text when active, hidden when off - -No icons. No badges. No backgrounds. Just typed status — a console heads-up display. - -### Participants table — the canvas - -Five columns: - -| # | Width | Content | Type | -|---|---|---|---| -| 1 | 24px | State LED — 8×8 filled cyan/coral or hollow neutral | hard-edged square, no rounding | -| 2 | * | Name (Inter 13/Medium) + codec/latency caption (Mono 11/Regular, tertiary fg) | "Alice Wong" / "NDIV5 · t:5ms" | -| 3 | 110px | Audio meter — 5 vertical bars, instantaneous level | hard-edged, cyan when LIVE, neutral when OFF | -| 4 | 130px | Output name | Mono 12 | -| 5 | 100px | ISO toggle pill | LIVE = cyan fill / OFF = hollow neutral / ERROR = coral outline | - -Row height: 52px (was 56). - -Active speaker: full-row background tint `bg.active-speaker` (cyan-tinted muted neutral). NOT a left-edge stripe — that trips the impeccable "side-stripe border" ban. - -Each row reacts to: -- Click anywhere → focuses the row, keyboard-actions apply -- Click the pill → toggle ISO -- Right-click → context menu (preview, custom name, copy NDI source name, save snapshot) -- Hover → reveals a kebab affordance in column 5 right edge for less-frequent actions - -### Conditional meeting bar - -Renders below the table only when `TeamsControlBridge.DetectCallState().IsInCall == true`. Slides up from below on transition (~120ms ease-out-quart on `RenderTransform.Y` + `Opacity`). - -Content: `IN CALL` label (Inter 11 SemiBold UPPER, cyan accent) + meeting title (Mono 12, truncated with ellipsis) + three buttons right-aligned (Mute / Cam / Leave). Share and Notes do NOT live here — they move to ⌘K, where they're invocable any time without the bar fighting for attention. - -Width matches the table — not full-bleed; respects the page padding. - -### Ctrl+K command palette - -The redesign's navigation move. Replaces ~80% of what's in the v1 rail + tabbed settings. - -Behavior: -- `Ctrl+K` (also `Ctrl+P`) opens a centered floating window over the main shell, 560×360px -- Search input at the top, results list below -- Empty input → frequent + recent commands -- Typing → fuzzy-matches across command label + category + keywords -- ↑/↓ navigates, Enter invokes, Esc closes - -Command categories (each command has icon, label, optional value preview, optional shortcut hint): -- **Quick** — Enable all online, Stop all ISOs, Refresh discovery, Drop snapshot of all -- **Teams** — Launch Teams, Hide / show Teams windows, Mute, Toggle camera, Open share, Leave call -- **Presets** — Apply … (one row per saved preset), Save current as preset, Manage presets -- **Output** — Framerate 24 / 30 / 60, Resolution 1080p / 720p, Aspect Pillarbox / Letterbox / Stretch -- **Network** — Apply transcoder topology, Restore default NDI groups, Edit output name template -- **App** — Theme dark / light / system, Open settings, About TeamsISO, Help (F1) - -This is the keyboard-first surface broadcasters with Stream Decks already mentally use. - -### Settings — slide-over drawer - -Triggered from the header gear icon, or from `Open settings` in the palette, or hotkey `,` (comma). - -- 420px wide, slides in from the right -- 40% canvas scrim behind -- Three tabs: **OUTPUT** (framerate / resolution / aspect / audio + Reset to defaults), **NETWORK** (discovery / output groups + Apply transcoder topology + Restore defaults + output name template), **APP** (theme tri-state, minimize to tray, sort order, Launch Teams on startup, Auto-hide Teams windows) -- Apply Changes button pinned to drawer footer; Esc dismisses; click outside the drawer dismisses - -DISPLAY tab from v1 gets renamed APP and absorbs the theme tri-state. - -### Empty states - -- No participants yet: a single centered mono sentence, "no ndi sources yet — open teams and start a meeting", and one tertiary button "Refresh discovery (Ctrl+R)". No illustration, no mascot. -- Not in a call: meeting bar simply doesn't render. No placeholder. -- Discovery degraded: amber dot in transport strip's session timer position, mono text "NDI discovery — restarting". No banner. - -## Color, theme, motion - -**Color strategy:** Restrained (impeccable product default). Cyan accent earns its place — reserved for LIVE state, focus ring, active speaker tint. Coral reserved for destructive + error. Status amber for warnings. Green NOT used (would compete with cyan for "ok / live" semantics). - -**Theme default:** Follow Windows. Theme persists per-operator via `UIPreferences.Theme`. Implementation: split `WildDragonTheme.xaml` into a single style + token-shape file plus two color-only ResourceDictionary files (`Theme.Dark.xaml`, `Theme.Light.xaml`). At runtime `ThemeManager` swaps the merged dictionary entry. WPF analog of WinUI's `ThemeDictionary`. - -**Motion:** -- 120ms `cubic-bezier(0.16, 1, 0.3, 1)` (ease-out-quart) on the meeting bar slide-in/out -- 200ms ease-out on the drawer slide -- 180ms cross-fade on theme swap -- 90ms on focus + hover transitions -- No bounce, no elastic, no spring overshoots. Animate `RenderTransform` and `Opacity` only — never layout properties. - -## Typography commitments - -| Token | Family | Size | Weight | Used for | -|---|---|---|---|---| -| `text.timer` | JetBrains Mono | 14 | Medium | Session timer in transport strip — instrument-grade | -| `text.caption` | Inter | 11 | SemiBold (600) | UPPER + tracking 0.06em — transport-strip labels, "IN CALL", "SPEAKING" | -| `text.display` | Inter | 22 | SemiBold | Settings drawer headings only | -| `text.title` | Inter | 13 | Medium | Wordmark, table column headers | -| `text.body` | Inter | 13 | Regular | Participant display names | -| `text.mono.code` | JetBrains Mono | 12 | Regular | Output names, NDI IDs, meeting title | -| `text.mono.tech` | JetBrains Mono | 11 | Regular | Latency readouts, codec captions, transport-strip values | - -## What this is NOT - -- Not Fluent-styled. Default Fluent accent integration is generic Windows; TeamsISO is a broadcaster's tool. -- Not minimalism for its own sake. The participants table is *dense*. Density is the broadcaster's virtue. -- Not chromeless. Default system title bar stays. Chromeless windows break embarrassingly at 4K + DPI scaling. -- Not vanity-branded. The Wild Dragon mark sits small in the header as a quality cue, never as decoration. - -## Migration path - -The view-model surface in `src/TeamsISO.App/ViewModels/` is the contract. The redesign rewrites `MainWindow.xaml` and `Themes/*` but leaves view-models, the engine, the control surface server, and the OSC bridge untouched. - -Order of operations (each step builds clean before the next): - -1. **Theme split** — Refactor `WildDragonTheme.xaml` → `Themes/Theme.Tokens.xaml` (styles + key shape) + `Themes/Theme.Dark.xaml` + `Themes/Theme.Light.xaml` (color resources only). Port `ThemeManager` from the deleted WinUI project; wire system app-mode detection via registry (`HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize\AppsUseLightTheme`). -2. **Main window shell** — Replace MainWindow.xaml's outer Grid. Add 32px header, transport strip, full-width content area, conditional meeting bar. Delete the 72px rail, the 380px right pane, the footer. -3. **Participants table redesign** — 5 columns, LED state, instantaneous audio meter, ISO pill. -4. **Settings drawer** — Slide-over from right, dismissable; reuses existing settings view-model. -5. **Command palette** — `Ctrl+K` floating window with fuzzy command list. - -Each step is a self-contained commit so the v1 build remains shippable at any rollback point. - -## Anti-references — explicit on the "AI made that" failure - -These are the failure modes the redesign defends against: -- Card-grid-of-icons (the v1 in-call bar's seven identical ghost buttons) -- Always-visible side panel (the v1 380px settings sidebar) -- Decorative chrome (the v1 "by Wild Dragon" pill, the 72px nav rail, the six-column footer) -- Generic Inter at 13 for everything -- Default WPF DataGrid (Excel) -- Custom chromeless title bars that look generic -- Gradient text, glassmorphism, side-stripe borders (impeccable absolute bans) -- "Hero metric + supporting stats + gradient" SaaS dashboards -- Mascots, "Welcome!" copy, illustrated onboarding cards diff --git a/docs/superpowers/plans/2026-05-07-teamsiso-phase-a-engine-foundation.md b/docs/superpowers/plans/2026-05-07-teamsiso-phase-a-engine-foundation.md deleted file mode 100644 index 710947f..0000000 --- a/docs/superpowers/plans/2026-05-07-teamsiso-phase-a-engine-foundation.md +++ /dev/null @@ -1,2847 +0,0 @@ -# TeamsISO Phase A — Engine Foundation Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Stand up the TeamsISO solution and ship a fully unit-tested NDI engine library — domain model, source parser, participant tracker with rename heuristic, frame processor with closest-frame timing, config store, and the `INdiInterop` test fake — without a single line of P/Invoke. Phase A ends with green CI on Linux runners and ≥80% coverage on `TeamsISO.Engine`. - -**Architecture:** Six-project .NET 8 solution per the spec. The engine library targets cross-platform `net8.0` so unit tests run on Linux CI. Production NDI P/Invoke is deferred to Phase B; in Phase A every NDI surface is reached through `INdiInterop`, with a `FakeNdiInterop` exercising the engine's behavior. WPF app and integration tests get scaffold projects that compile but stay empty — they'll fill in later phases. - -**Tech Stack:** .NET 8, C# 12, xUnit, FluentAssertions, Microsoft.Extensions.Logging, Serilog (console sink for Phase A), `System.Threading.Channels`, `System.Reactive` for `IObservable` plumbing. Build via `dotnet`. CI on Forgejo Actions, Linux runner. - -**Source spec:** `docs/superpowers/specs/2026-05-07-teamsiso-v1-design.md` - -**Repo:** `forge.wilddragon.net/zgaetano/teamsiso` (private, default branch `main`) - ---- - -## File Structure - -``` -teamsiso/ -├── .editorconfig -├── .gitignore -├── .forgejo/workflows/ci.yml -├── Directory.Build.props -├── README.md -├── TeamsISO.sln -├── docs/ -│ └── superpowers/ -│ ├── specs/2026-05-07-teamsiso-v1-design.md (already committed) -│ └── plans/2026-05-07-teamsiso-phase-a-engine-foundation.md -└── src/ - ├── TeamsISO.Engine/ - │ ├── TeamsISO.Engine.csproj - │ ├── Domain/ - │ │ ├── NdiSource.cs (NdiSource record + Kind enum) - │ │ ├── Participant.cs (Participant record) - │ │ ├── IsoAssignment.cs (IsoAssignment record) - │ │ ├── IsoOutput.cs (IsoOutput record + IsoState enum) - │ │ ├── FrameProcessingSettings.cs (settings record + enums) - │ │ ├── IsoHealthStats.cs - │ │ ├── EngineConfig.cs - │ │ └── EngineAlert.cs - │ ├── Discovery/ - │ │ ├── DiscoveryEvent.cs (Added/Removed/Renamed) - │ │ ├── NdiSourceParser.cs - │ │ ├── ParticipantTracker.cs - │ │ └── NdiDiscoveryService.cs - │ ├── Pipeline/ - │ │ ├── RawFrame.cs - │ │ ├── ProcessedFrame.cs - │ │ ├── IFrameClock.cs - │ │ ├── PeriodicTimerFrameClock.cs - │ │ ├── SolidFrameRenderer.cs - │ │ └── FrameProcessor.cs - │ ├── Persistence/ - │ │ └── ConfigStore.cs - │ └── Interop/ - │ └── INdiInterop.cs (interface lives in Engine; impl in NdiInterop project) - ├── TeamsISO.Engine.NdiInterop/ - │ └── TeamsISO.Engine.NdiInterop.csproj (empty for Phase A) - ├── TeamsISO.App/ - │ ├── TeamsISO.App.csproj - │ ├── App.xaml / App.xaml.cs - │ └── MainWindow.xaml / MainWindow.xaml.cs (placeholder) - └── tests/ - ├── TeamsISO.Engine.Tests/ - │ ├── TeamsISO.Engine.Tests.csproj - │ ├── Fakes/ - │ │ ├── FakeNdiInterop.cs - │ │ └── FakeFrameClock.cs - │ ├── Domain/NdiSourceParserTests.cs - │ ├── Discovery/ParticipantTrackerTests.cs - │ ├── Discovery/NdiDiscoveryServiceTests.cs - │ ├── Pipeline/FrameProcessorTests.cs - │ └── Persistence/ConfigStoreTests.cs - └── TeamsISO.Engine.IntegrationTests/ - └── TeamsISO.Engine.IntegrationTests.csproj (empty for Phase A) -``` - -**Decomposition logic:** -- Domain types live next to each other under `Domain/` because they change together. -- Discovery (`NdiSourceParser`, `ParticipantTracker`, `NdiDiscoveryService`) is one cohesive subsystem; one folder. -- Pipeline (`FrameProcessor`, frame types, clock, slate renderer) is its own subsystem. -- `INdiInterop` lives in `Engine/Interop/` (the engine consumes it). The implementation project `TeamsISO.Engine.NdiInterop` references the engine and provides the production P/Invoke implementation in Phase B. -- WPF app and IntegrationTests projects exist as scaffolds so the .sln is complete and CI can validate the full graph builds. - ---- - -## Conventions used by every task - -- **TDD where applicable:** For any task that adds engine behavior, write the failing test first, run it to confirm failure, implement, run again to confirm pass, then commit. Tasks that scaffold projects skip the TDD loop. -- **Commits are small.** Each task ends with one commit. The commit message follows Conventional Commits: `feat(scope): subject` / `test(scope): subject` / `chore: subject`. -- **All code targets `net8.0`** unless explicitly noted (the WPF app needs `net8.0-windows`). -- **Nullable reference types are on** project-wide (set in `Directory.Build.props`). -- **TreatWarningsAsErrors is on** (set in `Directory.Build.props`). -- **Records are immutable.** Use positional records for simple value types and `with` for derived copies. - ---- - -## Task 1: Initialize repo working directory and global build props - -**Files:** -- Create: `.gitignore` -- Create: `.editorconfig` -- Create: `Directory.Build.props` -- Create: `README.md` - -- [ ] **Step 1: Confirm clone is up to date** - -Run: -``` -cd /Users/zacgaetano/Documents/Claude/Projects/Team\ Dragon/teamsiso -git status -``` -Expected: clean working tree on `main`, nothing to commit. (If the local clone is missing, clone via `git clone https://forge.wilddragon.net/zgaetano/teamsiso.git` after configuring credentials.) - -- [ ] **Step 2: Create `.gitignore`** - -Create `.gitignore` with the standard .NET ignore set: - -```gitignore -# .NET -bin/ -obj/ -*.user -*.suo -.vs/ -.vscode/ -*.swp -*.bak -*.tmp - -# Test outputs -TestResults/ -coverage*.xml -*.coverage -*.coveragexml - -# Tooling -.idea/ -*.DotSettings.user - -# Build artifacts -artifacts/ -publish/ -*.nupkg -*.snupkg - -# OS -.DS_Store -Thumbs.db -``` - -- [ ] **Step 3: Create `.editorconfig`** - -Create `.editorconfig` with .NET defaults: - -```ini -root = true - -[*] -charset = utf-8 -end_of_line = lf -indent_style = space -insert_final_newline = true -trim_trailing_whitespace = true - -[*.{cs,csx,vb,vbx}] -indent_size = 4 - -[*.{xml,csproj,props,targets}] -indent_size = 2 - -[*.{md,yml,yaml,json}] -indent_size = 2 - -[*.cs] -dotnet_sort_system_directives_first = true -csharp_style_namespace_declarations = file_scoped:warning -csharp_style_var_when_type_is_apparent = true:suggestion -dotnet_style_qualification_for_field = false:suggestion -dotnet_style_qualification_for_property = false:suggestion -dotnet_style_qualification_for_method = false:suggestion -dotnet_style_qualification_for_event = false:suggestion -``` - -- [ ] **Step 4: Create `Directory.Build.props`** - -Create `Directory.Build.props` so every project inherits the same settings: - -```xml - - - latest - enable - enable - true - true - latest - 1.0.0-alpha.0 - Wild Dragon LLC - Wild Dragon LLC - TeamsISO - Copyright © Wild Dragon LLC 2026 - - -``` - -- [ ] **Step 5: Create `README.md`** - -Create `README.md` with a project summary: - -```markdown -# TeamsISO - -Per-Participant NDI ISO Controller for Microsoft Teams. - -TeamsISO sits between Microsoft Teams' raw NDI broadcast output and a live-production environment. It receives each participant's NDI stream, normalizes framerate and resolution per a configured target, and re-emits clean, individually-addressable NDI sources for ingestion into a switcher (vMix, OBS, Ross, hardware capture). - -## Status - -Pre-1.0. See `docs/superpowers/specs/` for the active spec and `docs/superpowers/plans/` for in-flight implementation plans. - -## Build - -Requires .NET 8 SDK. - - dotnet build - dotnet test - -## License - -Proprietary, © Wild Dragon LLC 2026. -``` - -- [ ] **Step 6: Commit** - -```bash -git add .gitignore .editorconfig Directory.Build.props README.md -git commit -m "chore: scaffold repo conventions and global build props" -git push origin main -``` - ---- - -## Task 2: Create the empty solution and src/tests folders - -**Files:** -- Create: `TeamsISO.sln` -- Create: `src/.gitkeep`, `src/tests/.gitkeep` - -- [ ] **Step 1: Create the solution file** - -Run: -``` -dotnet new sln -n TeamsISO -mkdir -p src src/tests -touch src/.gitkeep src/tests/.gitkeep -``` - -- [ ] **Step 2: Verify solution builds (empty)** - -Run: -``` -dotnet build TeamsISO.sln -``` -Expected: `Build succeeded. 0 Warning(s) 0 Error(s)`. - -- [ ] **Step 3: Commit** - -```bash -git add TeamsISO.sln src/.gitkeep src/tests/.gitkeep -git commit -m "chore: add empty TeamsISO solution" -git push origin main -``` - ---- - -## Task 3: Create `TeamsISO.Engine` class library - -**Files:** -- Create: `src/TeamsISO.Engine/TeamsISO.Engine.csproj` - -- [ ] **Step 1: Create the project** - -Run: -``` -cd src -dotnet new classlib -n TeamsISO.Engine -f net8.0 -cd .. -``` - -- [ ] **Step 2: Add NuGet dependencies** - -Run: -``` -cd src/TeamsISO.Engine -dotnet add package Microsoft.Extensions.Logging.Abstractions --version 8.0.0 -dotnet add package System.Reactive --version 6.0.0 -cd ../.. -``` - -- [ ] **Step 3: Delete the `Class1.cs` placeholder** - -Run: -``` -rm src/TeamsISO.Engine/Class1.cs -``` - -- [ ] **Step 4: Add to solution** - -Run: -``` -dotnet sln add src/TeamsISO.Engine/TeamsISO.Engine.csproj -``` - -- [ ] **Step 5: Build** - -Run: -``` -dotnet build TeamsISO.sln -``` -Expected: success, 0 warnings, 0 errors. - -- [ ] **Step 6: Commit** - -```bash -git add src/TeamsISO.Engine/ TeamsISO.sln -git commit -m "feat(engine): scaffold TeamsISO.Engine class library" -git push origin main -``` - ---- - -## Task 4: Create `TeamsISO.Engine.NdiInterop` class library (empty for Phase A) - -**Files:** -- Create: `src/TeamsISO.Engine.NdiInterop/TeamsISO.Engine.NdiInterop.csproj` -- Create: `src/TeamsISO.Engine.NdiInterop/Placeholder.cs` - -- [ ] **Step 1: Create the project** - -Run: -``` -cd src -dotnet new classlib -n TeamsISO.Engine.NdiInterop -f net8.0 -cd .. -rm src/TeamsISO.Engine.NdiInterop/Class1.cs -``` - -- [ ] **Step 2: Reference the engine** - -Run: -``` -cd src/TeamsISO.Engine.NdiInterop -dotnet add reference ../TeamsISO.Engine/TeamsISO.Engine.csproj -cd ../.. -``` - -- [ ] **Step 3: Add a placeholder file so the assembly has at least one type** - -Create `src/TeamsISO.Engine.NdiInterop/Placeholder.cs`: - -```csharp -namespace TeamsISO.Engine.NdiInterop; - -/// -/// Phase A placeholder. The production P/Invoke implementations of INdiInterop -/// will live in this assembly, added in Phase B. -/// -internal static class Placeholder -{ -} -``` - -- [ ] **Step 4: Add to solution and build** - -Run: -``` -dotnet sln add src/TeamsISO.Engine.NdiInterop/TeamsISO.Engine.NdiInterop.csproj -dotnet build TeamsISO.sln -``` -Expected: success. - -- [ ] **Step 5: Commit** - -```bash -git add src/TeamsISO.Engine.NdiInterop/ TeamsISO.sln -git commit -m "feat(interop): scaffold TeamsISO.Engine.NdiInterop project" -git push origin main -``` - ---- - -## Task 5: Create `TeamsISO.Engine.Tests` xUnit project - -**Files:** -- Create: `src/tests/TeamsISO.Engine.Tests/TeamsISO.Engine.Tests.csproj` -- Create: `src/tests/TeamsISO.Engine.Tests/SmokeTest.cs` - -- [ ] **Step 1: Create the project** - -Run: -``` -cd src/tests -dotnet new xunit -n TeamsISO.Engine.Tests -f net8.0 -cd ../.. -rm src/tests/TeamsISO.Engine.Tests/UnitTest1.cs -``` - -- [ ] **Step 2: Add references and packages** - -Run: -``` -cd src/tests/TeamsISO.Engine.Tests -dotnet add reference ../../TeamsISO.Engine/TeamsISO.Engine.csproj -dotnet add package FluentAssertions --version 6.12.0 -dotnet add package coverlet.collector --version 6.0.0 -cd ../../.. -``` - -- [ ] **Step 3: Add a smoke test** - -Create `src/tests/TeamsISO.Engine.Tests/SmokeTest.cs`: - -```csharp -namespace TeamsISO.Engine.Tests; - -public class SmokeTest -{ - [Fact] - public void TestProjectIsWired() - { - Assert.True(true); - } -} -``` - -- [ ] **Step 4: Add to solution and run tests** - -Run: -``` -dotnet sln add src/tests/TeamsISO.Engine.Tests/TeamsISO.Engine.Tests.csproj -dotnet test TeamsISO.sln -``` -Expected: `Passed: 1, Failed: 0`. - -- [ ] **Step 5: Commit** - -```bash -git add src/tests/TeamsISO.Engine.Tests/ TeamsISO.sln -git commit -m "test(engine): scaffold TeamsISO.Engine.Tests xUnit project" -git push origin main -``` - ---- - -## Task 6: Scaffold `TeamsISO.App` (WPF) and `TeamsISO.Engine.IntegrationTests` - -These are stubs for Phase A — they exist so the solution graph is complete and CI can validate it. - -**Files:** -- Create: `src/TeamsISO.App/TeamsISO.App.csproj` (and minimal WPF skeleton) -- Create: `src/tests/TeamsISO.Engine.IntegrationTests/TeamsISO.Engine.IntegrationTests.csproj` - -- [ ] **Step 1: Create the WPF app project** - -Run: -``` -cd src -dotnet new wpf -n TeamsISO.App -f net8.0 -cd .. -``` - -- [ ] **Step 2: Reference the engine** - -Run: -``` -cd src/TeamsISO.App -dotnet add reference ../TeamsISO.Engine/TeamsISO.Engine.csproj -dotnet add reference ../TeamsISO.Engine.NdiInterop/TeamsISO.Engine.NdiInterop.csproj -cd ../.. -``` - -- [ ] **Step 3: Create the integration tests project** - -Run: -``` -cd src/tests -dotnet new xunit -n TeamsISO.Engine.IntegrationTests -f net8.0 -cd ../.. -rm src/tests/TeamsISO.Engine.IntegrationTests/UnitTest1.cs -cd src/tests/TeamsISO.Engine.IntegrationTests -dotnet add reference ../../TeamsISO.Engine/TeamsISO.Engine.csproj -dotnet add reference ../../TeamsISO.Engine.NdiInterop/TeamsISO.Engine.NdiInterop.csproj -dotnet add package FluentAssertions --version 6.12.0 -cd ../../.. -``` - -- [ ] **Step 4: Add a placeholder fact in the integration test project so it has at least one test** - -Create `src/tests/TeamsISO.Engine.IntegrationTests/IntegrationTestsScaffold.cs`: - -```csharp -namespace TeamsISO.Engine.IntegrationTests; - -public class IntegrationTestsScaffold -{ - [Fact(Skip = "Phase A: integration tests require NDI runtime — added in Phase B.")] - [Trait("requires", "ndi")] - public void ScaffoldFactSkipsCleanly() - { - Assert.True(true); - } -} -``` - -- [ ] **Step 5: Add both to the solution and build** - -Run: -``` -dotnet sln add src/TeamsISO.App/TeamsISO.App.csproj -dotnet sln add src/tests/TeamsISO.Engine.IntegrationTests/TeamsISO.Engine.IntegrationTests.csproj -dotnet build TeamsISO.sln -dotnet test TeamsISO.sln -``` -Expected: build succeeds; `Passed: 1, Failed: 0, Skipped: 1` (the skipped one is the integration scaffold). - -- [ ] **Step 6: Commit** - -```bash -git add src/TeamsISO.App/ src/tests/TeamsISO.Engine.IntegrationTests/ TeamsISO.sln -git commit -m "chore: scaffold WPF app and integration test projects" -git push origin main -``` - ---- - -## Task 7: Set up Forgejo Actions CI on Linux - -**Files:** -- Create: `.forgejo/workflows/ci.yml` - -- [ ] **Step 1: Create the CI workflow** - -Create `.forgejo/workflows/ci.yml`: - -```yaml -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - build-and-test: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup .NET 8 - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 8.0.x - - - name: Restore - run: dotnet restore TeamsISO.sln - - - name: Build (Release, treat warnings as errors) - run: dotnet build TeamsISO.sln --configuration Release --no-restore - - - name: Test (excluding requires=ndi) - run: > - dotnet test TeamsISO.sln - --configuration Release - --no-build - --logger "trx;LogFileName=test-results.trx" - --collect:"XPlat Code Coverage" - --filter "Category!=ndi&requires!=ndi" - - - name: Upload test results - if: always() - uses: actions/upload-artifact@v4 - with: - name: test-results - path: '**/test-results.trx' -``` - -- [ ] **Step 2: Commit and push to trigger first CI run** - -```bash -mkdir -p .forgejo/workflows -git add .forgejo/workflows/ci.yml -git commit -m "ci: add Forgejo Actions build-and-test workflow" -git push origin main -``` - -- [ ] **Step 3: Verify the run goes green** - -Open `https://forge.wilddragon.net/zgaetano/teamsiso/actions` in a browser. Confirm the latest run on `main` succeeded. - -If `actions/setup-dotnet@v4` is unavailable on the Forgejo Actions runner, swap to a manual setup step: - -```yaml - - name: Install .NET 8 - run: | - curl -sSL https://dot.net/v1/dotnet-install.sh -o dotnet-install.sh - chmod +x dotnet-install.sh - ./dotnet-install.sh --channel 8.0 --install-dir $HOME/.dotnet - echo "$HOME/.dotnet" >> $GITHUB_PATH -``` - ---- - -## Task 8: Domain enums - -**Files:** -- Create: `src/TeamsISO.Engine/Domain/Enums.cs` -- Create: `src/tests/TeamsISO.Engine.Tests/Domain/EnumSanityTests.cs` - -- [ ] **Step 1: Write the failing sanity test** - -Create `src/tests/TeamsISO.Engine.Tests/Domain/EnumSanityTests.cs`: - -```csharp -using TeamsISO.Engine.Domain; - -namespace TeamsISO.Engine.Tests.Domain; - -public class EnumSanityTests -{ - [Fact] - public void NdiSourceKind_HasExpectedMembers() - { - var values = Enum.GetValues(); - values.Should().Contain(new[] - { - NdiSourceKind.Participant, - NdiSourceKind.ActiveSpeaker, - NdiSourceKind.Audio, - NdiSourceKind.ScreenShare - }); - } - - [Fact] - public void IsoState_HasExpectedMembers() - { - var values = Enum.GetValues(); - values.Should().Contain(new[] - { - IsoState.Idle, - IsoState.Receiving, - IsoState.Sending, - IsoState.NoSignal, - IsoState.Error - }); - } - - [Fact] - public void TargetFramerate_HasAllSupportedRates() - { - var values = Enum.GetValues(); - values.Should().HaveCount(8); // 23.976, 24, 25, 29.97, 30, 50, 59.94, 60 - } -} -``` - -- [ ] **Step 2: Run tests, expect failure** - -Run: -``` -dotnet test --filter "FullyQualifiedName~EnumSanityTests" -``` -Expected: compilation errors (`NdiSourceKind` etc. don't exist yet). - -- [ ] **Step 3: Implement the enums** - -Create `src/TeamsISO.Engine/Domain/Enums.cs`: - -```csharp -namespace TeamsISO.Engine.Domain; - -public enum NdiSourceKind -{ - Participant, - ActiveSpeaker, - Audio, - ScreenShare -} - -public enum IsoState -{ - Idle, - Receiving, - Sending, - NoSignal, - Error -} - -public enum AspectMode -{ - Pillarbox, - Letterbox, - Stretch -} - -public enum AudioMode -{ - Auto, - Isolated, - Mixed -} - -public enum TargetFramerate -{ - Fps23_976, - Fps24, - Fps25, - Fps29_97, - Fps30, - Fps50, - Fps59_94, - Fps60 -} - -public enum TargetResolution -{ - R720p, - R1080p, - R4K -} -``` - -- [ ] **Step 4: Run tests, expect pass** - -Run: -``` -dotnet test --filter "FullyQualifiedName~EnumSanityTests" -``` -Expected: 3 pass. - -- [ ] **Step 5: Commit** - -```bash -git add src/TeamsISO.Engine/Domain/Enums.cs src/tests/TeamsISO.Engine.Tests/Domain/EnumSanityTests.cs -git commit -m "feat(domain): add core enums (NdiSourceKind, IsoState, AspectMode, AudioMode, TargetFramerate, TargetResolution)" -git push origin main -``` - ---- - -## Task 9: `NdiSource` record + `NdiSourceParser` (TDD) - -**Files:** -- Create: `src/TeamsISO.Engine/Domain/NdiSource.cs` -- Create: `src/TeamsISO.Engine/Discovery/NdiSourceParser.cs` -- Create: `src/tests/TeamsISO.Engine.Tests/Domain/NdiSourceParserTests.cs` - -- [ ] **Step 1: Write the failing parser tests** - -Create `src/tests/TeamsISO.Engine.Tests/Domain/NdiSourceParserTests.cs`: - -```csharp -using TeamsISO.Engine.Discovery; -using TeamsISO.Engine.Domain; - -namespace TeamsISO.Engine.Tests.Domain; - -public class NdiSourceParserTests -{ - [Theory] - [InlineData("WORKSTATION-01 (Teams - Jane Doe)", "WORKSTATION-01", NdiSourceKind.Participant, "Jane Doe")] - [InlineData("PROD-PC (Teams - Élise O'Connor)", "PROD-PC", NdiSourceKind.Participant, "Élise O'Connor")] - [InlineData("HOST (Teams - Smith, Bob (PM))", "HOST", NdiSourceKind.Participant, "Smith, Bob (PM)")] - public void Parse_Participant_ExtractsMachineAndDisplayName( - string fullName, string expectedMachine, NdiSourceKind expectedKind, string expectedDisplay) - { - var result = NdiSourceParser.Parse(fullName); - - result.MachineName.Should().Be(expectedMachine); - result.Kind.Should().Be(expectedKind); - result.DisplayName.Should().Be(expectedDisplay); - result.FullName.Should().Be(fullName); - } - - [Theory] - [InlineData("HOST (Teams)", NdiSourceKind.ActiveSpeaker)] - [InlineData("HOST (Teams Audio)", NdiSourceKind.Audio)] - [InlineData("HOST (Teams Screen Share)", NdiSourceKind.ScreenShare)] - public void Parse_NonParticipantKinds_ClassifyCorrectly(string fullName, NdiSourceKind expectedKind) - { - var result = NdiSourceParser.Parse(fullName); - - result.Kind.Should().Be(expectedKind); - result.DisplayName.Should().BeNull(); - } - - [Theory] - [InlineData("Plain NDI Source")] // not a Teams source at all - [InlineData("HOST (Some Other Software)")] // wrong app prefix - [InlineData("(Teams - No Machine)")] // empty machine - public void Parse_NonTeamsSource_ReturnsNull(string fullName) - { - var result = NdiSourceParser.Parse(fullName); - result.Should().BeNull(); - } -} -``` - -- [ ] **Step 2: Run tests, expect failure** - -Run: -``` -dotnet test --filter "FullyQualifiedName~NdiSourceParserTests" -``` -Expected: compilation errors (types missing). - -- [ ] **Step 3: Implement `NdiSource`** - -Create `src/TeamsISO.Engine/Domain/NdiSource.cs`: - -```csharp -namespace TeamsISO.Engine.Domain; - -/// -/// Raw discovery record parsed from an NDI source string emitted by Microsoft Teams. -/// -public sealed record NdiSource( - string FullName, - string MachineName, - NdiSourceKind Kind, - string? DisplayName); -``` - -- [ ] **Step 4: Implement `NdiSourceParser`** - -Create `src/TeamsISO.Engine/Discovery/NdiSourceParser.cs`: - -```csharp -using TeamsISO.Engine.Domain; - -namespace TeamsISO.Engine.Discovery; - -/// -/// Parses NDI source strings emitted by Microsoft Teams. -/// -/// Examples Teams emits: -/// "MACHINE (Teams - Display Name)" -/// "MACHINE (Teams)" — auto-mixed active speaker -/// "MACHINE (Teams Audio)" — audio-only mix -/// "MACHINE (Teams Screen Share)" — screen share -/// -public static class NdiSourceParser -{ - public static NdiSource? Parse(string fullName) - { - if (string.IsNullOrWhiteSpace(fullName)) - return null; - - // Find the last '(' so machine names can themselves contain parens. - var openParen = fullName.LastIndexOf('('); - if (openParen <= 0 || !fullName.EndsWith(')')) - return null; - - var machine = fullName[..openParen].TrimEnd(); - if (machine.Length == 0) - return null; - - var inner = fullName.Substring(openParen + 1, fullName.Length - openParen - 2).Trim(); - - if (!inner.StartsWith("Teams", StringComparison.Ordinal)) - return null; - - // "Teams" alone → ActiveSpeaker - if (inner == "Teams") - return new NdiSource(fullName, machine, NdiSourceKind.ActiveSpeaker, DisplayName: null); - - if (inner == "Teams Audio") - return new NdiSource(fullName, machine, NdiSourceKind.Audio, DisplayName: null); - - if (inner == "Teams Screen Share") - return new NdiSource(fullName, machine, NdiSourceKind.ScreenShare, DisplayName: null); - - // "Teams - " → Participant - const string prefix = "Teams - "; - if (inner.StartsWith(prefix, StringComparison.Ordinal)) - { - var display = inner[prefix.Length..].Trim(); - if (display.Length == 0) - return null; - return new NdiSource(fullName, machine, NdiSourceKind.Participant, display); - } - - return null; - } -} -``` - -- [ ] **Step 5: Run tests, expect pass** - -Run: -``` -dotnet test --filter "FullyQualifiedName~NdiSourceParserTests" -``` -Expected: all 9 (3 + 3 + 3) pass. - -- [ ] **Step 6: Commit** - -```bash -git add src/TeamsISO.Engine/Domain/NdiSource.cs src/TeamsISO.Engine/Discovery/NdiSourceParser.cs src/tests/TeamsISO.Engine.Tests/Domain/NdiSourceParserTests.cs -git commit -m "feat(discovery): add NdiSource record and Teams source string parser" -git push origin main -``` - ---- - -## Task 10: `Participant`, `IsoAssignment`, `IsoOutput`, `IsoHealthStats` - -**Files:** -- Create: `src/TeamsISO.Engine/Domain/Participant.cs` -- Create: `src/TeamsISO.Engine/Domain/IsoAssignment.cs` -- Create: `src/TeamsISO.Engine/Domain/IsoOutput.cs` -- Create: `src/TeamsISO.Engine/Domain/IsoHealthStats.cs` - -These are pure data records; no behavior under test. We compile-check via the existing tests. - -- [ ] **Step 1: Implement `Participant`** - -Create `src/TeamsISO.Engine/Domain/Participant.cs`: - -```csharp -namespace TeamsISO.Engine.Domain; - -/// -/// Operator-facing identity for a single human in the meeting. -/// Id is engine-assigned and stable across the rename heuristic. -/// -public sealed record Participant( - Guid Id, - string DisplayName, - NdiSource? CurrentSource, - DateTimeOffset FirstSeen, - DateTimeOffset LastSeen); -``` - -- [ ] **Step 2: Implement `IsoAssignment`** - -Create `src/TeamsISO.Engine/Domain/IsoAssignment.cs`: - -```csharp -namespace TeamsISO.Engine.Domain; - -/// -/// Operator's intent for an ISO output. Persisted to config.json. -/// -public sealed record IsoAssignment( - Guid ParticipantId, - bool IsEnabled, - string? CustomOutputName); -``` - -- [ ] **Step 3: Implement `IsoHealthStats`** - -Create `src/TeamsISO.Engine/Domain/IsoHealthStats.cs`: - -```csharp -namespace TeamsISO.Engine.Domain; - -public sealed record IsoHealthStats( - long FramesIn, - long FramesOut, - long FramesDropped, - long FramesDuplicated, - DateTimeOffset? LastFrameAt, - double IncomingFps, - int IncomingWidth, - int IncomingHeight) -{ - public static readonly IsoHealthStats Empty = - new(0, 0, 0, 0, null, 0, 0, 0); -} -``` - -- [ ] **Step 4: Implement `IsoOutput`** - -Create `src/TeamsISO.Engine/Domain/IsoOutput.cs`: - -```csharp -namespace TeamsISO.Engine.Domain; - -public sealed record IsoOutput( - Guid ParticipantId, - string EffectiveOutputName, - IsoHealthStats Stats, - IsoState State); -``` - -- [ ] **Step 5: Verify the solution still builds** - -Run: -``` -dotnet build TeamsISO.sln -``` -Expected: success. - -- [ ] **Step 6: Commit** - -```bash -git add src/TeamsISO.Engine/Domain/Participant.cs src/TeamsISO.Engine/Domain/IsoAssignment.cs src/TeamsISO.Engine/Domain/IsoOutput.cs src/TeamsISO.Engine/Domain/IsoHealthStats.cs -git commit -m "feat(domain): add Participant, IsoAssignment, IsoOutput, IsoHealthStats records" -git push origin main -``` - ---- - -## Task 11: `FrameProcessingSettings`, `EngineConfig`, `EngineAlert` - -**Files:** -- Create: `src/TeamsISO.Engine/Domain/FrameProcessingSettings.cs` -- Create: `src/TeamsISO.Engine/Domain/EngineConfig.cs` -- Create: `src/TeamsISO.Engine/Domain/EngineAlert.cs` - -- [ ] **Step 1: Implement `FrameProcessingSettings`** - -Create `src/TeamsISO.Engine/Domain/FrameProcessingSettings.cs`: - -```csharp -namespace TeamsISO.Engine.Domain; - -public sealed record FrameProcessingSettings( - TargetFramerate Framerate, - TargetResolution Resolution, - AspectMode Aspect, - AudioMode Audio) -{ - public static readonly FrameProcessingSettings Default = - new(TargetFramerate.Fps30, TargetResolution.R1080p, AspectMode.Pillarbox, AudioMode.Auto); - - /// Returns the framerate enum value as a numeric frames-per-second. - public double FramerateHz => Framerate switch - { - TargetFramerate.Fps23_976 => 24000.0 / 1001.0, - TargetFramerate.Fps24 => 24.0, - TargetFramerate.Fps25 => 25.0, - TargetFramerate.Fps29_97 => 30000.0 / 1001.0, - TargetFramerate.Fps30 => 30.0, - TargetFramerate.Fps50 => 50.0, - TargetFramerate.Fps59_94 => 60000.0 / 1001.0, - TargetFramerate.Fps60 => 60.0, - _ => throw new InvalidOperationException($"Unknown framerate: {Framerate}") - }; - - /// Returns the resolution as (width, height). - public (int Width, int Height) ResolutionSize => Resolution switch - { - TargetResolution.R720p => (1280, 720), - TargetResolution.R1080p => (1920, 1080), - TargetResolution.R4K => (3840, 2160), - _ => throw new InvalidOperationException($"Unknown resolution: {Resolution}") - }; -} -``` - -- [ ] **Step 2: Implement `EngineConfig`** - -Create `src/TeamsISO.Engine/Domain/EngineConfig.cs`: - -```csharp -namespace TeamsISO.Engine.Domain; - -public sealed record EngineConfig( - FrameProcessingSettings Global, - IReadOnlyList Assignments) -{ - public static readonly EngineConfig Default = - new(FrameProcessingSettings.Default, Array.Empty()); -} -``` - -- [ ] **Step 3: Implement `EngineAlert`** - -Create `src/TeamsISO.Engine/Domain/EngineAlert.cs`: - -```csharp -namespace TeamsISO.Engine.Domain; - -/// -/// Structured engine alerts for UI banner display and ops logging. -/// -public abstract record EngineAlert(string Message) -{ - public sealed record NdiRuntimeMismatch(string DetectedVersion, string ExpectedVersion) - : EngineAlert($"NDI runtime version mismatch: detected {DetectedVersion}, expected {ExpectedVersion}."); - - public sealed record OutputNameCollision(string Name) - : EngineAlert($"Another TeamsISO instance on the LAN is emitting an output named '{Name}'."); - - public sealed record PipelineError(Guid ParticipantId, string Reason) - : EngineAlert($"Pipeline {ParticipantId} entered Error: {Reason}"); - - public sealed record ConfigSaveFailed(string Reason) - : EngineAlert($"Failed to save configuration: {Reason}"); -} -``` - -- [ ] **Step 4: Build the solution** - -Run: -``` -dotnet build TeamsISO.sln -``` -Expected: success. - -- [ ] **Step 5: Commit** - -```bash -git add src/TeamsISO.Engine/Domain/FrameProcessingSettings.cs src/TeamsISO.Engine/Domain/EngineConfig.cs src/TeamsISO.Engine/Domain/EngineAlert.cs -git commit -m "feat(domain): add FrameProcessingSettings, EngineConfig, EngineAlert" -git push origin main -``` - ---- - -## Task 12: `ConfigStore` — JSON persistence with atomic writes (TDD) - -**Files:** -- Create: `src/TeamsISO.Engine/Persistence/ConfigStore.cs` -- Create: `src/tests/TeamsISO.Engine.Tests/Persistence/ConfigStoreTests.cs` - -- [ ] **Step 1: Write the failing tests** - -Create `src/tests/TeamsISO.Engine.Tests/Persistence/ConfigStoreTests.cs`: - -```csharp -using Microsoft.Extensions.Logging.Abstractions; -using TeamsISO.Engine.Domain; -using TeamsISO.Engine.Persistence; - -namespace TeamsISO.Engine.Tests.Persistence; - -public class ConfigStoreTests : IDisposable -{ - private readonly string _dir; - - public ConfigStoreTests() - { - _dir = Path.Combine(Path.GetTempPath(), $"teamsiso-tests-{Guid.NewGuid():N}"); - Directory.CreateDirectory(_dir); - } - - public void Dispose() => Directory.Delete(_dir, recursive: true); - - private ConfigStore NewStore() => - new(Path.Combine(_dir, "config.json"), NullLogger.Instance); - - [Fact] - public void Load_WhenFileMissing_ReturnsDefault() - { - var store = NewStore(); - - var config = store.Load(); - - config.Should().Be(EngineConfig.Default); - } - - [Fact] - public void SaveThenLoad_RoundTripsExactly() - { - var store = NewStore(); - var input = new EngineConfig( - new FrameProcessingSettings(TargetFramerate.Fps59_94, TargetResolution.R720p, - AspectMode.Letterbox, AudioMode.Isolated), - new[] - { - new IsoAssignment(Guid.NewGuid(), IsEnabled: true, CustomOutputName: "TEAMSISO_A"), - new IsoAssignment(Guid.NewGuid(), IsEnabled: false, CustomOutputName: null), - }); - - store.Save(input); - var loaded = store.Load(); - - loaded.Should().BeEquivalentTo(input); - } - - [Fact] - public void Load_WhenFileCorrupt_ReturnsDefault() - { - var path = Path.Combine(_dir, "config.json"); - File.WriteAllText(path, "not valid json {{{"); - - var store = NewStore(); - - var config = store.Load(); - config.Should().Be(EngineConfig.Default); - } - - [Fact] - public void Save_WritesAtomically_NoPartialFileVisible() - { - var store = NewStore(); - var path = Path.Combine(_dir, "config.json"); - - // Pre-existing valid config - store.Save(EngineConfig.Default); - var firstWrite = File.ReadAllText(path); - - // Save a new value; verify the file is replaced atomically (no temp files left behind) - store.Save(new EngineConfig( - FrameProcessingSettings.Default with { Framerate = TargetFramerate.Fps60 }, - Array.Empty())); - - File.Exists(path).Should().BeTrue(); - File.ReadAllText(path).Should().NotBe(firstWrite); - - // No leftover temp files - Directory.GetFiles(_dir).Should().HaveCount(1); - } -} -``` - -- [ ] **Step 2: Run tests, expect failure** - -Run: -``` -dotnet test --filter "FullyQualifiedName~ConfigStoreTests" -``` -Expected: compilation errors (`ConfigStore` doesn't exist). - -- [ ] **Step 3: Implement `ConfigStore`** - -Create `src/TeamsISO.Engine/Persistence/ConfigStore.cs`: - -```csharp -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Extensions.Logging; -using TeamsISO.Engine.Domain; - -namespace TeamsISO.Engine.Persistence; - -/// -/// Loads and saves as JSON. -/// Writes are atomic: serialize to a temp file in the same directory, then File.Move with overwrite. -/// -public sealed class ConfigStore -{ - private static readonly JsonSerializerOptions Options = new() - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - Converters = { new JsonStringEnumConverter() } - }; - - private readonly string _path; - private readonly ILogger _logger; - - public ConfigStore(string path, ILogger logger) - { - _path = path; - _logger = logger; - } - - public EngineConfig Load() - { - if (!File.Exists(_path)) - { - _logger.LogInformation("Config file not found at {Path}; using defaults.", _path); - return EngineConfig.Default; - } - - try - { - var json = File.ReadAllText(_path); - var loaded = JsonSerializer.Deserialize(json, Options); - return loaded ?? EngineConfig.Default; - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "Config file at {Path} is not valid JSON; using defaults.", _path); - return EngineConfig.Default; - } - } - - public void Save(EngineConfig config) - { - Directory.CreateDirectory(Path.GetDirectoryName(_path)!); - var temp = _path + ".tmp"; - var json = JsonSerializer.Serialize(config, Options); - File.WriteAllText(temp, json); - File.Move(temp, _path, overwrite: true); - } -} -``` - -- [ ] **Step 4: Run tests, expect pass** - -Run: -``` -dotnet test --filter "FullyQualifiedName~ConfigStoreTests" -``` -Expected: all 4 pass. - -- [ ] **Step 5: Commit** - -```bash -git add src/TeamsISO.Engine/Persistence/ConfigStore.cs src/tests/TeamsISO.Engine.Tests/Persistence/ConfigStoreTests.cs -git commit -m "feat(persistence): add ConfigStore with atomic JSON writes and corruption-safe load" -git push origin main -``` - ---- - -## Task 13: `INdiInterop` interface - -**Files:** -- Create: `src/TeamsISO.Engine/Interop/INdiInterop.cs` -- Create: `src/TeamsISO.Engine/Interop/NdiFindHandle.cs` -- Create: `src/TeamsISO.Engine/Interop/NdiReceiverHandle.cs` -- Create: `src/TeamsISO.Engine/Interop/NdiSenderHandle.cs` - -The interface is the test seam: every NDI SDK call routes through it. In Phase A we have only a fake; in Phase B `TeamsISO.Engine.NdiInterop` ships the real P/Invoke. - -- [ ] **Step 1: Define the handle marker types** - -Create `src/TeamsISO.Engine/Interop/NdiFindHandle.cs`: - -```csharp -namespace TeamsISO.Engine.Interop; - -/// Opaque handle to an NDI Find instance. Implementation-private. -public abstract class NdiFindHandle : IDisposable -{ - public abstract void Dispose(); -} -``` - -Create `src/TeamsISO.Engine/Interop/NdiReceiverHandle.cs`: - -```csharp -namespace TeamsISO.Engine.Interop; - -public abstract class NdiReceiverHandle : IDisposable -{ - public abstract void Dispose(); -} -``` - -Create `src/TeamsISO.Engine/Interop/NdiSenderHandle.cs`: - -```csharp -namespace TeamsISO.Engine.Interop; - -public abstract class NdiSenderHandle : IDisposable -{ - public abstract void Dispose(); -} -``` - -- [ ] **Step 2: Define the interface** - -Create `src/TeamsISO.Engine/Interop/INdiInterop.cs`: - -```csharp -using TeamsISO.Engine.Domain; -using TeamsISO.Engine.Pipeline; - -namespace TeamsISO.Engine.Interop; - -/// -/// Test seam over the NDI SDK. Production: P/Invoke shim. Tests: FakeNdiInterop. -/// All methods are synchronous; the engine threads are responsible for orchestration. -/// -public interface INdiInterop -{ - // ----- Discovery ----- - NdiFindHandle CreateFinder(); - - /// Snapshots the currently-known sources visible to the finder. - IReadOnlyList GetCurrentSources(NdiFindHandle finder); - - // ----- Receive ----- - NdiReceiverHandle CreateReceiver(string sourceFullName); - - /// - /// Blocks for up to waiting for a frame. - /// Returns null on timeout. Returned ownership transfers to the caller. - /// - RawFrame? CaptureFrame(NdiReceiverHandle receiver, int timeoutMs); - - // ----- Send ----- - NdiSenderHandle CreateSender(string outputName); - void SendFrame(NdiSenderHandle sender, ProcessedFrame frame); - - // ----- Runtime probe ----- - string GetRuntimeVersion(); -} -``` - -- [ ] **Step 3: Build to confirm types resolve (will fail because RawFrame/ProcessedFrame don't exist yet)** - -Run: -``` -dotnet build TeamsISO.sln -``` -Expected: errors about `RawFrame` and `ProcessedFrame`. Continue to next task without committing — Task 14 introduces those types. - ---- - -## Task 14: `RawFrame`, `ProcessedFrame`, `IFrameClock` - -**Files:** -- Create: `src/TeamsISO.Engine/Pipeline/RawFrame.cs` -- Create: `src/TeamsISO.Engine/Pipeline/ProcessedFrame.cs` -- Create: `src/TeamsISO.Engine/Pipeline/IFrameClock.cs` -- Create: `src/TeamsISO.Engine/Pipeline/PeriodicTimerFrameClock.cs` - -- [ ] **Step 1: Implement `RawFrame`** - -Create `src/TeamsISO.Engine/Pipeline/RawFrame.cs`: - -```csharp -namespace TeamsISO.Engine.Pipeline; - -/// -/// A frame as captured from an NDI receiver. Pixel buffer is opaque to the engine — its -/// shape is determined by the NDI receive format. Timestamp is the source's reported time. -/// -public sealed record RawFrame( - int Width, - int Height, - long TimestampTicks, - ReadOnlyMemory Pixels, - PixelFormat Format); - -public enum PixelFormat -{ - Bgra, - Uyvy, - Rgba -} -``` - -- [ ] **Step 2: Implement `ProcessedFrame`** - -Create `src/TeamsISO.Engine/Pipeline/ProcessedFrame.cs`: - -```csharp -namespace TeamsISO.Engine.Pipeline; - -/// -/// A frame after framerate, resolution, and aspect normalization. Ready to send. -/// -public sealed record ProcessedFrame( - int Width, - int Height, - long TimestampTicks, - ReadOnlyMemory Pixels, - PixelFormat Format); -``` - -- [ ] **Step 3: Implement `IFrameClock`** - -Create `src/TeamsISO.Engine/Pipeline/IFrameClock.cs`: - -```csharp -namespace TeamsISO.Engine.Pipeline; - -/// -/// Test seam over the wall clock. Production: . -/// Tests: FakeFrameClock in TeamsISO.Engine.Tests. -/// -public interface IFrameClock -{ - /// Current monotonic time as ticks (100 ns). - long NowTicks { get; } - - /// Awaits the next tick at the current period. - ValueTask WaitForNextTickAsync(CancellationToken cancellationToken); -} -``` - -- [ ] **Step 4: Implement the production clock** - -Create `src/TeamsISO.Engine/Pipeline/PeriodicTimerFrameClock.cs`: - -```csharp -namespace TeamsISO.Engine.Pipeline; - -public sealed class PeriodicTimerFrameClock : IFrameClock, IDisposable -{ - private readonly PeriodicTimer _timer; - - public PeriodicTimerFrameClock(double framesPerSecond) - { - if (framesPerSecond <= 0) - throw new ArgumentOutOfRangeException(nameof(framesPerSecond)); - var periodMs = 1000.0 / framesPerSecond; - _timer = new PeriodicTimer(TimeSpan.FromMilliseconds(periodMs)); - } - - public long NowTicks => DateTime.UtcNow.Ticks; - - public ValueTask WaitForNextTickAsync(CancellationToken cancellationToken) => - _timer.WaitForNextTickAsync(cancellationToken); - - public void Dispose() => _timer.Dispose(); -} -``` - -- [ ] **Step 5: Build the solution** - -Run: -``` -dotnet build TeamsISO.sln -``` -Expected: success. - -- [ ] **Step 6: Commit** - -```bash -git add src/TeamsISO.Engine/Pipeline/RawFrame.cs src/TeamsISO.Engine/Pipeline/ProcessedFrame.cs src/TeamsISO.Engine/Pipeline/IFrameClock.cs src/TeamsISO.Engine/Pipeline/PeriodicTimerFrameClock.cs src/TeamsISO.Engine/Interop/INdiInterop.cs src/TeamsISO.Engine/Interop/NdiFindHandle.cs src/TeamsISO.Engine/Interop/NdiReceiverHandle.cs src/TeamsISO.Engine/Interop/NdiSenderHandle.cs -git commit -m "feat(pipeline,interop): add RawFrame, ProcessedFrame, IFrameClock and INdiInterop test seam" -git push origin main -``` - ---- - -## Task 15: `FakeNdiInterop` and `FakeFrameClock` - -**Files:** -- Create: `src/tests/TeamsISO.Engine.Tests/Fakes/FakeNdiInterop.cs` -- Create: `src/tests/TeamsISO.Engine.Tests/Fakes/FakeFrameClock.cs` - -- [ ] **Step 1: Implement `FakeNdiInterop`** - -Create `src/tests/TeamsISO.Engine.Tests/Fakes/FakeNdiInterop.cs`: - -```csharp -using System.Collections.Concurrent; -using TeamsISO.Engine.Interop; -using TeamsISO.Engine.Pipeline; - -namespace TeamsISO.Engine.Tests.Fakes; - -/// -/// In-memory test double for . Tests configure source lists and frame -/// queues; the fake feeds those into engine code as if a real NDI runtime were present. -/// -public sealed class FakeNdiInterop : INdiInterop -{ - public List Sources { get; } = new(); - public ConcurrentDictionary> ReceiverFrames { get; } = new(); - public ConcurrentDictionary> SentFrames { get; } = new(); - public string RuntimeVersion { get; set; } = "6.0.0"; - public Dictionary ReceiverCreatedCount { get; } = new(); - public Dictionary SenderCreatedCount { get; } = new(); - - public NdiFindHandle CreateFinder() => new FakeFindHandle(); - public IReadOnlyList GetCurrentSources(NdiFindHandle finder) => Sources.ToArray(); - - public NdiReceiverHandle CreateReceiver(string sourceFullName) - { - ReceiverCreatedCount[sourceFullName] = ReceiverCreatedCount.GetValueOrDefault(sourceFullName) + 1; - ReceiverFrames.GetOrAdd(sourceFullName, _ => new ConcurrentQueue()); - return new FakeReceiverHandle(sourceFullName); - } - - public RawFrame? CaptureFrame(NdiReceiverHandle receiver, int timeoutMs) - { - var key = ((FakeReceiverHandle)receiver).Source; - if (ReceiverFrames.TryGetValue(key, out var q) && q.TryDequeue(out var frame)) - return frame; - return null; // simulate timeout - } - - public NdiSenderHandle CreateSender(string outputName) - { - SenderCreatedCount[outputName] = SenderCreatedCount.GetValueOrDefault(outputName) + 1; - SentFrames.GetOrAdd(outputName, _ => new List()); - return new FakeSenderHandle(outputName); - } - - public void SendFrame(NdiSenderHandle sender, ProcessedFrame frame) - { - var key = ((FakeSenderHandle)sender).Output; - SentFrames[key].Add(frame); - } - - public string GetRuntimeVersion() => RuntimeVersion; - - private sealed class FakeFindHandle : NdiFindHandle - { - public override void Dispose() { } - } - - private sealed class FakeReceiverHandle : NdiReceiverHandle - { - public string Source { get; } - public FakeReceiverHandle(string source) => Source = source; - public override void Dispose() { } - } - - private sealed class FakeSenderHandle : NdiSenderHandle - { - public string Output { get; } - public FakeSenderHandle(string output) => Output = output; - public override void Dispose() { } - } -} -``` - -- [ ] **Step 2: Implement `FakeFrameClock`** - -Create `src/tests/TeamsISO.Engine.Tests/Fakes/FakeFrameClock.cs`: - -```csharp -using TeamsISO.Engine.Pipeline; - -namespace TeamsISO.Engine.Tests.Fakes; - -/// -/// Manual-tick clock. Tests advance the clock and trigger the awaiter explicitly. -/// -public sealed class FakeFrameClock : IFrameClock -{ - private long _nowTicks; - private TaskCompletionSource? _pendingTick; - private readonly object _gate = new(); - - public long NowTicks => Interlocked.Read(ref _nowTicks); - - public void Advance(TimeSpan by) - { - Interlocked.Add(ref _nowTicks, by.Ticks); - TaskCompletionSource? toSignal; - lock (_gate) - { - toSignal = _pendingTick; - _pendingTick = null; - } - toSignal?.TrySetResult(true); - } - - public ValueTask WaitForNextTickAsync(CancellationToken cancellationToken) - { - TaskCompletionSource tcs; - lock (_gate) - { - _pendingTick ??= new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - tcs = _pendingTick; - } - cancellationToken.Register(() => tcs.TrySetResult(false)); - return new ValueTask(tcs.Task); - } -} -``` - -- [ ] **Step 3: Build to make sure tests compile** - -Run: -``` -dotnet build TeamsISO.sln -``` -Expected: success. - -- [ ] **Step 4: Commit** - -```bash -git add src/tests/TeamsISO.Engine.Tests/Fakes/ -git commit -m "test(fakes): add FakeNdiInterop and FakeFrameClock" -git push origin main -``` - ---- - -## Task 16: `DiscoveryEvent` type - -**Files:** -- Create: `src/TeamsISO.Engine/Discovery/DiscoveryEvent.cs` - -- [ ] **Step 1: Implement the discriminated union** - -Create `src/TeamsISO.Engine/Discovery/DiscoveryEvent.cs`: - -```csharp -using TeamsISO.Engine.Domain; - -namespace TeamsISO.Engine.Discovery; - -public abstract record DiscoveryEvent -{ - public sealed record Added(NdiSource Source) : DiscoveryEvent; - public sealed record Removed(NdiSource Source) : DiscoveryEvent; -} -``` - -- [ ] **Step 2: Build** - -Run: -``` -dotnet build TeamsISO.sln -``` -Expected: success. - -- [ ] **Step 3: Commit** - -```bash -git add src/TeamsISO.Engine/Discovery/DiscoveryEvent.cs -git commit -m "feat(discovery): add DiscoveryEvent (Added/Removed)" -git push origin main -``` - -> Note: the spec mentions `Renamed` events. We model rename as Removed-then-Added because Teams emits a fresh source string on rename — the parser sees no link between them, but `ParticipantTracker` (Task 18) reconstitutes identity via the rename heuristic. - ---- - -## Task 17: `ParticipantTracker` rename heuristic (TDD) - -**Files:** -- Create: `src/TeamsISO.Engine/Discovery/ParticipantTracker.cs` -- Create: `src/tests/TeamsISO.Engine.Tests/Discovery/ParticipantTrackerTests.cs` - -- [ ] **Step 1: Write the failing tests** - -Create `src/tests/TeamsISO.Engine.Tests/Discovery/ParticipantTrackerTests.cs`: - -```csharp -using TeamsISO.Engine.Discovery; -using TeamsISO.Engine.Domain; - -namespace TeamsISO.Engine.Tests.Discovery; - -public class ParticipantTrackerTests -{ - private static NdiSource Source(string machine, string display) => - new($"{machine} (Teams - {display})", machine, NdiSourceKind.Participant, display); - - private static DateTimeOffset T0 => DateTimeOffset.UnixEpoch; - - [Fact] - public void Added_CreatesParticipant() - { - var tracker = new ParticipantTracker(renameWindow: TimeSpan.FromSeconds(5), now: () => T0); - - tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Jane"))); - - tracker.Participants.Should().HaveCount(1); - var p = tracker.Participants[0]; - p.DisplayName.Should().Be("Jane"); - p.CurrentSource!.MachineName.Should().Be("PC1"); - } - - [Fact] - public void Removed_NullsCurrentSourceButKeepsParticipant() - { - var time = T0; - var tracker = new ParticipantTracker(TimeSpan.FromSeconds(5), () => time); - tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Jane"))); - - time = T0.AddSeconds(1); - tracker.Apply(new DiscoveryEvent.Removed(Source("PC1", "Jane"))); - - tracker.Participants.Should().HaveCount(1); - tracker.Participants[0].CurrentSource.Should().BeNull(); - } - - [Fact] - public void RenameWithinWindow_TransfersParticipantId() - { - var time = T0; - var tracker = new ParticipantTracker(TimeSpan.FromSeconds(5), () => time); - tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Jane"))); - var originalId = tracker.Participants[0].Id; - - time = T0.AddSeconds(1); - tracker.Apply(new DiscoveryEvent.Removed(Source("PC1", "Jane"))); - - time = T0.AddSeconds(3); - tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Jane (PM)"))); - - tracker.Participants.Should().HaveCount(1); - tracker.Participants[0].Id.Should().Be(originalId); - tracker.Participants[0].DisplayName.Should().Be("Jane (PM)"); - } - - [Fact] - public void RenameAfterWindow_TreatsAsNewParticipant() - { - var time = T0; - var tracker = new ParticipantTracker(TimeSpan.FromSeconds(5), () => time); - tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Jane"))); - var originalId = tracker.Participants[0].Id; - - time = T0.AddSeconds(1); - tracker.Apply(new DiscoveryEvent.Removed(Source("PC1", "Jane"))); - - time = T0.AddSeconds(20); // way past the 5s window - tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Bob"))); - - tracker.Participants.Should().HaveCount(2); - tracker.Participants.Should().Contain(p => p.Id == originalId); - tracker.Participants.Should().Contain(p => p.DisplayName == "Bob" && p.Id != originalId); - } - - [Fact] - public void DifferentMachine_DoesNotInheritIdentity() - { - var time = T0; - var tracker = new ParticipantTracker(TimeSpan.FromSeconds(5), () => time); - tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Jane"))); - - time = T0.AddSeconds(1); - tracker.Apply(new DiscoveryEvent.Removed(Source("PC1", "Jane"))); - - time = T0.AddSeconds(2); - tracker.Apply(new DiscoveryEvent.Added(Source("PC2", "Jane (PM)"))); - - tracker.Participants.Should().HaveCount(2); - } - - [Fact] - public void NonParticipantSources_AreIgnored() - { - var tracker = new ParticipantTracker(TimeSpan.FromSeconds(5), () => T0); - var screen = new NdiSource("PC1 (Teams Screen Share)", "PC1", NdiSourceKind.ScreenShare, null); - - tracker.Apply(new DiscoveryEvent.Added(screen)); - - tracker.Participants.Should().BeEmpty(); - } -} -``` - -- [ ] **Step 2: Run tests, expect failure** - -Run: -``` -dotnet test --filter "FullyQualifiedName~ParticipantTrackerTests" -``` -Expected: compilation errors (ParticipantTracker missing). - -- [ ] **Step 3: Implement `ParticipantTracker`** - -Create `src/TeamsISO.Engine/Discovery/ParticipantTracker.cs`: - -```csharp -using TeamsISO.Engine.Domain; - -namespace TeamsISO.Engine.Discovery; - -/// -/// Maintains the operator-facing participant list, applying the rename heuristic -/// from the v1 spec: if a participant source disappears and another participant -/// source with the same MachineName appears within , -/// the existing transfers to the new source. -/// -public sealed class ParticipantTracker -{ - private readonly TimeSpan _renameWindow; - private readonly Func _now; - private readonly List _participants = new(); - private readonly List _recentlyRemoved = new(); - - public ParticipantTracker(TimeSpan renameWindow, Func now) - { - _renameWindow = renameWindow; - _now = now; - } - - public IReadOnlyList Participants => - _participants.Select(m => m.ToRecord()).ToList(); - - public void Apply(DiscoveryEvent ev) - { - switch (ev) - { - case DiscoveryEvent.Added a when a.Source.Kind == NdiSourceKind.Participant: - HandleAdded(a.Source); - break; - case DiscoveryEvent.Removed r when r.Source.Kind == NdiSourceKind.Participant: - HandleRemoved(r.Source); - break; - } - } - - private void HandleAdded(NdiSource source) - { - var now = _now(); - PruneRecentlyRemoved(now); - - var match = _recentlyRemoved.FirstOrDefault(rr => rr.MachineName == source.MachineName); - if (match is not null) - { - // Transfer identity - var existing = _participants.First(p => p.Id == match.Id); - existing.DisplayName = source.DisplayName!; - existing.CurrentSource = source; - existing.LastSeen = now; - _recentlyRemoved.Remove(match); - return; - } - - _participants.Add(new MutableParticipant( - Id: Guid.NewGuid(), - DisplayName: source.DisplayName!, - CurrentSource: source, - FirstSeen: now, - LastSeen: now)); - } - - private void HandleRemoved(NdiSource source) - { - var now = _now(); - var existing = _participants.FirstOrDefault(p => - p.CurrentSource is not null && p.CurrentSource.FullName == source.FullName); - if (existing is null) - return; - - existing.CurrentSource = null; - _recentlyRemoved.Add(new RecentlyRemoved(existing.Id, source.MachineName, now)); - } - - private void PruneRecentlyRemoved(DateTimeOffset now) - { - _recentlyRemoved.RemoveAll(rr => now - rr.RemovedAt > _renameWindow); - } - - private sealed class MutableParticipant - { - public Guid Id { get; init; } - public string DisplayName { get; set; } - public NdiSource? CurrentSource { get; set; } - public DateTimeOffset FirstSeen { get; init; } - public DateTimeOffset LastSeen { get; set; } - - public MutableParticipant(Guid Id, string DisplayName, NdiSource? CurrentSource, - DateTimeOffset FirstSeen, DateTimeOffset LastSeen) - { - this.Id = Id; - this.DisplayName = DisplayName; - this.CurrentSource = CurrentSource; - this.FirstSeen = FirstSeen; - this.LastSeen = LastSeen; - } - - public Participant ToRecord() => - new(Id, DisplayName, CurrentSource, FirstSeen, LastSeen); - } - - private sealed record RecentlyRemoved(Guid Id, string MachineName, DateTimeOffset RemovedAt); -} -``` - -- [ ] **Step 4: Run tests, expect pass** - -Run: -``` -dotnet test --filter "FullyQualifiedName~ParticipantTrackerTests" -``` -Expected: 6 pass. - -- [ ] **Step 5: Commit** - -```bash -git add src/TeamsISO.Engine/Discovery/ParticipantTracker.cs src/tests/TeamsISO.Engine.Tests/Discovery/ParticipantTrackerTests.cs -git commit -m "feat(discovery): add ParticipantTracker with rename heuristic" -git push origin main -``` - ---- - -## Task 18: `NdiDiscoveryService` (TDD against `FakeNdiInterop`) - -**Files:** -- Create: `src/TeamsISO.Engine/Discovery/NdiDiscoveryService.cs` -- Create: `src/tests/TeamsISO.Engine.Tests/Discovery/NdiDiscoveryServiceTests.cs` - -The service polls `INdiInterop.GetCurrentSources` on a background loop and emits `DiscoveryEvent`s on a channel. We test by stepping the loop manually rather than running the background thread. - -- [ ] **Step 1: Write the failing tests** - -Create `src/tests/TeamsISO.Engine.Tests/Discovery/NdiDiscoveryServiceTests.cs`: - -```csharp -using System.Threading.Channels; -using Microsoft.Extensions.Logging.Abstractions; -using TeamsISO.Engine.Discovery; -using TeamsISO.Engine.Domain; -using TeamsISO.Engine.Tests.Fakes; - -namespace TeamsISO.Engine.Tests.Discovery; - -public class NdiDiscoveryServiceTests -{ - [Fact] - public void PollOnce_AddsNewParticipantSources_AndIgnoresMalformedStrings() - { - var interop = new FakeNdiInterop(); - interop.Sources.Add("PC1 (Teams - Jane)"); - interop.Sources.Add("PC1 (Teams)"); - interop.Sources.Add("Just A Camera"); // not a Teams source - interop.Sources.Add("BAD (Teams - )"); // empty display name - var channel = Channel.CreateUnbounded(); - - var svc = new NdiDiscoveryService(interop, channel.Writer, NullLogger.Instance); - - svc.PollOnce(); - - var emitted = DrainChannel(channel.Reader); - emitted.OfType().Select(a => a.Source.FullName) - .Should().BeEquivalentTo(new[] { "PC1 (Teams - Jane)", "PC1 (Teams)" }); - } - - [Fact] - public void PollOnce_EmitsRemoved_WhenSourceDisappears() - { - var interop = new FakeNdiInterop(); - interop.Sources.Add("PC1 (Teams - Jane)"); - var channel = Channel.CreateUnbounded(); - - var svc = new NdiDiscoveryService(interop, channel.Writer, NullLogger.Instance); - svc.PollOnce(); - DrainChannel(channel.Reader); // first poll: Added - - interop.Sources.Clear(); - svc.PollOnce(); - - var emitted = DrainChannel(channel.Reader); - emitted.OfType().Select(r => r.Source.FullName) - .Should().BeEquivalentTo(new[] { "PC1 (Teams - Jane)" }); - } - - [Fact] - public void PollOnce_NoChange_EmitsNothing() - { - var interop = new FakeNdiInterop(); - interop.Sources.Add("PC1 (Teams - Jane)"); - var channel = Channel.CreateUnbounded(); - - var svc = new NdiDiscoveryService(interop, channel.Writer, NullLogger.Instance); - svc.PollOnce(); DrainChannel(channel.Reader); - - svc.PollOnce(); - - DrainChannel(channel.Reader).Should().BeEmpty(); - } - - private static List DrainChannel(ChannelReader reader) - { - var list = new List(); - while (reader.TryRead(out var ev)) list.Add(ev); - return list; - } -} -``` - -- [ ] **Step 2: Run tests, expect failure** - -Run: -``` -dotnet test --filter "FullyQualifiedName~NdiDiscoveryServiceTests" -``` -Expected: compilation errors (`NdiDiscoveryService` missing). - -- [ ] **Step 3: Implement `NdiDiscoveryService`** - -Create `src/TeamsISO.Engine/Discovery/NdiDiscoveryService.cs`: - -```csharp -using System.Threading.Channels; -using Microsoft.Extensions.Logging; -using TeamsISO.Engine.Domain; -using TeamsISO.Engine.Interop; - -namespace TeamsISO.Engine.Discovery; - -/// -/// Polls at a fixed cadence, diffs the -/// resulting set against the previous poll, and emits s -/// on a channel for downstream consumers. -/// -public sealed class NdiDiscoveryService -{ - private readonly INdiInterop _interop; - private readonly ChannelWriter _writer; - private readonly ILogger _logger; - private readonly NdiFindHandle _finder; - private readonly HashSet _previous = new(); - - public NdiDiscoveryService( - INdiInterop interop, - ChannelWriter writer, - ILogger logger) - { - _interop = interop; - _writer = writer; - _logger = logger; - _finder = interop.CreateFinder(); - } - - /// - /// Runs a single poll cycle. Public for unit testing; production uses . - /// - public void PollOnce() - { - var current = _interop.GetCurrentSources(_finder); - var currentSet = new HashSet(current); - - // Added - foreach (var name in currentSet.Except(_previous)) - { - var parsed = NdiSourceParser.Parse(name); - if (parsed is null) - { - _logger.LogTrace("Ignoring unrecognized source: {Name}", name); - continue; - } - _writer.TryWrite(new DiscoveryEvent.Added(parsed)); - } - - // Removed - foreach (var name in _previous.Except(currentSet)) - { - var parsed = NdiSourceParser.Parse(name); - if (parsed is null) continue; - _writer.TryWrite(new DiscoveryEvent.Removed(parsed)); - } - - _previous.Clear(); - foreach (var name in currentSet) _previous.Add(name); - } - - /// Long-running poll loop. Cancel the token to stop. - public async Task RunAsync(TimeSpan pollInterval, CancellationToken cancellationToken) - { - using var timer = new PeriodicTimer(pollInterval); - try - { - while (await timer.WaitForNextTickAsync(cancellationToken)) - { - try { PollOnce(); } - catch (Exception ex) { _logger.LogWarning(ex, "Discovery poll failed; will retry on next tick."); } - } - } - catch (OperationCanceledException) { /* expected */ } - finally - { - _finder.Dispose(); - } - } -} -``` - -- [ ] **Step 4: Run tests, expect pass** - -Run: -``` -dotnet test --filter "FullyQualifiedName~NdiDiscoveryServiceTests" -``` -Expected: 3 pass. - -- [ ] **Step 5: Commit** - -```bash -git add src/TeamsISO.Engine/Discovery/NdiDiscoveryService.cs src/tests/TeamsISO.Engine.Tests/Discovery/NdiDiscoveryServiceTests.cs -git commit -m "feat(discovery): add NdiDiscoveryService with diff-based event emission" -git push origin main -``` - ---- - -## Task 19: `SolidFrameRenderer` (no-signal slate) - -**Files:** -- Create: `src/TeamsISO.Engine/Pipeline/SolidFrameRenderer.cs` -- Create: `src/tests/TeamsISO.Engine.Tests/Pipeline/SolidFrameRendererTests.cs` - -- [ ] **Step 1: Write the failing test** - -Create `src/tests/TeamsISO.Engine.Tests/Pipeline/SolidFrameRendererTests.cs`: - -```csharp -using TeamsISO.Engine.Pipeline; - -namespace TeamsISO.Engine.Tests.Pipeline; - -public class SolidFrameRendererTests -{ - [Fact] - public void Render_ProducesBgraFrameOfTargetSize_FilledWithColor() - { - var renderer = new SolidFrameRenderer(); - var frame = renderer.Render(width: 1920, height: 1080, b: 0x80, g: 0x80, r: 0x80, a: 0xFF, timestampTicks: 12345); - - frame.Width.Should().Be(1920); - frame.Height.Should().Be(1080); - frame.Format.Should().Be(PixelFormat.Bgra); - frame.Pixels.Length.Should().Be(1920 * 1080 * 4); - frame.TimestampTicks.Should().Be(12345); - - // Spot-check first and last pixel - var span = frame.Pixels.Span; - span[0].Should().Be(0x80); span[1].Should().Be(0x80); span[2].Should().Be(0x80); span[3].Should().Be(0xFF); - var last = span.Length - 4; - span[last].Should().Be(0x80); span[last + 3].Should().Be(0xFF); - } -} -``` - -- [ ] **Step 2: Run tests, expect failure** - -Run: -``` -dotnet test --filter "FullyQualifiedName~SolidFrameRendererTests" -``` -Expected: compilation error. - -- [ ] **Step 3: Implement** - -Create `src/TeamsISO.Engine/Pipeline/SolidFrameRenderer.cs`: - -```csharp -namespace TeamsISO.Engine.Pipeline; - -/// -/// Generates a solid-color BGRA frame for use as a "no signal" slate. -/// -public sealed class SolidFrameRenderer -{ - public ProcessedFrame Render(int width, int height, byte b, byte g, byte r, byte a, long timestampTicks) - { - var pixels = new byte[width * height * 4]; - for (var i = 0; i < pixels.Length; i += 4) - { - pixels[i + 0] = b; - pixels[i + 1] = g; - pixels[i + 2] = r; - pixels[i + 3] = a; - } - return new ProcessedFrame(width, height, timestampTicks, pixels, PixelFormat.Bgra); - } -} -``` - -- [ ] **Step 4: Run tests, expect pass** - -Run: -``` -dotnet test --filter "FullyQualifiedName~SolidFrameRendererTests" -``` -Expected: 1 pass. - -- [ ] **Step 5: Commit** - -```bash -git add src/TeamsISO.Engine/Pipeline/SolidFrameRenderer.cs src/tests/TeamsISO.Engine.Tests/Pipeline/SolidFrameRendererTests.cs -git commit -m "feat(pipeline): add SolidFrameRenderer for no-signal slate" -git push origin main -``` - ---- - -## Task 20: `IFrameScaler` interface and identity scaler - -We separate the scaling step behind an interface so Phase A doesn't need libyuv. The Phase A scaler is "identity" — copy and emit at the requested size by truncating or padding. This lets us write the FrameProcessor's timing tests without the real libyuv dependency. Phase B replaces the implementation with a real libyuv-backed scaler. - -**Files:** -- Create: `src/TeamsISO.Engine/Pipeline/IFrameScaler.cs` -- Create: `src/TeamsISO.Engine/Pipeline/PassthroughFrameScaler.cs` - -- [ ] **Step 1: Define the interface** - -Create `src/TeamsISO.Engine/Pipeline/IFrameScaler.cs`: - -```csharp -using TeamsISO.Engine.Domain; - -namespace TeamsISO.Engine.Pipeline; - -public interface IFrameScaler -{ - ProcessedFrame Scale(RawFrame source, int targetWidth, int targetHeight, AspectMode aspect, long timestampTicks); -} -``` - -- [ ] **Step 2: Implement the passthrough scaler (Phase A)** - -Create `src/TeamsISO.Engine/Pipeline/PassthroughFrameScaler.cs`: - -```csharp -using TeamsISO.Engine.Domain; - -namespace TeamsISO.Engine.Pipeline; - -/// -/// Phase A scaler. Copies the source frame's pixel buffer through unchanged and tags the -/// output with the requested target dimensions. Real scaling is added in Phase B against libyuv. -/// -public sealed class PassthroughFrameScaler : IFrameScaler -{ - public ProcessedFrame Scale(RawFrame source, int targetWidth, int targetHeight, AspectMode aspect, long timestampTicks) - { - return new ProcessedFrame( - Width: targetWidth, - Height: targetHeight, - TimestampTicks: timestampTicks, - Pixels: source.Pixels, - Format: source.Format == PixelFormat.Bgra ? PixelFormat.Bgra : PixelFormat.Bgra); - } -} -``` - -- [ ] **Step 3: Build** - -Run: -``` -dotnet build TeamsISO.sln -``` -Expected: success. - -- [ ] **Step 4: Commit** - -```bash -git add src/TeamsISO.Engine/Pipeline/IFrameScaler.cs src/TeamsISO.Engine/Pipeline/PassthroughFrameScaler.cs -git commit -m "feat(pipeline): add IFrameScaler interface and PassthroughFrameScaler (Phase A)" -git push origin main -``` - ---- - -## Task 21: `FrameProcessor` timing logic (TDD against `FakeFrameClock`) - -This is the heart of Phase A's behavior coverage: closest-frame timing, frame duplication, and the slate fallback. - -**Files:** -- Create: `src/TeamsISO.Engine/Pipeline/FrameProcessor.cs` -- Create: `src/tests/TeamsISO.Engine.Tests/Pipeline/FrameProcessorTests.cs` - -- [ ] **Step 1: Write the failing tests** - -Create `src/tests/TeamsISO.Engine.Tests/Pipeline/FrameProcessorTests.cs`: - -```csharp -using System.Threading.Channels; -using Microsoft.Extensions.Logging.Abstractions; -using TeamsISO.Engine.Domain; -using TeamsISO.Engine.Pipeline; -using TeamsISO.Engine.Tests.Fakes; - -namespace TeamsISO.Engine.Tests.Pipeline; - -public class FrameProcessorTests -{ - private static readonly FrameProcessingSettings Settings1080p30 = - new(TargetFramerate.Fps30, TargetResolution.R1080p, AspectMode.Pillarbox, AudioMode.Auto); - - private static RawFrame MakeFrame(int width, int height, long ts) => - new(width, height, ts, new byte[width * height * 4], PixelFormat.Bgra); - - private static FrameProcessor NewProcessor( - FakeFrameClock clock, - Channel input, - Channel output, - FrameProcessingSettings? settings = null) - => new( - settings: settings ?? Settings1080p30, - scaler: new PassthroughFrameScaler(), - slateRenderer: new SolidFrameRenderer(), - clock: clock, - input: input.Reader, - output: output.Writer, - slateThreshold: TimeSpan.FromSeconds(2.5), - logger: NullLogger.Instance); - - [Fact] - public async Task ProcessOnce_NewFrameAvailable_EmitsScaledFrame() - { - var clock = new FakeFrameClock(); - var input = Channel.CreateBounded(4); - var output = Channel.CreateUnbounded(); - var proc = NewProcessor(clock, input, output); - - input.Writer.TryWrite(MakeFrame(640, 360, ts: 100)); - clock.Advance(TimeSpan.FromMilliseconds(34)); // 1 tick at ~30fps - await proc.ProcessOnceAsync(CancellationToken.None); - - output.Reader.TryRead(out var frame).Should().BeTrue(); - frame!.Width.Should().Be(1920); - frame.Height.Should().Be(1080); - } - - [Fact] - public async Task ProcessOnce_NoNewFrame_ReEmitsLastFrame() - { - var clock = new FakeFrameClock(); - var input = Channel.CreateBounded(4); - var output = Channel.CreateUnbounded(); - var proc = NewProcessor(clock, input, output); - - input.Writer.TryWrite(MakeFrame(640, 360, ts: 100)); - clock.Advance(TimeSpan.FromMilliseconds(34)); - await proc.ProcessOnceAsync(CancellationToken.None); - output.Reader.TryRead(out _).Should().BeTrue(); - - // Second tick, no new input - clock.Advance(TimeSpan.FromMilliseconds(34)); - await proc.ProcessOnceAsync(CancellationToken.None); - - output.Reader.TryRead(out var second).Should().BeTrue(); - second!.Width.Should().Be(1920); - proc.Stats.FramesDuplicated.Should().Be(1); - } - - [Fact] - public async Task ProcessOnce_NoFrameForLongerThanSlateThreshold_EmitsSlate() - { - var clock = new FakeFrameClock(); - var input = Channel.CreateBounded(4); - var output = Channel.CreateUnbounded(); - var proc = NewProcessor(clock, input, output); - - input.Writer.TryWrite(MakeFrame(640, 360, ts: 100)); - clock.Advance(TimeSpan.FromMilliseconds(34)); - await proc.ProcessOnceAsync(CancellationToken.None); - output.Reader.TryRead(out _); - - // Advance 3 seconds without input - clock.Advance(TimeSpan.FromSeconds(3)); - await proc.ProcessOnceAsync(CancellationToken.None); - - output.Reader.TryRead(out var slate).Should().BeTrue(); - slate!.Width.Should().Be(1920); - slate.Height.Should().Be(1080); - // First pixel is mid-grey BGRA - slate.Pixels.Span[0].Should().Be(0x80); - } - - [Fact] - public async Task ProcessOnce_PicksNewestFrame_DropsOlder() - { - var clock = new FakeFrameClock(); - var input = Channel.CreateBounded(4); - var output = Channel.CreateUnbounded(); - var proc = NewProcessor(clock, input, output); - - // Three frames queued before the tick fires - input.Writer.TryWrite(MakeFrame(640, 360, ts: 100)); - input.Writer.TryWrite(MakeFrame(640, 360, ts: 200)); - input.Writer.TryWrite(MakeFrame(640, 360, ts: 300)); - clock.Advance(TimeSpan.FromMilliseconds(34)); - await proc.ProcessOnceAsync(CancellationToken.None); - - proc.Stats.FramesIn.Should().Be(3); - proc.Stats.FramesDropped.Should().Be(2); // we kept ts=300, dropped ts=100 and ts=200 - } -} -``` - -- [ ] **Step 2: Run tests, expect failure** - -Run: -``` -dotnet test --filter "FullyQualifiedName~FrameProcessorTests" -``` -Expected: compilation errors (`FrameProcessor` missing). - -- [ ] **Step 3: Implement `FrameProcessor`** - -Create `src/TeamsISO.Engine/Pipeline/FrameProcessor.cs`: - -```csharp -using System.Threading.Channels; -using Microsoft.Extensions.Logging; -using TeamsISO.Engine.Domain; - -namespace TeamsISO.Engine.Pipeline; - -/// -/// Per-ISO frame timing engine. Implements closest-frame strategy: at each tick, -/// pick the newest available raw frame (dropping older queued frames), scale and emit it. -/// If no new frame is available, re-emit the last frame. If no frame has arrived for -/// , emit a no-signal slate instead. -/// -public sealed class FrameProcessor -{ - private readonly FrameProcessingSettings _settings; - private readonly IFrameScaler _scaler; - private readonly SolidFrameRenderer _slateRenderer; - private readonly IFrameClock _clock; - private readonly ChannelReader _input; - private readonly ChannelWriter _output; - private readonly TimeSpan _slateThreshold; - private readonly ILogger _logger; - - private RawFrame? _lastRawFrame; - private long _lastFrameTickTicks; - private long _framesIn; - private long _framesOut; - private long _framesDropped; - private long _framesDuplicated; - private long _framesSlated; - - public FrameProcessor( - FrameProcessingSettings settings, - IFrameScaler scaler, - SolidFrameRenderer slateRenderer, - IFrameClock clock, - ChannelReader input, - ChannelWriter output, - TimeSpan slateThreshold, - ILogger logger) - { - _settings = settings; - _scaler = scaler; - _slateRenderer = slateRenderer; - _clock = clock; - _input = input; - _output = output; - _slateThreshold = slateThreshold; - _logger = logger; - } - - public IsoHealthStats Stats => - new( - FramesIn: Interlocked.Read(ref _framesIn), - FramesOut: Interlocked.Read(ref _framesOut), - FramesDropped: Interlocked.Read(ref _framesDropped), - FramesDuplicated: Interlocked.Read(ref _framesDuplicated), - LastFrameAt: _lastFrameTickTicks == 0 ? null : new DateTimeOffset(_lastFrameTickTicks, TimeSpan.Zero), - IncomingFps: 0, // computed downstream from FramesIn delta over time - IncomingWidth: _lastRawFrame?.Width ?? 0, - IncomingHeight: _lastRawFrame?.Height ?? 0); - - public Task ProcessOnceAsync(CancellationToken cancellationToken) - { - // Drain the input channel non-blockingly, keeping only the newest frame. - RawFrame? newest = null; - while (_input.TryRead(out var frame)) - { - if (newest is not null) - Interlocked.Increment(ref _framesDropped); - newest = frame; - Interlocked.Increment(ref _framesIn); - } - - var (targetW, targetH) = _settings.ResolutionSize; - var nowTicks = _clock.NowTicks; - - ProcessedFrame toEmit; - if (newest is not null) - { - _lastRawFrame = newest; - _lastFrameTickTicks = nowTicks; - toEmit = _scaler.Scale(newest, targetW, targetH, _settings.Aspect, nowTicks); - } - else if (_lastRawFrame is not null && (nowTicks - _lastFrameTickTicks) <= _slateThreshold.Ticks) - { - Interlocked.Increment(ref _framesDuplicated); - toEmit = _scaler.Scale(_lastRawFrame, targetW, targetH, _settings.Aspect, nowTicks); - } - else - { - Interlocked.Increment(ref _framesSlated); - toEmit = _slateRenderer.Render(targetW, targetH, b: 0x80, g: 0x80, r: 0x80, a: 0xFF, nowTicks); - } - - Interlocked.Increment(ref _framesOut); - _output.TryWrite(toEmit); - return Task.CompletedTask; - } -} -``` - -- [ ] **Step 4: Run tests, expect pass** - -Run: -``` -dotnet test --filter "FullyQualifiedName~FrameProcessorTests" -``` -Expected: 4 pass. - -- [ ] **Step 5: Commit** - -```bash -git add src/TeamsISO.Engine/Pipeline/FrameProcessor.cs src/tests/TeamsISO.Engine.Tests/Pipeline/FrameProcessorTests.cs -git commit -m "feat(pipeline): add FrameProcessor with closest-frame timing and slate fallback" -git push origin main -``` - ---- - -## Task 22: Coverage threshold gate in CI - -We promised 80% line coverage on `TeamsISO.Engine`. Wire that into the CI workflow so a regression caught in PR. - -**Files:** -- Modify: `.forgejo/workflows/ci.yml` -- Create: `coverlet.runsettings` - -- [ ] **Step 1: Add a `coverlet.runsettings`** - -Create `coverlet.runsettings`: - -```xml - - - - - - - cobertura,opencover - [*.Tests]*,[*.NdiInterop]*,[*.IntegrationTests]* - GeneratedCodeAttribute,CompilerGeneratedAttribute - - - - - -``` - -- [ ] **Step 2: Update `.forgejo/workflows/ci.yml`** - -Replace the test step in `.forgejo/workflows/ci.yml` with: - -```yaml - - name: Test (excluding requires=ndi) - run: > - dotnet test TeamsISO.sln - --configuration Release - --no-build - --logger "trx;LogFileName=test-results.trx" - --collect:"XPlat Code Coverage" - --settings coverlet.runsettings - --filter "Category!=ndi&requires!=ndi" - - - name: Install ReportGenerator - run: dotnet tool install --global dotnet-reportgenerator-globaltool - - - name: Generate coverage report - run: | - export PATH="$PATH:/root/.dotnet/tools:$HOME/.dotnet/tools" - reportgenerator \ - -reports:"**/coverage.cobertura.xml" \ - -targetdir:coverage-report \ - -reporttypes:"Cobertura;TextSummary" \ - -assemblyfilters:"+TeamsISO.Engine;-TeamsISO.Engine.NdiInterop" - - - name: Enforce coverage threshold (80%) - run: | - summary=$(cat coverage-report/Summary.txt) - echo "$summary" - line_coverage=$(echo "$summary" | awk '/Line coverage/ {print $3}' | tr -d '%') - echo "Line coverage: $line_coverage%" - awk -v c="$line_coverage" 'BEGIN { if (c+0 < 80) { exit 1 } }' - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v4 - with: - name: coverage-report - path: coverage-report/ -``` - -- [ ] **Step 3: Commit** - -```bash -git add coverlet.runsettings .forgejo/workflows/ci.yml -git commit -m "ci: enforce 80% line coverage gate on TeamsISO.Engine" -git push origin main -``` - -- [ ] **Step 4: Verify CI passes** - -Open `https://forge.wilddragon.net/zgaetano/teamsiso/actions`. Confirm the latest run is green and the coverage step reports ≥80% on `TeamsISO.Engine`. - ---- - -## Task 23: Logging bootstrap - -Provide a tiny, self-contained way for engine consumers to wire `Microsoft.Extensions.Logging` quickly. Phase A ships a console-only configuration so the smoke runner in Phase B has logs out of the box. - -**Files:** -- Create: `src/TeamsISO.Engine/Logging/EngineLogging.cs` - -- [ ] **Step 1: Add Serilog packages** - -Run: -``` -cd src/TeamsISO.Engine -dotnet add package Serilog --version 4.0.0 -dotnet add package Serilog.Extensions.Logging --version 8.0.0 -dotnet add package Serilog.Sinks.Console --version 6.0.0 -cd ../.. -``` - -- [ ] **Step 2: Implement the helper** - -Create `src/TeamsISO.Engine/Logging/EngineLogging.cs`: - -```csharp -using Microsoft.Extensions.Logging; -using Serilog; -using Serilog.Extensions.Logging; - -namespace TeamsISO.Engine.Logging; - -/// -/// Convenience factory for an wired to Serilog's console sink. -/// Phase A wires console-only; Phase C will add the rolling-file sink under %APPDATA%\TeamsISO\logs\. -/// -public static class EngineLogging -{ - public static ILoggerFactory CreateConsole(LogLevel minimum = LogLevel.Information) - { - var serilog = new LoggerConfiguration() - .MinimumLevel.Is(MapLevel(minimum)) - .Enrich.WithProperty("Component", "TeamsISO.Engine") - .WriteTo.Console(outputTemplate: - "[{Timestamp:HH:mm:ss} {Level:u3}] [{Component}] {Message:lj}{NewLine}{Exception}") - .CreateLogger(); - return new SerilogLoggerFactory(serilog, dispose: true); - } - - private static Serilog.Events.LogEventLevel MapLevel(LogLevel level) => level switch - { - LogLevel.Trace => Serilog.Events.LogEventLevel.Verbose, - LogLevel.Debug => Serilog.Events.LogEventLevel.Debug, - LogLevel.Information => Serilog.Events.LogEventLevel.Information, - LogLevel.Warning => Serilog.Events.LogEventLevel.Warning, - LogLevel.Error => Serilog.Events.LogEventLevel.Error, - LogLevel.Critical => Serilog.Events.LogEventLevel.Fatal, - _ => Serilog.Events.LogEventLevel.Information - }; -} -``` - -- [ ] **Step 3: Build** - -Run: -``` -dotnet build TeamsISO.sln -``` -Expected: success. - -- [ ] **Step 4: Commit** - -```bash -git add src/TeamsISO.Engine/Logging/EngineLogging.cs src/TeamsISO.Engine/TeamsISO.Engine.csproj -git commit -m "feat(logging): add EngineLogging.CreateConsole helper" -git push origin main -``` - ---- - -## Task 24: Phase A wrap-up — release tag and follow-up plan stubs - -**Files:** -- Create: `docs/test-playbook.md` -- Create: `docs/superpowers/plans/_NEXT.md` - -- [ ] **Step 1: Add the (Phase A) test playbook stub** - -Create `docs/test-playbook.md`: - -```markdown -# TeamsISO Manual Test Playbook - -This doc grows with each phase. Phase A is unit-test only — nothing to verify against live Teams yet. Phase B will fill in NDI runtime checks; Phase C will add the live-meeting end-to-end checklist. - -## Pre-checks (run before each release branch) - -- [ ] `dotnet build TeamsISO.sln` succeeds with zero warnings. -- [ ] `dotnet test TeamsISO.sln` reports all unit tests passing. -- [ ] CI run on `main` is green. - -## Live-meeting checklist (Phase C) - -(To be added.) -``` - -- [ ] **Step 2: Add a `_NEXT.md` index** - -Create `docs/superpowers/plans/_NEXT.md`: - -```markdown -# Plan Backlog - -Phase A is implemented (this file's siblings). After Phase A merges and CI is green: - -1. **Phase B — NDI Interop & Pipeline** — add real P/Invoke shim in `TeamsISO.Engine.NdiInterop`, real `IFrameScaler` against libyuv, `NdiReceiver` and `NdiSender`, `IsoPipeline`, `IsoController`, runtime version probe. Console smoke runner. Integration test suite goes live (Windows + NDI runtime required). -2. **Phase C — UI & Packaging** — WPF MVVM app on top of the engine. Settings view, participant list, alert banner, system health indicators. WiX MSI installer, release pipeline on tag, About dialog. - -Each phase gets its own `YYYY-MM-DD-teamsiso-phase-X-.md` plan written by `superpowers:writing-plans` once the previous phase is shipped. -``` - -- [ ] **Step 3: Commit** - -```bash -git add docs/test-playbook.md docs/superpowers/plans/_NEXT.md -git commit -m "docs: add Phase A test playbook stub and plan backlog" -git push origin main -``` - -- [ ] **Step 4: Tag the Phase A milestone** - -Run: -``` -git tag -a phase-a-complete -m "Phase A: Engine foundation complete (domain, parser, tracker, processor, config, fakes, CI)" -git push origin phase-a-complete -``` - -- [ ] **Step 5: Final verification** - -Run: -``` -dotnet build TeamsISO.sln -dotnet test TeamsISO.sln -``` -Expected: build succeeds; all unit tests pass; integration test scaffold reports 1 skipped. CI on `main` is green at HEAD with coverage ≥80%. - -Phase A is done. Phase B starts when you're ready — open a fresh chat and ask for a "phase B plan" referencing the spec and `_NEXT.md`. - ---- - -## Self-review (run by the author of the plan) - -**1. Spec coverage** — every spec section maps to at least one task: - -- Spec §2 Architecture (six-project layout, engine/UI split): Tasks 2–6. -- Spec §3 Domain model: Tasks 8, 10, 11. -- Spec §4 Components — `NdiDiscoveryService`, `ParticipantTracker`: Tasks 17, 18. `FrameProcessor`: Task 21. `ConfigStore`: Task 12. `SolidFrameRenderer`: Task 19. Logging: Task 23. The remaining components — `IsoPipeline`, `NdiReceiver`, `NdiSender`, `IsoController` — are in Phase B by design (they require real interop or are pure orchestration over interop-touching components). -- Spec §5 Data flow & threading: covered for the processor's logic in Task 21; Phase B will assemble the full pipeline with dedicated capture/send threads. -- Spec §6 Error handling: `ConfigStore` corruption (Task 12) and slate fallback (Task 21). Pipeline isolation, restart backoff, and runtime probe alerts move to Phase B because they're orchestration concerns over real interop. -- Spec §7 Testing: Tasks 8, 9, 12, 17–19, 21 build the unit test suite; Task 22 enforces coverage gate. Integration test project scaffolded (Task 6); real integration tests added in Phase B alongside the production interop. -- Spec §8 Build & packaging: solution layout (Tasks 2–6), CI (Tasks 7, 22). MSI installer is Phase C. -- Spec §9 Open tasks: tracked in `_NEXT.md` (Task 24). - -**2. Placeholder scan** — searched for "TBD", "TODO", "implement later". None remain in the plan body. Each step has full code or full command. - -**3. Type consistency** — cross-checked names: `NdiSourceKind` (used in Tasks 8, 9), `IsoState` (Tasks 8, 10), `NdiSource.FullName/MachineName/Kind/DisplayName` (Tasks 9, 17, 18 consistent), `Participant.Id/DisplayName/CurrentSource/FirstSeen/LastSeen` (Tasks 10, 17 consistent), `IsoHealthStats` field set (Tasks 10, 21 consistent — `FramesIn/FramesOut/FramesDropped/FramesDuplicated/LastFrameAt/IncomingFps/IncomingWidth/IncomingHeight`), `INdiInterop` method shape (Tasks 13, 15, 18 consistent), `IFrameClock.NowTicks/WaitForNextTickAsync` (Tasks 14, 15, 21 consistent), `FrameProcessor.ProcessOnceAsync` signature (Task 21 internally consistent and matches test invocations). - -**4. Ambiguity check** — each task names exact file paths, exact commands, and exact expected output. Tasks declared "Phase A scope" explicitly defer items the spec mentions but that depend on real NDI runtime (interop implementation, IsoPipeline orchestration, runtime probe alerts). - -No issues to fix. Plan ready for execution. diff --git a/docs/superpowers/plans/2026-05-07-teamsiso-phase-b-1-pipeline-orchestration.md b/docs/superpowers/plans/2026-05-07-teamsiso-phase-b-1-pipeline-orchestration.md deleted file mode 100644 index 5d642d4..0000000 --- a/docs/superpowers/plans/2026-05-07-teamsiso-phase-b-1-pipeline-orchestration.md +++ /dev/null @@ -1,167 +0,0 @@ -# TeamsISO Phase B-1 — Pipeline Orchestration Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Implement the engine-side pipeline orchestration on top of the `INdiInterop` test seam from Phase A — `NdiReceiver`, `NdiSender`, `ExponentialBackoff`, `NdiRuntimeProbe`, `IsoPipeline` (lifecycle + restart loop), and `IsoController` (top-level engine API). All testable on Linux against `FakeNdiInterop`. Phase B-2 (real Windows P/Invoke for `INdiInterop` + libyuv `IFrameScaler` + integration tests) follows. - -**Architecture:** Pure orchestration. Each `IsoPipeline` wires one `NdiReceiver` → existing `FrameProcessor` → one `NdiSender` via two bounded channels. The pipeline owns a restart loop driven by `ExponentialBackoff`. `IsoController` is the top of the engine — holds the pipeline dictionary, the `ParticipantTracker`, the `ConfigStore`, and exposes the contract the WPF host (Phase C) will bind to. - -**Tech Stack:** .NET 8, xUnit, FluentAssertions. No new external dependencies. - -**Source spec:** `docs/superpowers/specs/2026-05-07-teamsiso-v1-design.md` - ---- - -## File structure additions - -``` -src/TeamsISO.Engine/ -├── Pipeline/ -│ ├── NdiReceiver.cs (NEW) -│ ├── NdiSender.cs (NEW) -│ ├── ExponentialBackoff.cs (NEW) -│ ├── IsoPipeline.cs (NEW) -│ └── IsoPipelineConfig.cs (NEW) -├── Interop/ -│ └── NdiRuntimeProbe.cs (NEW) -└── Controller/ - ├── IIsoController.cs (NEW) - └── IsoController.cs (NEW) - -src/tests/TeamsISO.Engine.Tests/ -├── Pipeline/NdiReceiverTests.cs (NEW) -├── Pipeline/NdiSenderTests.cs (NEW) -├── Pipeline/ExponentialBackoffTests.cs (NEW) -├── Pipeline/IsoPipelineTests.cs (NEW) -├── Interop/NdiRuntimeProbeTests.cs (NEW) -└── Controller/IsoControllerTests.cs (NEW) -``` - ---- - -## Task 1: `NdiReceiver` - -Receiver that wraps `INdiInterop.CaptureFrame` and pushes results into a `ChannelWriter`. Exposes a `CaptureOnce` test seam mirroring `FrameProcessor.ProcessOnceAsync`. `RunAsync` is the production loop with `LongRunning` thread semantics. - -TDD assertions: -- `CaptureOnce` writes a captured frame to the output channel; counter increments. -- `CaptureOnce` does nothing on null capture (timeout); counter does not change. -- `RunAsync` honors cancellation and disposes the receiver handle on exit. - -Commit: `feat(pipeline): add NdiReceiver with channel-based output` - ---- - -## Task 2: `NdiSender` - -Sender that pulls from a `ChannelReader` and forwards to `INdiInterop.SendFrame`. `SendNextAsync` returns true if a frame was sent; false if the channel completed. `RunAsync` loops until cancellation. - -TDD assertions: -- `SendNextAsync` forwards a frame to the interop and increments the sent counter. -- Returns `false` when channel completes. -- `RunAsync` honors cancellation and disposes the sender handle. - -Commit: `feat(pipeline): add NdiSender with channel-based input` - ---- - -## Task 3: `ExponentialBackoff` - -Pure policy type. Given an attempt count, returns the next delay (1, 2, 4, 8, 16 s, capped at 30 s) and decides whether to give up after N consecutive failures (default 5). - -TDD assertions: -- Sequence at attempts 1..5 is 1, 2, 4, 8, 16 seconds. -- `ShouldGiveUp` returns true after the 5th attempt. -- Cap: at attempt 7 the delay is 30 s, not 64. - -Commit: `feat(pipeline): add ExponentialBackoff policy` - ---- - -## Task 4: `NdiRuntimeProbe` - -Reads the runtime version via `INdiInterop.GetRuntimeVersion()`, compares to an expected value (passed in by the engine for now; a real comparison against the SDK headers is Phase B-2). Returns either `Match` or `Mismatch` with both versions populated. The `IsoController` will surface `EngineAlert.NdiRuntimeMismatch` from a mismatch. - -TDD assertions: -- Match when versions equal. -- Mismatch carries detected and expected. - -Commit: `feat(interop): add NdiRuntimeProbe with version-mismatch result` - ---- - -## Task 5: `IsoPipeline` core lifecycle - -Owns one `NdiReceiver`, one `FrameProcessor`, one `NdiSender`, and the two channels between them. `StartAsync` creates the channels, instantiates the receiver/processor/sender, kicks off the three loops on long-running tasks. `StopAsync` cancels the token, awaits the loops, and disposes everything. - -`IsoState` transitions: `Idle` → `Receiving` (after start) → `Sending` (after first send) → `NoSignal` (handled by FrameProcessor's slate path and exposed via Stats). On exception the loop transitions to `Error`. - -The restart loop is in Task 6. - -TDD assertions: -- Start transitions Idle → Receiving. -- Stop transitions back to Idle and disposes interop handles. -- Receiver/sender handles are created on Start, disposed on Stop. - -Commit: `feat(pipeline): add IsoPipeline core lifecycle` - ---- - -## Task 6: `IsoPipeline` restart loop - -Wraps the running pipeline in a supervisory loop that catches unhandled exceptions, applies `ExponentialBackoff`, and either restarts or transitions to `Error` after exhausting retries. State observable updates accordingly. - -TDD assertions (using a fault-injecting INdiInterop): -- Pipeline that fails once, then runs cleanly, restarts and ends up Sending. -- Pipeline that fails 5+ consecutive times transitions to Error and stays there. -- Backoff delays are honored (using a fake delay primitive for fast tests). - -Commit: `feat(pipeline): add IsoPipeline restart supervisor with backoff` - ---- - -## Task 7: `IIsoController` interface + `IsoController` implementation - -The top-of-engine API the WPF host will bind to in Phase C. - -Surface: -- `IObservable> Participants { get; }` -- `IObservable Alerts { get; }` -- `IsoHealthStats GetStats(Guid participantId)` -- `Task EnableIsoAsync(Guid participantId, string? customName, CancellationToken ct)` -- `Task DisableIsoAsync(Guid participantId, CancellationToken ct)` -- `Task SetGlobalSettingsAsync(FrameProcessingSettings settings, CancellationToken ct)` - -Implementation owns: `ParticipantTracker`, `NdiDiscoveryService`, dictionary of `IsoPipeline`, the `ConfigStore`, the runtime probe. - -TDD assertions: -- `EnableIsoAsync` creates and starts a pipeline; `DisableIsoAsync` stops and removes it. -- `SetGlobalSettingsAsync` persists via ConfigStore and applies to existing pipelines. -- Discovery events flow through to the participants observable. -- `NdiRuntimeProbe` mismatch surfaces an alert. - -Commit: `feat(controller): add IIsoController and IsoController implementation` - ---- - -## Task 8: Wrap-up & milestone tag - -- Run full test suite, confirm all green. -- Confirm coverage threshold still ≥80%. -- Update `docs/superpowers/plans/_NEXT.md` to describe Phase B-2 (Windows-only). -- Tag `phase-b-1-complete`. - -Commit: `chore: phase-b-1 milestone wrap-up` -Tag: `phase-b-1-complete` - ---- - -## Self-review - -**Spec coverage:** Spec §4 components NdiReceiver, NdiSender, IsoPipeline, IsoController — Tasks 1, 2, 5, 6, 7. Spec §6 error handling restart/backoff — Task 6. Spec §6 NDI runtime mismatch — Task 4 + Task 7. ConfigStore integration in IsoController — Task 7. - -**Phase B-2 (deferred):** Real `NdiInteropPInvoke` shim, real `LibYuvFrameScaler`, console smoke runner, integration tests against NDI Test Pattern source. All require Windows + NDI runtime so they live in their own plan. - -**Type consistency:** All new types reference Phase A types unchanged. `INdiInterop` surface is sufficient — no additions needed. - -No issues to fix. Ready to execute. diff --git a/docs/superpowers/plans/2026-05-07-teamsiso-phase-b-2-ndi-interop.md b/docs/superpowers/plans/2026-05-07-teamsiso-phase-b-2-ndi-interop.md deleted file mode 100644 index 02604da..0000000 --- a/docs/superpowers/plans/2026-05-07-teamsiso-phase-b-2-ndi-interop.md +++ /dev/null @@ -1,30 +0,0 @@ -# TeamsISO Phase B-2 — Real NDI Interop Plan - -**Goal:** Production `INdiInterop` implementation in `TeamsISO.Engine.NdiInterop` against NDI SDK 6, a managed BGRA scaler with aspect modes, an NDI version constant, and a `TeamsISO.Console` headless smoke runner that wires up the engine end-to-end. After this phase the engine can drive real Teams NDI streams once run on a Windows box with the NDI runtime installed. - -**Architecture:** P/Invoke against `Processing.NDI.Lib.x64.dll`. Frame marshalling translates NDI's `video_frame_v2_t` to/from our managed `RawFrame`/`ProcessedFrame`. Receive in BGRA color space (`NDIlib_recv_color_format_e_BGRX_BGRA`) so the scaler doesn't need to handle UYVY in v1.0. Memory management: every captured frame is freed via `NDIlib_recv_free_video_v2` once we've copied its pixels into a managed buffer. - -**Tech Stack:** .NET 8, `System.Runtime.InteropServices`, plain C# scaler (managed BGRA nearest-neighbor; libyuv is a v1.5 perf optimization). The console runner uses the existing `EngineLogging.CreateConsole`. - -**Source spec:** `docs/superpowers/specs/2026-05-07-teamsiso-v1-design.md` - -## Tasks - -1. **NDI native bindings:** `NdiNative.cs` with all `[DllImport]` declarations needed (`initialize`, `destroy`, `find_create_v2/destroy/get_current_sources`, `recv_create_v3/destroy/capture_v3/free_video_v2`, `send_create/destroy/send_video_v2`, `version`). Define `NDIlib_video_frame_v2_t`, `NDIlib_source_t`, `NDIlib_recv_create_v3_t`, `NDIlib_send_create_t` structs with explicit layout. -2. **Handles:** `NdiPInvokeFindHandle`, `NdiPInvokeReceiverHandle`, `NdiPInvokeSenderHandle` deriving from the abstract Phase A handles, owning the unmanaged pointers. -3. **NdiInteropPInvoke:** the production `INdiInterop` implementation. Initializes NDI on construction; destroys on dispose. Marshals between native and managed frame structs. Allocates managed pixel buffers and copies; frees the native frame immediately. -4. **NdiVersion:** a constants class exposing the version string the engine probe compares against. -5. **ManagedNearestNeighborFrameScaler:** managed BGRA scaler with `Pillarbox`, `Letterbox`, `Stretch` aspect modes. Fully unit-tested. -6. **TeamsISO.Console:** a small console host. Constructs `IsoController` against `NdiInteropPInvoke` + `ManagedNearestNeighborFrameScaler`, prints participant updates, listens for `q\n` to quit. Useful for headless validation. -7. **Wire-up tests:** integration scaffold uses `RuntimeInformation.IsOSPlatform(OSPlatform.Windows)` to skip cleanly on non-Windows. Add a smoke integration test that constructs the interop and probes the version. -8. **Wrap-up:** tag `phase-b-2-complete`. - -## What this phase intentionally does NOT include - -- libyuv-backed scaler (deferred to v1.5 per spec — managed scaler is functionally complete). -- Actual integration test suite running against an NDI Test Pattern source. Those tests need the NDI runtime; they're authored here but stay tagged `requires=ndi` and skip in the Linux CI. -- Audio handling (passthrough video only in this phase; audio support added later if v1.0 needs it before ship). - -## Self-review - -Spec coverage: §4 NdiReceiver/NdiSender/IsoController already done in B-1; this phase fills in the actual NDI SDK calls under `INdiInterop`. §6 startup preflight via `NdiVersion` + the existing `NdiRuntimeProbe`. §8 console smoke runner is a Phase B-2 deliverable for first end-to-end Windows validation before WPF. diff --git a/docs/superpowers/plans/2026-05-07-teamsiso-phase-c-wpf-ui.md b/docs/superpowers/plans/2026-05-07-teamsiso-phase-c-wpf-ui.md deleted file mode 100644 index 1dbff3c..0000000 --- a/docs/superpowers/plans/2026-05-07-teamsiso-phase-c-wpf-ui.md +++ /dev/null @@ -1,43 +0,0 @@ -# TeamsISO Phase C — WPF MVVM UI Plan - -**Goal:** Operator-facing WPF UI bound to `IIsoController`. Displays the live participant list, lets operators enable/disable per-participant ISO outputs, set the global framerate / resolution / aspect / audio mode, view engine alerts, and see basic system health. Plus a WiX MSI installer and a release CI pipeline. - -**Architecture:** MVVM with no third-party MVVM framework — small managed `ObservableObject` and `RelayCommand` helpers. The view models bind directly to `IIsoController`'s observables. UI runs on the WPF dispatcher; observable subscriptions marshal back via a captured `SynchronizationContext`. App.xaml.cs constructs the engine on startup and disposes on exit. - -**Tech stack:** WPF on .NET 8, MVVM hand-rolled, no external UI library yet (MaterialDesignThemes can be added in a polish pass). - -## File structure additions - -``` -src/TeamsISO.App/ -├── App.xaml / App.xaml.cs (DI bootstrap) -├── MainWindow.xaml / MainWindow.xaml.cs -├── ViewModels/ -│ ├── ObservableObject.cs -│ ├── RelayCommand.cs -│ ├── MainViewModel.cs -│ ├── ParticipantViewModel.cs -│ ├── GlobalSettingsViewModel.cs -│ └── AlertBannerViewModel.cs -├── Converters/ -│ ├── BoolToVisibilityConverter.cs -│ └── EnumDescriptionConverter.cs -└── TeamsISO.App.csproj - -src/TeamsISO.Installer/ -└── TeamsISO.Installer.wixproj (MSI installer; v5) -``` - -## Tasks - -1. **MVVM helpers** — `ObservableObject` base implementing `INotifyPropertyChanged`; `RelayCommand` and `AsyncRelayCommand`. -2. **GlobalSettingsViewModel** — exposes Framerate, Resolution, Aspect, Audio as bindable selected values; `Apply` command calls `controller.SetGlobalSettingsAsync`. -3. **ParticipantViewModel** — wraps a `Participant`, exposes IsEnabled, CustomOutputName, and current status; `EnableCommand` and `DisableCommand` call the controller. -4. **AlertBannerViewModel** — collects `EngineAlert`s and exposes the most recent one with a "dismiss" command. -5. **MainViewModel** — top-level. Owns the controller. Exposes `ObservableCollection`, the settings VM, and the banner VM. -6. **MainWindow.xaml** — DataGrid for participants with toggle column, settings panel docked to the right, alert banner docked top. -7. **Converters** — bool→visibility, enum→display string. -8. **App.xaml.cs** — wires DI: build engine + controller + main view model, set MainWindow's DataContext, dispose on exit. -9. **WiX installer (Phase C-2)** — separate task; can ship after the UI is alive. - -Each step ships as its own commit. Tag `phase-c-complete` after MainWindow renders and the controller is bound. diff --git a/docs/superpowers/plans/_NEXT.md b/docs/superpowers/plans/_NEXT.md deleted file mode 100644 index 8053f1f..0000000 --- a/docs/superpowers/plans/_NEXT.md +++ /dev/null @@ -1,183 +0,0 @@ -# Plan Backlog - -## Completed phases - -- **Phase A — Engine Foundation** (tag: `phase-a-complete`) — domain model, parsers, participant tracker, frame processor, config, fakes, CI gate. -- **Phase B-1 — Pipeline Orchestration** (tag: `phase-b-1-complete`) — NdiReceiver, NdiSender, ExponentialBackoff, NdiRuntimeProbe, IsoPipeline supervisor, IsoController. -- **Phase B-2 — Real NDI Interop** (tag: `phase-b-2-complete`) — `NdiInteropPInvoke` against NDI 6 SDK, managed BGRA scaler, `TeamsISO.Console` headless smoke runner, `NdiVersion` constants. -- **Phase C — WPF UI** (tag: `phase-c-complete`) — MVVM helpers, ParticipantViewModel, GlobalSettingsViewModel, AlertBannerViewModel, MainViewModel, MainWindow XAML with participants DataGrid + settings sidebar + alert banner, App.xaml DI bootstrap. -- **Hardening + brand pass — May 2026** — see "Done since the May 2026 hand-off" below. -- **Phase D — WiX Installer & Forgejo release** — WiX v5 MSI scaffold, ARP icon wired, tag-push release workflow that builds + uploads MSI as a release asset. - -## Done since the May 2026 hand-off - -### Engine -- Forward-slash project paths in `TeamsISO.sln` so `.slnf` filters work on Windows MSBuild. -- `NdiNativeLibraryResolver` resolves `Processing.NDI.Lib.x64.dll` via `NDI_RUNTIME_DIR_V6` (with V5 / V4 fallbacks), so the engine starts on installs where the NDI dir isn't on PATH. -- `NdiVersion.ExpectedRuntimeVersionPrefix` updated to match the shipping NDI 6 banner format (`NDI SDK WIN64 …`). -- `NdiSourceParser` accepts current Teams desktop's `MS Teams - ` brand format (plus legacy `Teams` and defensive `Microsoft Teams`); reserved suffixes (`Active Speaker`, `Audio`, `Audio Mix`, `Screen Share`) are recognized in both legacy and dash-prefixed forms. -- NDI **groups** end-to-end (discovery + output): `INdiInterop.CreateFinder(string?)` and `CreateSender(string, string?)` populate `p_groups`; `IsoController` threads them through from `EngineConfig.NdiGroups`. -- `ParticipantTracker` surfaces `NdiSourceKind.ActiveSpeaker` as a synthetic routable row named "Active Speaker" with a deterministic v5-GUID Id derived from `auto-mix:`. -- `IsoHealthStats` wired end-to-end: live receiver/sender/processor refs published from the inner pipeline, frame counters / source resolution / running FPS (30-frame moving window) / drops + duplicates / pipeline state surfaced via `IsoController.GetStats`. -- Rolling daily file logging at `%LOCALAPPDATA%\TeamsISO\Logs\` via Serilog.Sinks.File. - -### UI -- WPF rebuilt around Wild Dragon brand × Microsoft Teams flush layout — left rail with real dragon-mark logo (clickable → About dialog), chromeless title bar with custom min/max/close caption controls, cyan accent. -- Inter Variable + JetBrains Mono Variable bundled as `` so typography matches wilddragon.net regardless of system fonts. -- App icon `teamsiso.ico` (7 sizes) on taskbar / window / About / WiX MSI ARP. -- Single-instance enforcement via per-user named Mutex with broadcast bring-to-front. -- Empty-state placeholder when no Teams sources are visible (faded dragon + checklist). -- Live frame counters in the Source / Live columns (in/out/drops, source resolution, running FPS). -- Per-pipeline state surfaced in the ISO toggle: `● LIVE` (cyan), `● ERROR` (coral), `● NO SIGNAL` (amber), `…` (processing). -- "Stop all ISOs" emergency button at the participants header. -- Hide-(Local) toggle so the user's own self-preview is filtered from the participants list. -- Window position / size / state persisted to `%LOCALAPPDATA%\TeamsISO\window.json`, multi-monitor safe. -- Tooltips on every interactive control in the settings panel + per-row textbox + ISO toggle. -- Toast feedback for settings actions (Apply / Apply Transcoder Topology / Stop All / Auto-disable). -- **Auto-disable on participant departure** (configurable, off by default): when a participant's NDI source disappears the engine tears down their pipeline; the toggle lives in `DISPLAY` settings. -- **Operator presets**: chromeless `Presets…` dialog from the participants header. Saves the current per-participant `IsEnabled` + `CustomName` set keyed by display name to `%LOCALAPPDATA%\TeamsISO\presets.json` (atomic write, schema-versioned). Apply walks the live participants and reconciles via `EnableIsoAsync` / `DisableIsoAsync`; participants in the preset who aren't in the current meeting are reported in the toast. -- **Auto-apply last preset on launch**: opt-in checkbox in `DISPLAY` settings. After the operator's first manual Apply, every subsequent TeamsISO launch silently re-applies the same preset once participants populate (30-second grace window before applying with whoever's online). State lives in `presets.json` next to the preset list. -- **Refresh discovery** affordance: header pill that rebuilds the underlying NDI finder on the next poll tick. `IIsoController.RefreshDiscovery` flips a flag the discovery loop honors before the next tick — old finder disposed, new finder created, seen-set cleared so all currently-visible sources re-fire as Added. `ParticipantTracker.HandleAdded` is idempotent: re-emitting the same FullName refreshes LastSeen rather than minting a duplicate row. -- **Settings tabs**: the settings sidebar is now a TabControl with `OUTPUT` / `NETWORK` / `DISPLAY` tabs and a single Apply Changes button below. Underline-on-active tab style lives in `WildDragonTheme.xaml` (`Wd.TabControl` + `Wd.TabItem`). -- **Crash diagnostics**: `App.OnStartup` wires `AppDomain.UnhandledException`, `Application.DispatcherUnhandledException`, and `TaskScheduler.UnobservedTaskException` into a unified Serilog.Critical log line + user-facing dialog that points at the log directory. Dispatcher exceptions are marked `Handled = true` so a single bad UI thunk doesn't take the app down; AppDomain crashes are terminal but at least the user gets the log path before exit. -- **First-launch onboarding**: chromeless welcome dialog walks users through the once-per-machine setup (NDI runtime, Teams admin permission, transcoder topology, presets, log location). Suppressed after dismissal via marker file at `%LOCALAPPDATA%\TeamsISO\onboarding.flag`. Re-openable from the About dialog via "Show welcome" button. -- **Reset output to defaults**: ghost button at the bottom of the OUTPUT settings tab restores framerate / resolution / aspect / audio to `FrameProcessingSettings.Default` after confirmation. Doesn't touch NDI groups (sticky per-machine) or display toggles. -- **Per-output recording**: `IRecorderSink` interface + `RawBgraRecorderSink` implementation. When the operator enables "Record ISOs to disk" in the DISPLAY tab, each newly-enabled ISO writes its normalized output to `//video.bgra` plus a sidecar `manifest.json` (width / height / fps / frame counts) and a `convert.cmd` one-liner that pipes the raw stream into FFmpeg to produce a final H.264 `output.mkv`. Recorder runs on its own bounded queue (240-frame `DropOldest` buffer) so disk pressure never blocks the live ISO; recorder failures are caught and ignored at the channel-write layer for the same reason. Already-running ISOs are not retroactively captured — operator disables + re-enables to start recording. Recording can be wired to a real-time H.264 encoder later via Vortice.MediaFoundation; the `IRecorderSink` interface is designed to swap implementations without touching the pipeline. -- **REST control surface**: `ControlSurfaceServer` — `System.Net.HttpListener` on `127.0.0.1:9755` (configurable). Endpoints for participant ISO toggle (by Id or display name), refresh discovery, stop-all, recording on/off, preset apply, and Teams in-call commands (mute / camera / share / leave / raise-hand). Off by default; toggle in the DISPLAY tab. Bitfocus Companion / Stream Deck plugins / OSC bridges drive it. Documented at `docs/CONTROL-SURFACE.md`. -- **PresetApplier**: extracted from `PresetsDialog.OnApply`. Single source of truth for "apply this preset to live participants" — used by the dialog, by `MainViewModel.TryAutoApplyPendingPreset` (auto-apply on launch), and by the REST `POST /presets/{name}/apply` endpoint. Marshals UI-bound writes (CustomName / IsEnabled) through an optional Dispatcher so off-thread callers don't crash WPF. -- **In-app preview thumbnails**: 160×90 WriteableBitmap per participant, fed from the engine's most recent `ProcessedFrame` at the existing 1Hz stats tick. Inline nearest-neighbor scaler in `ParticipantViewModel.UpdateThumbnail` writes directly into the bitmap's pinned BackBuffer (unsafe block, `true` in the .csproj) for ~10× perf vs. going through Span. Falls back to a `—` placeholder card when no pipeline is running. New "Preview" column in the participants DataGrid. -- **WebSocket live state push**: `ws://127.0.0.1:9755/ws` — clients connect, receive a participants snapshot immediately, and get fresh snapshots within 250ms whenever state changes. Snapshot diffing on JSON string keeps the wire quiet during steady-state. Used by Stream Deck / Companion buttons that want to light up when an ISO goes LIVE without polling. -- **OSC bridge over UDP**: `OscBridge` listens on `127.0.0.1:9000` (TouchOSC's default). Same command vocabulary as the REST endpoints — `/teamsiso/iso "Jane" 1`, `/teamsiso/preset "Friday Show"`, `/teamsiso/teams/mute`, etc. Minimal OSC 1.0 parser (int / float / string / T / F type tags; no bundles). TouchOSC layouts and Companion's Generic OSC surface can both drive it directly. -- **Manual update check**: "Check for updates" button in the About dialog. Asks `forge.wilddragon.net/api/v1/repos/zgaetano/teamsiso/releases?limit=1`, compares the newest tag's SemVer to the running version, prompts to open the releases page if newer. Manual only — no background polling for v1 so a long-running show doesn't get interrupted by a surprise installer. -- **Auto-update banner on launch**: opt-in (default on) silent check throttled to once per 24h via `%LOCALAPPDATA%\TeamsISO\last-update-check.txt`. When a newer release is found, a non-modal banner appears above the body with "Get update" / "Dismiss" buttons. Suppression via flag file at `no-update-check.flag` for fleets that prefer central rollout. New `UpdateBannerViewModel` distinct from the engine alert banner. -- **Preset import / export**: Export / Import buttons in the Presets dialog footer, backed by `OperatorPresetStore.ExportAllAsJson` / `ImportBundle`. Bundle format is `teamsiso-presets-bundle/v1` JSON. On name collision the importer asks once (Overwrite/Keep/Cancel) rather than per-preset; deliberately doesn't include the operator's `LastAppliedName` / `AutoApplyOnStartup` since those are machine-local. -- **Recording markers**: `IRecorderSink.AddMarker(label)` plus `IIsoController.AddRecordingMarker(label)` fan-out to every active recorder. Surfaced via "Marker" button in the IN-CALL bar (auto-labels with timestamp), `POST /recording/marker` in the REST surface, and `/teamsiso/recording/marker "Label"` in OSC. Markers land in `manifest.json` under `markers[]` with `offsetMs` + `label` fields for post-production chaptering. -- **Custom NDI output name template**: `OutputNameTemplate` static helper persisted to `output-name-template.txt` with `{name}` / `{guid}` / `{machine}` / `{timestamp}` tokens. Default `TEAMSISO_{guid}` preserves the engine's hard-coded behavior; operator can switch to `TEAMSISO_{name}` for human-readable downstream switcher names. UI editor in the NETWORK settings tab. -- **Enriched footer status bar**: rec badge (coral dot + count) when at least one ISO is being recorded; control-surface badge (cyan dot + "REST :9755 + OSC :9000") when those services are running. Computed at the existing 1Hz stats tick from `IIsoController.RecordingEnabled` × running pipeline count and `App.ControlSurface.IsRunning` / `App.OscBridge.IsRunning`. -- **Disk space watcher**: `DiskSpaceWatcher` polls the recording drive every 5s while recording is on. Coral toast at <10GB free; auto-disables recording at <1GB so an unattended long show doesn't crash the host on disk-full. -- **Diagnostic bundle export**: "Export diagnostics" button in About zips logs + config + presets + window state + version metadata into a `teamsiso-diagnostics-.zip` in `~/Downloads`. Excludes screenshots / memory dumps; only files the user already wrote. -- **Per-participant recording opt-out**: new `Rec` column in the DataGrid lets the operator choose which ISOs get recorded when global recording is on. `IIsoController.EnableIsoAsync` gained an optional `bool? recordOverride` parameter — null = follow global flag, true = force on, false = force off. -- **Window-scoped keyboard shortcuts**: F1 (help), Ctrl+M (drop marker), Ctrl+Shift+S (stop all), Ctrl+R (refresh discovery). InputBindings on MainWindow → MainViewModel commands; F1 opens the new `HelpWindow` cheat sheet. -- **Help cheat sheet**: chromeless `HelpWindow` lists keyboard shortcuts, file locations (`%LOCALAPPDATA%\TeamsISO\Logs\`, `%APPDATA%\TeamsISO\config.json`, etc.), and links to the public docs. Reduces support friction. -- **Bulk enable**: header `Enable all` button (green dot) enables ISOs for every online + non-enabled participant. Per-participant best-effort with a count toast. -- **Live participant filter**: textbox above the DataGrid filters by display-name substring as you type. Backed by an `ICollectionView` Filter callback so the underlying `ObservableCollection` isn't mutated (preserving identity-tracking). -- **Right-click context menu** on participant rows: Toggle ISO, toggle Record-this-participant, Copy NDI source name to clipboard. Uses the existing per-row commands so the menu is just another binding surface. -- **CLI: `--apply-preset NAME`**: launch-time flag that auto-applies the named preset once participants populate. Same code path as the persisted auto-apply preference. Useful for `Friday Show.lnk` desktop shortcuts that drive recurring routings. -- **Dynamic status text**: footer's center text now reads "3/5 ISOs live · 2 recording" once routing starts, instead of the static "Engine running at X fps target." Composed in `OnStatsTick` from running participant + recording counts. -- **Embedded HTML control panel** at `GET /ui`: self-contained ~6KB page with WebSocket-driven live state and buttons for the common control actions. Open in a phone or second-monitor browser to drive TeamsISO without context-switching from the show. No external dependencies, no build step. -- **Session timer** in footer: shows `MM:SS` (or `HH:MM:SS` past an hour) elapsed since the first ISO went live this session. Resets when all ISOs go offline. Green dot indicator for at-a-glance status. -- **Show notes service**: `POST /notes` and `/teamsiso/notes "..."` (OSC) append timestamped lines to `%LOCALAPPDATA%\TeamsISO\Notes\.md`. Operators wire a Stream Deck button to drop notes during a live show without leaving the production app. Markdown format renders cleanly in any editor. -- **NotesWindow inline viewer**: chromeless dialog that displays today's notes file with 2s polling so REST/OSC-driven appends surface live. "Notes" button in the IN-CALL bar. -- **Duplicate-preset action**: "Duplicate" footer button in the Presets dialog. Custom inline prompt suggests ` (copy)` / `(copy 2)` / etc. names. -- **CHANGELOG.md**: project-wide changelog following keep-a-changelog format. Captures the full May 2026 batch under `[Unreleased]`. -- **README rewrite**: top-level README now lists what TeamsISO does, build instructions, doc links, keyboard shortcuts table, file-locations table. -- **Confirm-before-Stop-All**: stop-all button now requires Yes confirmation, preventing accidental mid-show clicks. Default-No so Enter cancels. - -### Networking automation -- One-click **transcoder topology** button in Settings: writes `%APPDATA%\NDI\ndi-config.v1.json` so all local senders broadcast on `teamsiso-input` and local receivers see both `public` + `teamsiso-input`. Engine settings auto-flip to receive-from `teamsiso-input` and emit-on `public`. Atomic write with timestamped backup of the prior config. - -### Phase E — embedded Teams orchestration -Spec at `docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md`. All three sub-phases shipped in May 2026: -- **E.1 — Launcher.** Rail "Launch / Stop Teams" toggle: launches via `ms-teams:` URI → `ms-teams.exe` → classic `Update.exe --processStart`, asks to confirm `WM_CLOSE` of all running Teams windows when toggled while Teams is up. -- **E.2 — Window orchestration.** Rail eye-icon button hides every visible top-level Teams window via `EnumWindows` + `ShowWindow(SW_HIDE)`. Click again to restore + foreground. Lets the operator drive Teams from TeamsISO without ever seeing the Teams UI. -- **E.3 — In-call controls.** UIAutomation-driven Mute / Camera / Share / Leave buttons in a new `IN-CALL` card at the top of the participants area. `TeamsControlBridge` walks Teams' automation tree by candidate Name list (`Mute`, `Unmute`, `Microphone`, `Toggle mute` …) and tries Invoke or Toggle pattern. Tolerant lookup: when a Teams update renames a button we extend the candidate list, no crash. Toasts reflect the four outcomes (Invoked / TeamsNotRunning / ControlNotFound / InvokeFailed). Bridge also exposes (UI-not-yet-wired) `ToggleRaiseHand`, `ToggleChat`, `OpenBackgroundEffects`. Candidate name lists localized for English/German/Spanish/French/Portuguese/Japanese — all locales matched in a single pass; the first match wins. -- **PostMessage shortcut forwarding fallback.** `TeamsLauncher.SendShortcut(modifiers, vk)` posts WM_KEYDOWN/UP to the most-recently-used hidden Teams HWND. Best-effort — modern WebView2-hosted Teams sometimes ignores synthesized key messages at the app-shortcut layer; UIA is preferred when a button exists for the action. - -### Diagnostics -- `TeamsISO.Console --list-sources` enumerates raw NDI source names visible to the local finder for ~5 seconds; debugging tool for setup issues. -- `TeamsISO.Console --version` prints engine version + build SHA + .NET + OS + NDI runtime banner + exit-code legend, for support tickets. -- About dialog inside the WPF host with the same info. - -### CI / Release / Docs -- Forgejo CI is green: `actions/upload-artifact@v3` (Forgejo doesn't support v4 yet). -- `.forgejo/workflows/release.yml`: tag-push (`v*.*.*`) builds + tests + publishes + builds the MSI on a Windows runner and attaches it to the auto-created Forgejo release via the REST API. -- **Optional code-signing** wired into `release.yml`: when `SIGN_CERT_PFX_BASE64` + `SIGN_CERT_PASSWORD` Forgejo secrets are set, the workflow signs both `TeamsISO.exe` (before MSI build) and the MSI (after) with SHA-256 + RFC 3161 timestamp. Skipped silently when the cert isn't configured. `docs/RELEASING.md` documents the OV vs EV trade-offs and the Azure Trusted Signing migration path. - -### Tests -- 78 unit tests passing; 9 NDI integration tests gated behind `--filter requires=ndi` (runtime probe, finder + sender lifecycle on default and custom groups, loopback discovery, full pipeline frame round-trip asserting 1080p normalization). - -## Done since May 10 hand-off - -### Engine -- **Audio peak metering wired end-to-end.** `IsoHealthStats.PeakAudioLevel` - now reports real values from a sibling NDI audio capture loop in - `NdiReceiver`. New `INdiInterop.CaptureAudioPeak` method (default- - implemented for FakeNdiInterop, overridden in NdiInteropPInvoke). - `AudioPeakComputer` handles FLTP / FLT / PCM s16 with 14 unit tests - covering edge cases. UI VU bars in the participants DataGrid now - animate; the existing decay logic in `ParticipantViewModel` was - already in place waiting for real values. - -### Control surface -- **LAN-reachable mode.** New checkbox in DISPLAY tab toggles whether the - REST/WebSocket surface and OSC bridge bind to `127.0.0.1` only or to all - interfaces (`http://+:port/`, `IPAddress.Any`). Settings panel surfaces - the routable URL with a Copy button (picker prefers physical NICs and - skips Tailscale / VPN tunnels / APIPA addresses). Use case: headless - host PC + thin client on the same LAN — operator runs Teams + TeamsISO - on a quiet machine, drives it from anywhere on the production network. - No auth — documented as a trusted-LAN-only mode. First-time bind - requires a one-shot `netsh http add urlacl`; the diagnostic warning - fires the exact remediation command if the bind fails. - -### "I only see TeamsISO" — Phase E.1+E.2 follow-ups -- **Launch + auto-hide Teams** preferences in DISPLAY tab. Teams runs in the - background; window appears briefly then hides automatically; operator - drives everything from the IN-CALL bar + participants DataGrid. -- **Quick-join from URL** in the IN-CALL bar. Paste a Teams meeting link, - click Join, Teams launches into the meeting. Eliminates the open-Teams - → Calendar → find → click join dance. -- **Teams meeting state pill** — `IN CALL · ` / `READY` / - empty. UIA probe at 1Hz for the Leave button; meeting title from - Teams' window title with the brand suffix stripped. -- **Launch Teams click semantics** — left-click = launch / surface / restore; - right-click = stop. Was previously ambushing operators with a stop-Teams - dialog when Teams was hidden via the eye-toggle. -- **Auto-record on meeting start** preference. Recording auto-flips ON when - Teams transitions into a call (UIA Leave button appears) and OFF when - the call ends — completes the unattended-show story. -- **MUTED / CAM OFF pills** in the IN-CALL bar via UIA — local-user state - visible at a glance without restoring Teams. -- **Phase E.4 (experimental) — Teams window embedding via SetParent.** - Reparents Teams' main window into a TeamsISO-owned host so Teams appears - visually INSIDE TeamsISO. WebView2 in modern Teams may render glitches - after reparent; if so operator unticks and falls back to auto-hide mode. - Live in `TeamsEmbedWindow` + `TeamsLauncher.EmbedTeamsInto` / `RestoreEmbed`. -- **Loudest sort mode** + **active speaker row highlight** (3px cyan left - border) — operators react to who's talking without scanning every VU bar. -- **NumPad 1-9 hotkeys** toggle Nth visible participant's ISO. Generic - `RelayCommand` added so XAML CommandParameter strings convert cleanly. -- **Snapshot frame to PNG** (per-participant via right-click + bulk header - action). Saves under `%USERPROFILE%\Pictures\TeamsISO\`. -- **Recording drive free space** in the footer (`· 245 GB free`). Coral - tint below 10GB; existing DiskSpaceWatcher still auto-disables at 1GB. -- **Recording elapsed duration** in the footer next to the count - (`REC 3 · 12:45`). -- **Quick-join meeting URL** + **IN-CALL pill with meeting title** for the - headless workflow — paste link, click Join, see what meeting you're in. - -### UI polish -- Visible hover affordances on every themed button (Ghost / Caption / - RailIcon / IsoToggle / Primary). Cyan accent borders + brighter fills - so mouse-hover and tab-focus give an unmistakable affordance regardless - of which dark surface the button sits on. -- Keyboard focus rings (`IsKeyboardFocused` triggers) so tab-cycling - through the UI gives visual feedback (was nothing — `FocusVisualStyle` - was `x:Null` with no replacement). -- ScrollBar restyled to slim transparent track + tinted thumb (Edge / VS - Code pattern) in place of the chunky Win9x default. -- ContextMenu / MenuItem styled to match the dark canvas — right-click on - a participant row no longer shows the cream-colored Notepad popup. -- ToolTip restyled: SurfaceElevated card with rounded corner + 320px - text wrap, replacing the cream Win98 popup. -- Wd.Button.Primary disabled state distinct (was identical to enabled). - -## Next - -1. **Smoke-test on real Teams.** Most of May's work hasn't run against a live meeting yet: the UIA in-call commands (mute / camera / share / leave) need their candidate-Name lists validated against the current Teams build, and the auto-apply-on-launch flow needs a real recurring meeting to confirm the 30-second grace window is right. Pin the AutomationIds for buttons we find — Name-based lookup is a starting point, AutomationId is what survives Teams UI updates. Now also includes: validate the audio peak metering against real Teams audio (check that FLTP decoding is correct for whatever sample rate Teams is broadcasting; the `--filter requires=ndi` integration tests don't exercise audio). - -2. **Acquire a code-signing cert.** Pipeline is wired (see "CI / Release / Docs" above); just needs `SIGN_CERT_PFX_BASE64` + `SIGN_CERT_PASSWORD` set in Forgejo Secrets. OV cert (~$200/yr) gets us signed but SmartScreen builds reputation slowly; EV cert (~$300/yr, hardware token) is SmartScreen-trusted immediately. Azure Trusted Signing is the cloud-native path if a token-on-runner is fiddly. - -3. **Port MediaFoundationRecorderSink to Vortice 3.6.2 API.** NuGet package added but the May 9 scaffold targeted an older Vortice API. Port pass needed before `MF_AVAILABLE` can be defined; see `docs/REAL-TIME-RECORDING.md` "Status — May 2026" section for the specific API gaps (MFVersion / MF_LOW_LATENCY / IMFMediaType setters / IMFMediaBuffer.Lock signature / IMFSinkWriter.Finalize_ rename). Once ported, gives ~10× recording disk-pressure reduction. - -4. **Forward Teams keyboard shortcuts via SendInput.** Phase E.2 hides the Teams window but doesn't forward Ctrl+Shift+M / Ctrl+Shift+O / Ctrl+Shift+H to it. UIA covers mute/camera/share/leave/raise-hand/chat/background already; SendInput would let us pass arbitrary global hotkeys through to a hidden Teams for actions UIA can't reach. Lower priority now that UIA covers the core actions. diff --git a/docs/superpowers/specs/2026-05-07-teamsiso-v1-design.md b/docs/superpowers/specs/2026-05-07-teamsiso-v1-design.md deleted file mode 100644 index 89f55e1..0000000 --- a/docs/superpowers/specs/2026-05-07-teamsiso-v1-design.md +++ /dev/null @@ -1,213 +0,0 @@ -# TeamsISO v1.0 — Implementation Spec - -**Status:** Draft, ready for plan-writing -**Date:** 2026-05-07 -**Owner:** Zac Gaetano (Wild Dragon LLC) -**Source design doc:** `TeamsISO Design Document.docx` v0.1 DRAFT (May 2026) - -This spec turns the source design document into an implementable plan for the v1.0 release. The product vision, problem statement, and feature matrix in the source document remain authoritative; this spec adds the architectural and operational decisions needed to start building. - -## 1. Scope - -v1.0 ships the feature set in §6 of the source document, exactly as written: - -- NDI participant discovery (auto) -- Per-participant ISO NDI output -- Global framerate lock (23.976 / 24 / 25 / 29.97 / 30 / 50 / 59.94 / 60 fps) -- Global resolution normalize (720p / 1080p / 4K) -- Custom output stream naming -- Isolated audio per ISO with mixed-audio fallback -- Screen share as ISO output - -Deferred to v1.5: per-stream framerate override, thumbnail previews, GPU-accelerated scaling. -Deferred to v2.0: multi-machine cluster coordination, OSC/WebSocket control API. - -Out of scope for v1.0: automatic peer discovery between TeamsISO instances, audio resampling, code signing of the installer. - -## 2. Architecture - -**Pattern:** engine/UI separation from day one. The NDI engine is a class library with no UI dependency; the WPF app is a thin host that binds to the engine through a typed C# API. v2.0's control APIs and multi-machine coordinator drop in cleanly because the boundary already exists. - -**Solution layout:** - -- `TeamsISO.Engine` — class library. Discovery, receive, frame processing, send, configuration, logging abstraction. Exposes `IIsoController` and observable streams. Owns all threading. -- `TeamsISO.Engine.NdiInterop` — internal P/Invoke shim for `NDIlib_*` and libyuv. Kept separate so the rest of the engine speaks managed types and unit tests can fake the interop surface. -- `TeamsISO.App` — WPF + MVVM host. Instantiates the engine, binds view models to engine observables, persists window layout. Zero NDI knowledge. -- `TeamsISO.Engine.Tests` — xUnit unit tests against `FakeNdiInterop`. Pure managed. -- `TeamsISO.Engine.IntegrationTests` — xUnit integration tests against the real NDI runtime. Tagged `[Trait("requires", "ndi")]`. -- `TeamsISO.Installer` — WiX v5 project producing the MSI. - -**Engine ↔ App contract:** `IIsoController` exposes `IObservable>`, `IObservable` per output, `IObservable`, and async command methods (`EnableIsoAsync`, `SetTargetFramerate`, `SetCustomName`, `SetGlobalSettings`, etc.). All commands are cancellable. - -## 3. Domain model - -Defined in `TeamsISO.Engine.Domain`. All types are immutable records unless noted. - -- **`NdiSource`** — raw discovery record. `string FullName`, parsed `MachineName`, `Kind` (`Participant | ActiveSpeaker | Audio | ScreenShare`), `DisplayName` (null for non-participant kinds). -- **`Participant`** — operator-facing identity. `Guid Id` (engine-assigned, stable across rename heuristic), `string DisplayName` (last seen), `NdiSource? CurrentSource`, `DateTimeOffset FirstSeen / LastSeen`. Mutable via the engine; observable. -- **`IsoAssignment`** — operator's intent. `Guid ParticipantId`, `bool IsEnabled`, `string? CustomOutputName`. Persisted to `config.json`. Reserves room for v1.5 per-stream overrides. -- **`IsoOutput`** — runtime state. `Guid ParticipantId`, `string EffectiveOutputName`, `IsoHealthStats Stats`, `IsoState State` (`Idle | Receiving | Sending | NoSignal | Error`). -- **`FrameProcessingSettings`** — `TargetFramerate`, `TargetResolution`, `AspectMode` (`Pillarbox | Letterbox | Stretch`), `AudioMode` (`Isolated | Mixed | Auto`). -- **`IsoHealthStats`** — `FramesIn`, `FramesOut`, `FramesDropped`, `FramesDuplicated`, `LastFrameAt`, `IncomingFps`, `IncomingResolution`. -- **`EngineConfig`** — root persisted record: `FrameProcessingSettings Global`, `IReadOnlyList Assignments`. Stored at `%APPDATA%\TeamsISO\config.json`. -- **`EngineAlert`** — discriminated union: `NdiRuntimeMismatch | OutputNameCollision | PipelineError | ConfigSaveFailed`. - -**Participant identity across rename / disconnect.** Teams source strings change when a participant renames. Engine policy: if a source disappears and within 5 seconds a new participant source with the same `MachineName` appears, the engine transfers the existing `Participant.Id` (and any `IsoAssignment` bound to it) to the new source. The UI shows a brief rename toast. Operators can opt out per-meeting in settings. - -## 4. Components - -Eight subsystems inside `TeamsISO.Engine`. Each has one responsibility. - -**`NdiDiscoveryService`** — owns one `NDIlib_find_create_v2` instance on a long-running background thread. Polls every ~500 ms, diffs the source list, classifies each source, pushes `DiscoveryEvent` (`Added | Removed | Renamed`) onto a `Channel`. - -**`ParticipantTracker`** — consumes `DiscoveryEvent`s, applies the rename heuristic, maintains the canonical `IObservable>`. Stateful, pure-managed, unit-testable without NDI. - -**`IsoPipeline`** — per-ISO unit. Owns one receiver, one frame processor, one sender, all health stats. Lifecycle methods `Start`, `Stop`. Created by `IsoPipelineFactory` when the operator enables an ISO. - -**`NdiReceiver`** — wraps `NDIlib_recv_create_v3`. Dedicated thread loops on `NDIlib_recv_capture_v3`. Pushes captured frames into a bounded `Channel` (capacity 4, drop-oldest under backpressure). Records dropped-frame count. - -**`FrameProcessor`** — driven by `PeriodicTimer` at the target framerate. At each tick: read newest frame from the channel non-blocking; if available, scale via libyuv to target resolution + aspect mode, recalculate timecodes, hand to sender; if unavailable, re-emit `lastFrame`; if `lastFrame` is older than 2.5 s, emit a no-signal slate (`SolidFrameRenderer`, mid-grey). - -**`NdiSender`** — wraps `NDIlib_send_create`. Dedicated thread sends video on its tick and audio passthrough on its own queue. Audio mode `Auto` probes for isolated audio at startup and falls back to mixed if unavailable. - -**`IsoController`** — top of engine. Holds the pipeline dictionary, the `ParticipantTracker`, the `ConfigStore`. Exposes the `IIsoController` API. Translates "operator enabled this participant" into pipeline creation and start. - -**`ConfigStore`** — load/save `EngineConfig` to `%APPDATA%\TeamsISO\config.json`. Atomic writes via temp file + rename. - -**Logging:** Serilog file sink at `%APPDATA%\TeamsISO\logs\teamsiso-{Date}.log`, 14-day retention, structured. Engine code logs through `ILogger` from `Microsoft.Extensions.Logging`. - -## 5. Data flow and threading - -Per ISO: - -``` -NDI source on LAN - │ - ▼ -[Capture thread] (1 dedicated thread) - NDIlib_recv_capture_v3, blocking loop - │ - ▼ Channel (capacity 4, drop-oldest) - │ -[Processor tick] (PeriodicTimer on ThreadPool, target framerate) - pick newest frame → libyuv scale/aspect → retimecode - │ - ▼ ProcessedFrame - │ -[Send thread] (1 dedicated thread) - NDIlib_send_send_video_v2 + audio - │ - ▼ -ISO output on LAN -``` - -System-wide threads at 3 active ISOs: 3 capture + 3 send (dedicated, blocking-friendly), 1 discovery, 1 participant-tracker async loop on ThreadPool, 1 UI dispatcher, processor work on ThreadPool. Approximately 9 dedicated threads plus ThreadPool work — within budget for the recommended hardware. - -**Why dedicated threads for capture and send:** NDI capture and send calls block. Mixing them onto the .NET ThreadPool risks starving worker threads. Processing is short-lived per frame and fits the ThreadPool model. - -**Frame timing strategy (closest-frame):** simple, deterministic, works across all supported framerates without interpolation. Frame duplication = re-send `lastFrame`. After 2.5 s of no incoming frames, slate. - -**Audio:** v1.0 forwards audio passthrough on its own NDI queue, no resampling. Isolated audio is forwarded as-is when available; mixed audio is forwarded on the active-speaker stream only as fallback. - -**Cancellation:** every loop respects a per-ISO `CancellationToken`. Stopping an ISO triggers cancellation, joins capture and send threads (1 s timeout), disposes NDI handles. - -## 6. Error handling and recovery - -**Pipeline isolation.** Each `IsoPipeline` runs independently. One pipeline failing never affects others. - -**Per-pipeline failure recovery.** Unhandled exception → pipeline transitions to `Error`, releases NDI handles, logs with full context, auto-restarts after 1 s. Exponential backoff: 1, 2, 4, 8, 16 s, capped at 30 s. After 5 consecutive failures, stays `Error` and waits for operator action. Participant remains visible in the UI list so the operator can re-enable manually. - -**Source disconnect (expected, not error).** Pipeline transitions to `NoSignal` after 2.5 s, keeps the assignment bound, keeps emitting the slate. If the source returns within 60 s, reconnects automatically. After 60 s the pipeline stops the sender to free NDI bandwidth; reconnects when the source reappears. - -**NDI runtime version mismatch.** Detected at startup by `NdiRuntimeProbe`. Surfaces `EngineAlert.NdiRuntimeMismatch`. UI shows a banner with instructions to re-download Teams' NDI binaries (per source doc §7.2). Engine still attempts to run — it's a warning, not a hard fail. - -**Output name collision on the LAN.** Logged and surfaced as `EngineAlert.OutputNameCollision`. v1.0 does not auto-rename; the operator picks unique names. - -**Startup preflight.** Run before the UI accepts commands: - -- NDI runtime present and queryable -- Smoke test: create + destroy one `NDIlib_send_create` instance -- Config file readable; corrupt or missing → fall back to defaults and log -- libyuv DLL loadable -- Write access to `%APPDATA%\TeamsISO\` - -A failing preflight surfaces a single error dialog with a copyable diagnostic string; the app does not enter the main UI. - -**Engine alert channel.** `IObservable` exposes structured alerts to the UI for banner display and to the log for ops. - -## 7. Testing - -**Three layers, three test projects.** - -**Unit (`TeamsISO.Engine.Tests`)** — pure managed, no NDI runtime, fast (<1 s). Covers: - -- `ParticipantTracker` rename heuristic (synthetic event streams). -- `FrameProcessor` timing logic against fake clock and fake interop. Asserts: 30 fps target / 24 fps incoming yields 30 frames/s with appropriate duplication; 60 fps target / 30 fps incoming doubles each frame; 2.5 s of silence triggers slate. -- `IsoPipeline` lifecycle (start → run → stop → restart on simulated fault, with backoff schedule asserted). -- `ConfigStore` round-trip (missing → defaults; save → reload identical; corrupt JSON → defaults + log). -- `NdiSourceParser` against a corpus of real Teams source strings (participant, active speaker, audio, screen share, multi-word names with parens, unicode). - -**Integration (`TeamsISO.Engine.IntegrationTests`)** — Windows-only, real NDI runtime. Tagged `[Trait("requires", "ndi")]`. - -- Spin up a NewTek NDI Test Pattern source as a synthetic participant; route through `IsoPipeline`; receive on a second NDI receiver; assert output stream existence, naming, framerate (measured over 5 s), resolution. -- Source disappear / reappear: stop the test pattern source mid-stream, assert pipeline transitions through `NoSignal`, restart the source, assert pipeline resumes. -- Output name collision: spin two pipelines with the same name, assert `EngineAlert.OutputNameCollision`. - -**Manual / live test playbook (`docs/test-playbook.md`)** — checklist for verifying against real Teams meetings before each release. - -**TDD discipline.** Every behavior in the engine starts as a failing unit test against fakes. NDI interop has an `INdiInterop` interface; production wires `NdiInteropPInvoke`, tests wire `FakeNdiInterop`. - -**Coverage target.** 80% line coverage on `TeamsISO.Engine`, excluding the P/Invoke shim. Enforced in CI. - -## 8. Build, packaging, distribution - -**Source repo.** `forge.wilddragon.net/zgaetano/teamsiso`. Default branch `main`. Trunk-based with feature branches; PR review for engine-touching changes. - -**Build.** MSBuild via `dotnet build` and `dotnet publish`. Solution targets `net8.0-windows` with `TargetPlatformVersion=10.0.19041.0`. `TeamsISO.App` publishes self-contained, single-file, ReadyToRun: - -``` -dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -p:PublishReadyToRun=true -``` - -**CI.** Forgejo Actions (GitHub-Actions-compatible). Two pipelines: - -- `ci.yml` — every push and PR. Builds, runs unit tests, enforces coverage threshold, lints (treat-warnings-as-errors). Linux runner. Integration tests skip cleanly because `requires=ndi` is absent. -- `release.yml` — on tag push (`v*`). Windows runner with NDI runtime preinstalled. Builds release, runs unit + integration, builds WiX installer, attaches `.msi` to a Forgejo release. - -**Versioning.** SemVer in `Directory.Build.props`. Flows to assembly metadata and installer. Tag `v1.0.0` triggers the release pipeline. - -**Installer (WiX v5).** Produces `TeamsISO-x.y.z.msi`. Behavior: - -- Detects NDI runtime via registry probe; if absent or older, prompts the operator to download from `ndi.video/tools/`. The runtime is not bundled — NDI's redistribution license requires user consent. -- Installs to `%ProgramFiles%\TeamsISO\`. -- Creates Start Menu shortcut, optional desktop shortcut. -- `%APPDATA%\TeamsISO\` is created on first run, not at install (per-user data, per-machine MSI). -- Adds Add/Remove Programs entry. - -**NDI redistribution.** Per NDI SDK License v5 the runtime is not bundled. Detection is by registry key. Mismatches show a dialog with the official download link. Captured open task: legal review of NDI SDK License v5 before public v1.0 release. - -**Distribution.** v1.0 ships as MSI from Forgejo releases. No auto-update in v1.0. The About dialog shows the current version and links to the Forgejo releases page. - -## 9. Open tasks blocking v1.0 release - -- Legal review of NDI SDK License v5 (per source doc §7.3) — required before public release; not required for development. -- Confirmation that the Microsoft Teams tenant has the admin policy enabling NDI broadcast (the relevant Teams meeting-policy setting; current name varies by Teams admin center version — verified against the live tenant during development). -- Selection of code-signing approach for v1.0 vs. v1.5 (currently deferred). - -## 10. Out of scope for v1.0 (deferred) - -- Per-stream framerate override (v1.5) -- Thumbnail previews (v1.5) -- GPU-accelerated frame scaling (v1.5) -- Multi-machine cluster auto-coordination (v2.0) -- OSC / WebSocket control API (v2.0) -- Code signing of the installer -- Auto-update -- Audio resampling - -## 11. Glossary - -- **NDI** — Network Device Interface (Vizrt/NewTek). LAN video transport protocol used by Teams' broadcast mode. -- **ISO** — In live production, an "isolated" feed of a single source, separate from the program mix. ZoomISO and TeamsISO produce per-participant ISO feeds. -- **Active speaker** — Teams' auto-mixed feed that follows whoever is talking. A separate NDI source from individual participant streams. -- **Slate** — a static frame (typically a solid color or "no signal" graphic) emitted when the source has stopped delivering frames. diff --git a/docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md b/docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md deleted file mode 100644 index 9fa9635..0000000 --- a/docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md +++ /dev/null @@ -1,106 +0,0 @@ -# Spec: Embedded Teams meeting orchestration - -**Status:** Draft. Authored 2026-05-08. - -## Problem - -Operators currently run two apps side by side: Microsoft Teams (which broadcasts -NDI from its meetings) and TeamsISO (which consumes those NDI sources, normalizes -them, and re-emits clean ISOs). Two issues fall out: - -1. **Two interfaces, one workflow.** Switching between Teams to drive the meeting - and TeamsISO to drive ISO routing is friction during a live show. -2. **Teams' raw NDI bleeds into the production network.** Even with - TeamsISO running, Teams broadcasts its at-source-resolution / at-source-framerate - feeds on the same `Public` NDI group that switchers and recorders subscribe to. - Operators see "garbage" NDI sources alongside the clean TeamsISO outputs unless - they manually configure NDI groups (which most don't). - -The user's stated north star: **let me host the meeting from inside TeamsISO. Run -Teams in the background. Show me one interface; expose only the proper outputs.** - -## Constraints - -- Microsoft Teams' NDI broadcast feature is desktop-only — the web client does not - broadcast NDI. We cannot replace Teams with a WebView2 view of `teams.microsoft.com`. -- We do not have a native Teams SDK. The Microsoft Graph API exposes some meeting - control (create/join/end), but in-call operations (mute, share, react) are largely - out of scope or behind enterprise tenant configuration. -- Win32 window embedding (`SetParent`) of a foreign process's window is technically - possible but produces a fragile UX — Teams will break out, render incorrectly, or - fail to honor parent-window inputs. -- NDI group routing is the standard primitive for hiding noisy producers. We - shipped this in commit `909237f`. It works. - -## Architecture - -A three-phase rollout. Each phase is shippable on its own. - -### Phase E.1 — Teams launcher (launches Teams as a subprocess) - -The minimum viable embed. TeamsISO grows a "Launch Teams" affordance on the rail. -Clicking it: - -1. Reads the global `NdiGroupSettings.DiscoveryGroups` from `EngineConfig`. If - empty, defaults to `teamsiso-input`. -2. Opens **NDI Access Manager** (or programmatically writes its config) so Teams - broadcasts on `teamsiso-input` rather than `Public`. -3. Launches `ms-teams:` URI (or the `MSTeams.exe` directly for the new client) in - the background. -4. Marks Teams as "owned by TeamsISO" — the rail icon flips to "Stop Teams"; on - click, sends WM_CLOSE to the Teams main window. -5. Surfaces meeting health in the existing engine-status pill (e.g. "Teams running - • 2 participants"). - -Implementation effort: **a few hours.** Pure WPF + ProcessStartInfo + a small -NdiAccessManagerHelper that reads/writes Teams' config. - -### Phase E.2 — Window orchestration - -Teams' main window is repositioned + minimized when launched, so the user's -foreground experience is the TeamsISO window. Optional: - -- Pin Teams to a hidden virtual desktop with `IVirtualDesktopManager`. -- Forward keyboard shortcuts (mute, camera, share) from TeamsISO into Teams via - `SendInput` while Teams' window is hidden. - -Implementation effort: **a day.** Mostly Win32 plumbing. - -### Phase E.3 — Meeting controls in TeamsISO's UI - -A "Meeting" panel in the left rail that shows the active call's participant list -(with their mute / video state) and exposes Join/Leave/Mute/Share controls. Two -ways to plumb this: - -- **Microsoft Graph API for the chrome.** Auth as the user via OAuth (interactive - device-code flow), poll `/me/onlineMeetings` for active meetings, render in - TeamsISO's UI. In-call mute/cam state is not exposed via Graph as of writing — - Phase E.3 would surface participant *presence* but not mic/cam controls. -- **Teams' UI Automation tree.** Walk Teams' window with `UIAutomation` to read - call state. Brittle but usable; what other "Teams remote" tools do. - -Implementation effort: **a week per route.** Recommend Graph for read paths, -UIAutomation for write paths. - -## Out of scope (for now) - -- Hosting the actual meeting media stack (audio/video render, mixer, network). - Teams owns this and we don't want to. -- Replacing Teams entirely with our own SIP/WebRTC stack. That's a different - product. - -## Decision required - -User to confirm: - -1. Phase E.1 first (just launcher + group routing). Yes/no. -2. Whether to use `ms-teams:` URI launch or the new MSTeams.exe binary path - (`%LOCALAPPDATA%\Microsoft\WindowsApps\ms-teams.exe`). -3. Whether to ship NDI Access Manager config writes, or just document the manual - steps and trust the user to set them once. - -## Implementation log - -- 2026-05-08: First version of this spec drafted while user is asleep. -- 2026-05-08: Phase E.1 partial — "Launch Teams" rail button shipped (commit - pending). Group-routing automation deferred until user confirms approach. diff --git a/docs/test-playbook.md b/docs/test-playbook.md deleted file mode 100644 index 7f0a204..0000000 --- a/docs/test-playbook.md +++ /dev/null @@ -1,34 +0,0 @@ -# TeamsISO Manual Test Playbook - -## Phase A — Engine foundation (CI) - -- [ ] `dotnet build TeamsISO.Linux.slnf` succeeds with zero warnings. -- [ ] `dotnet test TeamsISO.Linux.slnf --filter "Category!=ndi&requires!=ndi"` passes. -- [ ] CI on Forgejo Actions is green at HEAD. -- [ ] Code coverage on `TeamsISO.Engine` is ≥80%. - -## First Windows validation (after Phase B-2 ships) - -Prerequisite: Windows 10/11 + NDI Runtime installed (https://ndi.video/tools/) + .NET 8 SDK. - -- [ ] Clone the repo on the Windows machine: `git clone https://forge.wilddragon.net/zgaetano/teamsiso.git`. -- [ ] `dotnet build TeamsISO.sln --configuration Release` succeeds. -- [ ] `dotnet test --filter "requires=ndi"` passes against an NDI Test Pattern source (start the test pattern from the NDI Tools menu before running). -- [ ] Run `dotnet run --project src/TeamsISO.Console` — confirm the engine starts, version probe matches, and Ctrl+C exits cleanly. - -## Live-meeting validation (after Phase C ships) - -- [ ] Configure a Teams meeting with 3+ participants, with NDI broadcast enabled in Teams. -- [ ] `dotnet run --project src/TeamsISO.App` launches the WPF UI without an NDI runtime warning banner. -- [ ] Participants list populates within ~2 seconds of opening the app. -- [ ] Participant rename mid-meeting transfers the row's identity (the rename heuristic). -- [ ] Toggle ISO on for one participant. Confirm the named output appears in vMix / OBS / Studio Monitor on the same LAN. -- [ ] Change global framerate to 59.94 fps; click Apply. New ISOs honor the new rate. -- [ ] Disconnect one participant; confirm their ISO transitions to the no-signal slate within 2.5 s. -- [ ] Run for 30 minutes; check FramesDropped / FramesDuplicated counters in the engine log are reasonable. - -## Pre-release checklist - -- [ ] Legal review of NDI SDK License v5 complete (per spec §7.3). -- [ ] Code-signing decision confirmed (yes/no for v1.0). -- [ ] WiX installer produces a working MSI on a clean Windows machine. diff --git a/installer/Package.wxs b/installer/Package.wxs index 42add94..911a206 100644 --- a/installer/Package.wxs +++ b/installer/Package.wxs @@ -24,12 +24,23 @@ Compressed="yes" InstallerVersion="500"> - + + @@ -52,10 +63,15 @@ - + + + @@ -115,23 +131,13 @@