namespace SomhairlesDream.Modeling open System open System.Diagnostics open System.IO open System.Text.Json type BlenderProcessBridge(python: string, stageRoot: string, ?timeout: TimeSpan) = let timeout = defaultArg timeout (TimeSpan.FromSeconds 120.) let bridgeError message = if String.IsNullOrWhiteSpace(message) then "blender bridge: process failed without diagnostics" else $"blender bridge: {message.Trim()}" let diagnostics (stderr: string) (stdout: string) = let value = if String.IsNullOrWhiteSpace(stderr) then stdout else stderr if String.IsNullOrWhiteSpace(value) then "no process diagnostics" elif value.Length > 2000 then value.Substring(0, 2000) else value let strings (root: JsonElement) (name: string) = let property = root.GetProperty(name) if property.ValueKind <> JsonValueKind.Array then failwith $"'{name}' must be an array" let values = property.EnumerateArray() |> Seq.map (fun item -> item.GetString()) |> Seq.toArray if values |> Array.exists isNull then failwith $"'{name}' contains a null value" values let utcTimestamp (root: JsonElement) (name: string) = let value = root.GetProperty(name).GetDateTimeOffset() if value.Offset <> TimeSpan.Zero then failwith $"'{name}' must be a UTC timestamp" value let parseResult (request: BridgeRequest) = if not (File.Exists(request.ResultPath)) then failwith $"fixture result file is missing: {request.ResultPath}" use document = JsonDocument.Parse(File.ReadAllText(request.ResultPath)) let root = document.RootElement if not (root.GetProperty("ok").GetBoolean()) then failwith "fixture returned ok=false" let renderPath = let mutable property = Unchecked.defaultof if root.TryGetProperty("renderPath", &property) then Some(property.GetString()) else None let renderedAt = let mutable property = Unchecked.defaultof if root.TryGetProperty("renderedAt", &property) then Some(utcTimestamp root "renderedAt") else None if request.RenderPath.IsSome <> renderPath.IsSome then failwith "fixture render result did not match the request" if request.RenderPath.IsSome && request.RenderPath <> renderPath then failwith "fixture returned an unexpected render path" { BlenderVersion = root.GetProperty("blenderVersion").GetString() ExportedAt = utcTimestamp root "exportedAt" ObjectIds = strings root "objectIds" VerifiedObjectIds = strings root "verifiedObjectIds" GlbBytes = root.GetProperty("glbBytes").GetInt64() RenderPath = renderPath RenderedAt = renderedAt } let writeProcessLog (path: string) (stdout: string) (stderr: string) = match Path.GetDirectoryName(path) with | null -> () | directory -> Directory.CreateDirectory(directory) |> ignore let content = String.concat Environment.NewLine [| "[stdout]" stdout "[stderr]" stderr |] File.WriteAllText(path, content) member private _.ScriptPath(stepId: string) = let scriptName = if stepId.StartsWith("probe-") then "probe.py" elif stepId.StartsWith("ship-") then "ship.py" else "fixture.py" Path.GetFullPath(Path.Combine(stageRoot, "blender", scriptName)) member private this.StartInfo(request: BridgeRequest) = let scriptPath = this.ScriptPath(request.Step.StepId) let info = ProcessStartInfo() info.FileName <- python info.UseShellExecute <- false info.CreateNoWindow <- true info.RedirectStandardOutput <- true info.RedirectStandardError <- true info.ArgumentList.Add(scriptPath) info.ArgumentList.Add("--step-id") info.ArgumentList.Add(request.Step.StepId) info.ArgumentList.Add("--output") info.ArgumentList.Add(Path.GetFullPath(request.GlbPath)) info.ArgumentList.Add("--result-output") info.ArgumentList.Add(Path.GetFullPath(request.ResultPath)) match request.RenderPath with | Some path -> info.ArgumentList.Add("--render-output") info.ArgumentList.Add(Path.GetFullPath(path)) | None -> () info interface IArtifactBridge with member this.Export(request, heartbeat) = try let scriptPath = this.ScriptPath(request.Step.StepId) if not (File.Exists(scriptPath)) then Error(bridgeError $"blender script is missing: {scriptPath}") else match Path.GetDirectoryName(request.GlbPath) with | null -> () | directory -> Directory.CreateDirectory(directory) |> ignore match request.RenderPath with | Some path -> match Path.GetDirectoryName(path) with | null -> () | directory -> Directory.CreateDirectory(directory) |> ignore | None -> () match Path.GetDirectoryName(request.ResultPath) with | null -> () | directory -> Directory.CreateDirectory(directory) |> ignore match Path.GetDirectoryName(request.LogPath) with | null -> () | directory -> Directory.CreateDirectory(directory) |> ignore if File.Exists(request.ResultPath) then File.Delete(request.ResultPath) if File.Exists(request.LogPath) then File.Delete(request.LogPath) use childProcess = new Process() childProcess.StartInfo <- this.StartInfo(request) if not (childProcess.Start()) then Error(bridgeError "process could not be started") else let stdoutTask = childProcess.StandardOutput.ReadToEndAsync() let stderrTask = childProcess.StandardError.ReadToEndAsync() let started = Stopwatch.StartNew() let mutable timedOut = false while not childProcess.HasExited && not timedOut do let remaining = timeout - started.Elapsed if remaining <= TimeSpan.Zero then timedOut <- true else let waitMilliseconds = int (min 5000.0 remaining.TotalMilliseconds) if not (childProcess.WaitForExit(waitMilliseconds)) then if childProcess.HasExited then () else heartbeat () if timedOut && not childProcess.HasExited then childProcess.Kill(true) childProcess.WaitForExit() let stdout = stdoutTask.GetAwaiter().GetResult() let stderr = stderrTask.GetAwaiter().GetResult() writeProcessLog request.LogPath stdout stderr if timedOut then Error(bridgeError $"process timed out after {timeout.TotalSeconds} seconds") elif childProcess.ExitCode <> 0 then Error(bridgeError $"process exited with code {childProcess.ExitCode}: {diagnostics stderr stdout}") else try Ok(parseResult request) with ex -> Error(bridgeError $"invalid fixture result: {ex.Message}") with ex -> Error(bridgeError ex.Message)