| 1 | //! The authored performance tables, read from the vendored handoff export |
| 2 | //! (`vendor/whale-character-v2/handoff/animation.json`) so the Rust Director |
| 3 | //! and the JavaScript oracle share one copy of every pose, clip, spring and |
| 4 | //! rate. The three small clips the export does not carry (the in-work hmm |
| 5 | //! beat, the resting puff and the blink) are transcribed from `acting.js`. |
| 6 | |
| 7 | use serde::Deserialize; |
| 8 | use serde_json::Value; |
| 9 | use std::collections::HashMap; |
| 10 | use std::sync::OnceLock; |
| 11 | |
| 12 | /// Every animated parameter, in the export's order. The pose is a fixed |
| 13 | /// array indexed by this list. |
| 14 | pub const PARAMS: [&str; 48] = [ |
| 15 | "x", |
| 16 | "y", |
| 17 | "rot", |
| 18 | "scale", |
| 19 | "squash", |
| 20 | "tilt", |
| 21 | "yaw", |
| 22 | "curl", |
| 23 | "arch", |
| 24 | "head", |
| 25 | "fluke", |
| 26 | "flukeSpread", |
| 27 | "fin", |
| 28 | "finFar", |
| 29 | "lookX", |
| 30 | "lookY", |
| 31 | "lid", |
| 32 | "lidLow", |
| 33 | "eyeScale", |
| 34 | "brow", |
| 35 | "browTilt", |
| 36 | "mouth", |
| 37 | "smile", |
| 38 | "mouthSide", |
| 39 | "page", |
| 40 | "pageFlip", |
| 41 | "pageRot", |
| 42 | "pageDX", |
| 43 | "pageDY", |
| 44 | "pad", |
| 45 | "padLines", |
| 46 | "pencil", |
| 47 | "scribX", |
| 48 | "scribY", |
| 49 | "lens", |
| 50 | "wrench", |
| 51 | "wrenchSpin", |
| 52 | "wrenchY", |
| 53 | "glass", |
| 54 | "glassExt", |
| 55 | "pod", |
| 56 | "spout", |
| 57 | "splash", |
| 58 | "cursor", |
| 59 | "cursorX", |
| 60 | "cursorY", |
| 61 | "link", |
| 62 | "linkTilt", |
| 63 | ]; |
| 64 | pub const N: usize = PARAMS.len(); |
| 65 | pub type Pose = [f64; N]; |
| 66 | |
| 67 | /// Named indices into a [`Pose`]. |
| 68 | #[allow(non_upper_case_globals, dead_code)] |
| 69 | pub mod p { |
| 70 | pub const x: usize = 0; |
| 71 | pub const y: usize = 1; |
| 72 | pub const rot: usize = 2; |
| 73 | pub const scale: usize = 3; |
| 74 | pub const squash: usize = 4; |
| 75 | pub const tilt: usize = 5; |
| 76 | pub const yaw: usize = 6; |
| 77 | pub const curl: usize = 7; |
| 78 | pub const arch: usize = 8; |
| 79 | pub const head: usize = 9; |
| 80 | pub const fluke: usize = 10; |
| 81 | pub const flukeSpread: usize = 11; |
| 82 | pub const fin: usize = 12; |
| 83 | pub const finFar: usize = 13; |
| 84 | pub const lookX: usize = 14; |
| 85 | pub const lookY: usize = 15; |
| 86 | pub const lid: usize = 16; |
| 87 | pub const lidLow: usize = 17; |
| 88 | pub const eyeScale: usize = 18; |
| 89 | pub const brow: usize = 19; |
| 90 | pub const browTilt: usize = 20; |
| 91 | pub const mouth: usize = 21; |
| 92 | pub const smile: usize = 22; |
| 93 | pub const mouthSide: usize = 23; |
| 94 | pub const page: usize = 24; |
| 95 | pub const pageFlip: usize = 25; |
| 96 | pub const pageRot: usize = 26; |
| 97 | pub const pageDX: usize = 27; |
| 98 | pub const pageDY: usize = 28; |
| 99 | pub const pad: usize = 29; |
| 100 | pub const padLines: usize = 30; |
| 101 | pub const pencil: usize = 31; |
| 102 | pub const scribX: usize = 32; |
| 103 | pub const scribY: usize = 33; |
| 104 | pub const lens: usize = 34; |
| 105 | pub const wrench: usize = 35; |
| 106 | pub const wrenchSpin: usize = 36; |
| 107 | pub const wrenchY: usize = 37; |
| 108 | pub const glass: usize = 38; |
| 109 | pub const glassExt: usize = 39; |
| 110 | pub const pod: usize = 40; |
| 111 | pub const spout: usize = 41; |
| 112 | pub const splash: usize = 42; |
| 113 | pub const cursor: usize = 43; |
| 114 | pub const cursorX: usize = 44; |
| 115 | pub const cursorY: usize = 45; |
| 116 | pub const link: usize = 46; |
| 117 | pub const linkTilt: usize = 47; |
| 118 | } |
| 119 | |
| 120 | pub fn param_index(name: &str) -> Option<usize> { |
| 121 | PARAMS.iter().position(|p| *p == name) |
| 122 | } |
| 123 | |
| 124 | /// The 17 acting states. One-to-one with the export's `acts`. |
| 125 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] |
| 126 | pub enum Act { |
| 127 | Rest, |
| 128 | Listen, |
| 129 | Think, |
| 130 | Busy, |
| 131 | Read, |
| 132 | Search, |
| 133 | Write, |
| 134 | Run, |
| 135 | Browse, |
| 136 | Talk, |
| 137 | Pod, |
| 138 | Needs, |
| 139 | Done, |
| 140 | Hmm, |
| 141 | Computer, |
| 142 | Connect, |
| 143 | Sleep, |
| 144 | } |
| 145 | |
| 146 | impl Act { |
| 147 | pub const ALL: [Act; 17] = [ |
| 148 | Act::Rest, |
| 149 | Act::Listen, |
| 150 | Act::Think, |
| 151 | Act::Busy, |
| 152 | Act::Read, |
| 153 | Act::Search, |
| 154 | Act::Write, |
| 155 | Act::Run, |
| 156 | Act::Browse, |
| 157 | Act::Talk, |
| 158 | Act::Pod, |
| 159 | Act::Needs, |
| 160 | Act::Done, |
| 161 | Act::Hmm, |
| 162 | Act::Computer, |
| 163 | Act::Connect, |
| 164 | Act::Sleep, |
| 165 | ]; |
| 166 | pub fn id(self) -> &'static str { |
| 167 | match self { |
| 168 | Act::Rest => "rest", |
| 169 | Act::Listen => "listen", |
| 170 | Act::Think => "think", |
| 171 | Act::Busy => "busy", |
| 172 | Act::Read => "read", |
| 173 | Act::Search => "search", |
| 174 | Act::Write => "write", |
| 175 | Act::Run => "run", |
| 176 | Act::Browse => "browse", |
| 177 | Act::Talk => "talk", |
| 178 | Act::Pod => "pod", |
| 179 | Act::Needs => "needs", |
| 180 | Act::Done => "done", |
| 181 | Act::Hmm => "hmm", |
| 182 | Act::Computer => "computer", |
| 183 | Act::Connect => "connect", |
| 184 | Act::Sleep => "sleep", |
| 185 | } |
| 186 | } |
| 187 | pub fn from_id(id: &str) -> Option<Act> { |
| 188 | Act::ALL.into_iter().find(|act| act.id() == id) |
| 189 | } |
| 190 | fn index(self) -> usize { |
| 191 | Act::ALL.iter().position(|a| *a == self).unwrap() |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | #[derive(Clone, Copy, Debug, PartialEq)] |
| 196 | pub enum Ease { |
| 197 | Linear, |
| 198 | In, |
| 199 | Out, |
| 200 | InOut, |
| 201 | Back, |
| 202 | Step, |
| 203 | } |
| 204 | |
| 205 | impl Ease { |
| 206 | fn parse(value: Option<&str>) -> Ease { |
| 207 | match value { |
| 208 | Some("l") => Ease::Linear, |
| 209 | Some("i") => Ease::In, |
| 210 | Some("o") => Ease::Out, |
| 211 | Some("b") => Ease::Back, |
| 212 | Some("s") => Ease::Step, |
| 213 | _ => Ease::InOut, |
| 214 | } |
| 215 | } |
| 216 | pub fn apply(self, t: f64) -> f64 { |
| 217 | match self { |
| 218 | Ease::Linear => t, |
| 219 | Ease::In => t * t * t, |
| 220 | Ease::Out => 1. - (1. - t).powf(3.), |
| 221 | Ease::InOut => { |
| 222 | if t < 0.5 { |
| 223 | 4. * t * t * t |
| 224 | } else { |
| 225 | 1. - (-2. * t + 2.).powf(3.) / 2. |
| 226 | } |
| 227 | } |
| 228 | Ease::Back => { |
| 229 | let c1 = 1.70158; |
| 230 | let c3 = c1 + 1.; |
| 231 | 1. + c3 * (t - 1.).powf(3.) + c1 * (t - 1.).powf(2.) |
| 232 | } |
| 233 | Ease::Step => { |
| 234 | if t < 1. { |
| 235 | 0. |
| 236 | } else { |
| 237 | 1. |
| 238 | } |
| 239 | } |
| 240 | } |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | #[derive(Clone, Copy, Debug)] |
| 245 | pub struct Key { |
| 246 | pub frame: f64, |
| 247 | pub value: f64, |
| 248 | pub ease: Ease, |
| 249 | } |
| 250 | |
| 251 | /// A particle emission authored on a clip frame. |
| 252 | #[derive(Clone, Copy, Debug, PartialEq)] |
| 253 | pub enum Emit { |
| 254 | Spout { n: u32, big: bool }, |
| 255 | Thought, |
| 256 | Speech, |
| 257 | Blub, |
| 258 | Sleep, |
| 259 | } |
| 260 | |
| 261 | #[derive(Clone, Debug)] |
| 262 | pub struct Clip { |
| 263 | pub dur: f64, |
| 264 | /// Parameter index and its keys, in authored order. |
| 265 | pub tracks: Vec<(usize, Vec<Key>)>, |
| 266 | pub events: Vec<(f64, Emit)>, |
| 267 | } |
| 268 | |
| 269 | impl Clip { |
| 270 | /// `track()` from acting.js: keys are additive offsets, omitted easing is |
| 271 | /// cubic in/out. |
| 272 | pub fn track(keys: &[Key], f: f64) -> f64 { |
| 273 | if keys.is_empty() || !f.is_finite() { |
| 274 | return 0.; |
| 275 | } |
| 276 | if f <= keys[0].frame { |
| 277 | return keys[0].value; |
| 278 | } |
| 279 | for i in 1..keys.len() { |
| 280 | let k1 = keys[i]; |
| 281 | if f <= k1.frame { |
| 282 | let k0 = keys[i - 1]; |
| 283 | let t = if k1.frame == k0.frame { |
| 284 | 1. |
| 285 | } else { |
| 286 | (f - k0.frame) / (k1.frame - k0.frame) |
| 287 | }; |
| 288 | return k0.value + (k1.value - k0.value) * k1.ease.apply(t); |
| 289 | } |
| 290 | } |
| 291 | keys[keys.len() - 1].value |
| 292 | } |
| 293 | pub fn eval(&self, f: f64, out: &mut Pose, weight: f64) { |
| 294 | if !f.is_finite() || !weight.is_finite() { |
| 295 | return; |
| 296 | } |
| 297 | for (param, keys) in &self.tracks { |
| 298 | if let Some(value) = out.get_mut(*param) { |
| 299 | *value += Self::track(keys, f) * weight; |
| 300 | } |
| 301 | } |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | #[derive(Clone, Copy, Debug, Default)] |
| 306 | pub struct Ambient { |
| 307 | pub breath: f64, |
| 308 | pub bob: f64, |
| 309 | pub blink: f64, |
| 310 | pub dart: f64, |
| 311 | pub puff: f64, |
| 312 | pub sleepy: f64, |
| 313 | pub period: f64, |
| 314 | } |
| 315 | |
| 316 | #[derive(Clone, Copy, Debug, PartialEq)] |
| 317 | pub enum PosterParticle { |
| 318 | ThoughtCloud, |
| 319 | Speech, |
| 320 | } |
| 321 | |
| 322 | #[derive(Clone, Debug)] |
| 323 | pub struct ActData { |
| 324 | /// Absolute pose: defaults, then the act's base. |
| 325 | pub base: Vec<(usize, f64)>, |
| 326 | pub enter: Clip, |
| 327 | pub loop_: Clip, |
| 328 | pub beat: Option<Clip>, |
| 329 | pub exit: Option<Clip>, |
| 330 | pub ambient: Ambient, |
| 331 | pub poster: Vec<(usize, f64)>, |
| 332 | pub poster_particles: Vec<PosterParticle>, |
| 333 | } |
| 334 | |
| 335 | pub struct Tables { |
| 336 | pub defaults: Pose, |
| 337 | acts: Vec<ActData>, |
| 338 | pub springs: [(f64, f64); N], |
| 339 | pub rates: [f64; N], |
| 340 | pub wake: Clip, |
| 341 | pub hmm_beat: Clip, |
| 342 | pub puff: Clip, |
| 343 | pub blink: Clip, |
| 344 | kind_to_act: HashMap<String, Act>, |
| 345 | /// The owner's canonical kinds the export maps, for coverage checks. |
| 346 | pub owner_kinds: Vec<String>, |
| 347 | } |
| 348 | |
| 349 | impl Tables { |
| 350 | pub fn act(&self, act: Act) -> &ActData { |
| 351 | &self.acts[act.index()] |
| 352 | } |
| 353 | /// `KIND_TO_ACT`: the owner's canonical kind to its action. Unknown or |
| 354 | /// future kinds have no action and fall back to plain work. |
| 355 | pub fn act_for_kind(&self, kind: &str) -> Option<Act> { |
| 356 | self.kind_to_act.get(kind).copied() |
| 357 | } |
| 358 | } |
| 359 | |
| 360 | #[derive(Deserialize)] |
| 361 | #[serde(rename_all = "camelCase")] |
| 362 | struct Export { |
| 363 | fps: f64, |
| 364 | parameters: Vec<String>, |
| 365 | defaults: HashMap<String, f64>, |
| 366 | acts: HashMap<String, Value>, |
| 367 | springs: HashMap<String, (f64, f64)>, |
| 368 | rate_limits: HashMap<String, f64>, |
| 369 | wake: Value, |
| 370 | owner_kind_to_action: HashMap<String, String>, |
| 371 | } |
| 372 | |
| 373 | fn clip(value: &Value) -> Clip { |
| 374 | let dur = value["dur"].as_f64().expect("clip duration"); |
| 375 | let mut tracks = Vec::new(); |
| 376 | if let Some(keys) = value["keys"].as_object() { |
| 377 | for (name, list) in keys { |
| 378 | let param = param_index(name).unwrap_or_else(|| panic!("unknown parameter {name}")); |
| 379 | let keys = list |
| 380 | .as_array() |
| 381 | .expect("keys") |
| 382 | .iter() |
| 383 | .map(|key| Key { |
| 384 | frame: key[0].as_f64().expect("key frame"), |
| 385 | value: key[1].as_f64().expect("key value"), |
| 386 | ease: Ease::parse(key.get(2).and_then(Value::as_str)), |
| 387 | }) |
| 388 | .collect(); |
| 389 | tracks.push((param, keys)); |
| 390 | } |
| 391 | } |
| 392 | let events = value["events"] |
| 393 | .as_array() |
| 394 | .map(|events| { |
| 395 | events |
| 396 | .iter() |
| 397 | .map(|event| { |
| 398 | let frame = event[0].as_f64().expect("event frame"); |
| 399 | let kind = match event[1].as_str().expect("event kind") { |
| 400 | "spout" => Emit::Spout { |
| 401 | n: event[2]["n"].as_u64().unwrap_or(4) as u32, |
| 402 | big: event[2]["big"].as_bool().unwrap_or(false), |
| 403 | }, |
| 404 | "thought" => Emit::Thought, |
| 405 | "speech" => Emit::Speech, |
| 406 | "blub" => Emit::Blub, |
| 407 | "sleep" => Emit::Sleep, |
| 408 | other => panic!("unknown event {other}"), |
| 409 | }; |
| 410 | (frame, kind) |
| 411 | }) |
| 412 | .collect() |
| 413 | }) |
| 414 | .unwrap_or_default(); |
| 415 | Clip { |
| 416 | dur, |
| 417 | tracks, |
| 418 | events, |
| 419 | } |
| 420 | } |
| 421 | |
| 422 | fn pairs(value: &Value) -> Vec<(usize, f64)> { |
| 423 | value |
| 424 | .as_object() |
| 425 | .map(|object| { |
| 426 | object |
| 427 | .iter() |
| 428 | .filter_map(|(name, value)| Some((param_index(name)?, value.as_f64()?))) |
| 429 | .collect() |
| 430 | }) |
| 431 | .unwrap_or_default() |
| 432 | } |
| 433 | |
| 434 | /// One authored key: `[frame, value, ease?]`. |
| 435 | type Authored<'a> = (f64, f64, Option<&'a str>); |
| 436 | |
| 437 | /// A clip written the way acting.js writes it. |
| 438 | fn authored(dur: f64, tracks: &[(&str, &[Authored])], events: Vec<(f64, Emit)>) -> Clip { |
| 439 | Clip { |
| 440 | dur, |
| 441 | tracks: tracks |
| 442 | .iter() |
| 443 | .map(|(name, keys)| { |
| 444 | ( |
| 445 | param_index(name).unwrap(), |
| 446 | keys.iter() |
| 447 | .map(|(frame, value, ease)| Key { |
| 448 | frame: *frame, |
| 449 | value: *value, |
| 450 | ease: Ease::parse(*ease), |
| 451 | }) |
| 452 | .collect(), |
| 453 | ) |
| 454 | }) |
| 455 | .collect(), |
| 456 | events, |
| 457 | } |
| 458 | } |
| 459 | |
| 460 | const EXPORT: &str = include_str!("../../assets/whale-motion/animation.json"); |
| 461 | |
| 462 | pub fn tables() -> &'static Tables { |
| 463 | static TABLES: OnceLock<Tables> = OnceLock::new(); |
| 464 | TABLES.get_or_init(|| { |
| 465 | let export: Export = serde_json::from_str(EXPORT).expect("whale animation export"); |
| 466 | assert_eq!(export.fps, 30.); |
| 467 | assert_eq!(export.parameters, PARAMS.map(str::to_string)); |
| 468 | let mut defaults = [0.; N]; |
| 469 | for (index, name) in PARAMS.iter().enumerate() { |
| 470 | defaults[index] = export.defaults[*name]; |
| 471 | } |
| 472 | let acts = Act::ALL |
| 473 | .iter() |
| 474 | .map(|act| { |
| 475 | let value = &export.acts[act.id()]; |
| 476 | let ambient = &value["ambient"]; |
| 477 | let number = |key: &str| ambient[key].as_f64().unwrap_or(0.); |
| 478 | let poster_particles = value["poster"]["particles"] |
| 479 | .as_array() |
| 480 | .map(|list| { |
| 481 | list.iter() |
| 482 | .filter_map(|entry| match entry[0].as_str()? { |
| 483 | "thought-cloud" => Some(PosterParticle::ThoughtCloud), |
| 484 | "speech" => Some(PosterParticle::Speech), |
| 485 | _ => None, |
| 486 | }) |
| 487 | .collect() |
| 488 | }) |
| 489 | .unwrap_or_default(); |
| 490 | ActData { |
| 491 | base: pairs(&value["base"]), |
| 492 | enter: clip(&value["enter"]), |
| 493 | loop_: clip(&value["loop"]), |
| 494 | beat: value.get("beat").map(clip), |
| 495 | exit: value.get("exit").map(clip), |
| 496 | ambient: Ambient { |
| 497 | breath: number("breath"), |
| 498 | bob: number("bob"), |
| 499 | blink: number("blink"), |
| 500 | dart: number("dart"), |
| 501 | puff: number("puff"), |
| 502 | sleepy: number("sleepy"), |
| 503 | period: number("period"), |
| 504 | }, |
| 505 | poster: pairs(&value["poster"]), |
| 506 | poster_particles, |
| 507 | } |
| 508 | }) |
| 509 | .collect(); |
| 510 | let fallback = export.springs["_"]; |
| 511 | let springs = PARAMS.map(|name| export.springs.get(name).copied().unwrap_or(fallback)); |
| 512 | let rate = export.rate_limits["_"]; |
| 513 | let rates = PARAMS.map(|name| export.rate_limits.get(name).copied().unwrap_or(rate)); |
| 514 | let kind_to_act = export |
| 515 | .owner_kind_to_action |
| 516 | .iter() |
| 517 | .map(|(kind, act)| (kind.clone(), Act::from_id(act).expect("mapped action"))) |
| 518 | .collect(); |
| 519 | let mut owner_kinds: Vec<String> = export.owner_kind_to_action.keys().cloned().collect(); |
| 520 | owner_kinds.sort(); |
| 521 | // acting.js HMM_BEAT, PUFF and BLINK. |
| 522 | let hmm_beat = authored( |
| 523 | 12., |
| 524 | &[ |
| 525 | ( |
| 526 | "head", |
| 527 | &[ |
| 528 | (0., 0., None), |
| 529 | (4., -4., Some("o")), |
| 530 | (8., -4., None), |
| 531 | (12., 0., None), |
| 532 | ], |
| 533 | ), |
| 534 | ( |
| 535 | "tilt", |
| 536 | &[ |
| 537 | (0., 0., None), |
| 538 | (5., -6., None), |
| 539 | (8., -6., None), |
| 540 | (12., 0., None), |
| 541 | ], |
| 542 | ), |
| 543 | ( |
| 544 | "mouthSide", |
| 545 | &[ |
| 546 | (0., 0., None), |
| 547 | (5., 1., None), |
| 548 | (8., 1., None), |
| 549 | (12., 0., None), |
| 550 | ], |
| 551 | ), |
| 552 | ], |
| 553 | Vec::new(), |
| 554 | ); |
| 555 | let puff = authored( |
| 556 | 34., |
| 557 | &[ |
| 558 | ( |
| 559 | "y", |
| 560 | &[ |
| 561 | (0., 0., None), |
| 562 | (6., 0.7, None), |
| 563 | (10., -0.6, Some("o")), |
| 564 | (34., 0., Some("io")), |
| 565 | ], |
| 566 | ), |
| 567 | ( |
| 568 | "squash", |
| 569 | &[ |
| 570 | (0., 0., None), |
| 571 | (6., 0.03, None), |
| 572 | (10., -0.02, None), |
| 573 | (20., 0., None), |
| 574 | ], |
| 575 | ), |
| 576 | ], |
| 577 | vec![(9., Emit::Spout { n: 3, big: false })], |
| 578 | ); |
| 579 | let blink = authored( |
| 580 | 7., |
| 581 | &[( |
| 582 | "lid", |
| 583 | &[ |
| 584 | (0., 0., None), |
| 585 | (2., 1., Some("i")), |
| 586 | (3., 1., None), |
| 587 | (7., 0., Some("o")), |
| 588 | ], |
| 589 | )], |
| 590 | Vec::new(), |
| 591 | ); |
| 592 | Tables { |
| 593 | defaults, |
| 594 | acts, |
| 595 | springs, |
| 596 | rates, |
| 597 | wake: clip(&export.wake), |
| 598 | hmm_beat, |
| 599 | puff, |
| 600 | blink, |
| 601 | kind_to_act, |
| 602 | owner_kinds, |
| 603 | } |
| 604 | }) |
| 605 | } |
| 606 |