Animate a finished character

One character, two baked clips and a timeline. Read what the file carries, start the body on cue, keep a camera on a character who walks, and check every claim by sampling the pose at a time and reading the numbers. It also says plainly where this scene stopped working.

Where the character comes from, and the credit. The character is made outside Strata, with three browser tools from the Web-CLI family, all of which run on your device (their pages say nothing leaves it). Character creation: portrait.webcli.app (portrait-webCLI: a photo in, a VRM out whose face blendshapes are driven from the face it read; the shape and colour matching is described on its page as a rough nudge, not a likeness) and look.webcli.app (look-webCLI: a VRM in, an edited VRM out, with its textures, colours, materials, outfits and accessories changed). Animation rigging: rig.webcli.app (bodyrig-webCLI: retargets BVH, FBX or GLB motion, and ARKit-52 face records, onto a skinned character and exports one GLB with every animation as its own named track). This page starts where they stop: with the finished file open in Strata.
What you'll need. The scene, facebind.json in tejaswigowda/test1, pinned to a commit (it opens without a token; it is a 21 MB scene with its geometry and textures beside it, so give it half a minute). Open the Shell tab and paste each block in order. Each Shell line is its own scope: a const on one line is gone on the next, so the blocks below repeat the selector instead of naming it.
The finished character, a young woman with silver hair in a white T-shirt and grey knee-length shorts, mid-gesture with one arm raised, in the Strata viewport at 6 seconds.
The scene as it opens, held at 6 s with holdTimelineAt(6): the body clip is playing, and the character is in the middle of the frame.

1 Give the character a name you can type

The importer names the character's group after the file and derives a class from it, .vroidv110femalev1132-tracksglb. Add a short class and use that from now on:

tagClass(scene.children[0], 'actor'); $S('.actor').count()
1

(End the line with the count: tagClass on its own returns the whole object, and the Shell prints all of it.)

2 Read what the file carries

The scene has 198 objects, 22,021 vertices and 32,914 triangles. Most of that is 73 skinned meshes (56 of them pieces of hair) sharing one skeleton of 118 bones, and three animations:

editor.scene.animations.map(a => a.name + ': ' + a.duration.toFixed(1) + ' s, ' + a.tracks.length + ' tracks')
[
  "Timeline: 35.1 s, 0 tracks",
  "test: 33.1 s, 119 tracks",
  "testface: 11.5 s, 9 tracks"
]
AnimationWhat it is
TimelineThe scene's own clock (35.1 s), with no tracks of its own: the timeline in step 3 drives the other two.
testThe body: 994 keys at 30 frames a second over 33.1 s, on 118 rotation tracks (one per bone) and one position track (the hips, J_Bip_C_Hips). The hips position is the character's root motion: she does not dance on the spot.
testfaceThe face: 346 keys over 11.5 s, as one morph-weight track on each of the nine face meshes. Each mesh has 124 morph targets, the VRoid expressions (Fcl_MTH_A, Fcl_EYE_Close, …) and the 52 ARKit names (jawOpen, mouthSmileLeft, eyeBlinkLeft, …). The clip moves 44 of the 124 by more than 0.05.

The file is named …_2-tracks.glb: two tracks, the body and the face. They are baked data, and Strata does not edit their keys. What you author is control: which clip, when, how fast, and what lies over it.

3 The timeline is two events

The scene's clock, editor.timeline, holds one play event per clip:

JSON.stringify(editor.timeline.toJSON().tracks.map(t => [t.target, t.events.map(e => [e.at, e.op, +e.dur.toFixed(1)])]))
[["#test",[[2,"play",33.1]]],["#testface",[[0,"play",11.5]]]]

The body starts at 2 s and the face at 0. To see what that does, holdTimelineAt(t) jumps the whole scene to t seconds, and then the bones and the morph weights can be read like any other property. Run it at a few times and read the hips, one upper-arm bone and the total morph weight on the face:

holdTimelineAt(6); JSON.stringify([scene.getObjectByName('J_Bip_C_Hips').position, scene.getObjectByName('J_Bip_R_UpperArm').quaternion].map(v => v.toArray().map(x => +x.toFixed(3))))
[[-0.766,0.82,-1.069],[-0.167,0.511,-0.363,0.761]]

The same reading at eight times (measured on this scene; the face column is the sum of the absolute morph weights on the first face mesh, Facebaked_custom):

t (s)hips (x, y, z)arm quaternionface
0−0.397, 0.848, −0.5770.027, 0.149, −0.306, 0.9409.573
1.9−0.397, 0.848, −0.5770.027, 0.149, −0.306, 0.9404.751
2−0.397, 0.848, −0.5770.027, 0.149, −0.306, 0.9409.358
6−0.766, 0.820, −1.069−0.167, 0.511, −0.363, 0.76112.896
11.4−0.794, 0.795, −1.123−0.074, 0.627, −0.463, 0.6224.483
11.6−0.827, 0.828, −1.128−0.083, 0.622, −0.488, 0.6075.493
20−0.031, 0.824, 0.046−0.156, 0.600, −0.459, 0.6365.493
35.1−1.316, 0.809, −0.023−0.162, 0.595, −0.496, 0.6115.493

Two things in the table are the point of this page:

  1. The body holds its first pose for two seconds. The hips and the arm are identical at 0, 1.9 and 2, then move. Before a clip's first play it holds its first frame, so a character that should be moving from the first frame of the render is standing still for 2 s.
  2. The face stops at 11.5 s. The face column changes up to 11.4 s and is then 5.493 at 11.6, 20 and 35.1: the clip has ended and holds its last frame, while the body carries on for another 23.6 s with a frozen face.

4 Start the body at zero

The obvious fix is wrong, and it is worth seeing why:

$S('#test').at(0).play('test')

This does not move the clip's event: it adds a second one. The timeline now has play events for test at 2 s and at 0 s, and the two fight. Sampling the arm shows it: at 1 s it moves, and at 2 s it is back at the first pose (0.027, 0.149, −0.306, 0.940), because the event at 2 s restarted the clip. Undo it (Undo, or undo()), then move the event that is already there, by its id:

editor.timeline.moveEvent(editor.timeline.tracks[0].events[0].id, 0)
true

(tracks[0] is the #test track here; the event is the purple block on that row of the Animations tab, which you can drag instead.) This edit is not on the Undo stack (checked: the undo count did not change, and Undo afterwards took back the tagClass of step 1 and left the event at 0), so reload the scene to get the original timing back. Sampling again, the arm is at its first pose at 0 s and has moved by 1 s and by 2 s, with no reset:

The Animations tab before the move: the body block starts later than the face block.
Before
The Animations tab after the move: both blocks start at the left edge, and the duration readout has dropped to 33.099 seconds.
After moveEvent(…, 0): both clips start at 0 and the readout is 00:33.099.

The panel does not redraw by itself. A screenshot taken right after moveEvent still showed the block at 2 s and the old duration; zooming or pressing Fit redrew it. The move had happened (the arm samples in the table above are from after it), so redraw before you trust what you see. The duration is not a separate step: it fell from 35.099 s to 33.099 s because the timeline's length follows its last event.

t (s)arm quaternion, body event at 0
00.027, 0.149, −0.306, 0.940
1−0.258, 0.428, −0.421, 0.757
2−0.141, 0.605, −0.533, 0.575

.play() takes the other controls too, and each adds its own event: { speed: 0.5 } makes the event 66.2 s long (the clip at half speed), and { loop: true } keeps the 33.1 s. Use them on a clip you have removed or not yet placed, not on top of the one already there.

5 The face: what the clip leaves, and a warning

Step 3 showed the face clip ending at 11.5 s and the body carrying on. Everything the clip drives is frozen at its last value from then on, including the eyelids: sampled at 11.6, 20 and 35.1 s the face is identical (5.493), so for the last 23.6 s of the take she does not blink. The way to fix it in Strata is an authored face (Authoring a face): keys that name the shapes you want, which win over the clip's frozen value for those shapes only, plus a seeded idle layer for the blink. In the one run of this scene where such a key finished, a smile key at 14 s (face.keyAt(14, { shapes: { mouthSmileLeft: 0.8, mouthSmileRight: 0.8 }, duration: 600, easing: 'ease-out' })) read back like this:

t (s)mouthSmileLefteyeBlinkLeft
100.5090.688
13.90.7640.431
14.70.8000.431
200.8000.431

The key took the smile from the clip's last value to 0.8; eyeBlinkLeft, which the key did not name, stayed at the clip's last value, 0.431, to the end of the take.

Do not paste face edits into this scene yet. They are not offered here as a recipe because they hung the browser tab on this character: of five attempts (face.keyAt twice on the long class and once on a short class, face.idle({ seed: 7 }) twice), four froze the tab until the browser killed its renderer, and only the smile above finished. Camera and clip edits (steps 4 and 6) did not do this. The cause is not known; it may be specific to a character whose face morphs are driven by a baked clip. If you try it, save first and expect to lose the tab.

6 Keep her in frame

The scene's camera, cam-render, sits at (−1.97, 0.80, 2.51) and has no keys of its own, but the hips travel about 1.3 m in x and 1.2 m in z in the sampled frames in step 3, so a camera that never turns lets her drift to the edge of the frame (the first screenshot below). A camera key arrives at a time with an aim point (lookAt). The first key of a camera needs a position (keyAt: the first key of a camera needs a pos is the error otherwise); later keys that name only lookAt turn the camera without moving it. Rather than type aim points, sample the hips with holdTimelineAt every 2.5 s and aim at them, at about chest height:

for (let t = 0; t <= 35; t += 2.5) { holdTimelineAt(t); const p = scene.getObjectByName('J_Bip_C_Hips').getWorldPosition(new THREE.Vector3()); const aim = [+p.x.toFixed(2), 1.0, +p.z.toFixed(2)]; $S('#cam-render').keyAt(t, t === 0 ? { pos: [-1.97, 0.8, 2.51], lookAt: aim } : { lookAt: aim }) }
… a promise: its result is shown when it settles
✨ Scene updated — 15 commands executed

Fifteen keys (the first lands at 0.001 s), one undo step each, and the camera never leaves its position. Check the aim the same way as the body: sample the scene, and measure the angle between the camera's forward direction and the direction from the camera to the hips at chest height:

The viewport through the render camera at 17.5 seconds with no camera keys: the character is at the far right edge of the frame and the rest of the frame is empty.
17.5 s, before the loop: the camera has not moved, and she is at the right edge.
The same moment after the camera keys: the character is in the middle of the frame.
17.5 s, after the fifteen keys: the same moment, with her in the middle.
t (s)         0.5  1.25    2   3.75     6   8.75    13  16.25    20  26.25    28    33    35
off aim (°)   0.6   1.5  2.3   2.1   1.8   1.2   0.4   12.5     0    1.2   1.5   1.3     0

The camera is within 2.3° of her at twelve of the thirteen times, and 12.5° off at 16.25 s, between the keys at 15 and 17.5 s: the hips move faster there than a straight turn between two keys follows. Add a key ($S('#cam-render').keyAt(16.25, { lookAt: [x, 1.0, z] }) with the hips at that time) or sample every second in the loop. $S('#cam-render').keys() lists the keys and .smooth(true) plays them as one curve; Editing camera moves by hand has the one-click moves.

7 Render it

Open the Render tab. The scene's one shot (cam-render from 0 s) is in the Camera Sequence; pick the size and frame rate, and render. The length of the take is the timeline's duration, which the saved scene has as 35.1 s because the body starts at 2 s. After step 4 the readout in the Animations tab fell to 33.1 s (checked, once the panel was redrawn), so the take ends when the body clip does; look at it before you render.

What was checked, and what was not. Checked in the editor, on the pinned scene, with the Shell blocks as written: steps 1 to 3 (their outputs are copied from the editor); the second event and the arm resetting at 2 s in step 4; moveEvent, the arm samples after it, and that it is not an undo step; the camera loop in step 6 and the thirteen aim measurements. The smile table in step 5 is from the single run that finished, with the long class .vroidv110femalev1132-tracksglb in place of a short one. The screenshots and the thumbnail are of this scene in headless Chrome with software rendering, taken by the blocks above in that order (one run, the editor at its default size), so the look of the shading is not what a graphics card gives. Not checked: dragging the event in the Animations tab instead of calling moveEvent; the Render tab in step 7 (no render was made); any face edit after the first (they hung the tab); and the "Open example" player for this page. Which of the three tools made which part of the file is as credited at the top; this page did not run those tools.

Credits and licences

People

Assets

Tools