From f083a4fbaec74e0780216cac06193d56eddf7e64 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 1 Jun 2026 14:43:12 +0100 Subject: [PATCH] Add Mimir output controls --- docs/implementation-plan.md | 27 +-- notes/current-system-map.md | 23 ++- notes/fresh-workspace-handoff.md | 19 +- src/Mimir.Runtime/MimirRuntime.cs | 91 ++++++--- .../Synchronization/MimirProgramRecorder.cs | 33 +++- .../Synchronization/MimirProgramStreamer.cs | 179 ++++++++++++++++++ state/evidence.jsonl | 1 + state/map.yaml | 14 +- 8 files changed, 321 insertions(+), 66 deletions(-) create mode 100644 src/Mimir.Runtime/Synchronization/MimirProgramStreamer.cs diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index b938482..554555f 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -281,17 +281,22 @@ a named invariant that the native runtime cannot protect yet. with preset paths and strength; until the renderer grows LUT texture sampling, preset exposure and bloom are mapped into existing `GraphicsSettings`. `Mimir.BufferSmoke --presentation-control-smoke` proves the state owner. -- `MimirProgramRecorder` is the first app-owned recording control. The Mimir UI - exposes `Record` / `Stop` in the Video Sources rail, tracks child-process - status, writes the latest MP4 under `artifacts/runtime/stream-proof/`, - and kills the process tree on stop. The current actuator deliberately delegates - to `scripts/record-stream-proof-output.ps1`, so the app records the same - stream-proof composite intended for the Yggdrasil/public path: Raven program - feed, Kiyo picture-in-picture, Starfire Scarlett audio, and NVENC/AAC - broadcast settings. `MIMIR_RECORD_START_RAVEN=1` lets the app ask the script - to start Raven's sender; otherwise it records from the already-running feed. - Native program-output recording can replace that actuator later behind the - same UI owner. +- The right rail `Output` panel owns OBS-like program actions below the + inspector. `MimirProgramRecorder` exposes `Record` / `Stop`, tracks + child-process status, writes timestamped MP4 receipts under + `C:\Users\Meta\Videos\Mimir` by default, and kills the process tree on stop. + `MIMIR_RECORD_OUTPUT_DIR` can redirect receipts. The current actuator + deliberately delegates to `scripts/record-stream-proof-output.ps1`, so app + recording produces the same stream-proof composite intended for the + Yggdrasil/public path: Raven program feed, Kiyo picture-in-picture, Starfire + Scarlett audio, and NVENC/AAC broadcast settings. + `MIMIR_RECORD_START_RAVEN=1` lets the app ask the script to start Raven's + sender; otherwise it records from the already-running feed. `MimirProgramStreamer` + exposes `Live` / `Stop` for the configured `Mimir.Broadcast` RTMP path, but it + refuses to publish unless `MIMIR_STREAM_INPUT` is set or + `MIMIR_STREAM_ALLOW_TEST_PATTERN=1` explicitly authorizes a synthetic smoke. + Native program-output recording/streaming can replace those actuators later + behind the same UI owner. - `MimirSceneEditorState` owns the new Mimir-window editor graph. It is not the OBS program output: it owns editor camera, selected node, sensor-feed panels, SDF text-panel nodes, model placeholders, visibility, locks, transforms, reset diff --git a/notes/current-system-map.md b/notes/current-system-map.md index 1e6dd62..4bf2f76 100644 --- a/notes/current-system-map.md +++ b/notes/current-system-map.md @@ -346,16 +346,19 @@ proves the boundary: alignment frames queue the cleaner DSP and input block, and cleaner output frames are what enter `NativeProgram` stem publication. This is the first hot-lane cleaner socket, not proof that the final studio denoiser is solved. -`MimirProgramRecorder` is the app feature for local broadcast-proof recording, -not for the isolated Kiyo/Faust mic receipt. The Video Sources rail exposes -`Record` / `Stop`; the recorder owns child-process status, latest output path, -and process-tree shutdown. Its current actuator is -`scripts/record-stream-proof-output.ps1`, so app recording produces the same -stream-proof composite intended for Yggdrasil/public judgment: Raven program -feed, Kiyo picture-in-picture, Starfire Scarlett audio, and NVENC/AAC broadcast -settings under `artifacts/runtime/stream-proof/`. `MIMIR_RECORD_START_RAVEN=1` -asks the app to start Raven's sender for the receipt. Later native recording -should replace the actuator behind this owner, not add another UI truth. +The right rail `Output` panel is the app feature for local broadcast-proof +recording and live publish control, not the isolated Kiyo/Faust mic receipt. +`MimirProgramRecorder` owns `Record` / `Stop`, child-process status, timestamped +MP4 receipts under `C:\Users\Meta\Videos\Mimir` by default, and process-tree +shutdown. Its current actuator is `scripts/record-stream-proof-output.ps1`, so +app recording produces the same stream-proof composite intended for +Yggdrasil/public judgment: Raven program feed, Kiyo picture-in-picture, Starfire +Scarlett audio, and NVENC/AAC broadcast settings. `MIMIR_RECORD_START_RAVEN=1` +asks the app to start Raven's sender for the receipt. `MimirProgramStreamer` +owns `Live` / `Stop` for the `Mimir.Broadcast` RTMP path; it requires +`MIMIR_STREAM_INPUT` unless `MIMIR_STREAM_ALLOW_TEST_PATTERN=1` explicitly +authorizes a synthetic smoke stream. Later native recording/streaming should +replace these actuators behind this owner, not add another UI truth. `MimirTargetedBioacousticProbePlanner` is the matching "ask the room a better question" owner. It consumes per-source spectral residuals and optional source pathology notes, then emits gain-bounded shaped probe bands for the diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 88b86ce..ebf29e2 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -175,15 +175,18 @@ Get-Content .\state\evidence.jsonl -Tail 8 Fresh run `20260601-051044` measured cardioid delay 359.398 samples (7487.452 us) at 48 kHz and applied holdback 359.398/0.000 to shotgun/cardioid before cleanup. -- `MimirProgramRecorder` exposes local broadcast-proof recording as a Mimir app - feature. The Video Sources rail has `Record` / `Stop`, status text, and latest - output under - `artifacts/runtime/stream-proof/mimir-program-record-latest.mp4`. It currently - delegates to `scripts/record-stream-proof-output.ps1`, so the app records the - same Raven + Kiyo PIP + Starfire Scarlett NVENC/AAC composite intended for +- The right rail `Output` panel now exposes OBS-like program controls below the + inspector. `MimirProgramRecorder` owns `Record` / `Stop`, status text, and + timestamped receipts under `C:\Users\Meta\Videos\Mimir` by default + (`MIMIR_RECORD_OUTPUT_DIR` overrides). It delegates to + `scripts/record-stream-proof-output.ps1`, so the app records the same Raven + + Kiyo PIP + Starfire Scarlett NVENC/AAC composite intended for Yggdrasil/public judgment. `MIMIR_RECORD_START_RAVEN=1` asks it to launch the - Raven sender too. Native program-output recording should replace that - actuator behind the same owner. + Raven sender too. `MimirProgramStreamer` owns `Live` / `Stop` for + `Mimir.Broadcast`; it requires `MIMIR_STREAM_INPUT` unless + `MIMIR_STREAM_ALLOW_TEST_PATTERN=1` explicitly allows a synthetic smoke. + Native program-output recording/streaming should replace those actuators + behind the same owner. - OBS receives final program surfaces; it does not own synchronization. ## Current Pressure diff --git a/src/Mimir.Runtime/MimirRuntime.cs b/src/Mimir.Runtime/MimirRuntime.cs index fd46cfe..2c4e55d 100644 --- a/src/Mimir.Runtime/MimirRuntime.cs +++ b/src/Mimir.Runtime/MimirRuntime.cs @@ -45,6 +45,7 @@ public sealed class MimirRuntime : IAquariumRuntime, IAquariumRuntimeServicesRec private readonly MimirPresentationControlState presentationControls = new(); private readonly MimirSceneEditorState sceneEditor = new(); private readonly MimirProgramRecorder programRecorder = new(); + private readonly MimirProgramStreamer programStreamer = new(); private readonly MimirProgramSurfaceConfigDocument programSurfaceConfig; private readonly MimirObsStemPublicationState obsStemPublication = new(MimirObsPublicationConfigurations.NativeProgram); private readonly MimirObsStemSharedMemoryPublisher? obsStemPublisher; @@ -827,6 +828,7 @@ public sealed class MimirRuntime : IAquariumRuntime, IAquariumRuntimeServicesRec UpdateAudioSpectra(); UpdateAudioSynchronization(); programRecorder.Refresh(); + programStreamer.Refresh(); EmitTelemetry(); } @@ -858,6 +860,7 @@ public sealed class MimirRuntime : IAquariumRuntime, IAquariumRuntimeServicesRec { obsStemPublisher?.Dispose(); programRecorder.Dispose(); + programStreamer.Dispose(); synchronization.Dispose(); } @@ -871,12 +874,6 @@ public sealed class MimirRuntime : IAquariumRuntime, IAquariumRuntimeServicesRec shell.Pane("mimir.video-sources", "Video Sources", graph => { graph.Text("video.program-summary", DescribeProgramVideo, "caption", weight: 0.75f); - graph.Row("video.program-recording", row => - { - row.Button("program.record", "Record", StartProgramRecording, weight: 0.7f); - row.Button("program.record-stop", "Stop", programRecorder.Stop, weight: 0.55f); - row.Text("program.record-status", programRecorder.Describe, "caption", weight: 1.65f); - }, weight: 0.65f); graph.Text("video.source-list-title", "Program Sources", "strong", weight: 0.55f); for (var slot = 0; slot < VideoSourceListSlotCount; slot++) { @@ -926,34 +923,56 @@ public sealed class MimirRuntime : IAquariumRuntime, IAquariumRuntimeServicesRec }, weight: 0.95f); }, weight: 1.75f); - shell.Pane("mimir.inspector", "Inspector", inspector => + shell.Vertical("mimir.right-rail", right => { - inspector.Text("inspector.selection", sceneEditor.DescribeSelection, "strong"); - inspector.Options("inspector.video-feed", "Feed", () => presentationControls.SelectedVideoIndex, SelectVideoLayer, VideoFeedOptions()); - inspector.Vertical("inspector.source-controls", source => + right.Pane("mimir.inspector", "Inspector", inspector => { - source.Toggle("video.visible", "Visible", () => sceneEditor.SelectedNode?.Visible ?? false, SetSelectedVideoVisible); - source.Toggle("video.solo", "Solo", () => presentationControls.SelectedVideo?.Solo ?? false, SetSelectedVideoSolo); - source.Slider("video.opacity", "Opacity", () => presentationControls.SelectedVideo?.Opacity ?? 1.0f, SetSelectedVideoOpacity, 0.0f, 1.0f, "0.00"); - source.Text("inspector.program-placement", "Program Frame", "strong"); - source.Slider("program.x", "X", () => sceneEditor.SelectedProgramX, sceneEditor.SetSelectedProgramX, 0.0f, 1.0f, "0.000"); - source.Slider("program.y", "Y", () => sceneEditor.SelectedProgramY, sceneEditor.SetSelectedProgramY, 0.0f, 1.0f, "0.000"); - source.Slider("program.w", "W", () => sceneEditor.SelectedProgramWidth, sceneEditor.SetSelectedProgramWidth, 0.01f, 1.0f, "0.000"); - source.Slider("program.h", "H", () => sceneEditor.SelectedProgramHeight, sceneEditor.SetSelectedProgramHeight, 0.01f, 1.0f, "0.000"); - source.Slider("program.rotation", "Rotation", () => sceneEditor.SelectedNode?.Transform.RotationRadians ?? 0.0f, sceneEditor.SetSelectedRotation, -MathF.PI, MathF.PI, "0.00"); - source.Options("program.fit-mode", "Fit", () => sceneEditor.SelectedFitModeIndex, sceneEditor.SetSelectedFitModeIndex, sceneEditor.FitModeOptions); - source.Row("program.fit", fit => + inspector.Text("inspector.selection", sceneEditor.DescribeSelection, "strong"); + inspector.Options("inspector.video-feed", "Feed", () => presentationControls.SelectedVideoIndex, SelectVideoLayer, VideoFeedOptions()); + inspector.Vertical("inspector.source-controls", source => { - fit.Button("program.center", "Center", sceneEditor.CenterSelectedProgramLayer); - fit.Button("program.fit-selected", "Fit", sceneEditor.FitSelectedProgramLayer); - fit.Button("program.fill-selected", "Fill", sceneEditor.FillSelectedProgramLayer); - }); - source.Row("program.order", order => + source.Toggle("video.visible", "Visible", () => sceneEditor.SelectedNode?.Visible ?? false, SetSelectedVideoVisible); + source.Toggle("video.solo", "Solo", () => presentationControls.SelectedVideo?.Solo ?? false, SetSelectedVideoSolo); + source.Slider("video.opacity", "Opacity", () => presentationControls.SelectedVideo?.Opacity ?? 1.0f, SetSelectedVideoOpacity, 0.0f, 1.0f, "0.00"); + source.Text("inspector.program-placement", "Program Frame", "strong"); + source.Slider("program.x", "X", () => sceneEditor.SelectedProgramX, sceneEditor.SetSelectedProgramX, 0.0f, 1.0f, "0.000"); + source.Slider("program.y", "Y", () => sceneEditor.SelectedProgramY, sceneEditor.SetSelectedProgramY, 0.0f, 1.0f, "0.000"); + source.Slider("program.w", "W", () => sceneEditor.SelectedProgramWidth, sceneEditor.SetSelectedProgramWidth, 0.01f, 1.0f, "0.000"); + source.Slider("program.h", "H", () => sceneEditor.SelectedProgramHeight, sceneEditor.SetSelectedProgramHeight, 0.01f, 1.0f, "0.000"); + source.Slider("program.rotation", "Rotation", () => sceneEditor.SelectedNode?.Transform.RotationRadians ?? 0.0f, sceneEditor.SetSelectedRotation, -MathF.PI, MathF.PI, "0.00"); + source.Options("program.fit-mode", "Fit", () => sceneEditor.SelectedFitModeIndex, sceneEditor.SetSelectedFitModeIndex, sceneEditor.FitModeOptions); + source.Row("program.fit", fit => + { + fit.Button("program.center", "Center", sceneEditor.CenterSelectedProgramLayer); + fit.Button("program.fit-selected", "Fit", sceneEditor.FitSelectedProgramLayer); + fit.Button("program.fill-selected", "Fill", sceneEditor.FillSelectedProgramLayer); + }); + source.Row("program.order", order => + { + order.Button("program.layer-up", "Layer Up", () => MoveSelectedVideoLayer(-1)); + order.Button("program.layer-down", "Layer Down", () => MoveSelectedVideoLayer(1)); + }); + }, isVisible: () => sceneEditor.HasSelectedProgramSource); + }, weight: 1.05f); + + right.Pane("mimir.output", "Output", output => + { + output.Text("output.program-summary", DescribeProgramOutput, "caption", weight: 0.6f); + output.Row("output.recording", row => { - order.Button("program.layer-up", "Layer Up", () => MoveSelectedVideoLayer(-1)); - order.Button("program.layer-down", "Layer Down", () => MoveSelectedVideoLayer(1)); - }); - }, isVisible: () => sceneEditor.HasSelectedProgramSource); + row.Button("program.record", "Record", StartProgramRecording, weight: 0.7f); + row.Button("program.record-stop", "Stop", programRecorder.Stop, weight: 0.55f); + row.Text("program.record-status", programRecorder.Describe, "caption", weight: 1.75f); + }, weight: 0.7f); + output.Text("output.record-path", () => $"folder {programRecorder.OutputDirectory}", "caption", weight: 0.7f); + output.Row("output.streaming", row => + { + row.Button("program.stream", "Live", StartProgramStreaming, weight: 0.7f); + row.Button("program.stream-stop", "Stop", programStreamer.Stop, weight: 0.55f); + row.Text("program.stream-status", programStreamer.Describe, "caption", weight: 1.75f); + }, weight: 0.7f); + output.Text("output.stream-target", () => $"target {programStreamer.TargetUrl}", "caption", weight: 0.7f); + }, weight: 0.95f); }, weight: 0.95f); }); }); @@ -1135,6 +1154,11 @@ public sealed class MimirRuntime : IAquariumRuntime, IAquariumRuntimeServicesRec programRecorder.Start(); } + private void StartProgramStreaming() + { + programStreamer.Start(); + } + private string DescribeVideoSourceSlot(int index) { var feeds = presentationControls.VideoFeeds; @@ -1320,6 +1344,13 @@ public sealed class MimirRuntime : IAquariumRuntime, IAquariumRuntimeServicesRec return string.Join(" | ", active); } + private string DescribeProgramOutput() + { + var videoCount = presentationControls.VideoFeeds.Count(feed => presentationControls.IncludesVideo(feed.SourceId)); + var audioCount = presentationControls.AudioFeeds.Count; + return $"program {videoCount} video / {audioCount} audio"; + } + private string DescribePostprocess() { var post = presentationControls.Postprocess; diff --git a/src/Mimir.Runtime/Synchronization/MimirProgramRecorder.cs b/src/Mimir.Runtime/Synchronization/MimirProgramRecorder.cs index a9c99a0..41af6cc 100644 --- a/src/Mimir.Runtime/Synchronization/MimirProgramRecorder.cs +++ b/src/Mimir.Runtime/Synchronization/MimirProgramRecorder.cs @@ -15,13 +15,16 @@ public sealed class MimirProgramRecorder : IDisposable this.repositoryRoot = string.IsNullOrWhiteSpace(repositoryRoot) ? ResolveRepositoryRoot() : Path.GetFullPath(repositoryRoot); - LatestOutputPath = Path.Combine(this.repositoryRoot, "artifacts", "runtime", "stream-proof", "mimir-program-record-latest.mp4"); + OutputDirectory = ResolveOutputDirectory(); + LatestOutputPath = CreateDefaultOutputPath(OutputDirectory); } public bool IsRecording => process is { HasExited: false }; public string LatestOutputPath { get; private set; } + public string OutputDirectory { get; } + public string Describe() { Refresh(); @@ -53,7 +56,7 @@ public sealed class MimirProgramRecorder : IDisposable var duration = Math.Max(1, durationSeconds ?? ReadDurationSeconds()); LatestOutputPath = Path.GetFullPath(string.IsNullOrWhiteSpace(outputPath) - ? Path.Combine(repositoryRoot, "artifacts", "runtime", "stream-proof", "mimir-program-record-latest.mp4") + ? CreateDefaultOutputPath(OutputDirectory) : Path.IsPathRooted(outputPath) ? outputPath : Path.Combine(repositoryRoot, outputPath)); var logRoot = Path.Combine(repositoryRoot, "artifacts", "runtime", "stream-proof"); Directory.CreateDirectory(logRoot); @@ -164,6 +167,32 @@ public sealed class MimirProgramRecorder : IDisposable : DefaultDurationSeconds; } + private static string ResolveOutputDirectory() + { + var configured = Environment.GetEnvironmentVariable("MIMIR_RECORD_OUTPUT_DIR"); + if (!string.IsNullOrWhiteSpace(configured)) + { + return Path.GetFullPath(configured); + } + + var videos = Environment.GetFolderPath(Environment.SpecialFolder.MyVideos); + if (string.IsNullOrWhiteSpace(videos)) + { + var profile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + videos = string.IsNullOrWhiteSpace(profile) + ? @"C:\Users\Meta\Videos" + : Path.Combine(profile, "Videos"); + } + + return Path.Combine(videos, "Mimir"); + } + + private static string CreateDefaultOutputPath(string outputDirectory) + { + var stamp = DateTimeOffset.Now.ToString("yyyyMMdd-HHmmss", System.Globalization.CultureInfo.InvariantCulture); + return Path.Combine(outputDirectory, $"mimir-program-{stamp}.mp4"); + } + private static bool IsTruthy(string? value) => !string.IsNullOrWhiteSpace(value) && (string.Equals(value, "1", StringComparison.OrdinalIgnoreCase) || diff --git a/src/Mimir.Runtime/Synchronization/MimirProgramStreamer.cs b/src/Mimir.Runtime/Synchronization/MimirProgramStreamer.cs new file mode 100644 index 0000000..b3752f5 --- /dev/null +++ b/src/Mimir.Runtime/Synchronization/MimirProgramStreamer.cs @@ -0,0 +1,179 @@ +using System.Diagnostics; + +namespace Mimir.Runtime.Synchronization; + +public sealed class MimirProgramStreamer : IDisposable +{ + private readonly string repositoryRoot; + private Process? process; + private string status = "idle"; + private DateTimeOffset? startedAt; + + public MimirProgramStreamer(string? repositoryRoot = null) + { + this.repositoryRoot = string.IsNullOrWhiteSpace(repositoryRoot) + ? ResolveRepositoryRoot() + : Path.GetFullPath(repositoryRoot); + } + + public bool IsStreaming => process is { HasExited: false }; + + public string TargetUrl => Environment.GetEnvironmentVariable("MIMIR_STREAM_TARGET") + ?? "rtmp://127.0.0.1:11935/live/mimir"; + + public string Describe() + { + Refresh(); + if (IsStreaming) + { + var elapsed = startedAt == null + ? TimeSpan.Zero + : DateTimeOffset.Now - startedAt.Value; + return $"live {elapsed.TotalSeconds:0}s pid={process!.Id}"; + } + + return status; + } + + public bool Start() + { + Refresh(); + if (IsStreaming) + { + return false; + } + + var projectPath = Path.Combine(repositoryRoot, "src", "Mimir.Broadcast", "Mimir.Broadcast.csproj"); + if (!File.Exists(projectPath)) + { + status = "broadcast project missing"; + return false; + } + + var inputPath = Environment.GetEnvironmentVariable("MIMIR_STREAM_INPUT"); + var allowTestPattern = IsTruthy(Environment.GetEnvironmentVariable("MIMIR_STREAM_ALLOW_TEST_PATTERN")); + if (string.IsNullOrWhiteSpace(inputPath) && !allowTestPattern) + { + status = "stream input missing: set MIMIR_STREAM_INPUT"; + return false; + } + + var logRoot = Path.Combine(repositoryRoot, "artifacts", "runtime", "stream-proof"); + Directory.CreateDirectory(logRoot); + + var startInfo = new ProcessStartInfo + { + FileName = "dotnet", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = repositoryRoot, + }; + startInfo.ArgumentList.Add("run"); + startInfo.ArgumentList.Add("--project"); + startInfo.ArgumentList.Add(projectPath); + startInfo.ArgumentList.Add("--"); + startInfo.ArgumentList.Add("--target"); + startInfo.ArgumentList.Add(TargetUrl); + if (!string.IsNullOrWhiteSpace(inputPath)) + { + startInfo.ArgumentList.Add("--no-test-pattern"); + startInfo.ArgumentList.Add("--input"); + startInfo.ArgumentList.Add(Path.GetFullPath(Path.IsPathRooted(inputPath) ? inputPath : Path.Combine(repositoryRoot, inputPath))); + } + + process = Process.Start(startInfo); + if (process == null) + { + status = "stream launch failed"; + return false; + } + + _ = DrainAsync(process.StandardOutput, Path.Combine(logRoot, "mimir-app-stream.out.log")); + _ = DrainAsync(process.StandardError, Path.Combine(logRoot, "mimir-app-stream.err.log")); + startedAt = DateTimeOffset.Now; + status = $"live pid={process.Id}"; + return true; + } + + public void Stop() + { + Refresh(); + if (!IsStreaming) + { + return; + } + + try + { + process!.Kill(entireProcessTree: true); + status = "stream stopped"; + } + catch (Exception ex) + { + status = $"stream stop failed: {ex.Message}"; + } + finally + { + process?.Dispose(); + process = null; + startedAt = null; + } + } + + public void Refresh() + { + if (process == null || !process.HasExited) + { + return; + } + + var exitCode = process.ExitCode; + process.Dispose(); + process = null; + startedAt = null; + status = exitCode == 0 + ? "stream ended" + : $"stream failed exit={exitCode}"; + } + + public void Dispose() + { + Stop(); + } + + private static async Task DrainAsync(StreamReader reader, string path) + { + await using var stream = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.Read); + await using var writer = new StreamWriter(stream); + while (await reader.ReadLineAsync().ConfigureAwait(false) is { } line) + { + await writer.WriteLineAsync(line).ConfigureAwait(false); + } + } + + private static bool IsTruthy(string? value) => + !string.IsNullOrWhiteSpace(value) && + (string.Equals(value, "1", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "on", StringComparison.OrdinalIgnoreCase)); + + private static string ResolveRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + if (Directory.Exists(Path.Combine(directory.FullName, "scripts")) && + File.Exists(Path.Combine(directory.FullName, "src", "Mimir.Runtime", "Mimir.Runtime.csproj"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + return Directory.GetCurrentDirectory(); + } +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 0c54be6..fc33219 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -331,3 +331,4 @@ {"ts": "2026-06-01T13:32:22+01:00", "type": "implementation", "status": "ok", "note": "Direct compositor handles are now wired: Fensalir CultUI preview hit-tests selected item corner handles/body drags and emits normalized AquariumUiPreviewInteraction gestures, while MimirSceneEditorState handles those gestures through the same snapping/aspect-preserving placement owner. Sliders remain fallback/precision controls."} {"ts": "2026-06-01T13:37:34+01:00", "type": "implementation", "status": "ok", "note": "Gated explicit Mimir inspector source/transform controls behind HasSelectedProgramSource. Direct compositor preview handles remain the primary edit path; inspector sliders/buttons are selected-source precision/fallback controls only. Scene-editor smoke now proves the editor camera is not a program source."} {"ts": "2026-06-01T14:26:12+01:00", "type": "implementation", "status": "ok", "note": "Picked the compositor UX low-hanging fruit: hover/active preview state, snap guides, empty-canvas deselect, selected-source contain/cover/stretch fit mode, arrow-key nudging that bypasses snap for precision, and PageUp/PageDown layer ordering. Scene-editor smoke now asserts hover, guide, fit, nudge, empty-deselect, direct move/resize handles, aspect lock, snap, and non-source inspector gating."} +{"ts": "2026-06-01T14:39:51+01:00", "type": "decision", "status": "ok", "note": "2026-06-01: Mimir editor output authority moved to a right-rail Output panel below Inspector. MimirProgramRecorder now writes timestamped receipts to the user Videos\\\\Mimir folder by default, while MimirProgramStreamer owns Live/Stop for the configured Broadcast RTMP path and refuses to publish without MIMIR_STREAM_INPUT unless a synthetic smoke is explicitly enabled."} diff --git a/state/map.yaml b/state/map.yaml index 1304c22..d681c7e 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -70,15 +70,19 @@ invariants: CultUI preview state; keyboard arrows nudge selected layers without snapping, PageUp/PageDown reorder layers, and the inspector owns the selected source contain/cover/stretch fit mode. - - `MimirProgramRecorder` owns the first app-level recording control. The Video - Sources rail exposes `Record` / `Stop`, status text, latest output path, and + - The right rail `Output` panel owns OBS-like program actions below the + inspector. `MimirProgramRecorder` owns `Record` / `Stop`, status text, + timestamped receipts under `C:\Users\Meta\Videos\Mimir` by default, and process-tree shutdown. Its current actuator delegates to `scripts/record-stream-proof-output.ps1`, so the app records the same Raven + Kiyo PIP + Starfire Scarlett NVENC/AAC composite intended for - Yggdrasil/public judgment under `artifacts/runtime/stream-proof/`. + Yggdrasil/public judgment. `MIMIR_RECORD_OUTPUT_DIR` redirects receipts and `MIMIR_RECORD_START_RAVEN=1` asks the app to launch Raven's sender too. - Native program-output recording should replace that actuator behind the - same owner. + `MimirProgramStreamer` owns `Live` / `Stop` for the `Mimir.Broadcast` RTMP + path and requires `MIMIR_STREAM_INPUT` unless + `MIMIR_STREAM_ALLOW_TEST_PATTERN=1` explicitly authorizes a synthetic + smoke. Native program-output recording/streaming should replace those + actuators behind the same owner. - Fast stereo depth belongs to Fensalir's D3D12 compute/resource authority. `research/d3d12-stereo-depth-provenance-2026-05-29.md` records libSGM as the permissive north-star reference for a future SGM-shaped stereo