feat: initialize Streamingle Utilities package

This commit is contained in:
KINDNICK
2026-07-23 22:43:07 +09:00
commit e9bbf8481a
107 changed files with 9746 additions and 0 deletions
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a819c052b0bc4324b4e9c30da130729a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,361 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
namespace Streamingle.AnimationCleanup.Editor
{
/// <summary>
/// Removes contaminated keys at the boundaries of shared blend-shape recording gaps.
/// No keys are generated and the original clip is never modified.
/// </summary>
public sealed class BlendShapeSpikeCleanupWindow : EditorWindow
{
private const float TimeEpsilon = 0.00001f;
private AnimationClip sourceClip;
private int minimumGapFrames = 6;
private int keysBeforeGap = 2;
private int keysAfterGap = 2;
private int maximumBoundaryDistanceFrames = 3;
private bool linearizeGapBridge = true;
private string summary = "애니메이션 클립을 선택하세요.";
[MenuItem("Tools/Streamingle Utilities/Animation/Blend Shape Gap Cleanup")]
private static void Open()
{
GetWindow<BlendShapeSpikeCleanupWindow>("공백 경계 튐 정리");
}
private void OnEnable()
{
minSize = new Vector2(500f, 330f);
}
private void OnGUI()
{
EditorGUILayout.LabelField("블렌드셰이프 공백 경계 튐 정리", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"모든 블렌드셰이프 키가 함께 비는 녹화 누락 구간을 찾습니다. " +
"그 구간의 바로 앞·뒤에 남은 오염 키만 제거합니다. 새 키를 만들거나 스무스를 주지 않으며, 원본 대신 복제본을 만듭니다.",
MessageType.Info);
EditorGUI.BeginChangeCheck();
sourceClip = (AnimationClip)EditorGUILayout.ObjectField("대상 애니메이션 클립", sourceClip, typeof(AnimationClip), false);
if (EditorGUI.EndChangeCheck())
{
summary = sourceClip == null ? "애니메이션 클립을 선택하세요." : "분석 버튼을 눌러 공백 경계 키 수를 확인하세요.";
}
using (new EditorGUI.DisabledScope(sourceClip == null))
{
float frameRate = sourceClip == null ? 30f : Mathf.Max(1f, sourceClip.frameRate);
EditorGUILayout.LabelField(string.Format("클립 프레임레이트: {0:F2}", frameRate), EditorStyles.miniLabel);
minimumGapFrames = Mathf.Max(1, EditorGUILayout.IntField(new GUIContent("최소 공백 길이 (프레임)", "이 프레임 이상 모든 블렌드셰이프 키가 비는 구간만 처리합니다."), minimumGapFrames));
keysBeforeGap = Mathf.Clamp(EditorGUILayout.IntField(new GUIContent("공백 직전 제거 키 수", "공백 시작 지점에서 과거 방향으로 제거할 키 수입니다."), keysBeforeGap), 0, 8);
keysAfterGap = Mathf.Clamp(EditorGUILayout.IntField(new GUIContent("공백 직후 제거 키 수", "공백 종료 지점에서 미래 방향으로 제거할 키 수입니다."), keysAfterGap), 0, 8);
int requiredDistance = Mathf.Max(keysBeforeGap, keysAfterGap);
maximumBoundaryDistanceFrames = Mathf.Max(requiredDistance, EditorGUILayout.IntField(new GUIContent("최대 경계 거리 (프레임)", "키가 공백 경계에서 이 거리보다 멀면 안전하게 건너뜁니다."), maximumBoundaryDistanceFrames));
linearizeGapBridge = EditorGUILayout.Toggle(new GUIContent("공백 구간 부드럽게 연결", "경계 키 제거 후 남은 양끝의 탄젠트를 같은 기울기로 맞춰, 공백 구간을 오버슈트 없이 직선으로 연결합니다."), linearizeGapBridge);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("적용 대상 분석"))
{
Analyze();
}
if (GUILayout.Button("복제본 만들기", GUILayout.Height(22f)))
{
CreateCopy();
}
EditorGUILayout.EndHorizontal();
}
EditorGUILayout.Space(8f);
EditorGUILayout.LabelField("작업 상태", EditorStyles.miniBoldLabel);
EditorGUILayout.LabelField(summary, EditorStyles.wordWrappedMiniLabel);
}
private void Analyze()
{
float frameRate = Mathf.Max(1f, sourceClip.frameRate);
List<GapRange> gaps = FindSharedBlendShapeGaps(sourceClip, minimumGapFrames / frameRate);
RemovalPlan plan = BuildPlan(sourceClip, gaps, frameRate);
summary = string.Format(
"공백 {0}개를 찾았습니다. {1}개 커브에서 경계 키 {2}개를 제거할 수 있습니다.",
gaps.Count,
plan.curveCount,
plan.keyCount);
}
private void CreateCopy()
{
string sourcePath = AssetDatabase.GetAssetPath(sourceClip);
if (string.IsNullOrEmpty(sourcePath))
{
EditorUtility.DisplayDialog("복제본 생성 불가", "프로젝트에 저장된 AnimationClip을 선택해야 합니다.", "확인");
return;
}
float frameRate = Mathf.Max(1f, sourceClip.frameRate);
List<GapRange> gaps = FindSharedBlendShapeGaps(sourceClip, minimumGapFrames / frameRate);
RemovalPlan plan = BuildPlan(sourceClip, gaps, frameRate);
if (plan.keyCount == 0)
{
summary = string.Format("공백 {0}개를 찾았지만 제거할 경계 키가 없습니다.", gaps.Count);
return;
}
string directory = Path.GetDirectoryName(sourcePath)?.Replace('\\', '/') ?? "Assets";
string destinationPath = AssetDatabase.GenerateUniqueAssetPath(directory + "/" + sourceClip.name + "_GapEdgeCleaned.anim");
if (!AssetDatabase.CopyAsset(sourcePath, destinationPath))
{
EditorUtility.DisplayDialog("복제 실패", "선택한 AnimationClip의 복제본을 만들지 못했습니다.", "확인");
return;
}
AnimationClip cleanedClip = AssetDatabase.LoadAssetAtPath<AnimationClip>(destinationPath);
int removedKeyCount = ApplyPlan(cleanedClip, plan, gaps, linearizeGapBridge);
EditorUtility.SetDirty(cleanedClip);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Selection.activeObject = cleanedClip;
EditorGUIUtility.PingObject(cleanedClip);
summary = string.Format(
"{0} 생성 완료: 공백 {1}개에서 {2}개 커브의 경계 키 {3}개를 제거{4}.",
Path.GetFileName(destinationPath),
gaps.Count,
plan.curveCount,
removedKeyCount,
linearizeGapBridge ? "하고 부드럽게 연결했습니다" : "했습니다");
}
private RemovalPlan BuildPlan(AnimationClip clip, List<GapRange> gaps, float frameRate)
{
Dictionary<EditorCurveBinding, List<int>> indicesByBinding = new Dictionary<EditorCurveBinding, List<int>>();
float maximumDistance = maximumBoundaryDistanceFrames / Mathf.Max(1f, frameRate);
foreach (EditorCurveBinding binding in AnimationUtility.GetCurveBindings(clip))
{
if (!IsBlendShape(binding))
{
continue;
}
AnimationCurve curve = AnimationUtility.GetEditorCurve(clip, binding);
if (curve == null || curve.length < 3)
{
continue;
}
Keyframe[] keys = curve.keys;
HashSet<int> indices = new HashSet<int>();
foreach (GapRange gap in gaps)
{
AddBeforeGapKeys(keys, indices, gap.startTime, maximumDistance);
AddAfterGapKeys(keys, indices, gap.endTime, maximumDistance);
}
if (indices.Count > 0)
{
indicesByBinding.Add(binding, indices.OrderByDescending(index => index).ToList());
}
}
return new RemovalPlan(indicesByBinding);
}
private void AddBeforeGapKeys(Keyframe[] keys, HashSet<int> indices, float startTime, float maximumDistance)
{
int index = FindLastKeyAtOrBefore(keys, startTime);
if (index < 0 || startTime - keys[index].time > maximumDistance)
{
return;
}
for (int count = 0; count < keysBeforeGap; count++, index--)
{
if (index <= 0 || startTime - keys[index].time > maximumDistance || keys.Length - indices.Count <= 2)
{
break;
}
indices.Add(index);
}
}
private void AddAfterGapKeys(Keyframe[] keys, HashSet<int> indices, float endTime, float maximumDistance)
{
int index = FindFirstKeyAtOrAfter(keys, endTime);
if (index < 0 || keys[index].time - endTime > maximumDistance)
{
return;
}
for (int count = 0; count < keysAfterGap; count++, index++)
{
if (index >= keys.Length - 1 || keys[index].time - endTime > maximumDistance || keys.Length - indices.Count <= 2)
{
break;
}
indices.Add(index);
}
}
private static int ApplyPlan(AnimationClip clip, RemovalPlan plan, List<GapRange> gaps, bool linearizeGaps)
{
int removedKeyCount = 0;
foreach (KeyValuePair<EditorCurveBinding, List<int>> entry in plan.indicesByBinding)
{
AnimationCurve curve = AnimationUtility.GetEditorCurve(clip, entry.Key);
if (curve == null)
{
continue;
}
foreach (int index in entry.Value)
{
if (index > 0 && index < curve.length - 1)
{
curve.RemoveKey(index);
removedKeyCount++;
}
}
if (linearizeGaps)
{
foreach (GapRange gap in gaps)
{
LinearizeGapBridge(curve, gap);
}
}
AnimationUtility.SetEditorCurve(clip, entry.Key, curve);
}
return removedKeyCount;
}
private static void LinearizeGapBridge(AnimationCurve curve, GapRange gap)
{
Keyframe[] keys = curve.keys;
int startIndex = FindLastKeyAtOrBefore(keys, gap.startTime);
int endIndex = FindFirstKeyAtOrAfter(keys, gap.endTime);
if (startIndex < 0 || endIndex < 0 || startIndex >= endIndex)
{
return;
}
float duration = keys[endIndex].time - keys[startIndex].time;
if (duration <= TimeEpsilon)
{
return;
}
float slope = (keys[endIndex].value - keys[startIndex].value) / duration;
AnimationUtility.SetKeyBroken(curve, startIndex, true);
AnimationUtility.SetKeyBroken(curve, endIndex, true);
AnimationUtility.SetKeyRightTangentMode(curve, startIndex, AnimationUtility.TangentMode.Free);
AnimationUtility.SetKeyLeftTangentMode(curve, endIndex, AnimationUtility.TangentMode.Free);
keys = curve.keys;
Keyframe startKey = keys[startIndex];
Keyframe endKey = keys[endIndex];
startKey.outTangent = slope;
endKey.inTangent = slope;
keys[startIndex] = startKey;
keys[endIndex] = endKey;
curve.keys = keys;
}
private static List<GapRange> FindSharedBlendShapeGaps(AnimationClip clip, float minimumGapDuration)
{
List<float> keyTimes = new List<float>();
foreach (EditorCurveBinding binding in AnimationUtility.GetCurveBindings(clip))
{
if (!IsBlendShape(binding))
{
continue;
}
AnimationCurve curve = AnimationUtility.GetEditorCurve(clip, binding);
if (curve != null)
{
keyTimes.AddRange(curve.keys.Select(key => key.time));
}
}
keyTimes.Sort();
List<GapRange> gaps = new List<GapRange>();
for (int index = 1; index < keyTimes.Count; index++)
{
float previous = keyTimes[index - 1];
float current = keyTimes[index];
if (current - previous >= minimumGapDuration)
{
gaps.Add(new GapRange(previous, current));
}
}
return gaps;
}
private static int FindLastKeyAtOrBefore(Keyframe[] keys, float time)
{
for (int index = keys.Length - 1; index >= 0; index--)
{
if (keys[index].time <= time + TimeEpsilon)
{
return index;
}
}
return -1;
}
private static int FindFirstKeyAtOrAfter(Keyframe[] keys, float time)
{
for (int index = 0; index < keys.Length; index++)
{
if (keys[index].time >= time - TimeEpsilon)
{
return index;
}
}
return -1;
}
private static bool IsBlendShape(EditorCurveBinding binding)
{
return binding.propertyName.IndexOf("blendShape.", System.StringComparison.OrdinalIgnoreCase) >= 0;
}
private readonly struct GapRange
{
public readonly float startTime;
public readonly float endTime;
public GapRange(float startTime, float endTime)
{
this.startTime = startTime;
this.endTime = endTime;
}
}
private sealed class RemovalPlan
{
public readonly Dictionary<EditorCurveBinding, List<int>> indicesByBinding;
public readonly int curveCount;
public readonly int keyCount;
public RemovalPlan(Dictionary<EditorCurveBinding, List<int>> indicesByBinding)
{
this.indicesByBinding = indicesByBinding;
curveCount = indicesByBinding.Count;
keyCount = indicesByBinding.Sum(pair => pair.Value.Count);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0b03e044d22c4017946616c520dbeee8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d03e575508fe405da6778f2c68030e3f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+274
View File
@@ -0,0 +1,274 @@
using Streamingle.Utils.RoughConstraints;
using UnityEditor;
using UnityEditorInternal;
using UnityEngine;
namespace Streamingle.Utils.RoughConstraintsEditor
{
[CustomEditor(typeof(RoughConstraintBase), true)]
[CanEditMultipleObjects]
public sealed class RoughConstraintEditor : UnityEditor.Editor
{
private SerializedProperty constraintActive;
private SerializedProperty locked;
private SerializedProperty weight;
private SerializedProperty sources;
private SerializedProperty damping;
private SerializedProperty updateMode;
private SerializedProperty useUnscaledTime;
private SerializedProperty snapOnEnable;
private ReorderableList sourceList;
private bool roughFollowFoldout = true;
private bool settingsFoldout = true;
private void OnEnable()
{
constraintActive = serializedObject.FindProperty("constraintActive");
locked = serializedObject.FindProperty("locked");
weight = serializedObject.FindProperty("weight");
sources = serializedObject.FindProperty("sources");
damping = serializedObject.FindProperty("damping");
updateMode = serializedObject.FindProperty("updateMode");
useUnscaledTime = serializedObject.FindProperty("useUnscaledTime");
snapOnEnable = serializedObject.FindProperty("snapOnEnable");
sourceList = new ReorderableList(serializedObject, sources, true, true, true, true)
{
drawHeaderCallback = DrawSourceHeader,
drawElementCallback = DrawSourceElement,
elementHeightCallback = _ => EditorGUIUtility.singleLineHeight + 6f,
onAddCallback = OnAddSource
};
}
public override void OnInspectorGUI()
{
serializedObject.Update();
DrawCommonSettings();
DrawSourceList();
DrawTypeSettings();
serializedObject.ApplyModifiedProperties();
}
private void DrawCommonSettings()
{
EditorGUILayout.PropertyField(constraintActive, new GUIContent("Constraint Active"));
DrawLockField();
EditorGUILayout.Slider(weight, 0f, 1f, new GUIContent("Weight"));
EditorGUILayout.BeginHorizontal();
GUILayout.FlexibleSpace();
if (GUILayout.Button("Activate", EditorStyles.miniButton, GUILayout.Width(72f)))
{
ApplyAction("Activate Rough Constraint", ActivateConstraint);
}
if (GUILayout.Button("Zero", EditorStyles.miniButton, GUILayout.Width(56f)))
{
ZeroOffsets();
}
EditorGUILayout.EndHorizontal();
}
private void DrawLockField()
{
EditorGUI.BeginChangeCheck();
var newValue = EditorGUILayout.Toggle(new GUIContent("Lock"), locked.boolValue);
if (!EditorGUI.EndChangeCheck())
{
return;
}
serializedObject.ApplyModifiedProperties();
foreach (var selectedTarget in targets)
{
var constraint = (RoughConstraintBase)selectedTarget;
Undo.RecordObject(constraint, "Change Rough Constraint Lock");
constraint.SetLocked(newValue);
EditorUtility.SetDirty(constraint);
}
serializedObject.Update();
}
private void DrawSourceList()
{
EditorGUILayout.Space(2f);
sourceList.DoLayoutList();
EditorGUILayout.Space(4f);
}
private void DrawTypeSettings()
{
roughFollowFoldout = EditorGUILayout.Foldout(roughFollowFoldout, "Rough Follow", true);
if (roughFollowFoldout)
{
using (new EditorGUI.IndentLevelScope())
{
EditorGUILayout.PropertyField(damping, new GUIContent("Damping"));
EditorGUILayout.PropertyField(updateMode, new GUIContent("Update Mode"));
EditorGUILayout.PropertyField(useUnscaledTime, new GUIContent("Use Unscaled Time"));
EditorGUILayout.PropertyField(snapOnEnable, new GUIContent("Snap On Enable"));
}
}
settingsFoldout = EditorGUILayout.Foldout(settingsFoldout, GetSettingsTitle(), true);
if (!settingsFoldout)
{
return;
}
using (new EditorGUI.IndentLevelScope())
{
DrawTypeProperties();
}
}
private void DrawTypeProperties()
{
if (target is RoughPositionConstraint)
{
DrawProperties("constrainedAxis", "positionOffset", "offsetInSourceSpace");
}
else if (target is RoughRotationConstraint)
{
DrawProperties("constrainedAxis", "rotationOffset");
}
else if (target is RoughScaleConstraint)
{
DrawProperties("constrainedAxis", "scaleOffset");
}
else if (target is RoughParentConstraint)
{
DrawProperties("constrainedPositionAxis", "positionOffset", "positionOffsetInSourceSpace", "constrainedRotationAxis", "rotationOffset");
}
else if (target is RoughAimConstraint)
{
DrawProperties("aimAxis", "upAxis", "worldUpVector", "rotationOffset");
}
else if (target is RoughLookAtConstraint)
{
DrawProperties("forwardAxis", "worldUpVector", "targetPositionOffset", "rotationOffset");
}
}
private void DrawProperties(params string[] propertyNames)
{
for (var i = 0; i < propertyNames.Length; i++)
{
var property = serializedObject.FindProperty(propertyNames[i]);
if (property != null)
{
EditorGUILayout.PropertyField(property, new GUIContent(ObjectNames.NicifyVariableName(property.name)));
}
}
}
private void ApplyAction(string undoName, System.Action<RoughConstraintBase> action)
{
serializedObject.ApplyModifiedProperties();
foreach (var selectedTarget in targets)
{
var constraint = (RoughConstraintBase)selectedTarget;
Undo.RecordObject(constraint.transform, undoName);
action(constraint);
EditorUtility.SetDirty(constraint.transform);
}
}
private void ActivateConstraint(RoughConstraintBase constraint)
{
Undo.RecordObject(constraint, "Activate Rough Constraint");
constraint.ActivateConstraint();
EditorUtility.SetDirty(constraint);
}
private void ZeroOffsets()
{
serializedObject.ApplyModifiedProperties();
Undo.RecordObjects(targets, "Zero Rough Constraint Offsets");
SetVector3IfExists("positionOffset", Vector3.zero);
SetVector3IfExists("rotationOffset", Vector3.zero);
SetVector3IfExists("scaleOffset", Vector3.zero);
SetVector3IfExists("targetPositionOffset", Vector3.zero);
serializedObject.ApplyModifiedProperties();
}
private void SetVector3IfExists(string propertyName, Vector3 value)
{
var property = serializedObject.FindProperty(propertyName);
if (property != null)
{
property.vector3Value = value;
}
}
private void DrawSourceHeader(Rect rect)
{
var objectRect = new Rect(rect.x + 14f, rect.y, rect.width - 88f, rect.height);
var weightRect = new Rect(rect.xMax - 70f, rect.y, 68f, rect.height);
EditorGUI.LabelField(objectRect, "Sources");
EditorGUI.LabelField(weightRect, "Weight");
}
private void DrawSourceElement(Rect rect, int index, bool isActive, bool isFocused)
{
var element = sources.GetArrayElementAtIndex(index);
var sourceTransform = element.FindPropertyRelative("sourceTransform");
var sourceWeight = element.FindPropertyRelative("weight");
rect.y += 3f;
rect.height = EditorGUIUtility.singleLineHeight;
if (sourceTransform == null || sourceWeight == null)
{
EditorGUI.PropertyField(rect, element, GUIContent.none);
return;
}
var weightWidth = 70f;
var gap = 6f;
var targetRect = new Rect(rect.x, rect.y, rect.width - weightWidth - gap, rect.height);
var weightRect = new Rect(targetRect.xMax + gap, rect.y, weightWidth, rect.height);
EditorGUI.PropertyField(targetRect, sourceTransform, GUIContent.none);
sourceWeight.floatValue = Mathf.Clamp01(EditorGUI.FloatField(weightRect, sourceWeight.floatValue));
}
private void OnAddSource(ReorderableList list)
{
sources.arraySize++;
var element = sources.GetArrayElementAtIndex(sources.arraySize - 1);
var sourceTransform = element.FindPropertyRelative("sourceTransform");
var sourceWeight = element.FindPropertyRelative("weight");
if (sourceTransform != null)
{
sourceTransform.objectReferenceValue = null;
}
if (sourceWeight != null)
{
sourceWeight.floatValue = 1f;
}
}
private string GetSettingsTitle()
{
if (target is RoughLookAtConstraint)
{
return "Look At";
}
var name = target.GetType().Name;
name = name.Replace("Rough", string.Empty).Replace("Constraint", string.Empty);
return ObjectNames.NicifyVariableName(name);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b69eed063de14090a1e439e86149a6a6
+44
View File
@@ -0,0 +1,44 @@
using Streamingle.Utils.RoughConstraints;
using UnityEditor;
using UnityEngine;
namespace Streamingle.Utils.RoughConstraintsEditor
{
[CustomPropertyDrawer(typeof(Vector3Bool))]
public sealed class Vector3BoolDrawer : UnityEditor.PropertyDrawer
{
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
var x = property.FindPropertyRelative("x");
var y = property.FindPropertyRelative("y");
var z = property.FindPropertyRelative("z");
position = EditorGUI.PrefixLabel(position, label);
var previousIndent = EditorGUI.indentLevel;
EditorGUI.indentLevel = 0;
var gap = 4f;
var width = (position.width - gap * 2f) / 3f;
DrawToggle(new Rect(position.x, position.y, width, position.height), x, "X");
DrawToggle(new Rect(position.x + width + gap, position.y, width, position.height), y, "Y");
DrawToggle(new Rect(position.x + (width + gap) * 2f, position.y, width, position.height), z, "Z");
EditorGUI.indentLevel = previousIndent;
}
private static void DrawToggle(Rect rect, SerializedProperty property, string label)
{
var enabled = property.boolValue;
var previousColor = GUI.backgroundColor;
GUI.backgroundColor = enabled ? new Color(0.55f, 0.82f, 1f) : previousColor;
if (GUI.Button(rect, label, EditorStyles.miniButton))
{
property.boolValue = !enabled;
}
GUI.backgroundColor = previousColor;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 78d95149f81448ad809828060b5800ce
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ecc5b45bd3ebb3548a0d9c27af991f81
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+3
View File
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Streamingle.Gaze.Tests.Editor")]
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ff643376513f90b4f88310d09238aa75
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: faf76a1077abde041aee942d9ec420ee
@@ -0,0 +1,473 @@
using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace Streamingle.Gaze.Editor
{
/// <summary>
/// Owns the temporary blendshape writes used by the calibration UI.
/// The session never modifies the profile or scene and always attempts to
/// restore the weights that were present when it started.
/// </summary>
internal sealed class GazeCalibrationPreviewSession : IDisposable
{
private int[] blendShapeIndices = Array.Empty<int>();
private string[] resolvedBlendShapeNames = Array.Empty<string>();
private float[] originalWeights = Array.Empty<float>();
private float[] previewWeights = Array.Empty<float>();
private Mesh sourceMesh;
private bool hasPreview;
private bool eventsSubscribed;
private bool driverOverrideAcquired;
private BlendshapeGazeProfile driverProfile;
internal event Action Stopped;
public bool IsActive { get; private set; }
public BlendshapeGazeDriver Driver { get; private set; }
public SkinnedMeshRenderer Renderer { get; private set; }
public BlendshapeGazeProfile Profile { get; private set; }
public Vector2 PreviewCoordinates { get; private set; }
public bool IsFreePreview { get; private set; }
public bool Start(BlendshapeGazeDriver driver, out string error)
{
return Start(driver, null, out error);
}
internal bool Start(
BlendshapeGazeDriver driver,
BlendshapeGazeProfile previewProfile,
out string error)
{
Stop();
error = null;
if (Application.isPlaying || EditorApplication.isPlayingOrWillChangePlaymode)
{
error = "Calibration preview is only available in Edit Mode.";
return false;
}
if (driver == null)
{
error = "A Blendshape Gaze Driver is required.";
return false;
}
var renderer = driver.TargetRenderer;
if (renderer == null)
{
error = "The driver does not have a Target Renderer.";
return false;
}
var mesh = renderer.sharedMesh;
if (mesh == null)
{
error = "The Target Renderer does not have a shared mesh.";
return false;
}
var assignedProfile = driver.Profile;
if (assignedProfile == null)
{
error = "The driver does not have a Gaze Profile.";
return false;
}
var profile = previewProfile != null ? previewProfile : assignedProfile;
if (!profile.IsCompatibleWith(mesh))
{
error = "The Gaze Profile was calibrated for a different source mesh.";
return false;
}
var channels = profile.Channels;
if (channels == null || channels.Count == 0)
{
error = "The Gaze Profile does not contain any blendshape channels.";
return false;
}
var indices = new int[channels.Count];
var names = new string[channels.Count];
var originals = new float[channels.Count];
for (var channelIndex = 0; channelIndex < channels.Count; channelIndex++)
{
var channel = channels[channelIndex];
if (channel == null || string.IsNullOrWhiteSpace(channel.BlendShapeName))
{
error = $"Gaze channel {channelIndex + 1} does not have a blendshape name.";
return false;
}
var blendShapeIndex = profile.FindBlendShapeIndex(mesh, channelIndex);
if (blendShapeIndex < 0 || blendShapeIndex >= mesh.blendShapeCount)
{
error = $"Blendshape '{channel.BlendShapeName}' was not found on '{mesh.name}'.";
return false;
}
indices[channelIndex] = blendShapeIndex;
names[channelIndex] = mesh.GetBlendShapeName(blendShapeIndex);
}
// The driver relinquishes its current gaze result only after the entire
// profile-to-mesh mapping has passed validation. Capture the baseline
// after that release so Stop restores the actual external facial pose.
driver.BeginCalibrationPreviewOverride();
try
{
for (var channelIndex = 0; channelIndex < channels.Count; channelIndex++)
{
var blendShapeIndex = indices[channelIndex];
originals[channelIndex] = renderer.GetBlendShapeWeight(blendShapeIndex);
}
Driver = driver;
Renderer = renderer;
Profile = profile;
driverProfile = assignedProfile;
sourceMesh = mesh;
blendShapeIndices = indices;
resolvedBlendShapeNames = names;
originalWeights = originals;
previewWeights = new float[channels.Count];
PreviewCoordinates = Vector2.zero;
IsFreePreview = false;
hasPreview = false;
driverOverrideAcquired = true;
IsActive = true;
SubscribeEvents();
return true;
}
catch (Exception exception)
{
driver.EndCalibrationPreviewOverride();
error = $"Could not start calibration preview: {exception.Message}";
return false;
}
}
public void PreviewSample(GazeCalibrationPoint point)
{
if (!IsActive || !IsSessionValid())
return;
var sampleIndex = (int)point;
var samples = Profile.Samples;
if (sampleIndex < 0 || sampleIndex >= samples.Count)
return;
var sample = samples[sampleIndex];
if (sample == null || !sample.Captured || sample.Weights.Count != previewWeights.Length)
return;
PreviewWeights(sample.Weights, point);
}
public void PreviewWeights(IReadOnlyList<float> weights, GazeCalibrationPoint point)
{
if (!IsActive || !IsSessionValid() || !IsValidPoint(point)
|| !TrySetPreviewWeights(weights, point == GazeCalibrationPoint.Center))
return;
PreviewCoordinates = CoordinatesFor(point);
IsFreePreview = false;
hasPreview = true;
ApplyPreviewWeights();
}
public void PreviewNormalized(Vector2 normalized)
{
if (!IsActive || !IsSessionValid())
return;
normalized = new Vector2(
Mathf.Clamp(normalized.x, -1f, 1f),
Mathf.Clamp(normalized.y, -1f, 1f));
var evaluated = new float[previewWeights.Length];
var direction = new GazeDirectionResult(
Vector3.forward,
Vector3.forward,
0f,
0f,
0f,
0f,
normalized.x,
normalized.y);
// TryEvaluate also guarantees that all nine calibration samples exist.
// Correctives are intentionally included so this is an exact preview of
// the completed profile rather than only its eye-look subset.
if (!Profile.TryEvaluate(direction, true, evaluated))
return;
if (!TrySetPreviewWeights(evaluated, false))
return;
PreviewCoordinates = normalized;
IsFreePreview = true;
hasPreview = true;
ApplyPreviewWeights();
}
public float[] GetOriginalWeightsCopy()
{
return (float[])originalWeights.Clone();
}
public void Stop()
{
var wasActive = IsActive;
UnsubscribeEvents();
var overriddenDriver = Driver;
try
{
if (IsActive)
RestoreOriginalWeights();
}
finally
{
// Restoring while the override is still held guarantees that a
// Timeline/editor evaluation cannot race the final face reset.
if (driverOverrideAcquired && overriddenDriver != null)
overriddenDriver.EndCalibrationPreviewOverride();
IsActive = false;
Driver = null;
Renderer = null;
Profile = null;
driverProfile = null;
sourceMesh = null;
blendShapeIndices = Array.Empty<int>();
resolvedBlendShapeNames = Array.Empty<string>();
originalWeights = Array.Empty<float>();
previewWeights = Array.Empty<float>();
PreviewCoordinates = Vector2.zero;
IsFreePreview = false;
hasPreview = false;
driverOverrideAcquired = false;
if (wasActive)
Stopped?.Invoke();
}
}
public void Dispose()
{
Stop();
}
private void OnEditorUpdate()
{
if (!IsActive)
return;
if (EditorApplication.isPlayingOrWillChangePlaymode || !IsSessionValid())
{
Stop();
return;
}
if (hasPreview)
ApplyPreviewWeights();
}
private void OnBeforeAssemblyReload()
{
Stop();
}
private void OnEditorQuitting()
{
Stop();
}
private void OnPlayModeStateChanged(PlayModeStateChange state)
{
Stop();
}
private void OnSceneSaving(Scene scene, string path)
{
Stop();
}
private void OnPrefabSaving(GameObject root)
{
Stop();
}
private void SubscribeEvents()
{
if (eventsSubscribed)
return;
EditorApplication.update += OnEditorUpdate;
EditorApplication.quitting += OnEditorQuitting;
EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload;
EditorSceneManager.sceneSaving += OnSceneSaving;
PrefabStage.prefabSaving += OnPrefabSaving;
eventsSubscribed = true;
}
private void UnsubscribeEvents()
{
if (!eventsSubscribed)
return;
EditorApplication.update -= OnEditorUpdate;
EditorApplication.quitting -= OnEditorQuitting;
EditorApplication.playModeStateChanged -= OnPlayModeStateChanged;
AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload;
EditorSceneManager.sceneSaving -= OnSceneSaving;
PrefabStage.prefabSaving -= OnPrefabSaving;
eventsSubscribed = false;
}
private bool IsSessionValid()
{
if (!IsActive || Driver == null || Renderer == null || Profile == null
|| driverProfile == null || sourceMesh == null)
return false;
// Cached indices belong exclusively to this exact mesh. Never use them
// after a renderer mesh swap, even if the replacement has matching names.
if (Renderer.sharedMesh != sourceMesh
|| Driver.TargetRenderer != Renderer
|| Driver.Profile != driverProfile
|| !Profile.IsCompatibleWith(sourceMesh))
return false;
var channels = Profile.Channels;
if (channels == null || channels.Count != blendShapeIndices.Length
|| channels.Count != resolvedBlendShapeNames.Length)
return false;
for (var channelIndex = 0; channelIndex < channels.Count; channelIndex++)
{
var channel = channels[channelIndex];
var blendShapeIndex = blendShapeIndices[channelIndex];
if (channel == null || blendShapeIndex < 0 || blendShapeIndex >= sourceMesh.blendShapeCount
|| !string.Equals(
sourceMesh.GetBlendShapeName(blendShapeIndex),
resolvedBlendShapeNames[channelIndex],
StringComparison.Ordinal)
|| Profile.FindBlendShapeIndex(sourceMesh, channelIndex) != blendShapeIndices[channelIndex])
return false;
}
return previewWeights.Length == blendShapeIndices.Length
&& originalWeights.Length == blendShapeIndices.Length;
}
private void ApplyPreviewWeights()
{
if (!hasPreview || !IsSessionValid())
return;
for (var channelIndex = 0; channelIndex < blendShapeIndices.Length; channelIndex++)
Renderer.SetBlendShapeWeight(blendShapeIndices[channelIndex], previewWeights[channelIndex]);
}
private void RestoreOriginalWeights()
{
if (Renderer == null || sourceMesh == null || Renderer.sharedMesh != sourceMesh)
return;
var count = Mathf.Min(blendShapeIndices.Length, originalWeights.Length);
if (count != resolvedBlendShapeNames.Length)
return;
// Validate the complete cached index/name mapping before writing any
// value. A mesh rebuilt in place may reuse an index for another shape.
for (var channelIndex = 0; channelIndex < count; channelIndex++)
{
var blendShapeIndex = blendShapeIndices[channelIndex];
if (blendShapeIndex < 0 || blendShapeIndex >= sourceMesh.blendShapeCount
|| !string.Equals(
sourceMesh.GetBlendShapeName(blendShapeIndex),
resolvedBlendShapeNames[channelIndex],
StringComparison.Ordinal))
return;
}
for (var channelIndex = 0; channelIndex < count; channelIndex++)
Renderer.SetBlendShapeWeight(blendShapeIndices[channelIndex], originalWeights[channelIndex]);
}
private bool TrySetPreviewWeights(IReadOnlyList<float> weights, bool inputRepresentsCenter)
{
var channels = Profile != null ? Profile.Channels : null;
if (weights == null || channels == null || weights.Count != previewWeights.Length
|| channels.Count != previewWeights.Length || originalWeights.Length != previewWeights.Length)
return false;
var translated = new float[weights.Count];
for (var channelIndex = 0; channelIndex < weights.Count; channelIndex++)
{
var sampleWeight = weights[channelIndex];
if (!IsFinite(sampleWeight))
return false;
var channel = channels[channelIndex];
if (channel == null)
return false;
if (channel.Usage == GazeChannelUsage.Corrective
&& channel.CorrectiveBlendMode == GazeCorrectiveBlendMode.AdditiveFromCenter)
{
var centerWeight = inputRepresentsCenter
? sampleWeight
: Profile.IsSampleCaptured(GazeCalibrationPoint.Center)
? Profile.GetSampleWeight(GazeCalibrationPoint.Center, channelIndex)
: originalWeights[channelIndex];
if (!IsFinite(centerWeight))
return false;
translated[channelIndex] = originalWeights[channelIndex] + sampleWeight - centerWeight;
}
else
{
translated[channelIndex] = sampleWeight;
}
if (!IsFinite(translated[channelIndex]))
return false;
}
Array.Copy(translated, previewWeights, translated.Length);
return true;
}
private static Vector2 CoordinatesFor(GazeCalibrationPoint point)
{
var index = (int)point;
return new Vector2(index % 3 - 1, index / 3 - 1);
}
private static bool IsValidPoint(GazeCalibrationPoint point)
{
var index = (int)point;
return index >= 0 && index < 9;
}
private static bool IsFinite(float value)
{
return !float.IsNaN(value) && !float.IsInfinity(value);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4fd84d575a717f54ba289016048bb22b
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6b4ab3e2eac3c4e46868fdc5ee5a449e
+426
View File
@@ -0,0 +1,426 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Streamingle.Gaze.Editor
{
/// <summary>
/// Result of an editor-only gaze-origin estimation pass.
/// </summary>
internal readonly struct GazeOriginEstimate
{
internal GazeOriginEstimate(
Vector3 worldPosition,
Vector3 leftEyeWorldPosition,
Vector3 rightEyeWorldPosition,
bool usedEyeBlendShapes,
int leftVertexCount,
int rightVertexCount,
string message)
{
WorldPosition = worldPosition;
LeftEyeWorldPosition = leftEyeWorldPosition;
RightEyeWorldPosition = rightEyeWorldPosition;
UsedEyeBlendShapes = usedEyeBlendShapes;
LeftVertexCount = leftVertexCount;
RightVertexCount = rightVertexCount;
Message = message;
}
internal Vector3 WorldPosition { get; }
internal Vector3 LeftEyeWorldPosition { get; }
internal Vector3 RightEyeWorldPosition { get; }
internal bool UsedEyeBlendShapes { get; }
internal int LeftVertexCount { get; }
internal int RightVertexCount { get; }
internal string Message { get; }
}
/// <summary>
/// Estimates the midpoint between the eyes from the vertices influenced by the configured
/// ARKit eyeLook blend shapes. This is deliberately editor-only: BakeMesh is never used by
/// the runtime gaze driver.
/// </summary>
internal static class GazeOriginEstimator
{
private const float MinimumDelta = 1e-7f;
private const int MinimumVerticesPerEye = 3;
internal static GazeOriginEstimate Estimate(
SkinnedMeshRenderer renderer,
IReadOnlyList<string> configuredChannelNames)
{
if (renderer == null)
{
return new GazeOriginEstimate(
Vector3.zero,
Vector3.zero,
Vector3.zero,
false,
0,
0,
"Target Renderer is not assigned.");
}
Mesh sourceMesh = renderer.sharedMesh;
if (sourceMesh == null || sourceMesh.vertexCount == 0)
{
return Fallback(renderer, "The renderer has no readable shared mesh.");
}
var leftShapeIndices = new HashSet<int>();
var rightShapeIndices = new HashSet<int>();
CollectShapeIndices(sourceMesh, configuredChannelNames, leftShapeIndices, rightShapeIndices);
if (leftShapeIndices.Count == 0 || rightShapeIndices.Count == 0)
{
return Fallback(
renderer,
"Could not find configured eyeLook blend shapes for both eyes. Used Renderer bounds center.");
}
int vertexCount = sourceMesh.vertexCount;
var leftMaximumDeltas = new float[vertexCount];
var rightMaximumDeltas = new float[vertexCount];
var deltaVertices = new Vector3[vertexCount];
var deltaNormals = new Vector3[vertexCount];
var deltaTangents = new Vector3[vertexCount];
AccumulateMaximumDeltas(
sourceMesh,
leftShapeIndices,
leftMaximumDeltas,
deltaVertices,
deltaNormals,
deltaTangents);
AccumulateMaximumDeltas(
sourceMesh,
rightShapeIndices,
rightMaximumDeltas,
deltaVertices,
deltaNormals,
deltaTangents);
Mesh bakedMesh = null;
try
{
bakedMesh = new Mesh { name = "GazeOriginEstimator_TemporaryMesh" };
renderer.BakeMesh(bakedMesh);
Vector3[] neutralVertices = bakedMesh.vertices;
if (neutralVertices == null || neutralVertices.Length != vertexCount)
{
return Fallback(renderer, "BakeMesh vertex count did not match the source mesh.");
}
bool hasLeft = TryCalculateWeightedCentroid(
renderer.transform,
neutralVertices,
leftMaximumDeltas,
out Vector3 leftCenter,
out int leftVertexCount);
bool hasRight = TryCalculateWeightedCentroid(
renderer.transform,
neutralVertices,
rightMaximumDeltas,
out Vector3 rightCenter,
out int rightVertexCount);
if (!hasLeft || !hasRight)
{
return Fallback(
renderer,
"Eye blend shapes did not contain enough significant vertices. Used Renderer bounds center.",
leftVertexCount,
rightVertexCount);
}
// Deliberately give both eyes equal influence. A larger or stronger blend shape on one
// side must not pull the gaze origin away from the center of the face.
Vector3 midpoint = (leftCenter + rightCenter) * 0.5f;
return new GazeOriginEstimate(
midpoint,
leftCenter,
rightCenter,
true,
leftVertexCount,
rightVertexCount,
$"Estimated from {leftVertexCount} left-eye and {rightVertexCount} right-eye vertices.");
}
catch (Exception exception)
{
return Fallback(renderer, $"Eye vertex estimation failed: {exception.Message}");
}
finally
{
if (bakedMesh != null)
{
UnityEngine.Object.DestroyImmediate(bakedMesh);
}
}
}
internal static int FindBlendShapeIndex(Mesh mesh, string configuredName)
{
if (mesh == null || string.IsNullOrWhiteSpace(configuredName))
{
return -1;
}
string normalizedConfiguredName = Normalize(configuredName);
int suffixMatch = -1;
for (int index = 0; index < mesh.blendShapeCount; index++)
{
string meshName = mesh.GetBlendShapeName(index);
if (string.Equals(meshName, configuredName, StringComparison.OrdinalIgnoreCase))
{
return index;
}
if (meshName.EndsWith(configuredName, StringComparison.OrdinalIgnoreCase))
{
suffixMatch = index;
continue;
}
string normalizedMeshName = Normalize(meshName);
if (normalizedMeshName == normalizedConfiguredName)
{
return index;
}
if (normalizedMeshName.EndsWith(normalizedConfiguredName, StringComparison.Ordinal))
{
suffixMatch = index;
}
}
return suffixMatch;
}
private static void CollectShapeIndices(
Mesh mesh,
IReadOnlyList<string> configuredChannelNames,
ISet<int> leftShapeIndices,
ISet<int> rightShapeIndices)
{
if (configuredChannelNames != null)
{
for (int index = 0; index < configuredChannelNames.Count; index++)
{
string channelName = configuredChannelNames[index];
int blendShapeIndex = FindBlendShapeIndex(mesh, channelName);
if (blendShapeIndex < 0)
{
continue;
}
string sideName = string.IsNullOrWhiteSpace(channelName)
? mesh.GetBlendShapeName(blendShapeIndex)
: channelName;
if (IsLeftChannel(sideName))
{
leftShapeIndices.Add(blendShapeIndex);
}
else if (IsRightChannel(sideName))
{
rightShapeIndices.Add(blendShapeIndex);
}
}
}
// A newly created profile has no bindings yet. Scanning the mesh for the standard suffixes
// still gives Auto Create a useful result before the user presses Sync Channels.
if (leftShapeIndices.Count > 0 && rightShapeIndices.Count > 0)
{
return;
}
for (int index = 0; index < mesh.blendShapeCount; index++)
{
string name = mesh.GetBlendShapeName(index);
string normalizedName = Normalize(name);
if (!normalizedName.Contains("eyelook"))
{
continue;
}
if (IsLeftChannel(name))
{
leftShapeIndices.Add(index);
}
else if (IsRightChannel(name))
{
rightShapeIndices.Add(index);
}
}
}
private static void AccumulateMaximumDeltas(
Mesh mesh,
IEnumerable<int> shapeIndices,
float[] maximumDeltas,
Vector3[] deltaVertices,
Vector3[] deltaNormals,
Vector3[] deltaTangents)
{
foreach (int shapeIndex in shapeIndices)
{
int frameCount = mesh.GetBlendShapeFrameCount(shapeIndex);
for (int frameIndex = 0; frameIndex < frameCount; frameIndex++)
{
mesh.GetBlendShapeFrameVertices(
shapeIndex,
frameIndex,
deltaVertices,
deltaNormals,
deltaTangents);
for (int vertexIndex = 0; vertexIndex < deltaVertices.Length; vertexIndex++)
{
float magnitude = deltaVertices[vertexIndex].magnitude;
if (magnitude > maximumDeltas[vertexIndex])
{
maximumDeltas[vertexIndex] = magnitude;
}
}
}
}
}
private static bool TryCalculateWeightedCentroid(
Transform rendererTransform,
IReadOnlyList<Vector3> neutralVertices,
IReadOnlyList<float> maximumDeltas,
out Vector3 worldCentroid,
out int includedVertexCount)
{
var positiveDeltas = new List<float>();
for (int index = 0; index < maximumDeltas.Count; index++)
{
float delta = maximumDeltas[index];
if (delta > MinimumDelta && IsFinite(delta))
{
positiveDeltas.Add(delta);
}
}
if (positiveDeltas.Count < MinimumVerticesPerEye)
{
worldCentroid = Vector3.zero;
includedVertexCount = 0;
return false;
}
positiveDeltas.Sort();
float median = Percentile(positiveDeltas, 0.5f);
float percentile95 = Percentile(positiveDeltas, 0.95f);
float percentile99 = Percentile(positiveDeltas, 0.99f);
// Remove tiny cross-face noise and reject pathological spikes. Valid high-motion eye
// vertices are retained and winsorized so one vertex cannot dominate the centroid.
float lowerThreshold = Mathf.Max(MinimumDelta, Mathf.Max(median * 0.05f, percentile95 * 0.015f));
float upperThreshold = Mathf.Max(percentile99 * 4f, lowerThreshold);
float maximumWeight = Mathf.Max(percentile95 * 2f, lowerThreshold);
Vector3 weightedPosition = Vector3.zero;
float weightSum = 0f;
includedVertexCount = 0;
for (int index = 0; index < maximumDeltas.Count; index++)
{
float delta = maximumDeltas[index];
if (!IsFinite(delta) || delta < lowerThreshold || delta > upperThreshold)
{
continue;
}
float weight = Mathf.Min(delta, maximumWeight);
weightedPosition += rendererTransform.TransformPoint(neutralVertices[index]) * weight;
weightSum += weight;
includedVertexCount++;
}
if (includedVertexCount < MinimumVerticesPerEye || weightSum <= Mathf.Epsilon)
{
worldCentroid = Vector3.zero;
return false;
}
worldCentroid = weightedPosition / weightSum;
return IsFinite(worldCentroid);
}
private static float Percentile(IReadOnlyList<float> sortedValues, float percentile)
{
if (sortedValues.Count == 0)
{
return 0f;
}
float position = Mathf.Clamp01(percentile) * (sortedValues.Count - 1);
int lowerIndex = Mathf.FloorToInt(position);
int upperIndex = Mathf.CeilToInt(position);
return Mathf.Lerp(sortedValues[lowerIndex], sortedValues[upperIndex], position - lowerIndex);
}
private static GazeOriginEstimate Fallback(
SkinnedMeshRenderer renderer,
string message,
int leftVertexCount = 0,
int rightVertexCount = 0)
{
Vector3 center = renderer != null ? renderer.bounds.center : Vector3.zero;
return new GazeOriginEstimate(
center,
center,
center,
false,
leftVertexCount,
rightVertexCount,
message);
}
private static bool IsLeftChannel(string name)
{
return Normalize(name).EndsWith("left", StringComparison.Ordinal);
}
private static bool IsRightChannel(string name)
{
return Normalize(name).EndsWith("right", StringComparison.Ordinal);
}
private static string Normalize(string value)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
var characters = new char[value.Length];
int count = 0;
for (int index = 0; index < value.Length; index++)
{
char character = value[index];
if (!char.IsLetterOrDigit(character))
{
continue;
}
characters[count++] = char.ToLowerInvariant(character);
}
return new string(characters, 0, count);
}
private static bool IsFinite(Vector3 value)
{
return IsFinite(value.x) && IsFinite(value.y) && IsFinite(value.z);
}
private static bool IsFinite(float value)
{
return !float.IsNaN(value) && !float.IsInfinity(value);
}
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4e767c5be201bbc4ca28ed9149cd78a5
@@ -0,0 +1,18 @@
{
"name": "Streamingle.Gaze.Editor",
"rootNamespace": "Streamingle.Gaze.Editor",
"references": [
"Streamingle.Gaze.Runtime"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 161a6877619d01f4fbf89fc2896a2b19
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,19 @@
{
"name": "Streamingle.Utilities.Editor",
"rootNamespace": "Streamingle.Utilities.Editor",
"references": [
"Streamingle.Utilities.Runtime",
"Streamingle.Subtitles.Runtime"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: f2adcdd669f914b439f27fb8dd249d99
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,188 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using TMPro;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;
namespace Streamingle.Subtitles.Editor
{
public static class SrtSubtitleInstaller
{
public const string GeneratedTrackName = "Subtitles [Generated]";
private static readonly Regex TimestampPattern = new Regex(
@"^(?<sh>\d+):(?<sm>\d{2}):(?<ss>\d{2}),(?<sms>\d{3})\s*-->\s*(?<eh>\d+):(?<em>\d{2}):(?<es>\d{2}),(?<ems>\d{3})$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
public static void Install(
PlayableDirector director,
TMP_Text target,
string srtAssetPath,
double offsetSeconds)
{
if (director == null)
throw new ArgumentNullException(nameof(director));
if (target == null)
throw new ArgumentNullException(nameof(target));
if (director.playableAsset is not TimelineAsset timeline)
throw new InvalidOperationException("The PlayableDirector must reference a TimelineAsset.");
if (double.IsNaN(offsetSeconds) || double.IsInfinity(offsetSeconds))
throw new ArgumentOutOfRangeException(nameof(offsetSeconds));
var fullSrtPath = ResolveProjectPath(srtAssetPath);
if (!File.Exists(fullSrtPath))
throw new FileNotFoundException("SRT file was not found.", fullSrtPath);
var cues = Parse(File.ReadAllText(fullSrtPath));
if (cues.Count == 0)
throw new InvalidDataException("The SRT file contains no subtitle cues.");
var undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Install generated subtitle track");
try
{
Undo.RegisterCompleteObjectUndo(timeline, "Replace generated subtitle track");
Undo.RecordObject(director, "Bind generated subtitle track");
RemoveGeneratedTracks(timeline, director);
var track = timeline.CreateTrack<SubtitleTrack>(null, GeneratedTrackName);
foreach (var cue in cues)
{
var start = Math.Max(0d, cue.StartSeconds + offsetSeconds);
var clip = track.CreateClip<SubtitleClip>();
clip.start = start;
clip.duration = cue.EndSeconds - cue.StartSeconds;
clip.displayName = CreateDisplayName(cue.Text);
if (clip.asset is SubtitleClip subtitleAsset)
{
subtitleAsset.Text = cue.Text;
EditorUtility.SetDirty(subtitleAsset);
}
}
director.SetGenericBinding(track, target);
EditorUtility.SetDirty(track);
EditorUtility.SetDirty(timeline);
EditorUtility.SetDirty(director);
EditorSceneManager.MarkSceneDirty(director.gameObject.scene);
AssetDatabase.SaveAssetIfDirty(timeline);
director.RebuildGraph();
director.Evaluate();
SceneView.RepaintAll();
}
finally
{
Undo.CollapseUndoOperations(undoGroup);
}
}
public static IReadOnlyList<SrtCue> Parse(string srtContents)
{
if (string.IsNullOrWhiteSpace(srtContents))
return Array.Empty<SrtCue>();
var normalized = srtContents.Replace("\r\n", "\n").Replace('\r', '\n').Trim();
var blocks = Regex.Split(normalized, @"\n{2,}");
var cues = new List<SrtCue>(blocks.Length);
for (var blockIndex = 0; blockIndex < blocks.Length; blockIndex++)
{
var lines = blocks[blockIndex]
.Split('\n')
.Select(line => line.TrimEnd())
.ToArray();
var timestampLineIndex = Array.FindIndex(lines, line => line.Contains("-->"));
if (timestampLineIndex < 0 || timestampLineIndex + 1 >= lines.Length)
throw new FormatException($"Invalid SRT cue at block {blockIndex + 1}.");
var match = TimestampPattern.Match(lines[timestampLineIndex].Trim());
if (!match.Success)
throw new FormatException($"Invalid SRT timestamp at block {blockIndex + 1}.");
var start = ParseTimestamp(match, "s");
var end = ParseTimestamp(match, "e");
if (end <= start)
throw new FormatException($"SRT cue {blockIndex + 1} must end after it starts.");
var text = string.Join("\n", lines.Skip(timestampLineIndex + 1)).Trim();
if (text.Length == 0)
continue;
if (cues.Count > 0 && start < cues[cues.Count - 1].EndSeconds)
throw new FormatException($"SRT cue {blockIndex + 1} overlaps the previous cue.");
cues.Add(new SrtCue(start, end, text));
}
return cues;
}
private static void RemoveGeneratedTracks(TimelineAsset timeline, PlayableDirector director)
{
var generatedTracks = timeline.GetRootTracks()
.OfType<SubtitleTrack>()
.Where(track => track.name == GeneratedTrackName)
.ToArray();
foreach (var track in generatedTracks)
{
director.ClearGenericBinding(track);
timeline.DeleteTrack(track);
}
}
private static string ResolveProjectPath(string path)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("An SRT asset path is required.", nameof(path));
if (Path.IsPathRooted(path))
return Path.GetFullPath(path);
var projectRoot = Directory.GetParent(Application.dataPath)?.FullName
?? throw new InvalidOperationException("Could not resolve the Unity project root.");
return Path.GetFullPath(Path.Combine(projectRoot, path));
}
private static double ParseTimestamp(Match match, string prefix)
{
var hours = int.Parse(match.Groups[prefix + "h"].Value);
var minutes = int.Parse(match.Groups[prefix + "m"].Value);
var seconds = int.Parse(match.Groups[prefix + "s"].Value);
var milliseconds = int.Parse(match.Groups[prefix + "ms"].Value);
return hours * 3600d + minutes * 60d + seconds + milliseconds / 1000d;
}
private static string CreateDisplayName(string text)
{
var singleLine = Regex.Replace(text ?? string.Empty, @"\s+", " ").Trim();
return singleLine.Length <= 36 ? singleLine : singleLine.Substring(0, 35) + "…";
}
public readonly struct SrtCue
{
public SrtCue(double startSeconds, double endSeconds, string text)
{
StartSeconds = startSeconds;
EndSeconds = endSeconds;
Text = text;
}
public double StartSeconds { get; }
public double EndSeconds { get; }
public string Text { get; }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cdb14b2eda3be7741a811ba8be8ab1b0