feat: initialize Streamingle Utilities package
This commit is contained in:
@@ -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:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d03e575508fe405da6778f2c68030e3f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ecc5b45bd3ebb3548a0d9c27af991f81
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Streamingle.Gaze.Tests.Editor")]
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user