Add Mimir output controls
This commit is contained in:
parent
a1de06a100
commit
f083a4fbae
8 changed files with 321 additions and 66 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) ||
|
||||
|
|
|
|||
179
src/Mimir.Runtime/Synchronization/MimirProgramStreamer.cs
Normal file
179
src/Mimir.Runtime/Synchronization/MimirProgramStreamer.cs
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue