using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Text; using UnityEditor; using UnityEngine; namespace Streamingle.Editor { internal sealed class AICameraCliRunner : IDisposable { internal const string ExecutableName = "CWCameraWorker.exe"; internal const string EventProtocolVersion = "1"; private readonly ConcurrentQueue messages = new ConcurrentQueue(); private Process process; private string cancellationFile = string.Empty; private bool cancellationRequested; private bool exitObserved; private int exitCode; internal bool IsRunning { get { if (process == null || exitObserved) { return false; } try { return !process.HasExited; } catch (InvalidOperationException) { return false; } } } internal bool CancellationRequested => cancellationRequested; internal void Start(ProcessRequest request) { if (process != null) { throw new InvalidOperationException( "A camera worker process is already attached to this runner."); } ValidateRequest(request); cancellationFile = Path.GetFullPath(request.CancellationFile); var cancellationDirectory = Path.GetDirectoryName(cancellationFile); if (string.IsNullOrWhiteSpace(cancellationDirectory)) { throw new InvalidOperationException( "The camera worker cancellation directory is invalid."); } Directory.CreateDirectory(cancellationDirectory); if (File.Exists(cancellationFile)) { File.Delete(cancellationFile); } var startInfo = new ProcessStartInfo { FileName = Path.GetFullPath(request.ExecutablePath), Arguments = JoinArguments(request.Arguments), WorkingDirectory = Path.GetFullPath(request.WorkingDirectory), UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; startInfo.EnvironmentVariables["PYTHONUTF8"] = "1"; startInfo.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8"; process = new Process { StartInfo = startInfo, EnableRaisingEvents = false }; process.OutputDataReceived += OnOutputDataReceived; process.ErrorDataReceived += OnErrorDataReceived; try { if (!process.Start()) { throw new InvalidOperationException( "CWCameraWorker could not be started."); } process.BeginOutputReadLine(); process.BeginErrorReadLine(); } catch { DisposeProcess(); throw; } } internal bool TryDequeueMessage(out ProcessMessage message) { return messages.TryDequeue(out message); } internal bool TryGetExitCode(out int completedExitCode) { completedExitCode = 0; if (process == null) { return false; } if (!exitObserved) { try { if (!process.HasExited) { return false; } // The parameterless overload also waits for redirected // asynchronous output handlers to receive their final line. process.WaitForExit(); exitCode = process.ExitCode; exitObserved = true; } catch (InvalidOperationException) { return false; } } completedExitCode = exitCode; return true; } internal bool RequestCancellation() { if (process == null || exitObserved || cancellationRequested) { return false; } var directory = Path.GetDirectoryName(cancellationFile); if (string.IsNullOrWhiteSpace(directory)) { throw new InvalidOperationException( "The camera worker cancellation path is unavailable."); } Directory.CreateDirectory(directory); File.WriteAllText( cancellationFile, DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture), new UTF8Encoding(false)); cancellationRequested = true; return true; } internal void CompleteAndDispose() { if (process != null && !exitObserved) { throw new InvalidOperationException( "A running camera worker cannot be disposed as completed."); } DisposeProcess(); TryDeleteCancellationFile(); } public void Dispose() { DisposeProcess(); if (exitObserved) { TryDeleteCancellationFile(); } } internal static string ResolveExecutable( string configuredPath, string cwAiRoot, string projectRoot, string packageRoot = "") { var candidates = new List(); AddCandidate(candidates, configuredPath); AddCandidate( candidates, Environment.GetEnvironmentVariable( "CWAI_WORKER_EXECUTABLE")); AddRootCandidates(candidates, cwAiRoot); if (!string.IsNullOrWhiteSpace(projectRoot)) { AddCandidate( candidates, Path.Combine( projectRoot, "Tools", "CWCameraWorker", ExecutableName)); } var physicalPackageRoot = packageRoot; if (string.IsNullOrWhiteSpace(physicalPackageRoot)) { try { physicalPackageRoot = FileUtil.GetPhysicalPath( "Packages/com.mingle.cw-ai"); } catch (Exception) { physicalPackageRoot = string.Empty; } } if (!string.IsNullOrWhiteSpace(physicalPackageRoot)) { AddCandidate( candidates, Path.Combine( physicalPackageRoot, "Tools~", "CWCameraWorker", ExecutableName)); AddCandidate( candidates, Path.Combine( physicalPackageRoot, "Tools~", ExecutableName)); } return candidates .Where(value => !string.IsNullOrWhiteSpace(value)) .Select(Path.GetFullPath) .FirstOrDefault(File.Exists) ?? string.Empty; } internal static string JoinArguments(IEnumerable arguments) { if (arguments == null) { throw new ArgumentNullException(nameof(arguments)); } return string.Join(" ", arguments.Select(QuoteArgument)); } internal static string QuoteArgument(string argument) { argument ??= string.Empty; if (argument.Length > 0 && argument.All(character => !char.IsWhiteSpace(character) && character != '"')) { return argument; } var result = new StringBuilder(argument.Length + 2); result.Append('"'); var backslashCount = 0; foreach (var character in argument) { if (character == '\\') { backslashCount++; continue; } if (character == '"') { result.Append('\\', backslashCount * 2 + 1); result.Append('"'); backslashCount = 0; continue; } result.Append('\\', backslashCount); backslashCount = 0; result.Append(character); } result.Append('\\', backslashCount * 2); result.Append('"'); return result.ToString(); } internal static bool TryParseEvent( string line, out CliEvent parsedEvent) { parsedEvent = default; var normalized = (line ?? string.Empty).Trim(); const string prefix = "CWAI_EVENT "; if (normalized.StartsWith(prefix, StringComparison.Ordinal)) { normalized = normalized.Substring(prefix.Length).TrimStart(); } if (normalized.Length < 2 || normalized[0] != '{' || normalized[normalized.Length - 1] != '}') { return false; } CliEventPayload payload; try { payload = JsonUtility.FromJson(normalized); } catch (ArgumentException) { return false; } var eventType = FirstNotEmpty( payload?.type, payload?.eventType, payload?.@event); if (string.IsNullOrWhiteSpace(eventType)) { return false; } var hasProgress = ContainsJsonProperty(normalized, "progress"); var hasEta = ContainsJsonProperty(normalized, "etaSeconds"); parsedEvent = new CliEvent( eventType.Trim(), payload.protocolVersion ?? string.Empty, payload.jobId ?? string.Empty, payload.phase ?? string.Empty, payload.message ?? string.Empty, hasProgress ? Mathf.Clamp01(payload.progress) : -1f, hasEta && IsFinite(payload.etaSeconds) ? Math.Max(0d, payload.etaSeconds) : -1d, payload.outputPath ?? string.Empty, payload.error ?? string.Empty); return true; } private static void ValidateRequest(ProcessRequest request) { if (string.IsNullOrWhiteSpace(request.ExecutablePath) || !File.Exists(request.ExecutablePath)) { throw new FileNotFoundException( "CWCameraWorker.exe was not found.", request.ExecutablePath); } if (string.IsNullOrWhiteSpace(request.WorkingDirectory) || !Directory.Exists(request.WorkingDirectory)) { throw new DirectoryNotFoundException( "The camera worker working directory was not found: " + request.WorkingDirectory); } if (request.Arguments == null || request.Arguments.Count == 0) { throw new ArgumentException( "The camera worker command is required.", nameof(request)); } if (string.IsNullOrWhiteSpace(request.CancellationFile)) { throw new ArgumentException( "The camera worker cancellation file is required.", nameof(request)); } } private static void AddRootCandidates( ICollection candidates, string root) { if (string.IsNullOrWhiteSpace(root)) { return; } AddCandidate( candidates, Path.Combine(root, "dist", "CWCameraWorker", ExecutableName)); AddCandidate( candidates, Path.Combine(root, "dist", ExecutableName)); AddCandidate( candidates, Path.Combine(root, "tools", "CWCameraWorker", ExecutableName)); AddCandidate( candidates, Path.Combine(root, "CWCameraWorker", ExecutableName)); AddCandidate(candidates, Path.Combine(root, ExecutableName)); } private static void AddCandidate( ICollection candidates, string candidate) { if (!string.IsNullOrWhiteSpace(candidate)) { candidates.Add(candidate.Trim()); } } private static string FirstNotEmpty(params string[] values) { return values.FirstOrDefault( value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty; } private static bool ContainsJsonProperty(string json, string name) { return json.IndexOf( "\"" + name + "\"", StringComparison.Ordinal) >= 0; } private static bool IsFinite(double value) { return !double.IsNaN(value) && !double.IsInfinity(value); } private void OnOutputDataReceived(object sender, DataReceivedEventArgs args) { EnqueueLine(args.Data, false); } private void OnErrorDataReceived(object sender, DataReceivedEventArgs args) { EnqueueLine(args.Data, true); } private void EnqueueLine(string line, bool isError) { if (string.IsNullOrWhiteSpace(line)) { return; } if (TryParseEvent(line, out var parsedEvent)) { messages.Enqueue(new ProcessMessage(parsedEvent)); return; } messages.Enqueue(new ProcessMessage(line, isError)); } private void DisposeProcess() { if (process == null) { return; } process.OutputDataReceived -= OnOutputDataReceived; process.ErrorDataReceived -= OnErrorDataReceived; process.Dispose(); process = null; } private void TryDeleteCancellationFile() { if (string.IsNullOrWhiteSpace(cancellationFile) || !File.Exists(cancellationFile)) { return; } try { File.Delete(cancellationFile); } catch (IOException) { // A stale sentinel is deleted before the next job starts. } catch (UnauthorizedAccessException) { // Preserve the completed result even if local cleanup is denied. } } [Serializable] private sealed class CliEventPayload { public string type; public string eventType; public string @event; public string protocolVersion; public string jobId; public string phase; public string message; public float progress; public double etaSeconds; public string outputPath; public string error; } internal readonly struct ProcessRequest { internal ProcessRequest( string executablePath, string workingDirectory, IReadOnlyList arguments, string cancellationFile) { ExecutablePath = executablePath; WorkingDirectory = workingDirectory; Arguments = arguments; CancellationFile = cancellationFile; } internal string ExecutablePath { get; } internal string WorkingDirectory { get; } internal IReadOnlyList Arguments { get; } internal string CancellationFile { get; } } internal readonly struct ProcessMessage { internal ProcessMessage(string text, bool isError) { Text = text ?? string.Empty; IsError = isError; IsEvent = false; Event = default; } internal ProcessMessage(CliEvent parsedEvent) { Text = string.Empty; IsError = false; IsEvent = true; Event = parsedEvent; } internal string Text { get; } internal bool IsError { get; } internal bool IsEvent { get; } internal CliEvent Event { get; } } internal readonly struct CliEvent { internal CliEvent( string eventType, string protocolVersion, string jobId, string phase, string message, float progress, double etaSeconds, string outputPath, string error) { EventType = eventType; ProtocolVersion = protocolVersion; JobId = jobId; Phase = phase; Message = message; Progress = progress; EtaSeconds = etaSeconds; OutputPath = outputPath; Error = error; } internal string EventType { get; } internal string ProtocolVersion { get; } internal string JobId { get; } internal string Phase { get; } internal string Message { get; } internal float Progress { get; } internal double EtaSeconds { get; } internal string OutputPath { get; } internal string Error { get; } } } }