unitytk 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unitytk-0.0.1/LICENSE +21 -0
- unitytk-0.0.1/MANIFEST.in +4 -0
- unitytk-0.0.1/PKG-INFO +20 -0
- unitytk-0.0.1/docs/README.md +3 -0
- unitytk-0.0.1/pyproject.toml +32 -0
- unitytk-0.0.1/setup.cfg +4 -0
- unitytk-0.0.1/test/test_audio_events_integration.py +600 -0
- unitytk-0.0.1/test/test_audio_trigger_roundtrip.py +790 -0
- unitytk-0.0.1/test/test_c130_fcr_integration.py +1283 -0
- unitytk-0.0.1/test/test_launcher.py +95 -0
- unitytk-0.0.1/test/test_render_opacity_controller.py +357 -0
- unitytk-0.0.1/test/test_render_opacity_integration.py +595 -0
- unitytk-0.0.1/test/test_standalone_enduser.py +1492 -0
- unitytk-0.0.1/unitytk/__init__.py +30 -0
- unitytk-0.0.1/unitytk/launcher.py +231 -0
- unitytk-0.0.1/unitytk/scene_builder.py +86 -0
- unitytk-0.0.1/unitytk/templates/AudioEventController.cs +471 -0
- unitytk-0.0.1/unitytk/templates/README.md +97 -0
- unitytk-0.0.1/unitytk/templates/RenderOpacityController.cs +602 -0
- unitytk-0.0.1/unitytk/templates/__init__.py +2 -0
- unitytk-0.0.1/unitytk/templates/maya_fbx_import_config.json +6 -0
- unitytk-0.0.1/unitytk.egg-info/PKG-INFO +20 -0
- unitytk-0.0.1/unitytk.egg-info/SOURCES.txt +24 -0
- unitytk-0.0.1/unitytk.egg-info/dependency_links.txt +1 -0
- unitytk-0.0.1/unitytk.egg-info/requires.txt +1 -0
- unitytk-0.0.1/unitytk.egg-info/top_level.txt +1 -0
unitytk-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 m3trik@live.com
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
unitytk-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: unitytk
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A modular Python toolkit for Unity interaction.
|
|
5
|
+
Author-email: Ryan Simpson <m3trik@outlook.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/unitytk/unitytk
|
|
8
|
+
Project-URL: Repository, https://github.com/unitytk/unitytk
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: pythontk>=0.7.71
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# unitytk
|
|
19
|
+
|
|
20
|
+
A modular Python toolkit for Unity interaction.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "unitytk"
|
|
7
|
+
dynamic = ["version", "readme"]
|
|
8
|
+
description = "A modular Python toolkit for Unity interaction."
|
|
9
|
+
authors = [{name = "Ryan Simpson", email = "m3trik@outlook.com"}]
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
]
|
|
17
|
+
dependencies = ["pythontk>=0.7.71"]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/unitytk/unitytk"
|
|
21
|
+
Repository = "https://github.com/unitytk/unitytk"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.dynamic]
|
|
24
|
+
version = {attr = "unitytk.__version__"}
|
|
25
|
+
readme = {file = "docs/README.md", content-type = "text/markdown"}
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
where = ["."]
|
|
29
|
+
include = ["unitytk*"]
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.package-data]
|
|
32
|
+
"*" = ["*.ui", "*.json", "*.txt", "*.md", "*.cs"]
|
unitytk-0.0.1/setup.cfg
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
# !/usr/bin/python
|
|
2
|
+
# coding=utf-8
|
|
3
|
+
"""
|
|
4
|
+
Integration test: EventTriggers Maya module -> FBX -> Unity readback.
|
|
5
|
+
|
|
6
|
+
Validates the full pipeline using the EventTriggers module:
|
|
7
|
+
1. Maya (mayapy): Create object, add event triggers via EventTriggers.create(),
|
|
8
|
+
key events with EventTriggers.set_key(), export FBX.
|
|
9
|
+
2. Unity (batchmode): Import FBX, verify the event_trigger curve exists
|
|
10
|
+
and the event_manifest string property arrives intact.
|
|
11
|
+
|
|
12
|
+
Requires:
|
|
13
|
+
- Maya 2025 installed (mayapy.exe)
|
|
14
|
+
- Unity Hub with at least one editor installed
|
|
15
|
+
"""
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import json
|
|
19
|
+
import shutil
|
|
20
|
+
import tempfile
|
|
21
|
+
import unittest
|
|
22
|
+
import subprocess
|
|
23
|
+
import logging
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
scripts_dir = r"O:\Cloud\Code\_scripts"
|
|
28
|
+
if scripts_dir not in sys.path:
|
|
29
|
+
sys.path.insert(0, scripts_dir)
|
|
30
|
+
|
|
31
|
+
from unitytk import UnityLauncher, UnityFinder
|
|
32
|
+
|
|
33
|
+
MAYAPY = r"C:\Program Files\Autodesk\Maya2025\bin\mayapy.exe"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _maya_available() -> bool:
|
|
37
|
+
return os.path.exists(MAYAPY)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _unity_available() -> bool:
|
|
41
|
+
return bool(UnityFinder.find_editors())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ======================================================================
|
|
45
|
+
# Maya-side script — uses EventTriggers module
|
|
46
|
+
# ======================================================================
|
|
47
|
+
|
|
48
|
+
MAYA_EXPORT_SCRIPT = r'''
|
|
49
|
+
"""Executed inside mayapy — sets up event triggers via the EventTriggers API."""
|
|
50
|
+
import sys, os, json
|
|
51
|
+
|
|
52
|
+
for pkg_dir in (r"O:\Cloud\Code\_scripts\mayatk", r"O:\Cloud\Code\_scripts\pythontk"):
|
|
53
|
+
if pkg_dir not in sys.path:
|
|
54
|
+
sys.path.insert(0, pkg_dir)
|
|
55
|
+
|
|
56
|
+
import maya.standalone
|
|
57
|
+
maya.standalone.initialize(name="python")
|
|
58
|
+
import maya.cmds as cmds
|
|
59
|
+
import pymel.core as pm
|
|
60
|
+
|
|
61
|
+
import mayatk
|
|
62
|
+
from mayatk import EventTriggers
|
|
63
|
+
|
|
64
|
+
output_dir = sys.argv[1]
|
|
65
|
+
fbx_path = os.path.join(output_dir, "audio_events_test.fbx")
|
|
66
|
+
|
|
67
|
+
cmds.file(new=True, force=True)
|
|
68
|
+
pm.playbackOptions(min=1, max=60)
|
|
69
|
+
|
|
70
|
+
cube = pm.polyCube(name="AudioCube")[0]
|
|
71
|
+
cube2 = pm.polyCube(name="VfxCube")[0]
|
|
72
|
+
|
|
73
|
+
# ================================================================
|
|
74
|
+
# Test ensure() — auto-detect create vs update
|
|
75
|
+
# ================================================================
|
|
76
|
+
|
|
77
|
+
# ensure() on a fresh object should create
|
|
78
|
+
result = EventTriggers.ensure(
|
|
79
|
+
objects=[cube],
|
|
80
|
+
category="audio",
|
|
81
|
+
events=["Footstep", "Jump", "Land"],
|
|
82
|
+
)
|
|
83
|
+
assert cube.hasAttr("audio_trigger"), "ensure(create): audio_trigger not created"
|
|
84
|
+
events = EventTriggers.get_events(cube, category="audio")
|
|
85
|
+
assert events == ["None", "Footstep", "Jump", "Land"], f"ensure(create) bad events: {events}"
|
|
86
|
+
print(f"[PASS] ensure(create): {events}")
|
|
87
|
+
|
|
88
|
+
# ensure() on an existing attr should append (not recreate)
|
|
89
|
+
result2 = EventTriggers.ensure(
|
|
90
|
+
objects=[cube],
|
|
91
|
+
category="audio",
|
|
92
|
+
events=["Slide"],
|
|
93
|
+
)
|
|
94
|
+
events2 = EventTriggers.get_events(cube, category="audio")
|
|
95
|
+
assert events2 == ["None", "Footstep", "Jump", "Land", "Slide"], f"ensure(update) bad events: {events2}"
|
|
96
|
+
print(f"[PASS] ensure(update): {events2}")
|
|
97
|
+
|
|
98
|
+
# ensure() with mixed objects (one new, one existing)
|
|
99
|
+
result3 = EventTriggers.ensure(
|
|
100
|
+
objects=[cube, cube2],
|
|
101
|
+
category="audio",
|
|
102
|
+
events=["Footstep", "Jump"],
|
|
103
|
+
)
|
|
104
|
+
assert cube2.hasAttr("audio_trigger"), "ensure(mixed): cube2 should have attr"
|
|
105
|
+
events_cube2 = EventTriggers.get_events(cube2, category="audio")
|
|
106
|
+
assert "Footstep" in events_cube2, f"ensure(mixed) cube2 missing Footstep: {events_cube2}"
|
|
107
|
+
print(f"[PASS] ensure(mixed): cube={EventTriggers.get_events(cube, category='audio')}, cube2={events_cube2}")
|
|
108
|
+
|
|
109
|
+
# ================================================================
|
|
110
|
+
# Test multi-category support
|
|
111
|
+
# ================================================================
|
|
112
|
+
|
|
113
|
+
EventTriggers.ensure(
|
|
114
|
+
objects=[cube],
|
|
115
|
+
category="vfx",
|
|
116
|
+
events=["Sparks", "Smoke"],
|
|
117
|
+
)
|
|
118
|
+
assert cube.hasAttr("vfx_trigger"), "vfx_trigger not created"
|
|
119
|
+
vfx_events = EventTriggers.get_events(cube, category="vfx")
|
|
120
|
+
assert vfx_events == ["None", "Sparks", "Smoke"], f"vfx bad events: {vfx_events}"
|
|
121
|
+
# audio_trigger should be unaffected
|
|
122
|
+
audio_events = EventTriggers.get_events(cube, category="audio")
|
|
123
|
+
assert "Footstep" in audio_events, f"audio events disturbed: {audio_events}"
|
|
124
|
+
print(f"[PASS] multi-category: audio={audio_events}, vfx={vfx_events}")
|
|
125
|
+
|
|
126
|
+
# ================================================================
|
|
127
|
+
# Key events on timeline (audio category on cube)
|
|
128
|
+
# ================================================================
|
|
129
|
+
|
|
130
|
+
EventTriggers.set_key(cube, event="Footstep", time=10, category="audio")
|
|
131
|
+
EventTriggers.set_key(cube, event="Jump", time=30, category="audio")
|
|
132
|
+
EventTriggers.set_key(cube, event="Land", time=45, category="audio")
|
|
133
|
+
|
|
134
|
+
key_count = pm.keyframe(cube, attribute="audio_trigger", q=True, keyframeCount=True)
|
|
135
|
+
assert key_count > 0, "No keyframes on audio_trigger"
|
|
136
|
+
|
|
137
|
+
val_at_10 = pm.keyframe(cube, attribute="audio_trigger", time=(10, 10), q=True, vc=True)
|
|
138
|
+
val_at_30 = pm.keyframe(cube, attribute="audio_trigger", time=(30, 30), q=True, vc=True)
|
|
139
|
+
val_at_45 = pm.keyframe(cube, attribute="audio_trigger", time=(45, 45), q=True, vc=True)
|
|
140
|
+
assert val_at_10 and val_at_10[0] == 1.0, f"Expected 1.0 at f10, got {val_at_10}"
|
|
141
|
+
assert val_at_30 and val_at_30[0] == 2.0, f"Expected 2.0 at f30, got {val_at_30}"
|
|
142
|
+
assert val_at_45 and val_at_45[0] == 3.0, f"Expected 3.0 at f45, got {val_at_45}"
|
|
143
|
+
print(f"[PASS] keyframes: f10={val_at_10[0]}, f30={val_at_30[0]}, f45={val_at_45[0]}")
|
|
144
|
+
|
|
145
|
+
# ================================================================
|
|
146
|
+
# Bake manifest (auto-bake already ran, but explicit call too)
|
|
147
|
+
# ================================================================
|
|
148
|
+
|
|
149
|
+
baked = EventTriggers.bake_manifest([cube], category="audio")
|
|
150
|
+
assert cube.hasAttr("audio_manifest"), "audio_manifest not created by bake"
|
|
151
|
+
manifest = cube.audio_manifest.get()
|
|
152
|
+
assert "10:Footstep" in manifest, f"Missing 10:Footstep in: {manifest}"
|
|
153
|
+
assert "30:Jump" in manifest, f"Missing 30:Jump in: {manifest}"
|
|
154
|
+
assert "45:Land" in manifest, f"Missing 45:Land in: {manifest}"
|
|
155
|
+
print(f"[PASS] baked manifest: {manifest}")
|
|
156
|
+
|
|
157
|
+
# VFX manifest (no keys yet — should be empty)
|
|
158
|
+
EventTriggers.bake_manifest([cube], category="vfx")
|
|
159
|
+
assert cube.hasAttr("vfx_manifest"), "vfx_manifest not created"
|
|
160
|
+
vfx_manifest = cube.vfx_manifest.get()
|
|
161
|
+
assert vfx_manifest == "", f"VFX manifest should be empty, got: {vfx_manifest}"
|
|
162
|
+
print(f"[PASS] vfx manifest (empty): '{vfx_manifest}'")
|
|
163
|
+
|
|
164
|
+
# Key a VFX event and re-bake
|
|
165
|
+
EventTriggers.set_key(cube, event="Sparks", time=15, category="vfx")
|
|
166
|
+
EventTriggers.bake_manifest([cube], category="vfx")
|
|
167
|
+
vfx_manifest = cube.vfx_manifest.get()
|
|
168
|
+
assert "15:Sparks" in vfx_manifest, f"Missing 15:Sparks in: {vfx_manifest}"
|
|
169
|
+
print(f"[PASS] vfx manifest after keying: {vfx_manifest}")
|
|
170
|
+
|
|
171
|
+
# ================================================================
|
|
172
|
+
# Test remove()
|
|
173
|
+
# ================================================================
|
|
174
|
+
|
|
175
|
+
# Remove VFX only — audio should survive
|
|
176
|
+
EventTriggers.remove([cube], category="vfx")
|
|
177
|
+
assert not cube.hasAttr("vfx_trigger"), "vfx_trigger should be removed"
|
|
178
|
+
assert not cube.hasAttr("vfx_manifest"), "vfx_manifest should be removed"
|
|
179
|
+
assert cube.hasAttr("audio_trigger"), "audio_trigger should survive vfx removal"
|
|
180
|
+
print(f"[PASS] remove(vfx): vfx gone, audio intact")
|
|
181
|
+
|
|
182
|
+
# Remove cube2 + all categories
|
|
183
|
+
EventTriggers.remove([cube2], category="*")
|
|
184
|
+
assert not cube2.hasAttr("audio_trigger"), "cube2 audio_trigger should be removed"
|
|
185
|
+
print(f"[PASS] remove(*, cube2): all triggers gone")
|
|
186
|
+
|
|
187
|
+
# ================================================================
|
|
188
|
+
# Carrier animation + FBX export
|
|
189
|
+
# ================================================================
|
|
190
|
+
|
|
191
|
+
pm.setKeyframe(cube, attribute="translateY", time=1, value=0)
|
|
192
|
+
pm.setKeyframe(cube, attribute="translateY", time=60, value=3)
|
|
193
|
+
|
|
194
|
+
pm.select(cube)
|
|
195
|
+
if not cmds.pluginInfo("fbxmaya", q=True, loaded=True):
|
|
196
|
+
cmds.loadPlugin("fbxmaya")
|
|
197
|
+
pm.mel.eval("FBXResetExport")
|
|
198
|
+
pm.mel.eval("FBXExportBakeComplexAnimation -v true")
|
|
199
|
+
pm.mel.eval("FBXExportBakeComplexStart -v 1")
|
|
200
|
+
pm.mel.eval("FBXExportBakeComplexEnd -v 60")
|
|
201
|
+
pm.mel.eval('FBXProperty "Export|AdvOptGrp|UI|ShowWarningsManager" -v 0')
|
|
202
|
+
fbx_mel = fbx_path.replace("\\", "/")
|
|
203
|
+
pm.mel.eval(f'FBXExport -f "{fbx_mel}" -s')
|
|
204
|
+
|
|
205
|
+
# ---- Collect results ----
|
|
206
|
+
all_keys = {}
|
|
207
|
+
for t, v in zip(
|
|
208
|
+
pm.keyframe(cube, at="audio_trigger", q=True, tc=True),
|
|
209
|
+
pm.keyframe(cube, at="audio_trigger", q=True, vc=True),
|
|
210
|
+
):
|
|
211
|
+
all_keys[str(int(t))] = int(v)
|
|
212
|
+
|
|
213
|
+
results = {
|
|
214
|
+
"fbx_path": fbx_path,
|
|
215
|
+
"fbx_exists": os.path.exists(fbx_path),
|
|
216
|
+
"baked_manifest": manifest,
|
|
217
|
+
"events": list(EventTriggers.get_events(cube, category="audio")),
|
|
218
|
+
"keyframes": all_keys,
|
|
219
|
+
"ensure_tests_passed": True,
|
|
220
|
+
"multi_category_passed": True,
|
|
221
|
+
"remove_tests_passed": True,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
with open(os.path.join(output_dir, "maya_results.json"), "w") as f:
|
|
225
|
+
json.dump(results, f, indent=2)
|
|
226
|
+
|
|
227
|
+
print("\n" + "=" * 60)
|
|
228
|
+
print("MAYA EXPORT COMPLETE — ALL TESTS PASSED")
|
|
229
|
+
print("=" * 60)
|
|
230
|
+
maya.standalone.uninitialize()
|
|
231
|
+
'''
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ======================================================================
|
|
235
|
+
# Unity-side C# scripts
|
|
236
|
+
# ======================================================================
|
|
237
|
+
|
|
238
|
+
UNITY_POSTPROCESSOR_CS = r"""
|
|
239
|
+
using UnityEngine;
|
|
240
|
+
using UnityEditor;
|
|
241
|
+
using System.IO;
|
|
242
|
+
using System.Collections.Generic;
|
|
243
|
+
|
|
244
|
+
public class AudioEventTestPostprocessor : AssetPostprocessor
|
|
245
|
+
{
|
|
246
|
+
const string RESULTS_DIR = @"__RESULTS_DIR__";
|
|
247
|
+
|
|
248
|
+
void OnPreprocessModel()
|
|
249
|
+
{
|
|
250
|
+
var importer = assetImporter as ModelImporter;
|
|
251
|
+
if (importer != null && !importer.importAnimatedCustomProperties)
|
|
252
|
+
importer.importAnimatedCustomProperties = true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
void OnPostprocessGameObjectWithUserProperties(
|
|
256
|
+
GameObject go, string[] propNames, object[] values)
|
|
257
|
+
{
|
|
258
|
+
var entries = new List<string>();
|
|
259
|
+
for (int i = 0; i < propNames.Length; i++)
|
|
260
|
+
{
|
|
261
|
+
string typeName = values[i] != null ? values[i].GetType().Name : "null";
|
|
262
|
+
string valStr = values[i] != null ? values[i].ToString() : "null";
|
|
263
|
+
entries.Add(" \"" + Escape(propNames[i]) + "\": {\"type\": \""
|
|
264
|
+
+ typeName + "\", \"value\": \"" + Escape(valStr) + "\"}");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
string outPath = Path.Combine(RESULTS_DIR, "postprocessor_results.json");
|
|
268
|
+
string json = "{\n \"gameObject\": \"" + go.name + "\",\n"
|
|
269
|
+
+ string.Join(",\n", entries) + "\n}";
|
|
270
|
+
File.WriteAllText(outPath, json);
|
|
271
|
+
|
|
272
|
+
Debug.Log("[AudioEventTest] Captured " + propNames.Length
|
|
273
|
+
+ " user properties on " + go.name);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
static string Escape(string s)
|
|
277
|
+
{
|
|
278
|
+
return s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
UNITY_VERIFIER_CS = r"""
|
|
284
|
+
using UnityEngine;
|
|
285
|
+
using UnityEditor;
|
|
286
|
+
using System.IO;
|
|
287
|
+
using System.Linq;
|
|
288
|
+
using System.Collections.Generic;
|
|
289
|
+
|
|
290
|
+
public class AudioEventTestVerifier
|
|
291
|
+
{
|
|
292
|
+
public static void Verify()
|
|
293
|
+
{
|
|
294
|
+
string resultsFile = @"__RESULTS_PATH__";
|
|
295
|
+
string fbxAssetPath = "Assets/audio_events_test.fbx";
|
|
296
|
+
|
|
297
|
+
// Reimport with custom properties enabled
|
|
298
|
+
var importer = AssetImporter.GetAtPath(fbxAssetPath) as ModelImporter;
|
|
299
|
+
if (importer != null)
|
|
300
|
+
{
|
|
301
|
+
importer.importAnimation = true;
|
|
302
|
+
importer.animationType = ModelImporterAnimationType.Generic;
|
|
303
|
+
importer.importAnimatedCustomProperties = true;
|
|
304
|
+
EditorUtility.SetDirty(importer);
|
|
305
|
+
AssetDatabase.WriteImportSettingsIfDirty(fbxAssetPath);
|
|
306
|
+
AssetDatabase.ImportAsset(fbxAssetPath,
|
|
307
|
+
ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport);
|
|
308
|
+
}
|
|
309
|
+
AssetDatabase.Refresh();
|
|
310
|
+
|
|
311
|
+
var results = new Dictionary<string, object>();
|
|
312
|
+
|
|
313
|
+
// Check animation curves
|
|
314
|
+
var clips = AssetDatabase.LoadAllAssetsAtPath(fbxAssetPath)
|
|
315
|
+
.OfType<AnimationClip>()
|
|
316
|
+
.Where(c => !c.name.StartsWith("__preview__"))
|
|
317
|
+
.ToArray();
|
|
318
|
+
|
|
319
|
+
bool hasAudioEventCurve = false;
|
|
320
|
+
int audioEventKeyCount = 0;
|
|
321
|
+
var allCurveNames = new List<string>();
|
|
322
|
+
|
|
323
|
+
if (clips.Length > 0)
|
|
324
|
+
{
|
|
325
|
+
var clip = clips[0];
|
|
326
|
+
var bindings = AnimationUtility.GetCurveBindings(clip);
|
|
327
|
+
|
|
328
|
+
foreach (var binding in bindings)
|
|
329
|
+
{
|
|
330
|
+
allCurveNames.Add(binding.propertyName);
|
|
331
|
+
if (binding.propertyName == "audio_trigger")
|
|
332
|
+
{
|
|
333
|
+
hasAudioEventCurve = true;
|
|
334
|
+
var curve = AnimationUtility.GetEditorCurve(clip, binding);
|
|
335
|
+
audioEventKeyCount = curve != null ? curve.length : 0;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
results["clips_found"] = clips.Length;
|
|
341
|
+
results["has_audio_event_curve"] = hasAudioEventCurve;
|
|
342
|
+
results["audio_event_key_count"] = audioEventKeyCount;
|
|
343
|
+
results["all_curve_names"] = allCurveNames.ToArray();
|
|
344
|
+
|
|
345
|
+
// Read postprocessor results
|
|
346
|
+
string postprocPath = Path.Combine(
|
|
347
|
+
Path.GetDirectoryName(resultsFile), "postprocessor_results.json");
|
|
348
|
+
results["postprocessor_fired"] = File.Exists(postprocPath);
|
|
349
|
+
|
|
350
|
+
// Write results
|
|
351
|
+
File.WriteAllText(resultsFile, DictToJson(results));
|
|
352
|
+
Debug.Log("AUDIO EVENT TEST COMPLETE. Results at: " + resultsFile);
|
|
353
|
+
|
|
354
|
+
EditorApplication.Exit(0);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
static string DictToJson(Dictionary<string, object> dict)
|
|
358
|
+
{
|
|
359
|
+
var parts = new List<string>();
|
|
360
|
+
foreach (var kv in dict)
|
|
361
|
+
{
|
|
362
|
+
string val;
|
|
363
|
+
if (kv.Value is string[] arr)
|
|
364
|
+
val = "[" + string.Join(",", System.Array.ConvertAll(arr,
|
|
365
|
+
s => "\"" + Escape(s) + "\"")) + "]";
|
|
366
|
+
else if (kv.Value is string s)
|
|
367
|
+
val = "\"" + Escape(s) + "\"";
|
|
368
|
+
else if (kv.Value is bool b)
|
|
369
|
+
val = b ? "true" : "false";
|
|
370
|
+
else if (kv.Value is int i)
|
|
371
|
+
val = i.ToString();
|
|
372
|
+
else
|
|
373
|
+
val = "\"" + Escape(kv.Value != null ? kv.Value.ToString() : "null") + "\"";
|
|
374
|
+
parts.Add("\"" + Escape(kv.Key) + "\": " + val);
|
|
375
|
+
}
|
|
376
|
+
return "{" + string.Join(",\n", parts) + "}";
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
static string Escape(string s)
|
|
380
|
+
{
|
|
381
|
+
return s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n");
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
"""
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# ======================================================================
|
|
388
|
+
# Test class
|
|
389
|
+
# ======================================================================
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class TestAudioEventsIntegration(unittest.TestCase):
|
|
393
|
+
"""Full pipeline: EventTriggers module -> FBX -> Unity verification."""
|
|
394
|
+
|
|
395
|
+
@classmethod
|
|
396
|
+
def setUpClass(cls):
|
|
397
|
+
cls.temp_dir = tempfile.mkdtemp(prefix="audio_events_integ_")
|
|
398
|
+
cls.fbx_path = None
|
|
399
|
+
cls.maya_results = None
|
|
400
|
+
cls.unity_results = None
|
|
401
|
+
logger.info(f"Temp dir: {cls.temp_dir}")
|
|
402
|
+
|
|
403
|
+
@classmethod
|
|
404
|
+
def tearDownClass(cls):
|
|
405
|
+
logger.info(f"Results preserved in: {cls.temp_dir}")
|
|
406
|
+
|
|
407
|
+
# ------------------------------------------------------------------
|
|
408
|
+
# Step 1: Maya export using EventTriggers module
|
|
409
|
+
# ------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
@unittest.skipUnless(_maya_available(), "Maya 2025 not installed")
|
|
412
|
+
def test_01_maya_export(self):
|
|
413
|
+
"""Create event triggers via EventTriggers API, verify attrs, export FBX."""
|
|
414
|
+
script_path = os.path.join(self.temp_dir, "maya_export.py")
|
|
415
|
+
with open(script_path, "w", encoding="utf-8") as f:
|
|
416
|
+
f.write(MAYA_EXPORT_SCRIPT)
|
|
417
|
+
|
|
418
|
+
env = os.environ.copy()
|
|
419
|
+
env["PYTHONPATH"] = os.pathsep.join(
|
|
420
|
+
[
|
|
421
|
+
os.path.join(scripts_dir, "mayatk"),
|
|
422
|
+
os.path.join(scripts_dir, "pythontk"),
|
|
423
|
+
]
|
|
424
|
+
)
|
|
425
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
426
|
+
|
|
427
|
+
proc = subprocess.run(
|
|
428
|
+
[MAYAPY, script_path, self.temp_dir],
|
|
429
|
+
capture_output=True,
|
|
430
|
+
text=True,
|
|
431
|
+
timeout=120,
|
|
432
|
+
env=env,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
if proc.stdout:
|
|
436
|
+
logger.info(f"MAYAPY stdout:\n{proc.stdout[-2000:]}")
|
|
437
|
+
if proc.stderr:
|
|
438
|
+
logger.warning(f"MAYAPY stderr:\n{proc.stderr[-2000:]}")
|
|
439
|
+
|
|
440
|
+
self.assertEqual(proc.returncode, 0, f"mayapy failed:\n{proc.stderr[-1000:]}")
|
|
441
|
+
|
|
442
|
+
results_path = os.path.join(self.temp_dir, "maya_results.json")
|
|
443
|
+
self.assertTrue(os.path.exists(results_path))
|
|
444
|
+
|
|
445
|
+
with open(results_path) as f:
|
|
446
|
+
self.__class__.maya_results = json.load(f)
|
|
447
|
+
|
|
448
|
+
r = self.__class__.maya_results
|
|
449
|
+
self.__class__.fbx_path = r["fbx_path"]
|
|
450
|
+
self.assertTrue(r["fbx_exists"])
|
|
451
|
+
self.assertIn("10:Footstep", r["baked_manifest"])
|
|
452
|
+
self.assertIn("30:Jump", r["baked_manifest"])
|
|
453
|
+
self.assertIn("45:Land", r["baked_manifest"])
|
|
454
|
+
self.assertEqual(r["keyframes"]["10"], 1) # Footstep
|
|
455
|
+
self.assertEqual(r["keyframes"]["30"], 2) # Jump
|
|
456
|
+
self.assertEqual(r["keyframes"]["45"], 3) # Land
|
|
457
|
+
self.assertTrue(r["ensure_tests_passed"])
|
|
458
|
+
self.assertTrue(r["multi_category_passed"])
|
|
459
|
+
self.assertTrue(r["remove_tests_passed"])
|
|
460
|
+
|
|
461
|
+
# ------------------------------------------------------------------
|
|
462
|
+
# Step 2: Unity import and readback
|
|
463
|
+
# ------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
@unittest.skipUnless(_maya_available(), "Maya 2025 not installed")
|
|
466
|
+
@unittest.skipUnless(_unity_available(), "No Unity editor found")
|
|
467
|
+
def test_02_unity_import(self):
|
|
468
|
+
"""Import FBX into Unity, verify curve and manifest survived."""
|
|
469
|
+
fbx_path = self.__class__.fbx_path
|
|
470
|
+
if not fbx_path or not os.path.exists(fbx_path):
|
|
471
|
+
self.skipTest("FBX not available — test_01 must run first")
|
|
472
|
+
|
|
473
|
+
launcher = UnityLauncher()
|
|
474
|
+
project_path = os.path.join(self.temp_dir, "UnityProject")
|
|
475
|
+
|
|
476
|
+
logger.info(f"Creating Unity project: {project_path}")
|
|
477
|
+
created = launcher.create_project(project_path, batch_mode=True)
|
|
478
|
+
self.assertTrue(created, "Failed to create Unity project")
|
|
479
|
+
|
|
480
|
+
assets_path = os.path.join(project_path, "Assets")
|
|
481
|
+
dest_fbx = os.path.join(assets_path, "audio_events_test.fbx")
|
|
482
|
+
shutil.copy2(fbx_path, dest_fbx)
|
|
483
|
+
|
|
484
|
+
editor_path = os.path.join(assets_path, "Editor")
|
|
485
|
+
os.makedirs(editor_path, exist_ok=True)
|
|
486
|
+
|
|
487
|
+
# Deploy postprocessor
|
|
488
|
+
results_dir_escaped = self.temp_dir.replace("\\", "\\\\")
|
|
489
|
+
postproc_cs = UNITY_POSTPROCESSOR_CS.replace(
|
|
490
|
+
"__RESULTS_DIR__", results_dir_escaped
|
|
491
|
+
)
|
|
492
|
+
with open(
|
|
493
|
+
os.path.join(editor_path, "AudioEventTestPostprocessor.cs"),
|
|
494
|
+
"w",
|
|
495
|
+
encoding="utf-8",
|
|
496
|
+
) as f:
|
|
497
|
+
f.write(postproc_cs)
|
|
498
|
+
|
|
499
|
+
# Deploy verifier
|
|
500
|
+
results_path = os.path.join(self.temp_dir, "unity_results.json")
|
|
501
|
+
results_path_escaped = results_path.replace("\\", "\\\\")
|
|
502
|
+
verifier_cs = UNITY_VERIFIER_CS.replace(
|
|
503
|
+
"__RESULTS_PATH__", results_path_escaped
|
|
504
|
+
)
|
|
505
|
+
with open(
|
|
506
|
+
os.path.join(editor_path, "AudioEventTestVerifier.cs"),
|
|
507
|
+
"w",
|
|
508
|
+
encoding="utf-8",
|
|
509
|
+
) as f:
|
|
510
|
+
f.write(verifier_cs)
|
|
511
|
+
|
|
512
|
+
log_path = os.path.join(self.temp_dir, "unity_log.txt")
|
|
513
|
+
launcher.project_path = project_path
|
|
514
|
+
|
|
515
|
+
proc = launcher.launch_editor(
|
|
516
|
+
batch_mode=True,
|
|
517
|
+
execute_method="AudioEventTestVerifier.Verify",
|
|
518
|
+
log_file=log_path,
|
|
519
|
+
extra_args=["-quit"],
|
|
520
|
+
detached=False,
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
if proc:
|
|
524
|
+
try:
|
|
525
|
+
proc.wait(timeout=300)
|
|
526
|
+
except subprocess.TimeoutExpired:
|
|
527
|
+
proc.kill()
|
|
528
|
+
raise TimeoutError("Unity timed out")
|
|
529
|
+
|
|
530
|
+
if os.path.exists(log_path):
|
|
531
|
+
with open(log_path) as f:
|
|
532
|
+
log_tail = f.read()[-3000:]
|
|
533
|
+
logger.info(f"Unity log tail:\n{log_tail}")
|
|
534
|
+
|
|
535
|
+
self.assertTrue(
|
|
536
|
+
os.path.exists(results_path),
|
|
537
|
+
f"unity_results.json not created — check {log_path}",
|
|
538
|
+
)
|
|
539
|
+
with open(results_path) as f:
|
|
540
|
+
self.__class__.unity_results = json.load(f)
|
|
541
|
+
|
|
542
|
+
logger.info(
|
|
543
|
+
f"Unity results:\n{json.dumps(self.__class__.unity_results, indent=2)}"
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
# ------------------------------------------------------------------
|
|
547
|
+
# Step 3: Analyze round-trip results
|
|
548
|
+
# ------------------------------------------------------------------
|
|
549
|
+
|
|
550
|
+
@unittest.skipUnless(_maya_available(), "Maya 2025 not installed")
|
|
551
|
+
@unittest.skipUnless(_unity_available(), "No Unity editor found")
|
|
552
|
+
def test_03_analyze(self):
|
|
553
|
+
"""Verify the event_trigger float curve and manifest string survived."""
|
|
554
|
+
unity = self.__class__.unity_results
|
|
555
|
+
if not unity:
|
|
556
|
+
self.skipTest("Unity results not available")
|
|
557
|
+
|
|
558
|
+
# Read postprocessor output
|
|
559
|
+
postproc_path = os.path.join(self.temp_dir, "postprocessor_results.json")
|
|
560
|
+
postproc = {}
|
|
561
|
+
if os.path.exists(postproc_path):
|
|
562
|
+
with open(postproc_path) as f:
|
|
563
|
+
postproc = json.load(f)
|
|
564
|
+
|
|
565
|
+
# ---- Verdicts ----
|
|
566
|
+
print("\n" + "=" * 60)
|
|
567
|
+
print(" EVENT TRIGGERS INTEGRATION TEST RESULTS")
|
|
568
|
+
print("=" * 60)
|
|
569
|
+
|
|
570
|
+
# Baked manifest survived as user property?
|
|
571
|
+
has_manifest = "audio_manifest" in postproc
|
|
572
|
+
manifest_value = postproc.get("audio_manifest", {}).get("value", "")
|
|
573
|
+
print(f"\n audio_manifest: {'PASS' if has_manifest else 'FAIL'}")
|
|
574
|
+
if has_manifest:
|
|
575
|
+
print(f" Value: {manifest_value}")
|
|
576
|
+
|
|
577
|
+
# Postprocessor fired?
|
|
578
|
+
print(
|
|
579
|
+
f" Postprocessor fired: {'PASS' if unity.get('postprocessor_fired') else 'FAIL'}"
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
# All curves found (may include legacy float curve or enum curve)
|
|
583
|
+
print(f" All curves: {unity.get('all_curve_names', [])}")
|
|
584
|
+
|
|
585
|
+
print("=" * 60)
|
|
586
|
+
print(f" Temp dir: {self.temp_dir}")
|
|
587
|
+
print("=" * 60 + "\n")
|
|
588
|
+
|
|
589
|
+
# Assertions — the baked manifest string is the sole transport now
|
|
590
|
+
self.assertTrue(
|
|
591
|
+
has_manifest, "audio_manifest string did not arrive as user property"
|
|
592
|
+
)
|
|
593
|
+
self.assertIn("Footstep", manifest_value)
|
|
594
|
+
self.assertIn("Jump", manifest_value)
|
|
595
|
+
self.assertIn("Land", manifest_value)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
if __name__ == "__main__":
|
|
599
|
+
logging.basicConfig(level=logging.INFO)
|
|
600
|
+
unittest.main()
|