diff --git a/COREVIDEO_PRO_PRODUCT_SPEC.md b/COREVIDEO_PRO_PRODUCT_SPEC.md index 581239358..be9020bc3 100644 --- a/COREVIDEO_PRO_PRODUCT_SPEC.md +++ b/COREVIDEO_PRO_PRODUCT_SPEC.md @@ -1,5 +1,11 @@ # CoreVideo Pro Product Spec and MVP Plan +> **First-release scope, November 9, 2026:** CoreVideo Tiles replaces legacy Magic +> Scene composition. Set & Forget and legacy Magic Scene are excluded and cannot +> be enabled. Scene selection and Take remain manual. Older automation promises +> below are deferred vision, not release acceptance. See +> [first-release scene control](docs/reference/first-release-scene-control.md). + > **Implementation status (2026-06-21).** This document is the product vision and MVP > plan. The shipping architecture is a **WinUI 3 (.NET 9) shell embedding a React/TypeScript > operator UI over a C++20 media core**, **Windows-first** (macOS is a roadmap target, not diff --git a/README.md b/README.md index b53135017..d7833d40e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ CoreVideo Pro is a **native desktop production studio for Windows and macOS** for building polished live shows, recordings, and streams from Zoom participants, cameras, screen capture and -media: Magic Scene auto-layout, lower-thirds, graphics, audio leveling, and +media: CoreVideo Tiles composition, lower-thirds, graphics, audio leveling, and multi-destination output, in a single operator console. **Work order lives in one place:** [`docs/BACKLOG.md`](docs/BACKLOG.md). This README is @@ -93,7 +93,7 @@ the stream, every ISO writer and the camera publisher ran clean, and Program rec | | DeckLink / AJA | Not shipped: probe only ([#537](https://github.com/iamfatness/CoreVideoPro/issues/537)) | | **Media** | Local video and still playback with core-owned cue/live/pause | Shipped, not live-verified; ProRes fails to start in the installed build ([#473](https://github.com/iamfatness/CoreVideoPro/issues/473)); stutter reported ([#701](https://github.com/iamfatness/CoreVideoPro/issues/701)) | | **Compositor** | D3D11 Program/Preview/Multiview, per-source framing, borders, grade presets and curves, lower-thirds and graphics templates | Proven: Program render held every deadline in the soak (minimum slack 3.6 ms) | -| | Set & Forget and Magic Scene automation driven by the core's slot bindings | Shipped, not live-verified: bound in [#1021](https://github.com/iamfatness/CoreVideoPro/pull/1021), unit-tested to the coordinator | +| | Scene selection and Take | Manual control through Studio, keyboard and Stream Deck. Legacy Magic Scene and Set & Forget are excluded from the first release. | | | Captions | Not shipped: the controls were removed in #1021 because no caption source exists; `push-caption-cue` remains for external cue feeds | | **Audio** | Routing matrix, Program and ISO taps, BS.1770 meter, bus dynamics and limiter, WASAPI monitor output | Proven on live shows; recorded Program A/V skew measured near -20 ms, live monitor skew -46 ms after [#655](https://github.com/iamfatness/CoreVideoPro/pull/655) ([#652](https://github.com/iamfatness/CoreVideoPro/issues/652) open on the limit) | | | Per-participant audio delay | Not shipped ([#627](https://github.com/iamfatness/CoreVideoPro/issues/627)) | @@ -180,7 +180,7 @@ The first fully useful milestone: 1. Join Zoom. 2. See clean participant feeds and metadata. -3. Click Magic Scene. +3. Compose the show with CoreVideo Tiles and cue the desired scene on Preview. 4. Get a polished show with lower-thirds, graphics, audio leveling, and stream / local-recording / virtual-camera controls. diff --git a/companion-module-corevideopro/src/feedbacks.ts b/companion-module-corevideopro/src/feedbacks.ts index 16cb2aaa3..87a9da01e 100644 --- a/companion-module-corevideopro/src/feedbacks.ts +++ b/companion-module-corevideopro/src/feedbacks.ts @@ -7,6 +7,7 @@ export interface FlagSource { /** The boolean ControlState fields worth lighting a button. */ export const FEEDBACK_FLAGS: { id: string; name: string; on: number; off?: number }[] = [ + { id: 'programBlackout', name: 'Program faded to black', on: combineRgb(200, 40, 40) }, { id: 'recording', name: 'Recording active', on: combineRgb(200, 40, 40) }, { id: 'streaming', name: 'Streaming active', on: combineRgb(200, 40, 40) }, { id: 'engineOn', name: 'Capture engine on', on: combineRgb(40, 140, 60) }, diff --git a/companion-module-corevideopro/src/presets.ts b/companion-module-corevideopro/src/presets.ts index 7db091228..225382a49 100644 --- a/companion-module-corevideopro/src/presets.ts +++ b/companion-module-corevideopro/src/presets.ts @@ -7,6 +7,13 @@ const black = combineRgb(0, 0, 0) export function buildPresets(): CompanionPresetDefinitions { const presets: CompanionPresetDefinitions = {} + presets['ftb'] = { + type: 'button', category: 'Transport', name: 'Fade to Black', + style: { text: 'FTB', size: '18', color: white, bgcolor: black }, + steps: [{ down: [{ actionId: 'transport.ftb.toggle', options: {} }], up: [] }], + feedbacks: [{ feedbackId: 'programBlackout', options: {}, style: { text: 'BLACK', bgcolor: combineRgb(200, 40, 40) } }], + } + presets['take'] = { type: 'button', category: 'Transport', diff --git a/contracts/observation.schema.json b/contracts/observation.schema.json index 42e44b09a..47ae87968 100644 --- a/contracts/observation.schema.json +++ b/contracts/observation.schema.json @@ -36,6 +36,16 @@ "qualification": "include", "control": "include" }, + "programBlackout": { + "typed": "include", + "qualification": "include", + "control": "include" + }, + "programBlackoutOpacity": { + "typed": "include", + "qualification": "include", + "control": "include" + }, "brandKit": { "typed": "include", "qualification": "include", diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 2172bf660..4a79cb19d 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -1,5 +1,12 @@ # CoreVideo Pro — ranked backlog +**Owner release-scope decision, November 9:** defer the legacy scene director +tracked under [#668](https://github.com/iamfatness/CoreVideoPro/issues/668). +Exclude Set & Forget and old Magic Scene from the first release, including +background and remote activation. Preserve CoreVideo Tiles and manual Take. +Implementation contract: [first-release scene control](reference/first-release-scene-control.md). +This is a release exclusion, not completion of the deferred director's acceptance. + ## Now — Zoom ISO recording size, November 9 | Order | Issue | Scope and acceptance | diff --git a/docs/reference/first-release-scene-control.md b/docs/reference/first-release-scene-control.md new file mode 100644 index 000000000..a131a991b --- /dev/null +++ b/docs/reference/first-release-scene-control.md @@ -0,0 +1,86 @@ +# First-release scene control + +Owner decision, November 9, 2026: exclude Set & Forget and the legacy Magic Scene +workflow from the first release. CoreVideo Tiles owns composition. Scene selection +and Take remain manual through Studio, keyboard, and Stream Deck/Companion. +This supersedes the automation promises in the earlier product and director specs; +it does not change the Tiles speaker-follow or membership policies. + +Windows removes the legacy footer buttons and Automation navigation. A restored +legacy page provides manual-production guidance and the separate free-input filling +setting. The production MagicSceneCoordinator constructor cannot enable scene +automation: commands, direct mode assignments and automatic-Take intent are blocked, +and snapshot/timer evaluation cannot bind Preview, start the timer, change graphics, +or Take. The internal opt-in constructor is solely for deferred-engine unit tests; +there is no user setting, environment variable or license override. + +The remote action catalog no longer advertises automation.magic, automation.toggle +or automation.autoTake.set. Saved HTTP/OSC/Companion commands fail validation instead +of silently re-enabling the feature. The adapter also rejects those legacy IDs if +called directly. Existing clients should remove those buttons. Manual transport.take, +scene controls and Tiles controls remain registered. The outputs drill no longer +sends the retired auto-Take command. + +macOS removes the same controls and navigation. Its immutable release gate prevents +setAutoDirect from starting a timer; the evaluator and one-shot command are guarded. +Legacy auto-Take preferences are ignored. Native macOS build/runtime verification +requires macOS and is not covered by Windows checks. + +Automatic filling of free input slots and the separate native graphics automation +are not the retired scene director and remain available. No installer or saved user +profile is modified by this source change. + +## Studio manual controls + +The Windows Studio footer groups Graphics and Outputs on the left, Take/FTB on the right, above a separate +health/audio row. Output option flyouts remain available. Edit Tiles on a Tiles +scene cues that scene to Preview and opens its wall inspector; it never Takes. + +Ctrl+Enter invokes the existing Take command in Studio. Ctrl+Shift+B invokes FTB. +Both shortcuts ignore text-entry controls. Existing graphics F-key mappings and +remote `transport.take` remain unchanged. Companion discovers `transport.ftb.toggle` +and `transport.ftb.set(enabled)` from the action manifest; its FTB preset lights +from `programBlackout` feedback. Use the idempotent set action when retrying requests. + +FTB fades Program video over 300 ms; pressing again restores the picture from the +current fade level. The core appends a neutral black GPU layer above scene and +global graphics. Preview, source ISOs, audio, and running output sessions are +unchanged. Taking another scene while black does not release the blackout. The +RC button has a black fill with no visible text and a red outline when black is selected. No saved +preference automatically engages FTB; a new media-core session starts clear. +Older cores without the blackout observation reject FTB rather than implying success. + +Layout checks run in an isolated offscreen window. GPU pixel tests prove the native +compositor fade and restoration; these checks are not a live multi-output soak. + +The layout fidelity correction keeps Edit Tiles inside its scene card, separates +Take from the transition selector, arranges Graphics / Outputs / transport as three +measured groups, and gives telemetry its own full-width row. Zoom and Health are +direct top navigation actions. The graphics panel starts closed and the on-air/cued +strip appears with that panel; the Graphics button and function keys remain available. +Scene removal remains in the scene context menu. Background import is available in +the background picker context menu and the scene editor. Layout review builds are +separate from the running application so reviewing them does not restart a meeting. + +The footer keeps the lower-third shortcut but omits the generic Preview graphic / +Show on Program buttons. Show, Cue and Hide remain attached to each graphic in +the Graphics workspace; graphics keyboard and remote actions are unchanged. + +RC footer outputs use compact Record, broadcast, webcam icons and an NDI badge. +Separate option arrows preserve output settings; tooltips and accessible names +identify each action. Take, transition choice, and the black FTB key are anchored +to the far right. Keyboard and remote commands remain unchanged. Manual blend +is excluded from the RC. + +Graphics and output shortcuts form a left-aligned group with a fixed gap; only +the space before Take grows with the window. Output icons use matching 26px +line artwork and neutral idle backgrounds, with record/stream colors reserved +for their active states. + +## Compiled Windows layout + +These captures come from the isolated compiled layout check, with no live video. + +![Studio controls](images/rc-studio-controls.png) + +![Footer close-up](images/rc-studio-footer.png) diff --git a/docs/reference/images/rc-studio-controls.png b/docs/reference/images/rc-studio-controls.png new file mode 100644 index 000000000..8941c6035 Binary files /dev/null and b/docs/reference/images/rc-studio-controls.png differ diff --git a/docs/reference/images/rc-studio-footer.png b/docs/reference/images/rc-studio-footer.png new file mode 100644 index 000000000..c375d60b1 Binary files /dev/null and b/docs/reference/images/rc-studio-footer.png differ diff --git a/mac-shell/Sources/CoreVideoProShell/AppModel.swift b/mac-shell/Sources/CoreVideoProShell/AppModel.swift index 77590d1df..b00151a55 100644 --- a/mac-shell/Sources/CoreVideoProShell/AppModel.swift +++ b/mac-shell/Sources/CoreVideoProShell/AppModel.swift @@ -368,8 +368,9 @@ final class AppModel: ObservableObject { @Published var autoSceneId = "" @Published var autoConfidence = 0 @Published var autoRationale = "" - @Published var autoDirectEnabled = false - @Published var autoTakeEnabled = true + static let sceneAutomationAvailable = false + @Published private(set) var autoDirectEnabled = false + @Published var autoTakeEnabled = false @Published var autoConfidenceThreshold = 70.0 @Published var autoHoldSeconds = 4.0 @Published var autoStatus = "Manual mode" @@ -491,7 +492,7 @@ final class AppModel: ObservableObject { monitorVolume = prefs.monitorVolume isoRecordingEnabled = prefs.isoRecordingEnabled isoSelectedSourceIds = Set(prefs.isoSelectedSourceIds) - autoTakeEnabled = prefs.autoTakeEnabled + autoTakeEnabled = false // Ignore legacy saved auto-take intent in the first release. autoConfidenceThreshold = prefs.autoConfidenceThreshold autoHoldSeconds = prefs.autoHoldSeconds // Optional key: a prefs file written before this shipped decodes to nil @@ -1875,12 +1876,13 @@ final class AppModel: ObservableObject { private var pendingAutoSince = Date.distantPast func setAutoDirect(enabled: Bool) { - autoDirectEnabled = enabled + autoDirectEnabled = enabled && Self.sceneAutomationAvailable + autoTakeEnabled = autoTakeEnabled && Self.sceneAutomationAvailable pendingAutoSceneId = "" autoTimer?.invalidate() autoTimer = nil - guard enabled else { - autoStatus = "Manual mode" + guard autoDirectEnabled else { + autoStatus = "Scene automation is unavailable in this release" evaluateAutomationLowerThird() // retires an automation-owned key return } @@ -1891,7 +1893,7 @@ final class AppModel: ObservableObject { } private func evaluateAutoDirect() { - guard autoDirectEnabled else { return } + guard Self.sceneAutomationAvailable && autoDirectEnabled else { return } // Overlay policy runs BEFORE the roster/confidence guards — the // MagicSceneCoordinator.EvaluateAutomationPolicy order. It is a no-op // unless the resolved program source actually changed. diff --git a/mac-shell/Sources/CoreVideoProShell/AutomationExtras.swift b/mac-shell/Sources/CoreVideoProShell/AutomationExtras.swift index f375fb49d..13fee50e5 100644 --- a/mac-shell/Sources/CoreVideoProShell/AutomationExtras.swift +++ b/mac-shell/Sources/CoreVideoProShell/AutomationExtras.swift @@ -124,12 +124,16 @@ extension AppModel { // ── Magic Scene (MagicSceneCoordinator.RunMagicScene, :112) ─────────────── - var canRunMagicScene: Bool { !autoSceneId.isEmpty && recommendedScene != nil } + var canRunMagicScene: Bool { AppModel.sceneAutomationAvailable && !autoSceneId.isEmpty && recommendedScene != nil } /// Queues the director's recommendation onto PREVIEW — never program (the /// Windows command sets PreviewSceneId + SchedulePreviewRoutingRefresh; the /// mac equivalent of that refresh is the coalesced syncScenes()). func runMagicScene() { + guard Self.sceneAutomationAvailable else { + autoStatus = "Scene automation is unavailable in this release" + return + } guard meetingState == "in_meeting" || !roster.isEmpty else { autoStatus = "Magic Scene requires an active Zoom meeting" return @@ -149,7 +153,7 @@ extension AppModel { /// The Windows defaults, verbatim: auto-take on, prefer screen share on, /// lower thirds on, confidence 70, hold 4s, panel at 4. func resetAutomationDefaults() { - autoTakeEnabled = true + autoTakeEnabled = Self.sceneAutomationAvailable autoConfidenceThreshold = 70 autoHoldSeconds = 4 autoExtras = AutomationExtrasState() diff --git a/mac-shell/Sources/CoreVideoProShell/ShellApp.swift b/mac-shell/Sources/CoreVideoProShell/ShellApp.swift index 78b436f53..14024745b 100644 --- a/mac-shell/Sources/CoreVideoProShell/ShellApp.swift +++ b/mac-shell/Sources/CoreVideoProShell/ShellApp.swift @@ -239,7 +239,7 @@ struct HeaderBar: View { TabPill(tab: .studio) GroupLabel("Setup") ForEach([StudioTab.zoom, .sources, .scenes, .routing], id: \.self) { TabPill(tab: $0) } - ForEach([StudioTab.overlays, .audio, .media, .automation], id: \.self) { + ForEach([StudioTab.overlays, .audio, .media], id: \.self) { TabPill(tab: $0) } GroupLabel("Diagnose") @@ -1110,42 +1110,6 @@ struct TransportBar: View { var body: some View { HStack(spacing: 10) { - // Magic Scene = auto-direct with auto-take, the one-tap AI pill. - Button { - model.autoTakeEnabled = true - model.setAutoDirect(enabled: !model.autoDirectEnabled) - } label: { - HStack(spacing: 5) { - Image(systemName: "sparkles").font(.system(size: 10)) // design-lint: allow - VStack(alignment: .leading, spacing: 0) { - Text("Magic Scene").font(.grotesk(11, .semibold)) - Text("AI auto-direct").font(.grotesk(8)) - } - } - .foregroundStyle(model.autoDirectEnabled ? .black : Studio.accent) - .padding(.horizontal, 10).padding(.vertical, 5) - .background(RoundedRectangle(cornerRadius: 8) - .fill(model.autoDirectEnabled ? Studio.accent - : Studio.accent.opacity(0.12))) - .overlay(RoundedRectangle(cornerRadius: 8) - .stroke(Studio.accent.opacity(0.7), lineWidth: 1)) - } - .buttonStyle(.plain) - HStack(spacing: 6) { - VStack(alignment: .leading, spacing: 0) { - Text("Set & Forget").font(.grotesk(10, .medium)) - Text("Automation \(model.autoDirectEnabled ? "On" : "Off")") - .font(.grotesk(8)).foregroundStyle(Studio.secondary) - } - Toggle("", isOn: Binding( - get: { model.autoDirectEnabled }, - set: { model.setAutoDirect(enabled: $0) })) - .toggleStyle(.switch) - .controlSize(.mini) - .labelsHidden() - } - .padding(.horizontal, 8).padding(.vertical, 4) - .background(RoundedRectangle(cornerRadius: 8).fill(Studio.card)) HStack(spacing: 4) { Text("LT") .font(.grotesk(10, .bold)) @@ -2004,51 +1968,8 @@ struct AutomationPane: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - Text("Automation").font(.grotesk(14, .semibold)) - Toggle("Set & Forget (auto-direct)", isOn: Binding( - get: { model.autoDirectEnabled }, - set: { model.setAutoDirect(enabled: $0) })) - Toggle("Auto-take (off = queue on preview)", isOn: $model.autoTakeEnabled) - .disabled(!model.autoDirectEnabled) - HStack { - Text("Confidence ≥ \(Int(model.autoConfidenceThreshold))") - .font(.grotesk(11)) - Slider(value: $model.autoConfidenceThreshold, in: 0...100, step: 5) - } - HStack { - Text(String(format: "Hold %.0fs", model.autoHoldSeconds)).font(.grotesk(11)) - Slider(value: $model.autoHoldSeconds, in: 0...30, step: 1) - } - Text(model.autoStatus) - .font(.plexMono(11)) - .foregroundStyle(model.autoDirectEnabled ? Studio.accent : Studio.secondary) - Divider() - Text("SCENE INTELLIGENCE") - .font(.plexMono(10, .bold)) - .foregroundStyle(Studio.secondary) - if model.autoSceneId.isEmpty { - Text("Director idle — recommendations appear once the meeting has participants.") - .font(.grotesk(11)).foregroundStyle(Studio.secondary) - } else { - HStack { - Text(model.autoSceneId) - .font(.grotesk(13, .semibold)) - Text("\(model.autoConfidence)%") - .font(.plexMono(11)) - .foregroundStyle(model.autoConfidence >= Int(model.autoConfidenceThreshold) - ? Studio.accent : Studio.secondary) - Spacer() - Text(model.autoRuleId) - .font(.plexMono(9)) - .foregroundStyle(Studio.secondary) - } - Text(model.autoRationale) - .font(.grotesk(11)).foregroundStyle(Studio.secondary) - } - // AutomationPage.xaml parity: Magic Scene, dynamic lower thirds, - // reset defaults, the scene-intelligence readouts, and the honest - // disabled block. Lives in AutomationExtras.swift. - AutomationExtrasSection() + Text("Manual production").font(.grotesk(14, .semibold)) + Text("Scene automation is unavailable in this release. Compose your show and use manual Take.").font(.grotesk(12)) } .modifier(StudioPanel()) } diff --git a/mac-shell/Sources/CoreVideoProShell/ShellPrefs.swift b/mac-shell/Sources/CoreVideoProShell/ShellPrefs.swift index 5cb68e39e..6c5970b11 100644 --- a/mac-shell/Sources/CoreVideoProShell/ShellPrefs.swift +++ b/mac-shell/Sources/CoreVideoProShell/ShellPrefs.swift @@ -62,7 +62,7 @@ struct ShellPrefs: Codable, Equatable { var monitorVolume = 0.7 var isoRecordingEnabled = false var isoSelectedSourceIds: [String] = [] - var autoTakeEnabled = true + var autoTakeEnabled = false var autoConfidenceThreshold = 70.0 var autoHoldSeconds = 4.0 // Automation-tab policy (AutomationExtras.swift). OPTIONAL on purpose: a @@ -116,7 +116,7 @@ struct ShellPrefs: Codable, Equatable { monitorVolume = v(.monitorVolume, 0.7) isoRecordingEnabled = v(.isoRecordingEnabled, false) isoSelectedSourceIds = v(.isoSelectedSourceIds, []) - autoTakeEnabled = v(.autoTakeEnabled, true) + autoTakeEnabled = false // Retired scene automation cannot be restored from preferences. autoConfidenceThreshold = v(.autoConfidenceThreshold, 70.0) autoHoldSeconds = v(.autoHoldSeconds, 4.0) lowerThirdName = v(.lowerThirdName, "") diff --git a/native-shell/CoreVideoPro.Control.Tests/HttpControlRouterTests.cs b/native-shell/CoreVideoPro.Control.Tests/HttpControlRouterTests.cs index 59e359587..e30ac33b5 100644 --- a/native-shell/CoreVideoPro.Control.Tests/HttpControlRouterTests.cs +++ b/native-shell/CoreVideoPro.Control.Tests/HttpControlRouterTests.cs @@ -57,9 +57,13 @@ public async Task Get_Manifest_ReturnsTheActionContract() Assert.Equal(200, response.Status); Assert.Contains("transport.take", response.Body); + Assert.Contains("transport.ftb.toggle", response.Body); + Assert.Contains("transport.ftb.set", response.Body); Assert.Contains("scene.cueWithRoutes", response.Body); Assert.Contains("scene.route.set", response.Body); - Assert.Contains("automation.magic", response.Body); + Assert.DoesNotContain("automation.magic", response.Body); + Assert.DoesNotContain("automation.toggle", response.Body); + Assert.DoesNotContain("automation.autoTake.set", response.Body); Assert.Contains("/cvp/transport/take", response.Body); } @@ -71,6 +75,7 @@ public async Task Get_State_ReturnsCamelCaseState() State = ControlState.Empty with { Recording = true, + ProgramBlackout = true, ActiveSceneId = "interview", ZoomAudioMode = "perGuestIso", ProgramTruePeakDbfs = -8.5, @@ -84,6 +89,7 @@ public async Task Get_State_ReturnsCamelCaseState() Assert.Equal(200, response.Status); using var doc = JsonDocument.Parse(response.Body); Assert.True(doc.RootElement.GetProperty("recording").GetBoolean()); + Assert.True(doc.RootElement.GetProperty("programBlackout").GetBoolean()); Assert.Equal("interview", doc.RootElement.GetProperty("activeSceneId").GetString()); Assert.Equal("perGuestIso", doc.RootElement.GetProperty("zoomAudioMode").GetString()); Assert.Equal(-8.5, doc.RootElement.GetProperty("programTruePeakDbfs").GetDouble()); @@ -92,6 +98,22 @@ public async Task Get_State_ReturnsCamelCaseState() Assert.Equal(-6.2, source.GetProperty("peakDbfs").GetDouble()); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task FadeToBlackSetRoutesBooleanIntentAndReportsNativeFailure(bool enabled) + { + var surface = new FakeControlSurface { Handler = (_, _) => ControlInvokeResult.Fail("core offline") }; + var response = await new HttpControlRouter(surface).HandleAsync("POST", "/invoke", + JsonSerializer.Serialize(new { action = "transport.ftb.set", args = new[] { enabled } })); + var invocation = Assert.Single(surface.Invocations); + Assert.Equal("transport.ftb.set", invocation.ActionId); + Assert.Equal(enabled, invocation.Args[0]); + Assert.Contains("core offline", response.Body); + using var json = JsonDocument.Parse(response.Body); + Assert.False(json.RootElement.GetProperty("ok").GetBoolean()); + } + [Fact] public async Task Post_Invoke_BindsArgsAndInvokesTheSurface() { @@ -168,6 +190,19 @@ public async Task Post_Invoke_Returns422WhenSurfaceFails() Assert.Contains("slot out of range", response.Body); } + [Theory] + [InlineData("automation.magic")] + [InlineData("automation.toggle")] + [InlineData("automation.autoTake.set")] + public async Task LegacySceneAutomationCannotBeInvokedBySavedRemoteButtons(string action) + { + var surface = new FakeControlSurface(); + var router = new HttpControlRouter(surface); + var body = JsonSerializer.Serialize(new { action, args = new object[] { true } }); + Assert.Equal(400, (await router.HandleAsync("POST", "/invoke", body)).Status); + Assert.Empty(surface.Invocations); + } + [Fact] public async Task UnknownRoute_Returns404() { diff --git a/native-shell/CoreVideoPro.Control/ControlActionRegistry.cs b/native-shell/CoreVideoPro.Control/ControlActionRegistry.cs index cd0a9febc..70e92696f 100644 --- a/native-shell/CoreVideoPro.Control/ControlActionRegistry.cs +++ b/native-shell/CoreVideoPro.Control/ControlActionRegistry.cs @@ -52,6 +52,9 @@ private static IReadOnlyList BuildActions() new("zoom.leave", "Leave Zoom", "Leave the current embedded Zoom meeting."), // ---- Transport -------------------------------------------------------------- + new("transport.ftb.toggle", "Fade to Black", "Fade Program video to black or back in over 300 ms. Audio and output sessions continue."), + new("transport.ftb.set", "Set Fade to Black", "Set Program blackout; repeated identical requests do not reverse the fade.", + new[] { new ControlParam("enabled", b, true) }), new("transport.take", "Take", "Cut/transition Preview to Program."), new("transport.transition.set", "Set transition", "Set the Take transition (cut/fade/dip/wipe).", new[] { new ControlParam("mode", s, true, "cut | fade | dip | wipe") }), @@ -304,10 +307,6 @@ [new ControlParam("clip", s, true, "Staged clip key")]), new[] { new ControlParam("on", b, true) }), // ---- Automation ------------------------------------------------------------- - new("automation.magic", "Magic Scene", "Run the one-shot Magic Scene command using the current source roster."), - new("automation.toggle", "Automation toggle", "Toggle production automation on/off."), - new("automation.autoTake.set", "Auto-take", "Toggle automation auto-take.", - new[] { new ControlParam("on", b, true) }), new("automation.autoAssignInputs.set", "Auto-assign inputs", "Toggle auto-assigning roster participants to Show Inputs.", new[] { new ControlParam("on", b, true) }), new("automation.lowerThirds.set", "Auto lower-thirds", "Toggle automation lower-thirds.", diff --git a/native-shell/CoreVideoPro.Control/ControlState.cs b/native-shell/CoreVideoPro.Control/ControlState.cs index 7985f0fd9..075719c09 100644 --- a/native-shell/CoreVideoPro.Control/ControlState.cs +++ b/native-shell/CoreVideoPro.Control/ControlState.cs @@ -53,6 +53,7 @@ public sealed record ControlState public bool Streaming { get; init; } public bool EngineOn { get; init; } public bool CanTake { get; init; } + public bool ProgramBlackout { get; init; } public bool LowerThirdOnAir { get; init; } public string ZoomStatus { get; init; } = string.Empty; diff --git a/native-shell/CoreVideoPro.MediaCore.Tests/NativeMediaCoreStateMapperTests.cs b/native-shell/CoreVideoPro.MediaCore.Tests/NativeMediaCoreStateMapperTests.cs index 1d0302ee9..015dddfa7 100644 --- a/native-shell/CoreVideoPro.MediaCore.Tests/NativeMediaCoreStateMapperTests.cs +++ b/native-shell/CoreVideoPro.MediaCore.Tests/NativeMediaCoreStateMapperTests.cs @@ -7,6 +7,21 @@ namespace CoreVideoPro.MediaCore.Tests; public sealed class NativeMediaCoreStateMapperTests { + [Theory] + [InlineData(true, 0.5)] + [InlineData(false, 0.25)] + public void ProgramBlackoutRoundTripsNativeObservation(bool enabled, double opacity) + { + using var response = JsonDocument.Parse(JsonSerializer.Serialize(new { ok = true, + snapshot = new { health = (object?)null, profile = (object?)null, programBlackout = enabled, programBlackoutOpacity = opacity } })); + var wire = CoreProtocolParser.TryParseWireState(response); + Assert.NotNull(wire); + var snapshot = NativeMediaCoreStateMapper.MapNativeWireStateToSnapshot(0, 0, wire); + Assert.Equal(enabled, snapshot.ProgramBlackout); + Assert.Equal(opacity, snapshot.ProgramBlackoutOpacity); + Assert.Null(new NativeMediaCoreStateSnapshot().ProgramBlackout); + } + [Theory] [InlineData("snapshot")] [InlineData("state")] diff --git a/native-shell/CoreVideoPro.MediaCore/Models/CoreProtocolModels.cs b/native-shell/CoreVideoPro.MediaCore/Models/CoreProtocolModels.cs index 33dfd4690..e1072fc58 100644 --- a/native-shell/CoreVideoPro.MediaCore/Models/CoreProtocolModels.cs +++ b/native-shell/CoreVideoPro.MediaCore/Models/CoreProtocolModels.cs @@ -351,6 +351,8 @@ public sealed class RawParticipantEvent public sealed class NativeMediaCoreWireState { + public bool? ProgramBlackout { get; init; } + public double ProgramBlackoutOpacity { get; init; } public System.Text.Json.JsonElement? ProgramBuffer { get; init; } public NativeMediaCoreProgramFrame? ProgramFrame { get; init; } public NativeMediaCorePreviewScene? PreviewScene { get; init; } diff --git a/native-shell/CoreVideoPro.MediaCore/Models/NativeMediaCoreProtocol.cs b/native-shell/CoreVideoPro.MediaCore/Models/NativeMediaCoreProtocol.cs index f19b3019a..fbad9e3a2 100644 --- a/native-shell/CoreVideoPro.MediaCore/Models/NativeMediaCoreProtocol.cs +++ b/native-shell/CoreVideoPro.MediaCore/Models/NativeMediaCoreProtocol.cs @@ -942,6 +942,8 @@ public sealed record NativeZoomGuestAvSyncAck public sealed record NativeMediaCoreStateSnapshot { + public bool? ProgramBlackout { get; init; } + public double ProgramBlackoutOpacity { get; init; } /// Latest admitted engine capture observation; absent peers remain unknown. public bool? RawMediaActive { get; init; } public AudioOwnerObservation? AudioObservation { get; init; } diff --git a/native-shell/CoreVideoPro.MediaCore/Services/CoreObservationModel.g.cs b/native-shell/CoreVideoPro.MediaCore/Services/CoreObservationModel.g.cs index 4c98fab83..c454cea3f 100644 --- a/native-shell/CoreVideoPro.MediaCore/Services/CoreObservationModel.g.cs +++ b/native-shell/CoreVideoPro.MediaCore/Services/CoreObservationModel.g.cs @@ -28,6 +28,8 @@ public static string TypedJson(JsonElement root) public JsonElement? AppliedControlRevision => Get("appliedControlRevision"); public JsonElement? AudioRoutingMatrix => Get("audioRoutingMatrix"); public JsonElement? AutoProduction => Get("autoProduction"); + public JsonElement? ProgramBlackout => Get("programBlackout"); + public JsonElement? ProgramBlackoutOpacity => Get("programBlackoutOpacity"); public JsonElement? BrandKit => Get("brandKit"); public JsonElement? BreakoutRoomId => Get("breakoutRoomId"); public JsonElement? BreakoutRoomName => Get("breakoutRoomName"); @@ -125,6 +127,8 @@ private string Project(View view) "appliedControlRevision" => view switch { View.Typed => 1, View.Qualification => 1, _ => 1 }, "audioRoutingMatrix" => view switch { View.Typed => 1, View.Qualification => 1, _ => 1 }, "autoProduction" => view switch { View.Typed => 1, View.Qualification => 1, _ => 1 }, + "programBlackout" => view switch { View.Typed => 1, View.Qualification => 1, _ => 1 }, + "programBlackoutOpacity" => view switch { View.Typed => 1, View.Qualification => 1, _ => 1 }, "brandKit" => view switch { View.Typed => 1, View.Qualification => 1, _ => 1 }, "breakoutRoomId" => view switch { View.Typed => 1, View.Qualification => 1, _ => 1 }, "breakoutRoomName" => view switch { View.Typed => 1, View.Qualification => 1, _ => 1 }, diff --git a/native-shell/CoreVideoPro.MediaCore/Services/NativeMediaCoreStateMapper.cs b/native-shell/CoreVideoPro.MediaCore/Services/NativeMediaCoreStateMapper.cs index 8f8bc56f8..4445b2586 100644 --- a/native-shell/CoreVideoPro.MediaCore/Services/NativeMediaCoreStateMapper.cs +++ b/native-shell/CoreVideoPro.MediaCore/Services/NativeMediaCoreStateMapper.cs @@ -172,6 +172,8 @@ public static NativeMediaCoreStateSnapshot MapNativeWireStateToSnapshot( return baseSnapshot with { + ProgramBlackout = wire.ProgramBlackout, + ProgramBlackoutOpacity = wire.ProgramBlackoutOpacity, CaptureDevices = wire.CaptureDevices ?? baseSnapshot.CaptureDevices, ProgramBuffer = wire.ProgramBuffer is { ValueKind: JsonValueKind.Object } programBuffer ? programBuffer.Clone() : null, PreviewScene = wire.PreviewScene, diff --git a/native-shell/CoreVideoPro.WinUI.Tests/MagicSceneCoordinatorTests.cs b/native-shell/CoreVideoPro.WinUI.Tests/MagicSceneCoordinatorTests.cs index 663566bbd..366716832 100644 --- a/native-shell/CoreVideoPro.WinUI.Tests/MagicSceneCoordinatorTests.cs +++ b/native-shell/CoreVideoPro.WinUI.Tests/MagicSceneCoordinatorTests.cs @@ -102,10 +102,53 @@ public void DiagnosticPreservesTheObservedHoldAndDoesNotActuateWhenRead() private static (MagicSceneCoordinator Coordinator, FakeMagicSceneHost Host) Build(Func? clock = null) { var host = new FakeMagicSceneHost { Scenes = Scenes }; - var coordinator = new MagicSceneCoordinator(host, clock); + var coordinator = new MagicSceneCoordinator(host, clock, sceneAutomationAvailable: true); return (coordinator, host); } + [Fact] + public void ReleaseConstructorCannotActivateLegacyAutomationThroughAnyEntryPoint() + { + var host = new FakeMagicSceneHost { Scenes = Scenes, RoomVideoParticipantCount = 4, CanTake = true }; + var coordinator = new MagicSceneCoordinator(host); + coordinator.Recommendation = Recommendation("interview", 99); + coordinator.RunMagicSceneCommand.Execute(null); + coordinator.ToggleAutomationCommand.Execute(null); + coordinator.ProductionMode = ProductionMode.SetAndForget; + coordinator.AutomationAutoTakeEnabled = true; + coordinator.ResetAutomationDefaultsCommand.Execute(null); + coordinator.EvaluateAutomationPolicy(); + Assert.Equal(ProductionMode.Manual, coordinator.ProductionMode); + Assert.False(coordinator.AutomationAutoTakeEnabled); + Assert.Equal(0, host.Timer.StartCount); + Assert.Empty(host.PreviewBindings); + Assert.Equal("intro", host.PreviewSceneId); + Assert.Equal("intro", host.ActiveSceneId); + Assert.Equal(0, host.TakeAsyncCallCount); + Assert.Equal(0, host.GraphicPolicyCalls); + Assert.Contains("unavailable", coordinator.AutomationLastAction); + } + + [Fact] + public async Task DisabledDirectorLeavesManualTakeAndItsStatusAloneAcrossRefreshes() + { + var host = new FakeMagicSceneHost { Scenes = Scenes, RoomVideoParticipantCount = 4, CanTake = true }; + var coordinator = new MagicSceneCoordinator(host); + host.PreviewSceneId = "interview"; + await host.TakeAsync(); + ((IMagicSceneHost)host).CommandStatus = "Manual Take completed"; + coordinator.Recommendation = Recommendation("panel", 99); + coordinator.EvaluateAutomationPolicy(); + host.CoreEpoch = "restarted-core"; + host.RosterEpoch = "new-meeting"; + coordinator.EvaluateAutomationPolicy(); + Assert.Equal("interview", host.ActiveSceneId); + Assert.Equal("interview", host.PreviewSceneId); + Assert.Equal("Manual Take completed", host.CommandStatus); + Assert.Equal(1, host.TakeAsyncCallCount); + Assert.Equal(0, host.Timer.StartCount); + } + [Fact] public void SerializedNativeShotWithCurrentPolicyCuesAndTakesTheIntendedGuests() { diff --git a/native-shell/CoreVideoPro.WinUI.Tests/NativeControlEvidenceTests.cs b/native-shell/CoreVideoPro.WinUI.Tests/NativeControlEvidenceTests.cs index 93f5ed877..7df2d9cf8 100644 --- a/native-shell/CoreVideoPro.WinUI.Tests/NativeControlEvidenceTests.cs +++ b/native-shell/CoreVideoPro.WinUI.Tests/NativeControlEvidenceTests.cs @@ -125,18 +125,6 @@ public void ExistingPreviewSceneWireFieldDeserializesIntoEvidence() Assert.Equal("preview", state.NativePreviewSceneId); } - [Fact] - public void MagicApiInvokesTheExistingCommandOnlyWhenAvailable() - { - var available = false; - var calls = 0; - var command = new RelayCommand(() => calls++, () => available); - Assert.False(StudioControlSurface.RunMagicScene(command).Ok); - Assert.Equal(0, calls); - available = true; - Assert.True(StudioControlSurface.RunMagicScene(command).Ok); - Assert.Equal(1, calls); - } [Fact] public void LastRenderedFrameRemainsDistinctFromAcknowledgedScene() { diff --git a/native-shell/CoreVideoPro.WinUI/Controls/StudioFooterLayoutPanel.cs b/native-shell/CoreVideoPro.WinUI/Controls/StudioFooterLayoutPanel.cs index e2ad4f18e..6f1036b46 100644 --- a/native-shell/CoreVideoPro.WinUI/Controls/StudioFooterLayoutPanel.cs +++ b/native-shell/CoreVideoPro.WinUI/Controls/StudioFooterLayoutPanel.cs @@ -4,45 +4,46 @@ namespace CoreVideoPro.WinUI.Controls; -// Keep the existing automation, transport and telemetry groups intact. Decide -// from measured content width, rather than the monitor size or display scale. +// Graphics, outputs, manual transport at the right edge, then full-width telemetry. +// Keep graphics and outputs together; spare space precedes the right-hand transport. public sealed class StudioFooterLayoutPanel : Panel { - private const double Gap = 8, RowGap = 6; - protected override Size MeasureOverride(Size availableSize) + private const double Gap = 12, RowGap = 12; + protected override Size MeasureOverride(Size available) { - if (Children.Count != 3) return base.MeasureOverride(availableSize); - Children[0].Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); - Children[1].Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); - Children[2].Measure(new Size(availableSize.Width, double.PositiveInfinity)); - var left = Children[0].DesiredSize; var transport = Children[1].DesiredSize; - var stacked = left.Width + Gap + transport.Width > availableSize.Width; - var actionsHeight = stacked ? left.Height + RowGap + transport.Height : Math.Max(left.Height, transport.Height); - return new Size(double.IsInfinity(availableSize.Width) ? left.Width + Gap + transport.Width : availableSize.Width, - actionsHeight + RowGap + Children[2].DesiredSize.Height); + if (Children.Count != 4) return base.MeasureOverride(available); + for (var i = 0; i < 3; i++) Children[i].Measure(new(double.PositiveInfinity, double.PositiveInfinity)); + Children[3].Measure(new(available.Width, double.PositiveInfinity)); + var g = Children[0].DesiredSize; var o = Children[1].DesiredSize; var t = Children[2].DesiredSize; + var wrap = g.Width + t.Width + o.Width + Gap * 2 > available.Width; + var height = wrap ? g.Height + RowGap + Math.Max(t.Height, o.Height) : Math.Max(g.Height, Math.Max(t.Height, o.Height)); + return new(double.IsInfinity(available.Width) ? g.Width + t.Width + o.Width + Gap * 2 : available.Width, + height + RowGap + Children[3].DesiredSize.Height); } - - protected override Size ArrangeOverride(Size finalSize) + protected override Size ArrangeOverride(Size final) { - if (Children.Count != 3) return base.ArrangeOverride(finalSize); - var left = Children[0].DesiredSize; var transport = Children[1].DesiredSize; - var stacked = left.Width + Gap + transport.Width > finalSize.Width; + if (Children.Count != 4) return base.ArrangeOverride(final); + var g = Children[0].DesiredSize; var o = Children[1].DesiredSize; var t = Children[2].DesiredSize; + var wrap = g.Width + t.Width + o.Width + Gap * 2 > final.Width; + var rowHeight = Math.Max(t.Height, o.Height); double telemetryTop; - if (stacked) + if (wrap) { - Children[0].Arrange(new Rect(0, 0, finalSize.Width, left.Height)); - Children[1].Arrange(new Rect(Math.Max(0, (finalSize.Width - transport.Width) / 2), left.Height + RowGap, transport.Width, transport.Height)); - telemetryTop = left.Height + RowGap + transport.Height + RowGap; + Children[0].Arrange(new(0, 0, g.Width, g.Height)); + var top = g.Height + RowGap; + Children[1].Arrange(new(0, top + rowHeight - o.Height, o.Width, o.Height)); + Children[2].Arrange(new(Math.Max(o.Width + Gap, final.Width - t.Width), top + rowHeight - t.Height, t.Width, t.Height)); + telemetryTop = top + rowHeight + RowGap; } else { - var height = Math.Max(left.Height, transport.Height); - Children[0].Arrange(new Rect(0, 0, left.Width, height)); - var remaining = finalSize.Width - left.Width - Gap; - Children[1].Arrange(new Rect(left.Width + Gap + (remaining - transport.Width) / 2, 0, transport.Width, height)); - telemetryTop = height + RowGap; + rowHeight = Math.Max(g.Height, rowHeight); + Children[0].Arrange(new(0, rowHeight - g.Height, g.Width, g.Height)); + Children[1].Arrange(new(g.Width + Gap, rowHeight - o.Height, o.Width, o.Height)); + Children[2].Arrange(new(final.Width - t.Width, rowHeight - t.Height, t.Width, t.Height)); + telemetryTop = rowHeight + RowGap; } - Children[2].Arrange(new Rect(0, telemetryTop, finalSize.Width, Children[2].DesiredSize.Height)); - return finalSize; + Children[3].Arrange(new(0, telemetryTop, final.Width, Children[3].DesiredSize.Height)); + return final; } } diff --git a/native-shell/CoreVideoPro.WinUI/Models/SceneDisplayItem.cs b/native-shell/CoreVideoPro.WinUI/Models/SceneDisplayItem.cs index 5f04958ef..23e9644a6 100644 --- a/native-shell/CoreVideoPro.WinUI/Models/SceneDisplayItem.cs +++ b/native-shell/CoreVideoPro.WinUI/Models/SceneDisplayItem.cs @@ -13,7 +13,7 @@ public sealed class SceneDisplayItem new(Windows.UI.Color.FromArgb(255, 61, 220, 151)); private static readonly SolidColorBrush ProgramBorder = - new(Windows.UI.Color.FromArgb(255, 240, 168, 92)); + new(Windows.UI.Color.FromArgb(255, 192, 61, 65)); private static readonly SolidColorBrush DefaultBackground = new(Windows.UI.Color.FromArgb(255, 16, 22, 30)); @@ -22,7 +22,7 @@ public sealed class SceneDisplayItem new(Windows.UI.Color.FromArgb(255, 18, 36, 30)); private static readonly SolidColorBrush ProgramBackground = - new(Windows.UI.Color.FromArgb(255, 28, 24, 18)); + new(Windows.UI.Color.FromArgb(255, 36, 21, 24)); private static readonly SolidColorBrush SlotIdleBrush = new(Windows.UI.Color.FromArgb(255, 30, 40, 48)); @@ -48,6 +48,7 @@ public sealed class SceneDisplayItem public IRelayCommand? DuplicateCommand { get; init; } public string Name => Scene.Name; + public Visibility EditTilesVisibility => Scene.DynamicGallery is not null ? Visibility.Visible : Visibility.Collapsed; public string LayoutLabel => string.IsNullOrWhiteSpace(Scene.Layout) ? "Custom" : Scene.Layout.Replace('-', ' '); @@ -58,17 +59,17 @@ public sealed class SceneDisplayItem public string MetaLabel => $"{LayoutLabel} · {DurationLabel}"; public string StatusLabel => - IsOnProgram ? "PGM" : IsOnPreview ? "PVW" : "IDLE"; + IsOnProgram ? "PROGRAM" : IsOnPreview ? "PREVIEW" : "IDLE"; public Brush StatusBackgroundBrush => IsOnProgram - ? new SolidColorBrush(Windows.UI.Color.FromArgb(255, 240, 168, 92)) + ? new SolidColorBrush(Windows.UI.Color.FromArgb(255, 132, 43, 47)) : IsOnPreview ? new SolidColorBrush(Windows.UI.Color.FromArgb(255, 61, 220, 151)) : new SolidColorBrush(Windows.UI.Color.FromArgb(255, 30, 40, 48)); public Brush StatusForegroundBrush => - IsOnProgram || IsOnPreview + IsOnPreview && !IsOnProgram ? new SolidColorBrush(Windows.UI.Color.FromArgb(255, 8, 14, 12)) : new SolidColorBrush(Windows.UI.Color.FromArgb(255, 184, 200, 192)); diff --git a/native-shell/CoreVideoPro.WinUI/Services/StudioControlSurface.cs b/native-shell/CoreVideoPro.WinUI/Services/StudioControlSurface.cs index 5a0aeeec0..88b2999eb 100644 --- a/native-shell/CoreVideoPro.WinUI/Services/StudioControlSurface.cs +++ b/native-shell/CoreVideoPro.WinUI/Services/StudioControlSurface.cs @@ -63,7 +63,7 @@ public static ControlInvokeResult NdiSetResult(string? refusal) => public static IReadOnlySet SupportedActionIds { get; } = new HashSet(StringComparer.Ordinal) { "zoom.join", "zoom.leave", - "transport.take", "transport.transition.set", + "transport.take", "transport.transition.set", "transport.ftb.toggle", "transport.ftb.set", "transport.record.toggle", "transport.record.set", "transport.stream.toggle", "transport.stream.set", "transport.stream.rtmp.configure", "transport.stream.srt.configure", @@ -87,7 +87,7 @@ public static ControlInvokeResult NdiSetResult(string? refusal) => "audio.mastering.set", "audio.mastering.target", "audio.vst.scan", "multiview.layout.set", "multiview.tileCount.set", "multiview.showLabels.set", "multiview.showTally.set", "multiview.showMeters.set", "multiview.showClock.set", - "automation.magic", "automation.toggle", "automation.autoTake.set", "automation.autoAssignInputs.set", + "automation.autoAssignInputs.set", "automation.lowerThirds.set", "automation.captions.set", "browser.add", "browser.remove", "browser.reload", "settings.programBuffer.set", @@ -251,6 +251,10 @@ private async Task DispatchAsync(string actionId, IReadOnly : ControlInvokeResult.Fail(_vm.Settings.JoinStatus); // ---- Transport ---------------------------------------------------------- + case "transport.ftb.toggle": + return await _vm.SetProgramBlackoutAsync(null); + case "transport.ftb.set": + return await _vm.SetProgramBlackoutAsync(Bool(args, 0)); case "transport.take": return await RunTake(_vm.TakeCommand.CanExecute(null), _vm.TakeForControlAsync).ConfigureAwait(true); case "transport.transition.set": @@ -687,13 +691,9 @@ private async Task DispatchAsync(string actionId, IReadOnly // ---- Automation --------------------------------------------------------- case "automation.magic": - return RunMagicScene(_vm.RunMagicSceneCommand); case "automation.toggle": - _vm.ToggleAutomationCommand.Execute(null); - return ControlInvokeResult.Success; case "automation.autoTake.set": - _vm.AutomationAutoTakeEnabled = Bool(args, 0); - return ControlInvokeResult.Success; + return ControlInvokeResult.Fail(CoreVideoPro.WinUI.ViewModels.MagicScene.MagicSceneCoordinator.SceneAutomationUnavailable); case "automation.autoAssignInputs.set": _vm.AutomationAutoAssignInputsEnabled = Bool(args, 0); return ControlInvokeResult.Success; @@ -990,6 +990,7 @@ public ControlState GetState() Streaming = _vm.Streaming, EngineOn = _vm.ZoomCaptureSubscribed, CanTake = _vm.CanTake, + ProgramBlackout = _vm.ProgramBlackout, // Under instances authority the strip's lower-third chip is the evidence (#1076 C2); // NativeLowerThirdVisible below stays the legacy key's own observation. LowerThirdOnAir = _vm.ManagedLowerThirdAuthority ? _vm.GraphicsLowerThirdOnAir : _vm.ProgramLowerThirdKey.IsVisible, @@ -1221,17 +1222,6 @@ private static async Task RunTransportToggle( return ControlInvokeResult.Success; } - internal static ControlInvokeResult RunMagicScene(System.Windows.Input.ICommand command) - { - if (!command.CanExecute(null)) return ControlInvokeResult.Fail("Magic Scene is unavailable in the current state."); - try - { - command.Execute(null); - return ControlInvokeResult.Success; - } - catch (Exception ex) { return ControlInvokeResult.Fail($"Magic Scene failed: {ex.Message}"); } - } - internal static async Task RunTake(bool canTake, Func> take) { if (!canTake) diff --git a/native-shell/CoreVideoPro.WinUI/Services/StudioWorkspaceLayoutProbe.cs b/native-shell/CoreVideoPro.WinUI/Services/StudioWorkspaceLayoutProbe.cs index aed2c0019..8a4c2db76 100644 --- a/native-shell/CoreVideoPro.WinUI/Services/StudioWorkspaceLayoutProbe.cs +++ b/native-shell/CoreVideoPro.WinUI/Services/StudioWorkspaceLayoutProbe.cs @@ -23,6 +23,11 @@ internal static async Task CheckAsync(Window window, List checks, string try { await using var studio = new StudioViewModel(StudioStartupBootstrap.Create(new InMemoryProductionOutputPreferencesStore(), startInteractiveServices: false)); + studio.TryCreateNamedScene("tiles", "CoreVideo Tiles", out _); + var tilesSceneId = studio.PreviewSceneId; + var originalProgram = studio.ActiveSceneId; + studio.ActiveTab = CoreVideoPro.WinUI.Models.StudioTab.Studio; + studio.ShowGraphicsPanel = false; var page = new StudioWorkspace { ViewModel = studio, RequestedTheme = ElementTheme.Dark }; window.Content = page; window.AppWindow.MoveAndResize(new RectInt32(-20000, -20000, 1920, 1080)); @@ -47,9 +52,10 @@ internal static async Task CheckAsync(Window window, List checks, string if (Math.Min(a.X + automation.ActualWidth, t.X + transport.ActualWidth) - Math.Max(a.X, t.X) > 1 && Math.Min(a.Y + automation.ActualHeight, t.Y + transport.ActualHeight) - Math.Max(a.Y, t.Y) > 1) failures.Add($"{width}x{height}: automation and transport overlap"); - if (width >= 1600 && Math.Abs(a.Y - t.Y) > 1) failures.Add($"{width}x{height}: footer wrapped to two rows (automation y={a.Y:F0}, transport y={t.Y:F0}; footer {footer.ActualWidth:F0}px, automation {automation.DesiredSize.Width:F0}px, transport {transport.DesiredSize.Width:F0}px); owner ruling 2026-11-01 requires one row"); - ((Microsoft.UI.Xaml.Controls.TextBlock)page.FindName("AssignedLookLabel")).Text = "Panel discussion · Quarterly customer leadership summit guest introduction"; - foreach(var name in new[] {"ProgramOutputHealth","StreamOutputHealth","RecordOutputHealth","AutomationStatusText"}) + if (width >= 1600 && Math.Abs(a.Y + automation.ActualHeight - t.Y - transport.ActualHeight) > 1) failures.Add($"{width}x{height}: footer wrapped to two rows (automation y={a.Y:F0}, transport y={t.Y:F0}; footer {footer.ActualWidth:F0}px, automation {automation.DesiredSize.Width:F0}px, transport {transport.DesiredSize.Width:F0}px); owner ruling 2026-11-01 requires one row"); + studio.ProgramBlackoutLabel = "Restoring picture"; + var savedHealth = new[] {"ProgramOutputHealth","StreamOutputHealth","RecordOutputHealth"}.ToDictionary(name => name, name => ((Microsoft.UI.Xaml.Controls.TextBlock)page.FindName(name)).Text); + foreach(var name in new[] {"ProgramOutputHealth","StreamOutputHealth","RecordOutputHealth"}) ((Microsoft.UI.Xaml.Controls.TextBlock)page.FindName(name)).Text="Waiting for destination connection and source readiness"; page.UpdateLayout(); var after=transport.TransformToVisual(page).TransformPoint(new(0,0)); @@ -59,10 +65,10 @@ internal static async Task CheckAsync(Window window, List checks, string var actionRow = (FrameworkElement)page.FindName("FooterOutputActions"); var actionTop = actionRow.TransformToVisual(page).TransformPoint(new(0, 0)).Y; if (Math.Abs(actionRow.ActualHeight - 48) > 1) failures.Add($"{width}x{height}: output actions are not a single 48px row"); - if (Math.Abs(a.Y - t.Y) < 1 && Math.Abs(a.Y - actionTop) > 1) failures.Add($"{width}x{height}: adjacent automation and output action baselines differ"); + if (Math.Abs(a.Y - t.Y) < 1 && Math.Abs(a.Y + automation.ActualHeight - actionTop - 48) > 1) failures.Add($"{width}x{height}: adjacent automation and output action baselines differ"); var telemetry = (FrameworkElement)page.FindName("FooterTelemetry"); var headings = Descendants(telemetry).OfType() - .Where(text => text.Text is "PROGRAM" or "STREAM" or "RECORD" or "FRAME DROPS" or "ELAPSED").ToArray(); + .Where(text => text.Text is "PROGRAM" or "STREAM" or "RECORD" or "DROPPED" or "FEEDS").ToArray(); var headingTops = headings.Select(text => text.TransformToVisual(page).TransformPoint(new(0, 0)).Y).ToArray(); if (headings.Length != 5 || headingTops.Max() - headingTops.Min() > 1) failures.Add($"{width}x{height}: telemetry headings have inconsistent baselines"); if (Descendants(footer).OfType().Any(text => text.Text == "Outputs idle")) failures.Add("Redundant persistent output summary returned"); @@ -77,9 +83,24 @@ internal static async Task CheckAsync(Window window, List checks, string } CheckMultiviewMessagePriority(page, failures, $"{width}x{height}"); checks.Add($"Multiview assignment, preparation, renderer failure and available-picture states are mutually exclusive at {width}x{height}"); + foreach (var entry in savedHealth) ((Microsoft.UI.Xaml.Controls.TextBlock)page.FindName(entry.Key)).Text = entry.Value; + studio.ProgramBlackoutLabel = "Fade to Black"; + studio.Transport.ApplySnapshot(new NativeMediaCoreStateSnapshot(), false, false, "1080p60", false); + await Task.Delay(30); page.UpdateLayout(); await ReviewImageWriter.SaveAsync(page, Path.Combine(directory, $"studio-{width}x{height}.png")); - checks.Add($"Full Studio: {controls.Length} footer actions contained, automation/transport disjoint, long assigned label bounded at {width}x{height}; raster scale {page.XamlRoot.RasterizationScale}; native video pixels excluded"); + checks.Add($"Full Studio: {controls.Length} footer actions contained, automation/transport disjoint, FTB and status labels bounded at {width}x{height}; raster scale {page.XamlRoot.RasterizationScale}; native video pixels excluded"); } + studio.EditTiles(tilesSceneId); + await Task.Delay(150); page.UpdateLayout(); + var tilesPage = Descendants(page).OfType().Single(); + if (studio.PreviewSceneId != tilesSceneId || studio.ActiveSceneId != originalProgram || tilesPage.Workspace?.IsWallSelected != true) + failures.Add("Edit Tiles did not open the selected wall while preserving Program"); + checks.Add("Edit Tiles opens the chosen wall inspector without taking it to Program"); + studio.ActiveTab = CoreVideoPro.WinUI.Models.StudioTab.Studio; + studio.ProgramBlackout = true; studio.ProgramBlackoutLabel = "BLACK · Restore"; + await Task.Delay(30); page.UpdateLayout(); + await ReviewImageWriter.SaveAsync(page, Path.Combine(directory, "studio-ftb-active.png")); + studio.ProgramBlackout = false; studio.ProgramBlackoutLabel = "Fade to Black"; page.Width = 1424; page.Height = 860; foreach (var state in new[] { "preparing", "producing", "interrupted", "failed", "finalizing", "completed", "idle" }) { diff --git a/native-shell/CoreVideoPro.WinUI/ViewModels/MagicScene/MagicSceneCoordinator.cs b/native-shell/CoreVideoPro.WinUI/ViewModels/MagicScene/MagicSceneCoordinator.cs index 9e4c53115..914f4f109 100644 --- a/native-shell/CoreVideoPro.WinUI/ViewModels/MagicScene/MagicSceneCoordinator.cs +++ b/native-shell/CoreVideoPro.WinUI/ViewModels/MagicScene/MagicSceneCoordinator.cs @@ -21,6 +21,11 @@ namespace CoreVideoPro.WinUI.ViewModels.MagicScene; public sealed partial class MagicSceneCoordinator : ObservableObject { private readonly IMagicSceneHost _host; + // First-release scope: only isolated characterization tests can opt in. + // No settings, environment variable, license or control command enables this. + private readonly bool _sceneAutomationAvailable; + public const string SceneAutomationUnavailable = + "Scene automation is unavailable in this release. Use CoreVideo Tiles and manual Take."; private readonly IAutomationTimer _timer; private readonly Func _clock; private readonly DirectorBindingHoldPolicy _bindingHold = new(); @@ -95,8 +100,16 @@ public bool AutomationCaptionsEnabled ProductionStateHelper.BuildAutomationRecommendation([], ProductionCatalog.Scenes); public MagicSceneCoordinator(IMagicSceneHost host, Func? clock = null) + : this(host, clock, sceneAutomationAvailable: false) + { + } + + internal MagicSceneCoordinator(IMagicSceneHost host, Func? clock, bool sceneAutomationAvailable) { _host = host; + _sceneAutomationAvailable = sceneAutomationAvailable; + _automationAutoTakeEnabled = sceneAutomationAvailable; + _automationLowerThirdsEnabled = sceneAutomationAvailable; var start = System.Diagnostics.Stopwatch.GetTimestamp(); _clock = clock ?? (() => DateTimeOffset.UnixEpoch + System.Diagnostics.Stopwatch.GetElapsedTime(start)); _timer = host.CreateAutomationTimer(EvaluateAutomationPolicy); @@ -153,6 +166,7 @@ private void CuePreview(string sceneId) [RelayCommand] private void ToggleAutomation() { + if (!_sceneAutomationAvailable) { DisableSceneAutomation(); return; } ProductionMode = ProductionMode == ProductionMode.SetAndForget ? ProductionMode.Manual : ProductionMode.SetAndForget; @@ -169,6 +183,7 @@ private void ToggleAutomation() [RelayCommand] private void RunMagicScene() { + if (!_sceneAutomationAvailable) { DisableSceneAutomation(); return; } _host.RefreshAutomationRecommendation(); if (!_host.IsInMeeting) { @@ -214,6 +229,7 @@ public void EvaluateAutomationPolicy() private void EvaluateAutomationPolicyCore() { + if (!_sceneAutomationAvailable) { DisableSceneAutomation(reportCommand: false); return; } _host.RefreshAutomationRecommendation(); ObserveAutomationSession(); if (ProductionMode != ProductionMode.SetAndForget) @@ -459,6 +475,11 @@ public string BuildAutoProductionReadout() partial void OnProductionModeChanged(ProductionMode value) { + if (!_sceneAutomationAvailable && value != ProductionMode.Manual) + { + DisableSceneAutomation(); + return; + } _modeGeneration++; if (value != ProductionMode.SetAndForget) _timer.Stop(); _pendingSceneId = null; @@ -475,7 +496,11 @@ partial void OnProductionModeChanged(ProductionMode value) OnPropertyChanged(nameof(AutoProductionReadout)); } - partial void OnAutomationAutoTakeEnabledChanged(bool value) => OnAutomationPolicyChanged(); + partial void OnAutomationAutoTakeEnabledChanged(bool value) + { + if (!_sceneAutomationAvailable && value) { AutomationAutoTakeEnabled = false; return; } + OnAutomationPolicyChanged(); + } partial void OnAutomationPreferScreenShareChanged(bool value) => OnAutomationPolicyChanged(); @@ -533,6 +558,19 @@ private void ResetPendingCandidate() _bindingHold.Reset(); } + private void DisableSceneAutomation(bool reportCommand = true) + { + _timer.Stop(); + ResetPendingCandidate(); + _lastTakenBindings = null; + ProductionMode = ProductionMode.Manual; + AutomationAutoTakeEnabled = false; + AutomationLastAction = SceneAutomationUnavailable; + MagicSceneStatus = SceneAutomationUnavailable; + if (reportCommand) _host.CommandStatus = SceneAutomationUnavailable; + RefreshTransportAutomationState(); + } + private void ObserveAutomationSession() { var current = (_host.CoreEpoch, _host.RosterEpoch, _host.IsMediaCoreRunning, _host.IsInMeeting); diff --git a/native-shell/CoreVideoPro.WinUI/ViewModels/SceneWorkspaceViewModel.cs b/native-shell/CoreVideoPro.WinUI/ViewModels/SceneWorkspaceViewModel.cs index 70fa00692..cb341e910 100644 --- a/native-shell/CoreVideoPro.WinUI/ViewModels/SceneWorkspaceViewModel.cs +++ b/native-shell/CoreVideoPro.WinUI/ViewModels/SceneWorkspaceViewModel.cs @@ -95,6 +95,7 @@ private void OnStudioChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(StudioViewModel.PreviewSceneId)) { _sceneId = _studio.PreviewSceneId;_resetSavedFingerprint=true; _undo.Clear();_redo.Clear();_initialRects.Clear();Selected=null;UpdateHistory(); } + if (e.PropertyName == nameof(StudioViewModel.PendingTilesEditorSceneId)) Refresh(); if (e.PropertyName is nameof(StudioViewModel.PreviewCanvasLayers) or nameof(StudioViewModel.PreviewSceneId)) Refresh(); if(e.PropertyName is nameof(StudioViewModel.GalleryMembershipSummary) or nameof(StudioViewModel.GalleryMemberChoices)) RefreshMembers(); if(e.PropertyName==nameof(StudioViewModel.ActiveSceneId))OnPropertyChanged(nameof(PreviewBadge)); @@ -109,6 +110,11 @@ public void Refresh() for (var i=0;il.ItemId==selection) ?? Items.FirstOrDefault(); + if (_studio.PendingTilesEditorSceneId == _studio.PreviewSceneId) + { + Selected = Items.FirstOrDefault(l => l.IsTilesWall) ?? Selected; + _studio.PendingTilesEditorSceneId = null; + } OnSelectedChanged(Selected);AddTilesCommand.NotifyCanExecuteChanged(); OnPropertyChanged(nameof(TransparentBackground)); RefreshMembers(); diff --git a/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.GraphicsMode.cs b/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.GraphicsMode.cs index 7301e04c3..a4744c8b4 100644 --- a/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.GraphicsMode.cs +++ b/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.GraphicsMode.cs @@ -38,7 +38,7 @@ private bool ApplyGraphicsMode(string? value) } /// The compact playlist column in the Studio tab (spec: right panel of the console). - [ObservableProperty] private bool _showGraphicsPanel = true; + [ObservableProperty] private bool _showGraphicsPanel; /// Design mode shows the template-first builder by default; the old workspace /// (show templates, saved cues and styles, packages, automation) sits behind "Cues & automation". diff --git a/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.ProgramBlackout.cs b/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.ProgramBlackout.cs new file mode 100644 index 000000000..8f847f5a1 --- /dev/null +++ b/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.ProgramBlackout.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CoreVideoPro.Control; +using CoreVideoPro.MediaCore.Models; + +namespace CoreVideoPro.WinUI.ViewModels; + +public sealed partial class StudioViewModel +{ + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ProgramBlackoutBrush))] + private bool _programBlackout; + public Microsoft.UI.Xaml.Media.SolidColorBrush ProgramBlackoutBrush => new(ProgramBlackout + ? Windows.UI.Color.FromArgb(255, 240, 82, 82) : Windows.UI.Color.FromArgb(255, 65, 72, 78)); + [ObservableProperty] private string _programBlackoutLabel = "Fade to Black"; + private bool _blackoutInFlight; + + private void ApplyProgramBlackoutSnapshot(NativeMediaCoreStateSnapshot snapshot) + { + ProgramBlackout = snapshot.ProgramBlackout == true; + ProgramBlackoutLabel = snapshot.ProgramBlackout switch + { + true when snapshot.ProgramBlackoutOpacity < .999 => "Fading to black", + true => "BLACK · Restore", + false when snapshot.ProgramBlackoutOpacity > .001 => "Restoring picture", + _ => "Fade to Black" + }; + } + + [RelayCommand] + private async Task ToggleProgramBlackout() + { + var result = await SetProgramBlackoutAsync(null); + if (!result.Ok) CommandStatus = result.Error ?? "FTB unavailable"; + } + + // All callers share admission and native observation, including remote set/toggle. + public async Task SetProgramBlackoutAsync(bool? enabled) + { + if (_blackoutInFlight) return ControlInvokeResult.Fail("FTB command is already in progress."); + if (!_bridge.Running) return ControlInvokeResult.Fail("FTB unavailable: media core is offline."); + _blackoutInFlight = true; + try + { + var before = await _bridge.PollSnapshotAsync(); + if (before.ProgramBlackout is not bool current) + return ControlInvokeResult.Fail("This media core does not support Fade to Black."); + var target = enabled ?? !current; + var snapshot = await _bridge.SyncAsync([new NativeMediaCoreCommand + { + Type = "program-blackout", + ExtensionData = new() + { + ["enabled"] = JsonSerializer.SerializeToElement(target), + ["durationMs"] = JsonSerializer.SerializeToElement(300) + } + }]); + ApplyProgramBlackoutSnapshot(snapshot); + if (snapshot.ProgramBlackout != target) + return ControlInvokeResult.Fail("Media core did not acknowledge FTB."); + CommandStatus = target ? "Fading Program to black — audio continues" : "Restoring Program picture"; + return ControlInvokeResult.Success; + } + catch (Exception ex) + { + CommandStatus = $"FTB unavailable: {ex.Message}"; + return ControlInvokeResult.Fail(CommandStatus); + } + finally { _blackoutInFlight = false; } + } +} diff --git a/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.TilesNavigation.cs b/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.TilesNavigation.cs new file mode 100644 index 000000000..f5d484e7d --- /dev/null +++ b/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.TilesNavigation.cs @@ -0,0 +1,16 @@ +using CoreVideoPro.WinUI.Models; +namespace CoreVideoPro.WinUI.ViewModels; +public sealed partial class StudioViewModel +{ + public string? PendingTilesEditorSceneId { get; set; } + public void EditTiles(string sceneId) + { + if (!Scenes.Any(scene => scene.Id == sceneId && scene.DynamicGallery is not null)) return; + SelectSceneCommand.Execute(sceneId); + if (PreviewSceneId != sceneId) return; + PendingTilesEditorSceneId = sceneId; + ActiveTab = StudioTab.Sources; + OnPropertyChanged(nameof(PendingTilesEditorSceneId)); + CommandStatus = $"Editing Tiles in {PreviewScene.Name}"; + } +} diff --git a/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.cs b/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.cs index fe0f8bcbd..b03dfafc6 100644 --- a/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.cs +++ b/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.cs @@ -2099,11 +2099,11 @@ private async void RunNdiOutputSync(string reason) public Brush RecordButtonBackground => Recording ? new SolidColorBrush(Windows.UI.Color.FromArgb(255, 229, 72, 77)) - : new SolidColorBrush(Windows.UI.Color.FromArgb(255, 74, 32, 32)); + : new SolidColorBrush(Windows.UI.Color.FromArgb(255, 32, 38, 40)); public Brush RecordButtonBorder => Recording ? new SolidColorBrush(Windows.UI.Color.FromArgb(255, 229, 72, 77)) - : new SolidColorBrush(Windows.UI.Color.FromArgb(255, 224, 90, 90)); + : new SolidColorBrush(Windows.UI.Color.FromArgb(255, 43, 51, 55)); public Brush RecordButtonForeground => Recording ? new SolidColorBrush(Windows.UI.Color.FromArgb(255, 255, 248, 248)) @@ -2111,11 +2111,11 @@ private async void RunNdiOutputSync(string reason) public Brush StreamButtonBackground => Streaming ? new SolidColorBrush(Windows.UI.Color.FromArgb(255, 61, 220, 151)) - : new SolidColorBrush(Windows.UI.Color.FromArgb(255, 30, 48, 64)); + : new SolidColorBrush(Windows.UI.Color.FromArgb(255, 32, 38, 40)); public Brush StreamButtonBorder => Streaming ? new SolidColorBrush(Windows.UI.Color.FromArgb(255, 61, 220, 151)) - : new SolidColorBrush(Windows.UI.Color.FromArgb(255, 90, 159, 212)); + : new SolidColorBrush(Windows.UI.Color.FromArgb(255, 43, 51, 55)); public Brush StreamButtonForeground => Streaming ? new SolidColorBrush(Windows.UI.Color.FromArgb(255, 7, 17, 14)) @@ -10578,6 +10578,7 @@ void LogIfSlow(string exit) // The compositor is always on, so surfaces always accept the latest program frame. _surfaces.OnMediaCoreSnapshot(snapshot); + ApplyProgramBlackoutSnapshot(snapshot); ApplyRtmpIngestSnapshot(snapshot); Tm("surfaces"); diff --git a/native-shell/CoreVideoPro.WinUI/Views/AutomationPage.xaml b/native-shell/CoreVideoPro.WinUI/Views/AutomationPage.xaml index 4f3e4fec3..ec56fc5f8 100644 --- a/native-shell/CoreVideoPro.WinUI/Views/AutomationPage.xaml +++ b/native-shell/CoreVideoPro.WinUI/Views/AutomationPage.xaml @@ -2,195 +2,11 @@ x:Class="CoreVideoPro.WinUI.Views.AutomationPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - - - - - - - - - - - - - - - - - - - - - - - -