2026-08-03 04:42:44 +09:00

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; }
}
}
}