Setting the file. One moment.
Editor · Frost Sequence Camera Orbit · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page Three Mesh BVH LICENSE
— line 164
This file
Number 10.46
Position 46 of 76
Type TypeScript
Size 21 KB
Lines 673 source/src/motion/ editor.ts
TypeScript · 673 lines · 21 KB
type
CameraKey,
12 type CameraTrack,
13 type V3,
14 type Q4,
15 } from "./track" ;
16 import { objectFrameAt, shapeAt } from "./objectFrame" ;
17 import { skeletonMeshes } from "./meshes" ;
18 import { resolveSchedule, durationOf } from "../core/schedule" ;
19 const $ = < T extends HTMLElement = HTMLElement >( id : string ) => document. getElementById (id) as T ;
20 const field = ( id : string ) => $ < HTMLInputElement >(id),
21 select = ( id : string ) => $ < HTMLSelectElement >(id);
22 const status = ( s : string ) => {
23 $ ( "status" ).textContent = s;
24 };
25 const clone = < T >( v : T ) : T => JSON . parse ( JSON . stringify (v));
26 const store = {
27 get : ( key : string ) => {
28 try {
29 return localStorage. getItem (key);
30 } catch {
31 return null ;
32 }
33 },
34 set : ( key : string , value : string ) => {
35 try {
36 localStorage. setItem (key, value);
37 } catch {
38 status ( "Browser storage unavailable. Export JSON to keep this shot." );
39 }
40 },
41 };
42 let values : any = { ... preset },
43 time = 0 ,
44 playing = false ,
45 draft = false ,
46 selected = "" ,
47 roll = 0 ,
48 syncing = false ,
49 dirty = true ,
50 ready = false ;
51 let fullWindow : Window | null = null ;
52 const history = new ShotHistory ();
53 try {
54 const raw = new URLSearchParams (location.hash. slice ( 1 )). get ( "vars" );
55 if (raw) values = { ... values, ... JSON . parse (raw) };
56 const saved = store. get ( "frost-motion-draft-v1" );
57 if (saved) history.track = parseTrack (saved);
58 if (values.cameraMode === "authored" ) history.track = parseTrack (values.cameraTrack);
59 } catch (e) {
60 status ( String (e));
61 }
62 const schedule = () =>
63 resolveSchedule (
64 {
65 logoBreak: + values.logoBreakAt,
66 form1: + values.formHeadline1At,
67 break1: + values.headline1BreakAt,
68 form2: + values.formHeadline2At,
69 break2: + values.headline2BreakAt,
70 fadeOut: + values.fadeOutAt,
71 },
72 + values.breakDuration,
73 );
74 let S = schedule (),
75 duration = durationOf ( S );
76 const camera = baselineCamera (),
77 overviewCamera = new THREE . PerspectiveCamera ( 45 , 320 / 220 , 0.1 , 150 );
78 overviewCamera.position. set ( 19 , 14 , 22 );
79 overviewCamera. lookAt ( 0 , 0 , 4 );
80 const renderer = new THREE . WebGLRenderer ({
81 canvas: $ < HTMLCanvasElement >( "preview" ),
82 antialias: true ,
83 }),
84 overviewRenderer = new THREE . WebGLRenderer ({
85 canvas: $ < HTMLCanvasElement >( "overview-canvas" ),
86 antialias: true ,
87 });
88 for ( const r of [renderer, overviewRenderer]) {
89 r. setPixelRatio (Math. min (devicePixelRatio, 1.5 ));
90 r. setClearColor ( "#161b21" );
91 }
92 const scene = new THREE . Scene (),
93 motion = new THREE . Group (),
94 object = new THREE . Group ();
95 scene. add (motion);
96 motion. add (object);
97 scene. add ( new THREE . HemisphereLight ( "#d9edff" , "#485260" , 2 ));
98 const light = new THREE . DirectionalLight ( "#ffffff" , 2.5 );
99 light.position. set ( 3 , 7 , 9 );
100 scene. add (light);
101 const controls = new OrbitControls (camera, renderer.domElement);
102 controls.enableDamping = false ;
103 controls.target. set ( 0 , 0.05 , 0 );
104 controls.minDistance = 0.3 ;
105 controls.maxDistance = 100 ;
106 controls. update ();
107 const overviewControls = new OrbitControls (overviewCamera, overviewRenderer.domElement);
108 overviewControls.target. set ( 0 , 0 , 3 );
109 overviewControls.enableDamping = false ;
110 overviewControls. update ();
111 overviewCamera.layers. enable ( 1 );
112 const grid = new THREE . GridHelper ( 40 , 20 , 0x425c6e , 0x2b3945 );
113 grid.position.y = - 3 ;
114 grid.layers. set ( 1 );
115 scene. add (grid);
116 const pathGroup = new THREE . Group ();
117 pathGroup.layers. set ( 1 );
118 scene. add (pathGroup);
119 const helper = new THREE . CameraHelper (camera);
120 helper.layers. set ( 1 );
121 scene. add (helper);
122 let meshes : THREE . Mesh [] = [],
123 markers : THREE . Mesh [] = [];
124 const track = () => history.track;
125 const frame = () =>
126 objectFrameAt (time, S , values, shapeAt (time, S ), track ().objectMotion === "stationary" );
127 function objectsAt () {
128 const f = frame ();
129 motion.position. copy (f.position);
130 motion.quaternion. copy (f.rotation);
131 object.quaternion. copy (f.facing);
132 motion. updateMatrixWorld ( true );
133 const k = shapeAt (time, S ),
134 breaking = time >= [ S .logoBreak, S .break1, S .break2][k];
135 meshes. forEach (( m , i ) => {
136 m.visible = i === k;
137 (m.material as THREE . MeshLambertMaterial ).wireframe = breaking;
138 });
139 $ ( "phase" ).textContent =
140 [ "Logo" , "Headline 1" , "Headline 2" ][k] + (breaking ? " · breakup ghost" : " · solid geometry" );
141 return f;
142 }
143 function cameraReplay () {
144 syncing = true ;
145 const f = objectsAt ();
146 applyCameraAt (camera, track (), time, f.matrix, controls.target);
147 const state = cameraAt ( track (), time, f.matrix);
148 if ( track ().orientation === "free" )
149 controls.target
150 . copy (camera.position)
151 . add (
152 new THREE . Vector3 ( 0 , 0 , - 1 )
153 . applyQuaternion (camera.quaternion)
154 . multiplyScalar (Math. max ( 1 , camera.position. distanceTo (state.target))),
155 );
156 controls. update ();
157 applyCameraAt (camera, track (), time, f.matrix, new THREE . Vector3 ());
158 roll = state.roll;
159 field ( "view-fov" ).value = camera.fov. toFixed ( 2 );
160 field ( "view-zoom" ).value = camera.zoom. toFixed ( 3 );
161 field ( "view-roll" ).value = roll. toFixed ( 2 );
162 syncing = false ;
163 }
164 function seek ( t : number ) {
165 time = THREE .MathUtils. clamp (t, 0 , duration);
166 draft = false ;
167 cameraReplay ();
168 field ( "time" ).value = String (time);
169 $ ( "time-label" ).textContent = `${ time . toFixed ( 3 ) } / ${ duration . toFixed ( 3 ) } s` ;
170 $ ( "view-state" ).textContent = "CAMERA · Track replay" ;
171 dirty = true ;
172 }
173 controls. addEventListener ( "change" , () => {
174 if (syncing) return ;
175 playing = false ;
176 draft = true ;
177 if (roll) camera. rotateZ ( THREE .MathUtils. degToRad (roll));
178 $ ( "view-state" ).textContent = "CAMERA · Uncaptured view" ;
179 dirty = true ;
180 });
181 overviewControls. addEventListener ( "change" , () => (dirty = true ));
182 function freshId () {
183 let i = 1 ;
184 while ( track ().keys. some (( k ) => k.id === "key-" + i)) i ++ ;
185 return "key-" + i;
186 }
187 function commit ( next : CameraTrack , replay = true ) {
188 if (next.keys. some (( k ) => k.time > duration))
189 throw Error ( `Keep camera keys within ${ duration } seconds.` );
190 history. commit (next);
191 store. set ( "frost-motion-draft-v1" , JSON . stringify ( track ()));
192 refresh ();
193 if (replay) seek (time);
194 dirty = true ;
195 }
196 function capture ( update = false ) {
197 if ( ! ready) return ;
198 const next = clone ( track ()),
199 previous = next.keys. find (( k ) => k.id === selected),
200 target = controls.target. clone ();
201 if (next.anchorSpace === "local" ) target. applyMatrix4 ( frame ().matrix. clone (). invert ());
202 const q = camera.quaternion
203 . clone ()
204 . multiply (
205 new THREE . Quaternion (). setFromAxisAngle ( new THREE . Vector3 ( 0 , 0 , 1 ), ( - roll * Math. PI ) / 180 ),
206 );
207 const key : CameraKey = {
208 id: update && previous ? previous.id : freshId (),
209 time,
210 position: camera.position. toArray () as V3 ,
211 target: target. toArray () as V3 ,
212 quaternion: q. toArray () as Q4 ,
213 fov: camera.fov,
214 zoom: camera.zoom,
215 roll,
216 ease: previous?.ease || "flow" ,
217 };
218 if (update && ! previous) throw Error ( "Select a key to update." );
219 if (update) next.keys[next.keys. findIndex (( k ) => k.id === selected)] = key;
220 else next.keys. push (key);
221 selected = key.id;
222 commit (next);
223 status ( `Captured ${ key . id } at ${ time . toFixed ( 3 ) } s. Scrub or play to replay.` );
224 }
225 function editKey ( change : ( k : CameraKey ) => void ) {
226 const next = clone ( track ()),
227 key = next.keys. find (( k ) => k.id === selected);
228 if ( ! key) return ;
229 change (key);
230 commit (next);
231 }
232 function path () {
233 for ( const child of [ ... pathGroup.children]) {
234 pathGroup. remove (child);
235 (child as THREE . Mesh ).geometry?. dispose ();
236 const mat = (child as THREE . Mesh ).material;
237 if (mat && ! Array. isArray (mat)) mat. dispose ();
238 }
239 markers = [];
240 if ( ! track ().keys. length ) return ;
241 const points = [];
242 for ( let i = 0 ; i <= 200 ; i ++ ) points. push ( cameraAt ( track (), (duration * i) / 200 ).position);
243 const line = new THREE . Line (
244 new THREE . BufferGeometry (). setFromPoints (points),
245 new THREE . LineBasicMaterial ({ color: 0x7ba7c5 }),
246 );
247 line.layers. set ( 1 );
248 pathGroup. add (line);
249 for ( const key of track ().keys) {
250 const m = new THREE . Mesh (
251 new THREE . SphereGeometry ( 0.17 , 10 , 8 ),
252 new THREE . MeshBasicMaterial ({ color: key.id === selected ? 0xffd27b : 0x9adefc }),
253 );
254 m.position. fromArray (key.position);
255 m.userData.key = key.id;
256 m.layers. set ( 1 );
257 pathGroup. add (m);
258 markers. push (m);
259 }
260 }
261 function refresh () {
262 field ( "name" ).value = track ().name;
263 select ( "orientation" ).value = track ().orientation;
264 select ( "anchor" ).value = track ().anchorSpace;
265 select ( "objects" ).value = track ().objectMotion;
266 const list = select ( "key-list" );
267 list. replaceChildren ();
268 const lane = $ ( "keys" );
269 lane. replaceChildren ();
270 for ( const k of track ().keys) {
271 const o = new Option ( `${ k . time . toFixed ( 3 ) } s · ${ k . id }` , k.id);
272 list. add (o);
273 const b = document. createElement ( "button" );
274 b.className = "key" + (k.id === selected ? " selected" : "" );
275 b.style.left = `${ ( k . time / duration ) * 100 }%` ;
276 b.textContent = String ( track ().keys. indexOf (k) + 1 );
277 b. setAttribute ( "aria-label" , `${ k . id } at ${ k . time . toFixed ( 3 ) } seconds; drag to retime` );
278 b. onpointerdown = ( e ) => {
279 e. stopPropagation ();
280 selected = k.id;
281 b. setPointerCapture (e.pointerId);
282 const before = clone ( track ());
283 let moved = false ;
284 const move = ( event : PointerEvent ) => {
285 moved = true ;
286 const rect = lane. getBoundingClientRect ();
287 const t = THREE .MathUtils. clamp (
288 ((event.clientX - rect.left) / rect.width) * duration,
289 0 ,
290 duration,
291 );
292 b.style.left = `${ ( t / duration ) * 100 }%` ;
293 field ( "key-time" ).value = t. toFixed ( 3 );
294 };
295 b.onpointermove = move;
296 b. onpointerup = () => {
297 b.onpointermove = null ;
298 b.onpointerup = null ;
299 if (moved) {
300 const next = clone (before);
301 next.keys. find (( x ) => x.id === k.id) ! .time = + field ( "key-time" ).value;
302 safe (() => commit (next));
303 } else {
304 refresh ();
305 seek (k.time);
306 }
307 };
308 };
309 lane. append (b);
310 }
311 if ( ! track ().keys. length ) list. add ( new Option ( "No keys captured" , "" ));
312 if ( ! track ().keys. some (( k ) => k.id === selected)) selected = track ().keys[ 0 ]?.id || "" ;
313 list.value = selected;
314 const k = track ().keys. find (( k ) => k.id === selected);
315 if (k) {
316 field ( "key-time" ).value = String (k.time);
317 select ( "ease" ).value = k.ease;
318 [ "px" , "py" , "pz" ]. forEach (( id , j ) => ( field (id).value = k.position[j]. toFixed ( 4 )));
319 [ "tx" , "ty" , "tz" ]. forEach (( id , j ) => ( field (id).value = k.target[j]. toFixed ( 4 )));
320 }
321 path ();
322 }
323 function safe ( fn : () => void ) {
324 try {
325 fn ();
326 } catch (e) {
327 status ( String (e));
328 }
329 }
330 function button ( id : string , fn : () => void ) {
331 $ (id). onclick = () => safe (fn);
332 }
333 button ( "capture" , () => capture ());
334 button ( "update" , () => capture ( true ));
335 button ( "play" , () => {
336 playing = ! playing;
337 if (playing) {
338 draft = false ;
339 if (time >= duration) seek ( 0 );
340 }
341 });
342 button ( "undo" , () => {
343 history. undo ();
344 refresh ();
345 seek (time);
346 store. set ( "frost-motion-draft-v1" , JSON . stringify ( track ()));
347 });
348 button ( "redo" , () => {
349 history. redo ();
350 refresh ();
351 seek (time);
352 store. set ( "frost-motion-draft-v1" , JSON . stringify ( track ()));
353 });
354 field ( "time" ). oninput = () => {
355 playing = false ;
356 seek ( + field ( "time" ).value);
357 };
358 $ ( "keys" ). onclick = ( e ) => {
359 if (e.target !== $ ( "keys" )) return ;
360 const rect = $ ( "keys" ). getBoundingClientRect ();
361 seek (((e.clientX - rect.left) / rect.width) * duration);
362 };
363 select ( "key-list" ). onchange = () => {
364 selected = select ( "key-list" ).value;
365 refresh ();
366 seek ( track ().keys. find (( k ) => k.id === selected) ! .time);
367 };
368 field ( "key-time" ). onchange = () => safe (() => editKey (( k ) => (k.time = + field ( "key-time" ).value)));
369 select ( "ease" ). onchange = () => safe (() => editKey (( k ) => (k.ease = select ( "ease" ).value as any )));
370 for ( const [ ids , attr ] of [
371 [[ "px" , "py" , "pz" ], "position" ],
372 [[ "tx" , "ty" , "tz" ], "target" ],
373 ] as const )
374 for ( let j = 0 ; j < 3 ; j ++ )
375 field (ids[j]). onchange = () => safe (() => editKey (( k ) => (k[attr][j] = + field (ids[j]).value)));
376 for ( const [ id , attr ] of [
377 [ "name" , "name" ],
378 [ "orientation" , "orientation" ],
379 [ "anchor" , "anchorSpace" ],
380 [ "objects" , "objectMotion" ],
381 ])
382 field (id). onchange = () =>
383 safe (() => {
384 const next = clone ( track ());
385 if (attr === "anchorSpace" && next.anchorSpace !== field (id).value) {
386 for ( const k of next.keys) {
387 const m = objectFrameAt (
388 k.time,
389 S ,
390 values,
391 shapeAt (k.time, S ),
392 next.objectMotion === "stationary" ,
393 ).matrix;
394 if ( field (id).value === "local" ) m. invert ();
395 k.target = new THREE . Vector3 ( ... k.target). applyMatrix4 (m). toArray () as V3 ;
396 }
397 }
398 (next as any )[attr] = field (id).value;
399 commit (next);
400 });
401 for ( const id of [ "view-fov" , "view-zoom" , "view-roll" ])
402 field (id). onchange = () =>
403 safe (() => {
404 const f = + field ( "view-fov" ).value,
405 z = + field ( "view-zoom" ).value,
406 r = + field ( "view-roll" ).value;
407 if (f < 5 || f > 120 || z < 0.1 || z > 10 || ! Number. isFinite (r) || Math. abs (r) > 720 )
408 throw Error ( "FOV 5–120, zoom 0.1–10, roll −720–720." );
409 camera.fov = f;
410 camera.zoom = z;
411 camera. rotateZ (((r - roll) * Math. PI ) / 180 );
412 roll = r;
413 camera. updateProjectionMatrix ();
414 draft = true ;
415 playing = false ;
416 dirty = true ;
417 $ ( "view-state" ).textContent = "CAMERA · Uncaptured view" ;
418 });
419 button ( "duplicate" , () => {
420 const next = clone ( track ()),
421 k = next.keys. find (( k ) => k.id === selected);
422 if ( ! k) return ;
423 const copy = clone (k);
424 copy.id = freshId ();
425 copy.time = Math. min (duration, k.time + 0.5 );
426 while (next.keys. some (( x ) => Math. abs (x.time - copy.time) < 1 / 240 ) && copy.time > 0 )
427 copy.time -= 1 / 30 ;
428 next.keys. push (copy);
429 selected = copy.id;
430 commit (next);
431 seek (copy.time);
432 });
433 button ( "delete" , () => {
434 commit ({ ... clone ( track ()), keys: track ().keys. filter (( k ) => k.id !== selected) });
435 });
436 function library () : Map < string , CameraTrack > {
437 try {
438 return new Map (Object. entries ( JSON . parse (store. get ( "frost-motion-shots-v1" ) || "{}" )));
439 } catch {
440 return new Map ();
441 }
442 }
443 function refreshLibrary () {
444 select ( "saved" ). replaceChildren ( ... [ ... library (). keys ()]. sort (). map (( n ) => new Option (n, n)));
445 }
446 button ( "save" , () => {
447 const shots = library ();
448 shots. set ( track ().name, clone ( track ()));
449 store. set ( "frost-motion-shots-v1" , JSON . stringify (Object. fromEntries (shots)));
450 refreshLibrary ();
451 select ( "saved" ).value = track ().name;
452 status ( "Saved shot: " + track ().name);
453 });
454 button ( "load" , () => {
455 const shot = library (). get ( select ( "saved" ).value);
456 if (shot) commit ( parseTrack (shot));
457 });
458 const appliedValues = ( original = false ) => ({
459 ... values,
460 cameraMode: original ? "original" : "authored" ,
461 cameraTrack: original ? "" : JSON . stringify ( parseTrack ( track ())),
462 });
463 function download ( name : string , data : any ) {
464 const a = document. createElement ( "a" ),
465 url = URL . createObjectURL (
466 new Blob ([ JSON . stringify (data, null , 2 ) + " \n " ], { type: "application/json" }),
467 );
468 a.href = url;
469 a.download = name;
470 a. click ();
471 setTimeout (() => URL . revokeObjectURL (url), 1000 );
472 }
473 button ( "export" , () => download ( "frost-shot.json" , parseTrack ( track ())));
474 button ( "export-preset" , () => download ( "frost-motion-preset.json" , appliedValues ()));
475 button ( "import" , () => field ( "file" ). click ());
476 field ( "file" ). onchange = async () => {
477 try {
478 const file = field ( "file" ).files?.[ 0 ];
479 if ( ! file) return ;
480 const data = JSON . parse ( await file. text ());
481 if (data.version === 1 ) {
482 commit ( parseTrack (data));
483 } else {
484 const parsed = parseTrack (data.cameraTrack || "" );
485 values = { ... preset, ... data };
486 S = schedule ();
487 duration = durationOf ( S );
488 field ( "time" ).max = String (duration);
489 history. commit (parsed);
490 await loadMeshes ();
491 refresh ();
492 seek (Math. min (time, duration));
493 }
494 status ( "JSON imported." );
495 } catch (e) {
496 status ( String (e));
497 } finally {
498 field ( "file" ).value = "" ;
499 }
500 };
501 function apply ( original = false ) {
502 if ( ! original && ! track ().keys. length ) throw Error ( "Capture at least one view before applying." );
503 const vars = appliedValues (original),
504 url = "/workbench?block=frost#vars=" + encodeURIComponent ( JSON . stringify (vars));
505 store. set ( "frost-motion-applied-v1" , JSON . stringify (vars));
506 if (fullWindow && ! fullWindow.closed) fullWindow.location.href = url;
507 else fullWindow = window. open (url, "frost-motion-full" );
508 $ ( "applied" ).textContent = original
509 ? "Original camera restored in full Frost."
510 : "Applied " + track ().name + " to full Frost. Export preset for Studio/capture." ;
511 status (
512 "Full Frost opened with explicit camera variables. Approved repository defaults remain unchanged." ,
513 );
514 }
515 button ( "apply" , () => apply ());
516 button ( "reset" , () => {
517 commit ( emptyTrack ());
518 roll = 0 ;
519 seek (time);
520 apply ( true );
521 });
522 // Direct path manipulation in the overview's view plane. One undo step per drag.
523 const ray = new THREE . Raycaster ();
524 ray.layers. set ( 1 );
525 let drag : { id : string ; before : CameraTrack ; plane : THREE . Plane ; offset : THREE . Vector3 } | null =
526 null ;
527 overviewRenderer.domElement. addEventListener (
528 "pointerdown" ,
529 ( e ) => {
530 const rect = overviewRenderer.domElement. getBoundingClientRect ();
531 ray. setFromCamera (
532 new THREE . Vector2 (
533 ((e.clientX - rect.left) / rect.width) * 2 - 1 ,
534 ( - (e.clientY - rect.top) / rect.height) * 2 + 1 ,
535 ),
536 overviewCamera,
537 );
538 const hit = ray. intersectObjects (markers)[ 0 ];
539 if ( ! hit) return ;
540 e. stopImmediatePropagation ();
541 overviewControls.enabled = false ;
542 selected = hit.object.userData.key;
543 const point = hit.object.position. clone (),
544 plane = new THREE . Plane (). setFromNormalAndCoplanarPoint (
545 overviewCamera. getWorldDirection ( new THREE . Vector3 ()),
546 point,
547 ),
548 intersection = new THREE . Vector3 ();
549 ray.ray. intersectPlane (plane, intersection);
550 drag = { id: selected, before: clone ( track ()), plane, offset: point. sub (intersection) };
551 overviewRenderer.domElement. setPointerCapture (e.pointerId);
552 },
553 true ,
554 );
555 overviewRenderer.domElement. addEventListener ( "pointermove" , ( e ) => {
556 if ( ! drag) return ;
557 const rect = overviewRenderer.domElement. getBoundingClientRect ();
558 ray. setFromCamera (
559 new THREE . Vector2 (
560 ((e.clientX - rect.left) / rect.width) * 2 - 1 ,
561 ( - (e.clientY - rect.top) / rect.height) * 2 + 1 ,
562 ),
563 overviewCamera,
564 );
565 const point = new THREE . Vector3 ();
566 if (ray.ray. intersectPlane (drag.plane, point)) {
567 track ().keys. find (( k ) => k.id === drag ! .id) ! .position = point. add (drag.offset). toArray () as V3 ;
568 path ();
569 seek (time);
570 }
571 });
572 overviewRenderer.domElement. addEventListener ( "pointerup" , () => {
573 if ( ! drag) return ;
574 const next = clone ( track ());
575 history.track = drag.before;
576 drag = null ;
577 overviewControls.enabled = true ;
578 safe (() => commit (next));
579 });
580 window. addEventListener ( "keydown" , ( e ) => {
581 if ((e.target as HTMLElement ). matches ( "input,select,textarea" )) return ;
582 if (e.code === "Space" ) {
583 e. preventDefault ();
584 playing = ! playing;
585 }
586 if ((e.ctrlKey || e.metaKey) && e.key. toLowerCase () === "z" ) {
587 e. preventDefault ();
588 $ (e.shiftKey ? "redo" : "undo" ). click ();
589 }
590 });
591 async function loadMeshes () {
592 ready = false ;
593 status ( "Preparing cached structural meshes…" );
594 const geos = await skeletonMeshes (values, status);
595 for ( const m of meshes) {
596 object. remove (m);
597 (m.material as THREE . Material ). dispose ();
598 }
599 meshes = geos. map (( g ) => {
600 const mesh = new THREE . Mesh (g, new THREE . MeshLambertMaterial ({ color: 0xa8c2cd }));
601 object. add (mesh);
602 return mesh;
603 });
604 ready = true ;
605 status ( "Ready · lightweight meshes only · full Frost loads only when you Apply." );
606 }
607 const resize = () => {
608 for ( const [ r , el ] of [
609 [renderer, $ ( "camera-view" )],
610 [overviewRenderer, $ ( "overview" )],
611 ] as const ) {
612 const rect = el. getBoundingClientRect ();
613 r. setSize (rect.width, rect.height, false );
614 }
615 overviewCamera.aspect = $ ( "overview" ).clientWidth / $ ( "overview" ).clientHeight;
616 overviewCamera. updateProjectionMatrix ();
617 dirty = true ;
618 };
619 new ResizeObserver (resize). observe ( $ ( "camera-view" ));
620 resize ();
621 let last = 0 ;
622 function animate ( now : number ) {
623 requestAnimationFrame (animate);
624 if (playing && ready) {
625 seek (time + Math. min ( 0.1 , (now - last) / 1000 ));
626 if (time >= duration) playing = false ;
627 }
628 last = now;
629 $ ( "play" ).textContent = playing ? "Pause" : "Play" ;
630 if (dirty) {
631 objectsAt ();
632 camera. updateMatrixWorld ( true );
633 helper. update ();
634 renderer. render (scene, camera);
635 overviewRenderer. render (scene, overviewCamera);
636 dirty = false ;
637 }
638 }
639 requestAnimationFrame (animate);
640 refresh ();
641 refreshLibrary ();
642 loadMeshes ()
643 . then (() => seek ( 0 ))
644 . catch (( e ) => status ( "Mesh load failed: " + e));
645 // Small, inspectable automation surface for capture/edit/apply integration checks.
646 (window as any ).__frostMotion = {
647 build: "frost-motion-20260905-r13" ,
648 get ready () {
649 return ready;
650 },
651 get track () {
652 return clone ( track ());
653 },
654 get values () {
655 return clone (values);
656 },
657 get time () {
658 return time;
659 },
660 camera,
661 overviewCamera,
662 seek,
663 capture,
664 commit,
665 history,
666 appliedValues,
667 get matrices () {
668 return { camera: camera.matrixWorld. toArray (), object: frame ().matrix. toArray () };
669 },
670 get renderer () {
671 return "WebGL skeleton; no World/SDF/particles/post" ;
672 },
673 };