605 lines
19 KiB
C#
605 lines
19 KiB
C#
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<ProcessMessage> messages =
|
|
new ConcurrentQueue<ProcessMessage>();
|
|
|
|
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<string>();
|
|
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<string> 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<CliEventPayload>(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<string> 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<string> 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<string> arguments,
|
|
string cancellationFile)
|
|
{
|
|
ExecutablePath = executablePath;
|
|
WorkingDirectory = workingDirectory;
|
|
Arguments = arguments;
|
|
CancellationFile = cancellationFile;
|
|
}
|
|
|
|
internal string ExecutablePath { get; }
|
|
internal string WorkingDirectory { get; }
|
|
internal IReadOnlyList<string> 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; }
|
|
}
|
|
}
|
|
}
|