create-aura3d 1.1.2 → 1.3.0
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.
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/package.json +11 -9
- package/templates/{cartoon-channel → animation-channel}/README.md +11 -11
- package/templates/{cartoon-studio → animation-channel}/index.html +1 -1
- package/templates/{cartoon-channel → animation-channel}/package.json +2 -2
- package/templates/{cartoon-channel → animation-channel}/src/episode.ts +22 -22
- package/templates/{cartoon-channel → animation-channel}/src/experimental/README.md +5 -5
- package/templates/{cartoon-channel → animation-channel}/src/experimental/concept-episode-2-5d.css +3 -3
- package/templates/{cartoon-channel → animation-channel}/src/experimental/concept-episode-2-5d.ts +7 -7
- package/templates/{cartoon-channel → animation-channel}/src/experimental/image-puppet-episode.css +4 -4
- package/templates/{cartoon-channel → animation-channel}/src/experimental/image-puppet-episode.ts +6 -6
- package/templates/{cartoon-channel → animation-channel}/src/experimental/puppet-episode-2d.css +3 -3
- package/templates/{cartoon-channel → animation-channel}/src/experimental/puppet-episode-2d.ts +7 -7
- package/templates/{cartoon-channel → animation-channel}/src/main.ts +32 -32
- package/templates/{cartoon-channel → animation-channel}/src/render-plan.ts +13 -13
- package/templates/{cartoon-channel → animation-channel}/src/sample-episode-visual.css +34 -34
- package/templates/{cartoon-channel → animation-channel}/src/sample-episode-visual.ts +20 -20
- package/templates/{cartoon-channel → animation-channel}/tests/concept-2-5d-animation.spec.ts +3 -3
- package/templates/{cartoon-channel → animation-channel}/tests/concept-2-5d.spec.ts +6 -6
- package/templates/{cartoon-channel → animation-channel}/tests/image-puppet-animation.spec.ts +3 -3
- package/templates/{cartoon-channel → animation-channel}/tests/image-puppet.spec.ts +5 -5
- package/templates/{cartoon-channel → animation-channel}/tests/puppet-2d-animation.spec.ts +3 -3
- package/templates/{cartoon-channel → animation-channel}/tests/puppet-2d.spec.ts +4 -4
- package/templates/{prompt-cartoon-channel → animation-channel}/tests/route-health.spec.ts +1 -1
- package/templates/{cartoon-channel → animation-channel}/tests/sample-episode-visual.spec.ts +5 -5
- package/templates/{cartoon-channel → animation-channel}/tests/storyboard-playback.spec.ts +7 -7
- package/templates/animation-studio/AGENTS.md +71 -0
- package/templates/{cartoon-studio → animation-studio}/ASSET-LICENSES.md +7 -7
- package/templates/{cartoon-studio → animation-studio}/README.md +6 -6
- package/templates/animation-studio/aura.assets.json +669 -0
- package/templates/animation-studio/dist/episodes/scene/episode-3d.webm +0 -0
- package/templates/animation-studio/dist/episodes/scene/frames/action.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/frames/dialogue.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/frames/final.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/frames/first.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/frames/mouth-closed.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/frames/mouth-open.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/render-live-summary.json +10284 -0
- package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/worker-1.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/worker-2.png +0 -0
- package/templates/animation-studio/dist/generated/second-scene.document.json +1210 -0
- package/templates/animation-studio/dist/generated/studio-edited.document.json +1196 -0
- package/templates/animation-studio/dist/scene/inshader-accept.document.json +1 -0
- package/templates/animation-studio/dist/scene/inshader-test.document.json +1 -0
- package/templates/animation-studio/dist/scene/integ.document.json +1 -0
- package/templates/animation-studio/dist/scene/working.document.json +723 -0
- package/templates/animation-studio/dist/scene/working.history.json +1 -0
- package/templates/{cartoon-studio → animation-studio}/live-route.html +1 -1
- package/templates/{cartoon-studio → animation-studio}/package-lock.json +2 -2
- package/templates/animation-studio/package.json +32 -0
- package/templates/animation-studio/public/aura-assets/cast-a.catalog.glb +0 -0
- package/templates/animation-studio/public/aura-assets/cast-b.catalog.glb +0 -0
- package/templates/animation-studio/public/aura-assets/luma.catalog.glb +0 -0
- package/templates/animation-studio/public/aura-assets/luma2.catalog.glb +0 -0
- package/templates/animation-studio/public/aura-assets/miko.catalog.glb +0 -0
- package/templates/{cartoon-studio → animation-studio}/public/aura-assets/moonGarden.gltf +1 -1
- package/templates/animation-studio/public/aura-assets/mushroom.catalog.glb +0 -0
- package/templates/animation-studio/public/aura-assets/rusty.catalog.glb +0 -0
- package/templates/animation-studio/public/cast-library/cast-library.json +71 -0
- package/templates/animation-studio/public/clip-library/gesture.json +173903 -0
- package/templates/animation-studio/public/clip-library/idle.json +23035 -0
- package/templates/animation-studio/public/clip-library/manifest.json +154 -0
- package/templates/animation-studio/public/clip-library/nod.json +173903 -0
- package/templates/animation-studio/public/clip-library/point.json +69879 -0
- package/templates/animation-studio/public/clip-library/react.json +66084 -0
- package/templates/animation-studio/public/clip-library/run.json +9648 -0
- package/templates/animation-studio/public/clip-library/talk.json +69879 -0
- package/templates/animation-studio/public/clip-library/walk.json +8283 -0
- package/templates/animation-studio/public/hifi-cast/BrainStem.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/CesiumMan.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/RiggedFigure.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/kaykit/Barbarian.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/kaykit/Knight.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/kaykit/Mage.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/kaykit/Rogue.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/kaykit/Rogue_Hooded.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/khronos-report.json +295 -0
- package/templates/animation-studio/public/hifi-cast/quaternius-report.json +449 -0
- package/templates/animation-studio/public/hifi-cast/rpm/cesiumman.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/rpm/fox.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/rpm/riggedfigure.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/rpm-report.json +301 -0
- package/templates/animation-studio/scripts/anim.ts +103 -0
- package/templates/animation-studio/scripts/animation-scene.ts +457 -0
- package/templates/animation-studio/scripts/asset-motion-probe.ts +450 -0
- package/templates/animation-studio/scripts/asset-render-probe.ts +152 -0
- package/templates/animation-studio/scripts/author-office-scene.ts +65 -0
- package/templates/animation-studio/scripts/build-cast-library.ts +97 -0
- package/templates/animation-studio/scripts/build-characters.ts +1543 -0
- package/templates/animation-studio/scripts/build-clip-library.ts +972 -0
- package/templates/animation-studio/scripts/clip-decision-cli.ts +51 -0
- package/templates/animation-studio/scripts/determinism-check.ts +48 -0
- package/templates/animation-studio/scripts/generate-scene.ts +95 -0
- package/templates/animation-studio/scripts/hifi-cast-validate.ts +572 -0
- package/templates/animation-studio/scripts/motion-evidence-cli.ts +150 -0
- package/templates/animation-studio/scripts/motion-evidence.ts +362 -0
- package/templates/animation-studio/scripts/preview-server.ts +52 -0
- package/templates/animation-studio/scripts/regen-manifest.ts +156 -0
- package/templates/animation-studio/scripts/render-core.ts +360 -0
- package/templates/animation-studio/scripts/render-live.ts +813 -0
- package/templates/animation-studio/scripts/render-parallel.ts +70 -0
- package/templates/animation-studio/scripts/render-server.ts +119 -0
- package/templates/animation-studio/scripts/resolve-asset.ts +669 -0
- package/templates/animation-studio/scripts/skeleton-overlay-cli.ts +82 -0
- package/templates/animation-studio/scripts/skeleton-overlay.ts +293 -0
- package/templates/animation-studio/scripts/studio-tools-demo.ts +45 -0
- package/templates/animation-studio/scripts/texture-bake.ts +297 -0
- package/templates/{cartoon-studio → animation-studio}/scripts/validate-characters.ts +0 -0
- package/templates/animation-studio/scripts/verify-extracted-overlay-cli.ts +264 -0
- package/templates/animation-studio/src/animation-episode-validator.ts +97 -0
- package/templates/animation-studio/src/animation-performance.ts +556 -0
- package/templates/animation-studio/src/aura-assets.ts +71 -0
- package/templates/animation-studio/src/character.ts +40 -0
- package/templates/animation-studio/src/director/compile-episode-document.ts +45 -0
- package/templates/animation-studio/src/director/director-heuristics.ts +697 -0
- package/templates/animation-studio/src/director/prompt-to-scene.ts +369 -0
- package/templates/animation-studio/src/empty-document.ts +77 -0
- package/templates/animation-studio/src/episode-document.ts +442 -0
- package/templates/animation-studio/src/examples/moon-garden.example.ts +183 -0
- package/templates/animation-studio/src/fidelity.ts +129 -0
- package/templates/animation-studio/src/graph.ts +14 -0
- package/templates/animation-studio/src/procedural-hdri.ts +243 -0
- package/templates/animation-studio/src/profile.ts +97 -0
- package/templates/animation-studio/src/render-live-route.ts +42 -0
- package/templates/animation-studio/src/render-modes.ts +137 -0
- package/templates/animation-studio/src/scene-player.ts +867 -0
- package/templates/animation-studio/src/set-templates.ts +361 -0
- package/templates/animation-studio/src/studio/episode-document-store.ts +72 -0
- package/templates/animation-studio/src/studio/scene-tool-registry.ts +86 -0
- package/templates/animation-studio/src/studio/scene-tools.ts +169 -0
- package/templates/animation-studio/tests/reports/animation-studio/hifi-cast-validation.json +938 -0
- package/templates/animation-studio/tsconfig.json +26 -0
- package/templates/{cartoon-studio → animation-studio}/vite.config.ts +6 -1
- package/templates/character-controller/README.md +33 -0
- package/templates/character-controller/index.html +19 -0
- package/templates/character-controller/package.json +23 -0
- package/templates/character-controller/playwright.config.ts +14 -0
- package/templates/character-controller/src/controller.ts +44 -0
- package/templates/character-controller/src/main.ts +62 -0
- package/templates/character-controller/tests/route-health.spec.ts +26 -0
- package/templates/character-controller/tests/screenshot.spec.ts +9 -0
- package/templates/character-controller/tsconfig.json +15 -0
- package/templates/cinematic-scene/package.json +1 -1
- package/templates/episode-builder/README.md +4 -4
- package/templates/episode-builder/package.json +1 -1
- package/templates/episode-builder/src/aura-assets.ts +1 -1
- package/templates/episode-builder/src/builder.ts +4 -4
- package/templates/episode-builder/src/episode.ts +21 -21
- package/templates/episode-builder/src/main.ts +20 -20
- package/templates/episode-builder/src/render-plan.ts +13 -13
- package/templates/episode-builder/tests/storyboard-playback.spec.ts +7 -7
- package/templates/fighting-game/package.json +1 -1
- package/templates/mini-game/package.json +1 -1
- package/templates/product-viewer/package.json +1 -1
- package/templates/{prompt-cartoon-channel → prompt-animation-channel}/README.md +5 -5
- package/templates/{cartoon-channel → prompt-animation-channel}/index.html +1 -1
- package/templates/{prompt-cartoon-channel → prompt-animation-channel}/package.json +2 -2
- package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/aura-assets.ts +1 -1
- package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/episode.ts +22 -22
- package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/main.ts +28 -28
- package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/render-plan.ts +13 -13
- package/templates/{cartoon-channel → prompt-animation-channel}/tests/route-health.spec.ts +1 -1
- package/templates/{prompt-cartoon-channel → prompt-animation-channel}/tests/storyboard-playback.spec.ts +8 -8
- package/templates/cartoon-studio/aura.assets.json +0 -400
- package/templates/cartoon-studio/package.json +0 -33
- package/templates/cartoon-studio/public/aura-assets/luma.authored.glb +0 -0
- package/templates/cartoon-studio/public/aura-assets/miko.authored.glb +0 -0
- package/templates/cartoon-studio/scripts/build-characters.ts +0 -738
- package/templates/cartoon-studio/scripts/build-dialogue-audio.ts +0 -262
- package/templates/cartoon-studio/scripts/episode.ts +0 -323
- package/templates/cartoon-studio/scripts/render-live.ts +0 -873
- package/templates/cartoon-studio/src/aura-assets.ts +0 -64
- package/templates/cartoon-studio/src/characters.ts +0 -65
- package/templates/cartoon-studio/src/episode-renderer.ts +0 -605
- package/templates/cartoon-studio/src/episode.ts +0 -306
- package/templates/cartoon-studio/src/main.ts +0 -400
- package/templates/cartoon-studio/src/render-live-route.ts +0 -913
- package/templates/cartoon-studio/src/render-plan.ts +0 -567
- package/templates/cartoon-studio/src/review.ts +0 -99
- package/templates/cartoon-studio/src/studio.ts +0 -101
- package/templates/cartoon-studio/test-results/.last-run.json +0 -4
- package/templates/cartoon-studio/tests/episode-render.spec.ts +0 -44
- package/templates/cartoon-studio/tests/motion-quality.spec.ts +0 -45
- package/templates/cartoon-studio/tests/route-health.spec.ts +0 -7
- package/templates/cartoon-studio/tests/storyboard-playback.spec.ts +0 -99
- package/templates/cartoon-studio/tests/visual-review.spec.ts +0 -21
- package/templates/cartoon-studio/tsconfig.json +0 -14
- package/templates/prompt-cartoon-channel/index.html +0 -13
- package/templates/prompt-cartoon-channel/src/sets.ts +0 -14
- /package/templates/{cartoon-channel → animation-channel}/aura.assets.json +0 -0
- /package/templates/{cartoon-channel → animation-channel}/playwright.config.ts +0 -0
- /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/luma.047f5e5f.glb +0 -0
- /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/luma.thumb.svg +0 -0
- /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/miko.047f5e5f.glb +0 -0
- /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/miko.thumb.svg +0 -0
- /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/moon-garden-feature-frame.png +0 -0
- /package/templates/{cartoon-channel → animation-channel}/src/aura-assets.ts +0 -0
- /package/templates/{cartoon-channel → animation-channel}/src/characters.ts +0 -0
- /package/templates/{cartoon-channel → animation-channel}/src/contract.ts +0 -0
- /package/templates/{cartoon-channel → animation-channel}/src/sets.ts +0 -0
- /package/templates/{cartoon-channel → animation-channel}/test-results/.last-run.json +0 -0
- /package/templates/{cartoon-channel → animation-channel}/tests/reports/prompt-animation/cartoon-sample-episode.png +0 -0
- /package/templates/{cartoon-channel → animation-channel}/tsconfig.json +0 -0
- /package/templates/{cartoon-studio → animation-studio}/playwright.config.ts +0 -0
- /package/templates/{cartoon-studio → animation-studio}/public/aura-assets/luma.thumb.svg +0 -0
- /package/templates/{cartoon-studio → animation-studio}/public/aura-assets/miko.thumb.svg +0 -0
- /package/templates/{cartoon-studio → animation-studio}/src/contract.ts +0 -0
- /package/templates/{prompt-cartoon-channel → prompt-animation-channel}/playwright.config.ts +0 -0
- /package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/characters.ts +0 -0
- /package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/contract.ts +0 -0
- /package/templates/{cartoon-studio → prompt-animation-channel}/src/sets.ts +0 -0
- /package/templates/{prompt-cartoon-channel → prompt-animation-channel}/tsconfig.json +0 -0
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* director-heuristics.ts — the DETERMINISTIC tier of the Director.
|
|
3
|
+
*
|
|
4
|
+
* Given a scene's shots + dialogue + cast + walkable bounds + available props, it
|
|
5
|
+
* GENERATES the blocking / camera / performance / set-dressing / world-state of an
|
|
6
|
+
* EpisodeDocument — the parts that used to be hand-authored constants. No LLM, no
|
|
7
|
+
* randomness beyond a seeded scatter, fully deterministic.
|
|
8
|
+
*
|
|
9
|
+
* HONEST SCOPE (see docs/animation-studio/quality-and-limitations.md): these rules target
|
|
10
|
+
* the constrained genre the MVP targets — **1–2 characters, dialogue-driven, single
|
|
11
|
+
* walkable set**. They reliably stage conversation: characters at conversation distance
|
|
12
|
+
* facing each other, establishing→two-shot→close-up framing, the speaker emphasised in
|
|
13
|
+
* close-ups, props scattered over the walkable area, world-state ramped across the act.
|
|
14
|
+
* They do NOT handle action, crowds, complex choreography, or non-dialogue beats — those
|
|
15
|
+
* are the LLM director's job (and remain unproven). The output is always a *valid*
|
|
16
|
+
* document; "valid" is not "well-directed" — that needs the human rubric (the watchability gate).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { CameraPresetId } from "@aura3d/engine";
|
|
20
|
+
import type { CharacterBlocking, PropPlacement, ShotBlocking, ShotSpec, Vec3 } from "../episode-document";
|
|
21
|
+
|
|
22
|
+
export interface DirectorCharacter {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
/** Where the character starts the scene (drives a walk-in on the opening shot). */
|
|
25
|
+
readonly entersFrom?: "left" | "right" | "none";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DirectorShot {
|
|
29
|
+
readonly shotId: string;
|
|
30
|
+
readonly startTime: number;
|
|
31
|
+
readonly endTime: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DirectorDialogueLine {
|
|
35
|
+
readonly lineId: string;
|
|
36
|
+
readonly speakerId: string;
|
|
37
|
+
readonly startTime: number;
|
|
38
|
+
readonly endTime: number;
|
|
39
|
+
/**
|
|
40
|
+
* The spoken line. Drives the acting rules (Phase F1): questions, emphasis,
|
|
41
|
+
* disagreement and movement verbs are read off this text to choose intents.
|
|
42
|
+
* Optional so legacy callers (timing-only inputs) still type-check; when absent
|
|
43
|
+
* the beat falls back to plain talk + attentive listening.
|
|
44
|
+
*/
|
|
45
|
+
readonly text?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface DirectorPropSpec {
|
|
49
|
+
readonly propId: string;
|
|
50
|
+
readonly count: number;
|
|
51
|
+
readonly scaleRange: readonly [number, number];
|
|
52
|
+
readonly feetOffset: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface DirectorSceneInput {
|
|
56
|
+
readonly duration: number;
|
|
57
|
+
readonly characters: readonly DirectorCharacter[];
|
|
58
|
+
readonly shots: readonly DirectorShot[];
|
|
59
|
+
readonly dialogue: readonly DirectorDialogueLine[];
|
|
60
|
+
/** Walkable area characters and props stay inside. */
|
|
61
|
+
readonly walkableBounds: { readonly min: Vec3; readonly max: Vec3 };
|
|
62
|
+
readonly props: readonly DirectorPropSpec[];
|
|
63
|
+
/**
|
|
64
|
+
* @deprecated Per-beat clips now come from the fixed {@link PERFORMANCE_VOCABULARY}
|
|
65
|
+
* (idle/talk/gesture/point/nod/walk/run/react) chosen by role, so a retargeted clip
|
|
66
|
+
* library resolves them. This override is no longer consulted by {@link directScene}.
|
|
67
|
+
*/
|
|
68
|
+
readonly clips?: { readonly idle?: string; readonly walk?: string; readonly gesture?: string };
|
|
69
|
+
/**
|
|
70
|
+
* Optional duration estimator (Phase C2). When supplied, beat durations are taken
|
|
71
|
+
* from this (so the director shares Phase C's `estimateSpeechDuration`); otherwise
|
|
72
|
+
* the director uses its own self-contained words/165-wpm fallback so it never has to
|
|
73
|
+
* import Phase C's file.
|
|
74
|
+
*/
|
|
75
|
+
readonly durationEstimator?: (text: string) => number;
|
|
76
|
+
/**
|
|
77
|
+
* Optional per-character body-acting {@link RigGrade} (from `@aura3d/engine`'s `gradeRig`).
|
|
78
|
+
* When provided, the director consults {@link gradeAwareIntent} so a character whose rig can't
|
|
79
|
+
* perform an intent gets it honestly downgraded — a D-grade (no usable skeleton) rig is never
|
|
80
|
+
* told to gesture/walk; it talks or holds. Omitted ⇒ no restriction (every rig treated as A/B).
|
|
81
|
+
*/
|
|
82
|
+
readonly gradesByCharacter?: Readonly<Record<string, RigGrade>>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface DirectedScene {
|
|
86
|
+
readonly shots: readonly ShotSpec[];
|
|
87
|
+
readonly blocking: readonly CharacterBlocking[];
|
|
88
|
+
readonly setDressing: readonly PropPlacement[];
|
|
89
|
+
readonly worldState: { readonly glowSpanSeconds: number };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- small deterministic helpers ---
|
|
93
|
+
function mulberry32(seed: number): () => number {
|
|
94
|
+
let a = seed >>> 0;
|
|
95
|
+
return () => {
|
|
96
|
+
a |= 0;
|
|
97
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
98
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
99
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
100
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function hashSeed(s: string): number {
|
|
104
|
+
let h = 2166136261;
|
|
105
|
+
for (let i = 0; i < s.length; i += 1) h = Math.imul(h ^ s.charCodeAt(i), 16777619);
|
|
106
|
+
return h >>> 0;
|
|
107
|
+
}
|
|
108
|
+
function yawFacing(from: Vec3, to: Vec3): number {
|
|
109
|
+
return Math.atan2(to[0] - from[0], to[2] - from[2]);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The STANDARD performance vocabulary — the fixed contract of clip intents the Director
|
|
114
|
+
* emits per beat. A downstream retargeted clip library exposes exactly these states, so
|
|
115
|
+
* the player can resolve every beat to a real animation. The Director must only ever
|
|
116
|
+
* write one of these ids into a beat's `clip` (never a fictional / asset-specific name).
|
|
117
|
+
*/
|
|
118
|
+
export const PERFORMANCE_VOCABULARY = [
|
|
119
|
+
"idle",
|
|
120
|
+
"talk",
|
|
121
|
+
"gesture",
|
|
122
|
+
"point",
|
|
123
|
+
"nod",
|
|
124
|
+
"walk",
|
|
125
|
+
"run",
|
|
126
|
+
"react"
|
|
127
|
+
] as const;
|
|
128
|
+
export type PerformanceClip = (typeof PERFORMANCE_VOCABULARY)[number];
|
|
129
|
+
const VOCAB = new Set<string>(PERFORMANCE_VOCABULARY);
|
|
130
|
+
|
|
131
|
+
/** Distance (world units) beyond which a traversal reads as a run rather than a walk. */
|
|
132
|
+
const RUN_DISTANCE = 3.0;
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Phase F1 — inspectable per-beat ACTING record + C2 beat timing
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
/** The coarse camera intent the director assigns a beat (mirrors the shot presets). */
|
|
139
|
+
export type CameraIntent = "establishing" | "two-shot" | "medium" | "close-up";
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* One fully-inspectable performance beat (Phase F1). Every dialogue line becomes a
|
|
143
|
+
* beat; the record is the director's *explanation* of how the scene should be acted —
|
|
144
|
+
* who speaks, who listens, what gesture lands, how the camera frames it and how long
|
|
145
|
+
* the beat runs (C2). It is data, so tests and a UI preview can read the intent
|
|
146
|
+
* directly instead of inferring it from blocking.
|
|
147
|
+
*/
|
|
148
|
+
export interface DirectorBeat {
|
|
149
|
+
readonly lineId: string;
|
|
150
|
+
readonly speaker: string;
|
|
151
|
+
/** The character being addressed (the other party in a 1:1, else the next non-speaker). */
|
|
152
|
+
readonly listener: string | null;
|
|
153
|
+
readonly text: string;
|
|
154
|
+
/** What the speaker DOES while talking (talk / gesture / point / nod / walk / run). */
|
|
155
|
+
readonly speakingIntent: PerformanceClip;
|
|
156
|
+
/** What the listener DOES in response (react / nod / idle), never frozen. */
|
|
157
|
+
readonly listenerIntent: PerformanceClip;
|
|
158
|
+
/** Convenience mirror of {@link speakingIntent} for non-talk emphasis beats. */
|
|
159
|
+
readonly gesture: PerformanceClip;
|
|
160
|
+
readonly cameraIntent: CameraIntent;
|
|
161
|
+
/** Estimated beat length in seconds (C2). */
|
|
162
|
+
readonly durationSeconds: number;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Words that signal the speaker is disagreeing / negating — these earn a stronger react. */
|
|
166
|
+
const NEGATION_WORDS = [
|
|
167
|
+
"no",
|
|
168
|
+
"not",
|
|
169
|
+
"never",
|
|
170
|
+
"wrong",
|
|
171
|
+
"don't",
|
|
172
|
+
"dont",
|
|
173
|
+
"won't",
|
|
174
|
+
"wont",
|
|
175
|
+
"can't",
|
|
176
|
+
"cant",
|
|
177
|
+
"isn't",
|
|
178
|
+
"isnt",
|
|
179
|
+
"stop",
|
|
180
|
+
"refuse",
|
|
181
|
+
"disagree",
|
|
182
|
+
"nonsense"
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
/** Movement verbs that turn a beat into locomotion (walk/turn/reposition). */
|
|
186
|
+
const MOVEMENT_WORDS = ["walk", "go", "going", "run", "running", "cross", "leave", "leaving", "come", "coming", "move", "follow"];
|
|
187
|
+
|
|
188
|
+
function words(text: string): string[] {
|
|
189
|
+
return text
|
|
190
|
+
.toLowerCase()
|
|
191
|
+
.replace(/[^a-z' ]+/g, " ")
|
|
192
|
+
.split(/\s+/)
|
|
193
|
+
.filter(Boolean);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function isQuestion(text: string): boolean {
|
|
197
|
+
return text.includes("?");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Emphasis = a shout ("!") or a SHOUTED word (a 2+ letter ALL-CAPS run). */
|
|
201
|
+
function isEmphatic(text: string): boolean {
|
|
202
|
+
if (text.includes("!")) return true;
|
|
203
|
+
return /\b[A-Z]{2,}\b/.test(text);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isDisagreement(text: string): boolean {
|
|
207
|
+
const w = new Set(words(text));
|
|
208
|
+
return NEGATION_WORDS.some((n) => w.has(n));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function hasMovement(text: string): boolean {
|
|
212
|
+
const w = new Set(words(text));
|
|
213
|
+
return MOVEMENT_WORDS.some((m) => w.has(m));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The director's self-contained speech-duration fallback (C2). Mirrors Phase C's
|
|
218
|
+
* model — words / 165 wpm + punctuation pauses, clamped — but lives here so the
|
|
219
|
+
* director never imports the document module. A caller may inject Phase C's real
|
|
220
|
+
* estimator via {@link DirectorSceneInput.durationEstimator} instead.
|
|
221
|
+
*/
|
|
222
|
+
export function estimateBeatDuration(text: string): number {
|
|
223
|
+
const trimmed = (text ?? "").trim();
|
|
224
|
+
if (trimmed.length === 0) return 0.9;
|
|
225
|
+
const wordCount = trimmed.split(/\s+/).filter(Boolean).length;
|
|
226
|
+
const speaking = (wordCount / 165) * 60;
|
|
227
|
+
const soft = (trimmed.match(/[,;:]/g) ?? []).length * 0.25;
|
|
228
|
+
const hard = (trimmed.match(/[.!?]/g) ?? []).length * 0.4;
|
|
229
|
+
return Math.min(22, Math.max(0.9, speaking + soft + hard));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The speaker's varied performance intents, cycled across that speaker's emphasis
|
|
234
|
+
* beats so a talky scene isn't all low-motion `talk`. Order: gesture → point → nod.
|
|
235
|
+
*/
|
|
236
|
+
const SPEAKER_EMPHASIS_CYCLE: readonly PerformanceClip[] = ["gesture", "point", "nod"];
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Map a dialogue line (+ its position in a speaker's run of beats) to a speaking
|
|
240
|
+
* intent. Rules (F1):
|
|
241
|
+
* - movement verb → walk / run (locomotion wins; you can't argue mid-stride)
|
|
242
|
+
* - emphasis (!/ALL-CAPS) → a varied gesture (gesture/point/nod, rotated)
|
|
243
|
+
* - question ("?") → talk (the *listener* tilts/reacts; see listener rule)
|
|
244
|
+
* - plain line → mostly talk, but every Nth plain beat is upgraded to a
|
|
245
|
+
* rotated gesture so a speaker is never all-talk.
|
|
246
|
+
*/
|
|
247
|
+
function speakingIntentFor(text: string, emphasisOrdinal: number, plainOrdinal: number): PerformanceClip {
|
|
248
|
+
if (hasMovement(text)) {
|
|
249
|
+
// Long movement words ("run"/"running") read as a run; else a walk/reposition.
|
|
250
|
+
return /\b(run|running)\b/i.test(text) ? "run" : "walk";
|
|
251
|
+
}
|
|
252
|
+
if (isEmphatic(text)) {
|
|
253
|
+
return SPEAKER_EMPHASIS_CYCLE[emphasisOrdinal % SPEAKER_EMPHASIS_CYCLE.length]!;
|
|
254
|
+
}
|
|
255
|
+
// Plain line: upgrade every 3rd plain beat to a rotated gesture so it's not all talk.
|
|
256
|
+
if (plainOrdinal > 0 && plainOrdinal % 3 === 0) {
|
|
257
|
+
return SPEAKER_EMPHASIS_CYCLE[(plainOrdinal / 3) % SPEAKER_EMPHASIS_CYCLE.length]!;
|
|
258
|
+
}
|
|
259
|
+
return "talk";
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Map a dialogue line to the LISTENER's reaction (F1). The listener is never frozen:
|
|
264
|
+
* - question → nod (head-tilt / acknowledging the question)
|
|
265
|
+
* - disagreement → react (a stronger recoil / pose change)
|
|
266
|
+
* - emphasis → react (they flinch at the shout)
|
|
267
|
+
* - plain line → nod / idle-attentive (alternated so it's not static)
|
|
268
|
+
*/
|
|
269
|
+
function listenerIntentFor(text: string, listenerOrdinal: number): PerformanceClip {
|
|
270
|
+
if (isDisagreement(text) || isEmphatic(text)) return "react";
|
|
271
|
+
if (isQuestion(text)) return "nod";
|
|
272
|
+
// Plain: alternate nod ↔ react so the listener visibly tracks the conversation.
|
|
273
|
+
return listenerOrdinal % 2 === 0 ? "nod" : "react";
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const BEAT_CAMERA_CYCLE: readonly CameraIntent[] = ["establishing", "two-shot", "medium", "close-up"];
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Build the inspectable beat-by-beat acting plan (F1 + C2) from the scene's dialogue.
|
|
280
|
+
* One beat per dialogue line, in time order. The speaker's intent varies (gesture/
|
|
281
|
+
* point/nod rotate, movement verbs walk); the listener always has a non-idle reaction;
|
|
282
|
+
* each beat carries an estimated duration. Reactions land AFTER the line that triggers
|
|
283
|
+
* them by construction (a beat's listener reacts to that same beat's speaker line).
|
|
284
|
+
*/
|
|
285
|
+
export function directBeats(input: DirectorSceneInput): DirectorBeat[] {
|
|
286
|
+
const estimate = input.durationEstimator ?? estimateBeatDuration;
|
|
287
|
+
const lines = [...input.dialogue].sort((a, b) => a.startTime - b.startTime);
|
|
288
|
+
const cast = input.characters.map((c) => c.id);
|
|
289
|
+
|
|
290
|
+
// Per-speaker counters so emphasis gestures and plain-beat upgrades rotate per speaker.
|
|
291
|
+
const emphasisCount = new Map<string, number>();
|
|
292
|
+
const plainCount = new Map<string, number>();
|
|
293
|
+
let listenerOrdinal = 0;
|
|
294
|
+
|
|
295
|
+
return lines.map((line, index) => {
|
|
296
|
+
const text = line.text ?? "";
|
|
297
|
+
// The listener is the other party in a 1:1; otherwise the first cast member who
|
|
298
|
+
// isn't the speaker (so a non-speaking character always has someone to react to).
|
|
299
|
+
const listener = cast.find((id) => id !== line.speakerId) ?? null;
|
|
300
|
+
|
|
301
|
+
const emphatic = isEmphatic(text) && !hasMovement(text);
|
|
302
|
+
const plain = !emphatic && !hasMovement(text) && !isEmphatic(text);
|
|
303
|
+
const emphasisOrdinal = emphasisCount.get(line.speakerId) ?? 0;
|
|
304
|
+
const plainOrdinal = plainCount.get(line.speakerId) ?? 0;
|
|
305
|
+
|
|
306
|
+
const speakingIntent = speakingIntentFor(text, emphasisOrdinal, plainOrdinal);
|
|
307
|
+
if (emphatic) emphasisCount.set(line.speakerId, emphasisOrdinal + 1);
|
|
308
|
+
if (plain) plainCount.set(line.speakerId, plainOrdinal + 1);
|
|
309
|
+
|
|
310
|
+
const listenerIntent = listenerIntentFor(text, listenerOrdinal);
|
|
311
|
+
listenerOrdinal += 1;
|
|
312
|
+
|
|
313
|
+
const cameraIntent = BEAT_CAMERA_CYCLE[Math.min(index, BEAT_CAMERA_CYCLE.length - 1)]!;
|
|
314
|
+
const durationSeconds = estimate(text);
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
lineId: line.lineId,
|
|
318
|
+
speaker: line.speakerId,
|
|
319
|
+
listener,
|
|
320
|
+
text,
|
|
321
|
+
speakingIntent,
|
|
322
|
+
listenerIntent,
|
|
323
|
+
gesture: speakingIntent,
|
|
324
|
+
cameraIntent,
|
|
325
|
+
durationSeconds
|
|
326
|
+
};
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
// Phase B5 — rig-grade-aware intent restriction
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Body-acting suitability grade for a character's rig — mirrors {@link RigGrade} from
|
|
336
|
+
* `@aura3d/engine`'s `gradeRig`/RigQuality (A full humanoid … D no usable skeleton). We
|
|
337
|
+
* re-declare the union locally (rather than import the engine internal) so the director
|
|
338
|
+
* stays a leaf module, but the values are the SAME contract `gradeRig` reports.
|
|
339
|
+
*
|
|
340
|
+
* - **A/B** — enough body to act → the full performance vocabulary is allowed.
|
|
341
|
+
* - **C** — mascot/sparse (head + torso only, no real limb chains) → no full-limb
|
|
342
|
+
* gestures or locomotion; head/torso acting (nod / react / talk / idle) only.
|
|
343
|
+
* - **D** — not suitable for body acting (root/props only) → talk-only / minimal; any
|
|
344
|
+
* body-acting intent is honestly downgraded to `talk` (or `idle` if it was
|
|
345
|
+
* a non-locomotion non-vocal hold).
|
|
346
|
+
*/
|
|
347
|
+
export type RigGrade = "A" | "B" | "C" | "D";
|
|
348
|
+
|
|
349
|
+
/** Intents that need real limb chains (full arms/legs) — unsafe on a C (mascot) or D rig. */
|
|
350
|
+
const LIMB_INTENTS = new Set<PerformanceClip>(["gesture", "point", "walk", "run"]);
|
|
351
|
+
/** Head/torso-only intents a C (mascot) rig CAN still perform honestly. */
|
|
352
|
+
const HEAD_TORSO_INTENTS = new Set<PerformanceClip>(["idle", "talk", "nod", "react"]);
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Restrict a chosen performance intent to what a character's rig can honestly play, given its
|
|
356
|
+
* body-acting {@link RigGrade}. This is the director's one HONEST gate between the rich acting
|
|
357
|
+
* vocabulary and a rig that physically cannot perform it — a D-grade (no usable skeleton) rig
|
|
358
|
+
* must never be told to `walk`/`gesture`, only to talk or hold.
|
|
359
|
+
*
|
|
360
|
+
* The director consults this whenever a per-character grade is supplied (see
|
|
361
|
+
* {@link directScene}'s `gradesByCharacter` option); with no grade it is a no-op (returns the
|
|
362
|
+
* intent unchanged), so an ungraded pipeline is unaffected.
|
|
363
|
+
*
|
|
364
|
+
* Rules:
|
|
365
|
+
* - **A / B** (or undefined): no restriction — return the intent unchanged.
|
|
366
|
+
* - **C** (mascot / sparse): drop limb work — `gesture`/`point` → `nod` (head acting),
|
|
367
|
+
* `walk`/`run` → `idle` (it can't locomote); head/torso intents pass through.
|
|
368
|
+
* - **D** (no usable skeleton): talk-only / minimal — any vocal-ish intent collapses to
|
|
369
|
+
* `talk`; any pure body intent (gesture/point/walk/run/nod/react) collapses to `idle`.
|
|
370
|
+
*/
|
|
371
|
+
export function gradeAwareIntent(intent: PerformanceClip, grade?: RigGrade): PerformanceClip {
|
|
372
|
+
if (grade == null || grade === "A" || grade === "B") return intent;
|
|
373
|
+
|
|
374
|
+
if (grade === "C") {
|
|
375
|
+
// Mascot: keep head/torso acting; convert limb gestures to a nod, locomotion to a hold.
|
|
376
|
+
if (intent === "gesture" || intent === "point") return "nod";
|
|
377
|
+
if (intent === "walk" || intent === "run") return "idle";
|
|
378
|
+
return HEAD_TORSO_INTENTS.has(intent) ? intent : "idle";
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// grade === "D": not suitable for body acting. Talk-only / minimal — never gesture or walk.
|
|
382
|
+
if (intent === "talk") return "talk";
|
|
383
|
+
// A body-acting intent on a D rig is dishonest; collapse it to a static hold.
|
|
384
|
+
return LIMB_INTENTS.has(intent) || intent === "nod" || intent === "react" ? "idle" : intent;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// Phase F1 — director VALIDATION gate
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
export interface DirectorValidationIssue {
|
|
392
|
+
readonly code: "ALL_LOW_MOTION" | "STATIC_CHARACTER" | "NO_GESTURE" | "NO_REACTION";
|
|
393
|
+
readonly message: string;
|
|
394
|
+
readonly characterId?: string;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export interface DirectorValidationResult {
|
|
398
|
+
readonly ok: boolean;
|
|
399
|
+
readonly issues: readonly DirectorValidationIssue[];
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Intents that count as "low motion" — a scene made only of these reads as dead. */
|
|
403
|
+
const LOW_MOTION = new Set<PerformanceClip>(["idle", "talk"]);
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* The director validation gate (F1). A directed scene FAILS when:
|
|
407
|
+
* - every beat's intents are only idle/talk (the whole scene is low-motion), OR
|
|
408
|
+
* - any character is static — never given a non-idle intent across the whole scene
|
|
409
|
+
* (as speaker OR listener) — unless that character is explicitly `staged` (e.g. a
|
|
410
|
+
* background extra the director deliberately froze).
|
|
411
|
+
* It also flags the absence of any gesture or any listener reaction, which the
|
|
412
|
+
* prompt-to-scene proof asserts must both be present.
|
|
413
|
+
*
|
|
414
|
+
* `stagedStatic` lists character ids that are *allowed* to be motionless on purpose.
|
|
415
|
+
*/
|
|
416
|
+
export function validateDirectedActing(
|
|
417
|
+
beats: readonly DirectorBeat[],
|
|
418
|
+
options: { readonly cast?: readonly string[]; readonly stagedStatic?: readonly string[] } = {}
|
|
419
|
+
): DirectorValidationResult {
|
|
420
|
+
const issues: DirectorValidationIssue[] = [];
|
|
421
|
+
const staged = new Set(options.stagedStatic ?? []);
|
|
422
|
+
|
|
423
|
+
if (beats.length === 0) {
|
|
424
|
+
return { ok: false, issues: [{ code: "ALL_LOW_MOTION", message: "Scene has no acting beats." }] };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// (1) Whole-scene low-motion check: at least one beat must carry a non-idle/talk intent.
|
|
428
|
+
const anyHighMotion = beats.some(
|
|
429
|
+
(b) => !LOW_MOTION.has(b.speakingIntent) || !LOW_MOTION.has(b.listenerIntent)
|
|
430
|
+
);
|
|
431
|
+
if (!anyHighMotion) {
|
|
432
|
+
issues.push({
|
|
433
|
+
code: "ALL_LOW_MOTION",
|
|
434
|
+
message: "Every beat is idle/talk only — no gestures, reactions or movement anywhere in the scene."
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// (2) Per-character static check: each character must get a non-idle intent at least
|
|
439
|
+
// once (speaking or listening) unless explicitly staged as static.
|
|
440
|
+
const nonIdleByCharacter = new Map<string, boolean>();
|
|
441
|
+
const touched = new Set<string>();
|
|
442
|
+
const mark = (id: string | null, intent: PerformanceClip) => {
|
|
443
|
+
if (id == null) return;
|
|
444
|
+
touched.add(id);
|
|
445
|
+
if (intent !== "idle") nonIdleByCharacter.set(id, true);
|
|
446
|
+
else if (!nonIdleByCharacter.has(id)) nonIdleByCharacter.set(id, false);
|
|
447
|
+
};
|
|
448
|
+
for (const b of beats) {
|
|
449
|
+
mark(b.speaker, b.speakingIntent);
|
|
450
|
+
mark(b.listener, b.listenerIntent);
|
|
451
|
+
}
|
|
452
|
+
const cast = options.cast ?? [...touched];
|
|
453
|
+
for (const id of cast) {
|
|
454
|
+
if (staged.has(id)) continue;
|
|
455
|
+
const moved = nonIdleByCharacter.get(id);
|
|
456
|
+
if (moved !== true) {
|
|
457
|
+
issues.push({
|
|
458
|
+
code: "STATIC_CHARACTER",
|
|
459
|
+
characterId: id,
|
|
460
|
+
message: `Character "${id}" is static for the whole scene (no non-idle intent). Stage it explicitly or give it a reaction.`
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// (3) Variety asserts the prompt-to-scene proof depends on.
|
|
466
|
+
const GESTURE_INTENTS = new Set<PerformanceClip>(["gesture", "point", "nod"]);
|
|
467
|
+
if (!beats.some((b) => GESTURE_INTENTS.has(b.speakingIntent))) {
|
|
468
|
+
issues.push({ code: "NO_GESTURE", message: "No speaker ever gestures/points/nods — acting is all talk." });
|
|
469
|
+
}
|
|
470
|
+
const REACTION_INTENTS = new Set<PerformanceClip>(["react", "nod"]);
|
|
471
|
+
if (!beats.some((b) => b.listener != null && REACTION_INTENTS.has(b.listenerIntent))) {
|
|
472
|
+
issues.push({ code: "NO_REACTION", message: "No listener ever reacts — non-speakers are frozen." });
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return { ok: issues.length === 0, issues };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Render a human-readable beat-by-beat director report (for CLI inspection / proof).
|
|
480
|
+
*/
|
|
481
|
+
export function formatDirectorReport(beats: readonly DirectorBeat[]): string {
|
|
482
|
+
const lines: string[] = ["Director beat plan (speaker → listener · camera · intents · duration):"];
|
|
483
|
+
beats.forEach((b, i) => {
|
|
484
|
+
const listener = b.listener ?? "(none)";
|
|
485
|
+
lines.push(
|
|
486
|
+
` ${String(i + 1).padStart(2, "0")}. [${b.cameraIntent}] ${b.speaker} ${b.speakingIntent.toUpperCase()} ` +
|
|
487
|
+
`→ ${listener} ${b.listenerIntent} (${b.durationSeconds.toFixed(2)}s) "${b.text}"`
|
|
488
|
+
);
|
|
489
|
+
});
|
|
490
|
+
return lines.join("\n");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Was this character speaking at any point during the shot window? */
|
|
494
|
+
function isSpeakingDuring(input: DirectorSceneInput, characterId: string, shot: DirectorShot): boolean {
|
|
495
|
+
for (const line of input.dialogue) {
|
|
496
|
+
if (line.speakerId !== characterId) continue;
|
|
497
|
+
const overlap = Math.min(line.endTime, shot.endTime) - Math.max(line.startTime, shot.startTime);
|
|
498
|
+
if (overlap > 0) return true;
|
|
499
|
+
}
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Is there ANY dialogue (from anyone) during the shot window? */
|
|
504
|
+
function hasDialogueDuring(input: DirectorSceneInput, shot: DirectorShot): boolean {
|
|
505
|
+
for (const line of input.dialogue) {
|
|
506
|
+
const overlap = Math.min(line.endTime, shot.endTime) - Math.max(line.startTime, shot.startTime);
|
|
507
|
+
if (overlap > 0) return true;
|
|
508
|
+
}
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Concatenated dialogue text spoken during the shot window (empty if none / untexted). */
|
|
513
|
+
function dialogueTextDuring(input: DirectorSceneInput, shot: DirectorShot): string {
|
|
514
|
+
const parts: string[] = [];
|
|
515
|
+
for (const line of input.dialogue) {
|
|
516
|
+
const overlap = Math.min(line.endTime, shot.endTime) - Math.max(line.startTime, shot.startTime);
|
|
517
|
+
if (overlap > 0 && line.text) parts.push(line.text);
|
|
518
|
+
}
|
|
519
|
+
return parts.join(" ");
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Pick walk vs run from the planar travel distance between two marks. */
|
|
523
|
+
function locomotionClip(from: Vec3, to: Vec3): PerformanceClip {
|
|
524
|
+
const dist = Math.hypot(to[0] - from[0], to[2] - from[2]);
|
|
525
|
+
return dist >= RUN_DISTANCE ? "run" : "walk";
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** The character who speaks most during a shot (the shot's focus), or the first cast member. */
|
|
529
|
+
function shotSpeaker(input: DirectorSceneInput, shot: DirectorShot): string {
|
|
530
|
+
const tally = new Map<string, number>();
|
|
531
|
+
for (const line of input.dialogue) {
|
|
532
|
+
const overlap = Math.min(line.endTime, shot.endTime) - Math.max(line.startTime, shot.startTime);
|
|
533
|
+
if (overlap > 0) tally.set(line.speakerId, (tally.get(line.speakerId) ?? 0) + overlap);
|
|
534
|
+
}
|
|
535
|
+
let best = input.characters[0]?.id ?? "";
|
|
536
|
+
let bestV = -1;
|
|
537
|
+
for (const [id, v] of tally) if (v > bestV) { bestV = v; best = id; }
|
|
538
|
+
return best;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Stage a 1–2 character dialogue scene. Characters take base marks at conversation
|
|
543
|
+
* distance facing each other; cameras cycle establishing → two-shot → close-up (the
|
|
544
|
+
* close-up frames the active speaker); props scatter over the walkable bounds.
|
|
545
|
+
*/
|
|
546
|
+
export function directScene(sceneId: string, input: DirectorSceneInput): DirectedScene {
|
|
547
|
+
const cast = input.characters;
|
|
548
|
+
const { min, max } = input.walkableBounds;
|
|
549
|
+
const cx = (min[0] + max[0]) / 2;
|
|
550
|
+
const cz = (min[2] + max[2]) / 2;
|
|
551
|
+
|
|
552
|
+
// Base marks: spread the cast left→right across ~26% of the walkable width, facing center.
|
|
553
|
+
// Tighter than before (was 40%) so a 2-shot frames the cast LARGE instead of two small figures
|
|
554
|
+
// marooned in a wide empty plate.
|
|
555
|
+
const span = (max[0] - min[0]) * 0.26;
|
|
556
|
+
const baseMark = (i: number): Vec3 => {
|
|
557
|
+
if (cast.length === 1) return [cx, 0, cz];
|
|
558
|
+
const t = cast.length === 1 ? 0.5 : i / (cast.length - 1);
|
|
559
|
+
return [cx + (t - 0.5) * span, 0, cz];
|
|
560
|
+
};
|
|
561
|
+
const center: Vec3 = [cx, 0, cz];
|
|
562
|
+
|
|
563
|
+
// Camera presets cycle establishing → two-shot → medium. We DELIBERATELY avoid the tight
|
|
564
|
+
// "close-up": its camera offset is fixed and does not know which way the character faces,
|
|
565
|
+
// so for a face-off (characters turned to ±X) it frames their side/back. A stable two-shot
|
|
566
|
+
// / medium frames BOTH characters from the front at conversation height — never a body part.
|
|
567
|
+
// Open WIDE to show the place, then push to the tighter MEDIUM for the conversation so the cast
|
|
568
|
+
// fills the frame rather than sitting small under a big empty backdrop.
|
|
569
|
+
const presets: CameraPresetId[] = ["establishing", "medium", "medium"];
|
|
570
|
+
|
|
571
|
+
const shots: ShotSpec[] = input.shots.map((shot, index) => {
|
|
572
|
+
const presetId = presets[Math.min(index, presets.length - 1)]!;
|
|
573
|
+
// Frame the conversation midpoint at GROUND level — the preset itself supplies the camera
|
|
574
|
+
// height + head-height target (~1.25m), which createCameraPathFromPreset adds to this. So
|
|
575
|
+
// pass the ground point; passing a raised Y would double the height and frame over the head.
|
|
576
|
+
const cameraSubject: Vec3 = [cx, 0, cz];
|
|
577
|
+
return { shotId: shot.shotId, presetId, startTime: shot.startTime, endTime: shot.endTime, cameraSubject };
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
const blocking: CharacterBlocking[] = cast.map((character, i) => {
|
|
581
|
+
const mark = baseMark(i);
|
|
582
|
+
const faceCenter = yawFacing(mark, center);
|
|
583
|
+
// Per-character rotation counters so a speaker's emphasis/plain gestures vary across
|
|
584
|
+
// shots (F1: not all low-motion talk) and a listener's reactions alternate.
|
|
585
|
+
let shotEmphasis = 0;
|
|
586
|
+
let shotPlain = 0;
|
|
587
|
+
let shotListener = 0;
|
|
588
|
+
const beats: ShotBlocking[] = input.shots.map((shot, index) => {
|
|
589
|
+
// Opening shot: optional walk-in from the side the character enters from.
|
|
590
|
+
if (index === 0 && character.entersFrom && character.entersFrom !== "none") {
|
|
591
|
+
const offX = character.entersFrom === "left" ? min[0] - 0.5 : max[0] + 0.5;
|
|
592
|
+
const enterStart: Vec3 = [offX, 0, mark[2]];
|
|
593
|
+
const enterDur = Math.min(shot.endTime - shot.startTime, (input.duration) * 0.12);
|
|
594
|
+
return {
|
|
595
|
+
// Entering = traversing between marks → walk, or run for a long entrance.
|
|
596
|
+
shotId: shot.shotId,
|
|
597
|
+
clip: locomotionClip(enterStart, mark),
|
|
598
|
+
waypoints: [
|
|
599
|
+
{ time: shot.startTime, position: enterStart, yaw: yawFacing(enterStart, mark) },
|
|
600
|
+
{ time: shot.startTime + enterDur, position: mark, yaw: faceCenter }
|
|
601
|
+
]
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
// Middle beats: traverse toward the focus ONLY when that is a REAL walk — a meaningful
|
|
605
|
+
// distance covered over the shot (≥ ~0.35 m/s). A small converge over a long shot is an
|
|
606
|
+
// imperceptible drift (e.g. 0.65m over 8s ≈ 0.08 m/s) that reads as "walking in place while
|
|
607
|
+
// standing still". Below that speed the character HOLDS its mark and PERFORMS instead (it
|
|
608
|
+
// falls through to the speaker/listener acting logic below) — so we never generate a
|
|
609
|
+
// meaningless micro-traverse. Real repositioning (entrance, or a movement-verb line) still walks.
|
|
610
|
+
const isMiddle = index > 0 && index < input.shots.length - 1;
|
|
611
|
+
if (isMiddle) {
|
|
612
|
+
const converge: Vec3 = [mark[0] + (cx - mark[0]) * (index === 1 ? 0.45 : 0.25), 0, mark[2] + 0.25];
|
|
613
|
+
const traverseDist = Math.hypot(converge[0] - mark[0], converge[2] - mark[2]);
|
|
614
|
+
const shotDur = Math.max(1e-3, shot.endTime - shot.startTime);
|
|
615
|
+
if (traverseDist / shotDur >= 0.35) {
|
|
616
|
+
return {
|
|
617
|
+
// Traversing between marks at a real walking speed → walk (or run for a large move).
|
|
618
|
+
shotId: shot.shotId,
|
|
619
|
+
clip: locomotionClip(mark, converge),
|
|
620
|
+
sweeping: i === 0,
|
|
621
|
+
waypoints: [
|
|
622
|
+
{ time: shot.startTime, position: mark, yaw: yawFacing(mark, converge) },
|
|
623
|
+
{ time: shot.endTime, position: converge, yaw: yawFacing(converge, center) }
|
|
624
|
+
]
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
// else: fall through to hold-mark + perform (no pointless drift / walk-in-place).
|
|
628
|
+
}
|
|
629
|
+
// First/last beats: hold the mark, but FACE the active speaker (look-at / reaction).
|
|
630
|
+
// The CLIP now expresses the beat's ROLE from the standard vocabulary so a retargeted
|
|
631
|
+
// clip library can play it:
|
|
632
|
+
// - active speaker → `talk`, upgraded to `gesture` on the emphasis/close beat.
|
|
633
|
+
// - addressed non-speaker (someone IS speaking, just not them) → `nod` on the close
|
|
634
|
+
// (active listening) else `react`.
|
|
635
|
+
// - nobody speaking (or solo, silent) → `idle`.
|
|
636
|
+
const thisShot = input.shots[index]!;
|
|
637
|
+
const speaker = shotSpeaker(input, thisShot);
|
|
638
|
+
const spkIdx = Math.max(0, cast.findIndex((c) => c.id === speaker));
|
|
639
|
+
const spkMark = baseMark(spkIdx);
|
|
640
|
+
const facing = i === spkIdx ? faceCenter : yawFacing(mark, spkMark);
|
|
641
|
+
const isClose = index === input.shots.length - 1;
|
|
642
|
+
const speakingNow = isSpeakingDuring(input, character.id, thisShot);
|
|
643
|
+
const anyDialogue = hasDialogueDuring(input, thisShot);
|
|
644
|
+
// The line(s) spoken during this shot — drives the acting rules when text is present.
|
|
645
|
+
const shotText = dialogueTextDuring(input, thisShot);
|
|
646
|
+
|
|
647
|
+
let beatClip: PerformanceClip;
|
|
648
|
+
if (speakingNow) {
|
|
649
|
+
// Active speaker. With dialogue TEXT, the acting rules pick the intent
|
|
650
|
+
// (emphasis → gesture/point, movement → walk/run, else talk); on the close-up
|
|
651
|
+
// a plain hold is still emphasised to a gesture so it's never a flat lip-flap.
|
|
652
|
+
if (shotText) {
|
|
653
|
+
const acted = speakingIntentFor(shotText, shotEmphasis, shotPlain);
|
|
654
|
+
if (acted === "talk") {
|
|
655
|
+
// Plain hold: emphasise on the close-up so it's never a flat lip-flap.
|
|
656
|
+
beatClip = isClose ? "gesture" : "talk";
|
|
657
|
+
shotPlain += 1;
|
|
658
|
+
} else {
|
|
659
|
+
beatClip = acted;
|
|
660
|
+
shotEmphasis += 1;
|
|
661
|
+
}
|
|
662
|
+
} else {
|
|
663
|
+
beatClip = isClose ? "gesture" : "talk";
|
|
664
|
+
}
|
|
665
|
+
} else if (anyDialogue) {
|
|
666
|
+
// Being addressed. With text, react/nod follows the rules (disagreement/emphasis →
|
|
667
|
+
// stronger react, question → nod); otherwise settle into a nod on the close-up.
|
|
668
|
+
if (shotText) {
|
|
669
|
+
beatClip = listenerIntentFor(shotText, shotListener++);
|
|
670
|
+
} else {
|
|
671
|
+
beatClip = isClose ? "nod" : "react";
|
|
672
|
+
}
|
|
673
|
+
} else {
|
|
674
|
+
beatClip = "idle";
|
|
675
|
+
}
|
|
676
|
+
// B5: honestly restrict the chosen intent to what THIS character's rig can play. With no
|
|
677
|
+
// grade supplied this is a no-op; a D-grade rig never holds a gesture/walk it can't perform.
|
|
678
|
+
beatClip = gradeAwareIntent(beatClip, input.gradesByCharacter?.[character.id]);
|
|
679
|
+
return { shotId: shot.shotId, clip: beatClip, waypoints: [{ time: shot.startTime, position: mark, yaw: facing }] };
|
|
680
|
+
});
|
|
681
|
+
return { characterId: character.id, shots: beats };
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
// Deterministic prop scatter over the walkable bounds (seeded by scene id).
|
|
685
|
+
const rand = mulberry32(hashSeed(sceneId));
|
|
686
|
+
const setDressing: PropPlacement[] = [];
|
|
687
|
+
for (const prop of input.props) {
|
|
688
|
+
for (let n = 0; n < prop.count; n += 1) {
|
|
689
|
+
const x = min[0] + rand() * (max[0] - min[0]);
|
|
690
|
+
const z = min[2] + rand() * (max[2] - min[2]);
|
|
691
|
+
const scale = prop.scaleRange[0] + rand() * (prop.scaleRange[1] - prop.scaleRange[0]);
|
|
692
|
+
setDressing.push({ propId: prop.propId, position: [x, 0, z], scale, feetOffset: prop.feetOffset });
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return { shots, blocking, setDressing, worldState: { glowSpanSeconds: input.duration } };
|
|
697
|
+
}
|