Editor/TextToAnimation/Session/GenerationFlow.cs

Editor-side class that manages downloading models and running motion generation flows without blocking the UI. It starts/cancels operations, reports progress back to the main thread, calls GeneratorService to install or generate, records generation metadata, and adds or replaces animation clips in the workspace.

NetworkingFile Access
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Sandbox;
using TextToAnimation.Animation;
using TextToAnimation.Editor.Engine;
using TextToAnimation.Editor.Generation;
using TextToAnimation.Generation;

namespace TextToAnimation.Editor.Session;

/// <summary>
/// Runs model downloads and generations for the editor without ever blocking it: work happens on worker
/// threads, progress lines are marshalled to the main thread, everything can be cancelled, and failures leave
/// the workspace untouched. Results become new clips, or replace the active clip as one undoable edit.
/// </summary>
public sealed class GenerationFlow
{
	readonly EditorSession _session;
	CancellationTokenSource _cts;

	public GeneratorService Service => GeneratorService.Instance;

	/// <summary>Progress line for the loading indicator ("Downloading … · n% · … left" draws a bar).</summary>
	public event Action<string> Progress;

	/// <summary>True while a download or generation runs.</summary>
	public bool Running => _cts is not null;

	public GenerationFlow( EditorSession session ) => _session = session;

	public void Cancel() => _cts?.Cancel();

	void Report( string line ) => Progress?.Invoke( line );

	/// <summary>Downloads the model (the Download button).</summary>
	public async Task<bool> DownloadAsync()
	{
		if ( Running ) return false;
		_cts = new CancellationTokenSource();
		_session.SetBusy( true, "Downloading UniMate" );
		try
		{
			var progress = new EngineThread.MainThreadProgress<string>( Report );
			var ok = await Service.InstallAsync( progress, _cts.Token );
			await EngineThread.SwitchToMainThread();
			_session.SetStatus( ok ? "UniMate is installed. Describe an animation and press Enter." : $"Download failed: {Service.LastError}", ok ? UI.Tone.Accent : UI.Tone.Red );
			return ok;
		}
		catch ( OperationCanceledException )
		{
			await EngineThread.SwitchToMainThread();
			_session.SetStatus( "Download paused. It resumes where it stopped next time.", UI.Tone.Amber );
			return false;
		}
		catch ( Exception e )
		{
			await EngineThread.SwitchToMainThread();
			_session.SetStatus( $"Download failed: {e.Message}", UI.Tone.Red );
			return false;
		}
		finally
		{
			await EngineThread.SwitchToMainThread();
			_cts?.Dispose();
			_cts = null;
			_session.SetBusy( false );
			Service.Refresh();
		}
	}

	/// <summary>
	/// Generates and applies the result. <paramref name="replace"/> overwrites the active clip (Regenerate);
	/// otherwise every take becomes a new clip next to it (originals are never touched).
	/// </summary>
	public async Task<bool> GenerateAsync( GenerationRequest request, bool replace, string baseName )
	{
		if ( Running || !_session.HasModel ) return false;
		var rig = _session.Rig;
		var target = _session.ActiveClip;
		if ( replace && target is null ) return false;
		_cts = new CancellationTokenSource();
		var started = DateTime.UtcNow;
		_session.SetBusy( true, "Generating" );
		try
		{
			var load = new EngineThread.MainThreadProgress<string>( Report );
			var progress = new EngineThread.MainThreadProgress<GenerationProgress>( p => Report( $"{p.Stage} · {p.Fraction * 100:0}%" ) );
			var before = _session.Workspace.Clips.ToList();
			var results = await Service.GenerateAsync( rig, request, load, progress, _cts.Token );
			await EngineThread.SwitchToMainThread();
			var record = new GenerationRecord
			{
				Mode = request.Mode.ToString(), Prompts = request.Prompts.ToList(), Seed = request.Seed,
				DurationSeconds = request.DurationSeconds, Guidance = request.Guidance, Generator = Service.Backend.Name,
			};
			if ( replace )
			{
				var first = results[0];
				_session.ReplaceClipFrames( target, first.Frames, first.Fps, "Regenerate", c =>
				{
					c.Generation = record;
					if ( request.Mode != GenerationMode.InBetween ) c.PinnedFrames.Clear();
					ClipCleanup.GenerateFootsteps( c, rig );
				} );
				for ( var i = 1; i < results.Count; i++ ) AddResult( results[i], record, $"{target.Name} take {i + 1}", false );
			}
			else
			{
				for ( var i = 0; i < results.Count; i++ )
				{
					var (result, name, select) = (results[i], results.Count > 1 ? $"{baseName} {i + 1}" : baseName, i == 0);
					UI.FrameProbe.Time( "result: add clip", () => AddResult( result, record, name, select ) );
				}
			}
			var made = _session.Workspace.Clips.Except( before ).ToList();
			if ( replace ) made.Insert( 0, target );
			_session.RecordPrompt( string.Join( " → ", request.Prompts.Where( p => !string.IsNullOrWhiteSpace( p ) ) ), request.Mode.ToString(), request.Seed, made );
			var notes = results.SelectMany( r => r.Notes ).Distinct().ToList();
			// a GPU the network couldn't run on (or that disagreed with the CPU) leaves generation on the CPU: slow, and
			// the editor shares its cores - say so instead of just being slow
			if ( Inference.Onnx.GpuAcceleration.Status is { } gpu )
			{
				if ( gpu != _gpuLogged ) Log.Warning( $"[text-to-animation] generating on the CPU: {gpu}" );
				_gpuLogged = gpu;
				notes.Add( $"Generated on the CPU ({gpu})" );
			}
			var seconds = (DateTime.UtcNow - started).TotalSeconds;
			_session.SetStatus( notes.Count > 0 ? string.Join( " ", notes ) : $"Generated in {seconds:0} s.", notes.Count > 0 ? UI.Tone.Amber : UI.Tone.Accent );
			return true;
		}
		catch ( OperationCanceledException )
		{
			await EngineThread.SwitchToMainThread();
			_session.SetStatus( "Generation cancelled.", UI.Tone.Neutral );
			return false;
		}
		catch ( Exception e )
		{
			await EngineThread.SwitchToMainThread();
			Log.Warning( $"[text-to-animation] generation failed: {e}" );
			_session.SetStatus( $"Generation failed: {e.Message}", UI.Tone.Red );
			return false;
		}
		finally
		{
			await EngineThread.SwitchToMainThread();
			_cts?.Dispose();
			_cts = null;
			_session.SetBusy( false );
		}
	}

	string _gpuLogged;

	void AddResult( GeneratedMotion motion, GenerationRecord record, string name, bool select )
	{
		var clip = new AnimClip
		{
			Name = name,
			Fps = motion.Fps,
			Frames = motion.Frames,
			Origin = ClipOrigin.Generated,
			Generation = record.Clone(),
			Looping = false,
		};
		clip.Generation.Seed = motion.Seed;
		UI.FrameProbe.Time( "result: footsteps", () => ClipCleanup.GenerateFootsteps( clip, _session.Rig ) );
		_session.AddClip( clip, select );
	}

	/// <summary>
	/// The request for <paramref name="mode"/> on the active clip: its final frames, pinned frames and locked
	/// bones. Empty prompts are dropped; variations and in-betweens fall back to the clip's original prompt.
	/// </summary>
	public static GenerationRequest BuildRequest( EditorSession session, GenerationMode mode, IEnumerable<string> prompts,
		float durationSeconds, int seed, int count, float guidance, int steps, float variationStrength,
		IReadOnlyCollection<int> keepBones = null, bool cleanUp = true )
	{
		var clip = session.ActiveClip;
		var list = (prompts ?? Enumerable.Empty<string>()).Select( p => (p ?? "").Trim() ).ToList();
		if ( mode != GenerationMode.Expansion ) list = new List<string> { list.FirstOrDefault() ?? "" };
		else list = list.Where( p => p.Length > 0 ).ToList();
		if ( mode is GenerationMode.Variation or GenerationMode.InBetween && list.Count > 0 && list[0].Length == 0
			&& clip?.Generation?.Prompts.FirstOrDefault() is { } original )
			list[0] = original;
		var usesClip = clip is not null && mode is GenerationMode.InBetween or GenerationMode.TextEdit or GenerationMode.Variation;
		return new GenerationRequest
		{
			Mode = mode,
			Prompts = list,
			DurationSeconds = mode == GenerationMode.TextToMotion ? durationSeconds : 0,
			OutputFps = clip?.Fps ?? session.Workspace?.DefaultFps ?? 30f,
			Seed = seed,
			Count = Math.Clamp( count, 1, 8 ),
			Guidance = guidance,
			Steps = steps,
			CleanUp = cleanUp,
			SourceFrames = usesClip ? session.ActiveFrames : null,
			SourceFps = clip?.Fps ?? 30f,
			KeepFrames = usesClip ? clip.PinnedFrames.ToList() : new List<int>(),
			KeepBones = !usesClip ? Array.Empty<int>()
				: keepBones?.ToArray() ?? clip.LockedBones.Select( session.Rig.Skeleton.IndexOf ).Where( i => i >= 0 ).ToArray(),
			VariationStrength = variationStrength,
		};
	}

	/// <summary>What is missing before the request can run (null = ready), phrased for the status bar.</summary>
	public static string Validate( GenerationRequest request )
	{
		var needsClip = request.Mode is GenerationMode.InBetween or GenerationMode.TextEdit or GenerationMode.Variation;
		if ( needsClip && request.SourceFrames is not { Count: > 1 } ) return "Open an animation first.";
		if ( request.Mode is GenerationMode.TextToMotion or GenerationMode.TextEdit && string.IsNullOrWhiteSpace( request.Prompts.FirstOrDefault() ) )
			return "Describe the motion first.";
		if ( request.Mode == GenerationMode.Expansion && request.Prompts.Count == 0 ) return "Add at least one step.";
		if ( request.Mode == GenerationMode.InBetween && request.KeepFrames.Count < 2 )
			return "Pin at least two frames on the timeline first (double click the Pinned lane).";
		return null;
	}

	/// <summary>A clip name from a prompt (see <see cref="Inference.UniMate.UniMatePrompt.ClipName"/>).</summary>
	public static string NameFromPrompt( string prompt ) => Inference.UniMate.UniMatePrompt.ClipName( prompt );
}