Editor/TextToAnimation/Session/SaveFlow.cs

Editor-side SaveFlow class for the TextToAnimation editor. It plans and performs saves of AnimClip data into vmdl files or exports new animation models, handles replacing sequences, exporting DMX/FBX, compiling new vmdls, and tracking root-compensation and saved metadata.

File AccessExternal Download
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using Sandbox;
using TextToAnimation.Animation;
using TextToAnimation.Editor.Engine;
using TextToAnimation.Vmdl;

namespace TextToAnimation.Editor.Session;

/// <summary>
/// Saving clips into the model, replacing existing sequences, exporting files and creating an inheriting
/// animation model. Originals are never overwritten unless the user picks Replace (and even then a backup is
/// kept and a failed compile restores them).
/// </summary>
public sealed class SaveFlow
{
	readonly EditorSession _session;
	public SaveFlow( EditorSession session ) => _session = session;

	/// <summary>Where a save would put the clip, for the Save tab's summary.</summary>
	public (string Sequence, bool Updates, string Problem) Plan( AnimClip clip )
	{
		if ( clip is null || !_session.HasModel ) return ("", false, "No animation open.");
		var vmdl = ModelBridge.SourcePathOf( _session.ModelAsset );
		if ( vmdl is null || EnginePaths.IsUnderEngineInstall( vmdl ) )
			return ("", false, "This model belongs to s&box itself and can't be changed. Saving creates a new animation model in your project that uses it.");
		var seq = SequenceFor( clip, out var updates );
		return (seq, updates, null);
	}

	/// <summary>The sequence name a save uses: the clip's own earlier save is updated, anything else gets a free name.</summary>
	string SequenceFor( AnimClip clip, out bool updatesOwn )
	{
		var wanted = clip.EffectiveSequenceName;
		var info = _session.Sequences.FirstOrDefault( s => string.Equals( s.Name, wanted, StringComparison.Ordinal ) );
		updatesOwn = info is not null && info.DefinedInModel && clip.SavedUtc is not null
			&& info.SourceFile.Replace( '\\', '/' ).Contains( "/" + ClipVmdlWriter.OutputFolderName + "/", StringComparison.OrdinalIgnoreCase );
		if ( updatesOwn || info is null ) return wanted;
		return _session.Workspace.UniqueSequenceName( wanted, _session.Sequences.Select( s => s.Name ), clip.Id );
	}

	/// <summary>Saves the clip into the model's vmdl (new sequence, or update of its own earlier save).</summary>
	public Task<bool> SaveAsync( AnimClip clip ) => SaveCoreAsync( clip, replaceSequence: null );

	/// <summary>Overwrites an existing sequence of the model with the clip (keeping the sequence's settings).</summary>
	public Task<bool> ReplaceAsync( AnimClip clip, string sequence ) => SaveCoreAsync( clip, sequence );

	async Task<bool> SaveCoreAsync( AnimClip clip, string replaceSequence )
	{
		if ( clip is null || _session.Busy || !_session.HasModel ) return false;
		var vmdl = ModelBridge.SourcePathOf( _session.ModelAsset );
		if ( vmdl is null || EnginePaths.IsUnderEngineInstall( vmdl ) ) return await SaveAsNewModelAsync( new[] { clip } );

		var sequence = replaceSequence ?? SequenceFor( clip, out var updates );
		var replace = replaceSequence is not null || _session.Sequences.Any( s => s.Name == sequence && s.DefinedInModel );
		_session.FlushSave();
		_session.SetBusy( true, $"Saving {sequence}" );
		try
		{
			var request = new ClipSaveRequest { Clip = clip, Frames = FinalFrames( clip ), SequenceName = sequence, ReplaceExisting = replace };
			var result = await VmdlSaveService.SaveAsync( _session.ModelAsset, _session.Rig, new[] { request }, t => _session.SetStatus( t ), CancellationToken.None,
				RootCompensation );
			await EngineThread.SwitchToMainThread();
			if ( result.LearnedRootCompensation is { } learned )
			{
				// remember how this model's animations must be oriented, so the next save is right first time
				_session.Workspace.RootCompensation = new[] { learned.X, learned.Y, learned.Z, learned.W };
				_session.FlushSave();
			}
			if ( !result.Success )
			{
				// say the model is safe: a failed save puts the original vmdl back
				var restored = result.RolledBack && !result.Notes.Any( e => e.Contains( "restored", StringComparison.OrdinalIgnoreCase ) ) ? " Nothing was changed: the model is back to how it was." : "";
				_session.SetStatus( string.Join( " ", result.Errors.Concat( result.Notes ) ) + restored, UI.Tone.Red );
				return false;
			}
			clip.SavedUtc = DateTime.UtcNow;
			clip.Export.SequenceName = sequence;
			if ( replaceSequence is not null ) clip.SourceSequence = replaceSequence;
			UI.ClipListPanel.SavedRevisions[clip.Id] = clip.Revision;
			_session.ScheduleSave( clip );
			await _session.RefreshModelAsync();
			var check = result.PlaybackError.TryGetValue( sequence, out var err ) ? $" Playback matches the editor (within {err:0.00} in)." : "";
			_session.SetStatus( $"Saved \"{sequence}\" into {Path.GetFileName( result.VmdlPath )}.{check}{(result.Notes.Count > 0 ? " " + string.Join( " ", result.Notes ) : "")}", UI.Tone.Accent );
			return true;
		}
		catch ( Exception e )
		{
			await EngineThread.SwitchToMainThread();
			Log.Warning( $"[text-to-animation] save failed: {e}" );
			_session.SetStatus( $"Saving failed: {e.Message}", UI.Tone.Red );
			return false;
		}
		finally
		{
			await EngineThread.SwitchToMainThread();
			_session.SetBusy( false );
		}
	}

	/// <summary>
	/// For models that can't be edited (engine content, compiled-only): writes the clips and a new vmdl that
	/// inherits the model (base_model_name) into the project's Assets/text_to_animation folder and compiles it.
	/// </summary>
	public async Task<bool> SaveAsNewModelAsync( IReadOnlyList<AnimClip> clips )
	{
		var assets = Project.Current?.GetAssetsPath();
		if ( string.IsNullOrEmpty( assets ) ) { _session.SetStatus( "Open a project first.", UI.Tone.Red ); return false; }
		var modelName = Path.GetFileNameWithoutExtension( _session.ModelAsset.Path );
		var folder = Path.Combine( assets, "text_to_animation", AnimClip.SanitizeSequenceName( modelName ) );
		_session.SetBusy( true, "Creating animation model" );
		try
		{
			var items = clips.Select( c => (Sequence: c.EffectiveSequenceName, Clip: c, Frames: FinalFrames( c )) ).ToList();
			var files = VmdlSaveService.Export( folder, _session.ModelAsset.Path, _session.Rig, items, RootCompensation );
			var vmdl = files.FirstOrDefault( f => f.EndsWith( ".vmdl", StringComparison.OrdinalIgnoreCase ) );
			if ( vmdl is null ) { _session.SetStatus( "Couldn't create the animation model.", UI.Tone.Red ); return false; }
			var compile = await VmdlCompiler.RegisterAndCompileAsync( vmdl, files.Where( f => f != vmdl ) );
			await EngineThread.SwitchToMainThread();
			if ( !compile.Compiled ) { _session.SetStatus( $"The animation model didn't compile: {compile.Error}", UI.Tone.Red ); return false; }
			foreach ( var c in clips ) { c.SavedUtc = DateTime.UtcNow; UI.ClipListPanel.SavedRevisions[c.Id] = c.Revision; _session.ScheduleSave( c ); }
			_session.SetStatus( $"Created {Path.GetFileName( vmdl )} - use it instead of {modelName} (it has all of {modelName}'s animations plus yours).", UI.Tone.Accent );
			return true;
		}
		catch ( Exception e )
		{
			await EngineThread.SwitchToMainThread();
			_session.SetStatus( $"Saving failed: {e.Message}", UI.Tone.Red );
			return false;
		}
		finally
		{
			await EngineThread.SwitchToMainThread();
			_session.SetBusy( false );
		}
	}

	/// <summary>The frames that get written: keys applied, and travel removed when the clip is saved "in place".</summary>
	List<Maths.XForm[]> FinalFrames( AnimClip clip )
	{
		if ( clip.Export.RootMotion != ClipRootMotion.InPlace ) return clip.EvaluateFrames( _session.Rig.Skeleton );
		var copy = clip.CloneDeep();
		copy.Frames = clip.EvaluateFrames( _session.Rig.Skeleton );
		copy.Keys.Clear();
		try { ClipOps.MakeInPlace( copy, _session.Rig ); } catch ( InvalidOperationException ) { }
		return copy.Frames;
	}

	/// <summary>Writes the clip as a DMX (plus a ready-to-use vmdl when the folder is in the project's Assets).</summary>
	public void Export( AnimClip clip, string folder )
	{
		if ( clip is null ) return;
		try
		{
			var files = VmdlSaveService.Export( folder, _session.ModelAsset.Path, _session.Rig,
				new[] { (clip.EffectiveSequenceName, clip, FinalFrames( clip )) }, RootCompensation );
			_session.SetStatus( $"Exported {string.Join( ", ", files.Select( Path.GetFileName ) )} to {folder}.", UI.Tone.Accent );
		}
		catch ( Exception e )
		{
			_session.SetStatus( $"Export failed: {e.Message}", UI.Tone.Red );
		}
	}

	/// <summary>
	/// The model's source FBX that can take a clip: an FBX 7.x file its vmdl (or a prefab it includes) renders, the one
	/// holding most of the skeleton's bones; null when there is none (no FBX source, or only FBX 6 files).
	/// </summary>
	public string ModelFbx()
	{
		var vmdl = _session.ModelAsset is null ? null : ModelBridge.SourcePathOf( _session.ModelAsset );
		var text = _session.VmdlText;
		if ( vmdl is null || string.IsNullOrEmpty( text ) || _session.Rig is null ) return null;
		// reading and parsing the model's FBX files takes a moment: once per model and vmdl text
		var key = vmdl + "|" + text.GetHashCode();
		if ( _modelFbxKey == key ) return _modelFbx;
		_modelFbxKey = key;
		return _modelFbx = FindModelFbx( vmdl, text );
	}
	string _modelFbxKey, _modelFbx;

	string FindModelFbx( string vmdl, string text )
	{
		var texts = new List<string> { text };
		texts.AddRange( EngineThread.Try( () => VmdlSources.IncludedPrefabTexts( text, vmdl ) ) ?? new List<string>() );
		var bones = _session.Rig.Skeleton.Bones.Select( b => b.Name ).ToList();
		return texts.SelectMany( t => System.Text.RegularExpressions.Regex.Matches( t, @"filename\s*=\s*""([^""]+\.fbx)""", System.Text.RegularExpressions.RegexOptions.IgnoreCase ) )
			.Select( m => EngineThread.Try( () => VmdlSources.Resolve( m.Groups[1].Value, vmdl ) ) )
			.Where( p => p is not null && File.Exists( p ) ).Distinct( StringComparer.OrdinalIgnoreCase )
			.Select( p => (Path: p, Bytes: EngineThread.Try( () => File.ReadAllBytes( p ) )) )
			.Where( f => f.Bytes is not null && Formats.Fbx.FbxClipExport.CanWriteInto( f.Bytes ) )
			.Select( f => (f.Path, Matches: Formats.Fbx.FbxClipExport.MatchingBones( f.Bytes, bones )) )
			.Where( f => f.Matches >= 2 )
			.OrderByDescending( f => f.Matches ).Select( f => f.Path ).FirstOrDefault();
	}

	/// <summary>
	/// Writes <paramref name="clip"/> as an FBX: the model's own source FBX (mesh, skin, materials) carrying the clip
	/// when <paramref name="withModel"/>, else the skeleton with the clip.
	/// </summary>
	public bool ExportFbx( AnimClip clip, string file, bool withModel )
	{
		if ( clip is null || _session.Rig is null ) return false;
		try
		{
			var bones = _session.Rig.Skeleton.Bones.Select( b => new Formats.Fbx.FbxClipExport.Bone( b.Name, b.ParentIndex, b.RestLocal ) ).ToList();
			var frames = FinalFrames( clip );
			byte[] bytes;
			var note = "";
			if ( withModel )
			{
				var source = ModelFbx() ?? throw new InvalidOperationException( "This model has no FBX 2011-or-newer source to carry the animation - export the skeleton with the animation instead." );
				bytes = Formats.Fbx.FbxClipExport.WriteIntoSource( File.ReadAllBytes( source ), bones, frames, clip.Fps, clip.EffectiveSequenceName, out var report );
				note = $" ({Path.GetFileName( source )}: {report})";
			}
			else bytes = Formats.Fbx.FbxClipExport.WriteSkeleton( bones, frames, clip.Fps, clip.EffectiveSequenceName );
			Directory.CreateDirectory( Path.GetDirectoryName( file )! );
			File.WriteAllBytes( file, bytes );
			_session.SetStatus( $"Exported {Path.GetFileName( file )}{note}.", UI.Tone.Accent );
			return true;
		}
		catch ( Exception e )
		{
			_session.SetStatus( $"FBX export failed: {e.Message}", UI.Tone.Red );
			return false;
		}
	}

	/// <summary>The root compensation measured for this model (null until a save measured one).</summary>
	System.Numerics.Quaternion? RootCompensation => _session.Workspace?.RootCompensation is { Length: 4 } c
		? new System.Numerics.Quaternion( c[0], c[1], c[2], c[3] ) : null;
}