cozyclay 1.2.0 → 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.
Files changed (72) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +31 -0
  3. package/THIRD_PARTY_NOTICES.md +37 -1
  4. package/bin/cozyclay.mjs +51 -2
  5. package/dist/ai-camera-control/index.html +406 -0
  6. package/dist/app/index.html +5 -5
  7. package/dist/assets/app-B3U5aut1.js +4811 -0
  8. package/dist/assets/app-BrRF0wso.css +1 -0
  9. package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
  10. package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
  11. package/dist/fonts/Inter-OFL.txt +92 -0
  12. package/dist/fonts/README.md +15 -0
  13. package/dist/index.html +60 -16
  14. package/dist/sitemap.xml +7 -1
  15. package/mcp/LIVE-PROTOCOL.md +63 -0
  16. package/mcp/README.md +142 -0
  17. package/mcp/ardy-prompts.mjs +170 -0
  18. package/mcp/live-hub.mjs +105 -0
  19. package/mcp/package.json +24 -0
  20. package/mcp/server.mjs +1394 -0
  21. package/package.json +122 -90
  22. package/src/App.jsx +2957 -514
  23. package/src/ardy/cskel27.js +7 -2
  24. package/src/ardy/ik.js +25 -15
  25. package/src/ardy/npz.js +64 -3
  26. package/src/ardy/playback.js +31 -1
  27. package/src/ardy/prompt-clips.js +7 -2
  28. package/src/ardy/retime.js +211 -0
  29. package/src/ardy/timeline-coordinates.js +13 -0
  30. package/src/ardy/timeline.jsx +124 -5
  31. package/src/ardy/to-cskel27.js +34 -12
  32. package/src/ardy/trim.js +33 -0
  33. package/src/asset-pane.jsx +36 -0
  34. package/src/dualview.jsx +14 -8
  35. package/src/hierarchy-model.js +95 -13
  36. package/src/hierarchy-panel.jsx +139 -6
  37. package/src/live-control.js +122 -0
  38. package/src/matte-editor.js +543 -0
  39. package/src/matte.js +503 -0
  40. package/src/multimodel-ingest.js +344 -0
  41. package/src/object-gizmo.jsx +43 -15
  42. package/src/planview.jsx +43 -32
  43. package/src/pose-extract/detector.js +75 -0
  44. package/src/pose-extract/index.js +3 -0
  45. package/src/pose-extract/take.js +87 -0
  46. package/src/pose-extract/video-frames.js +91 -0
  47. package/src/pose-thumbs.js +152 -0
  48. package/src/posestudio.jsx +361 -11
  49. package/src/project-browser.jsx +135 -0
  50. package/src/project.js +289 -0
  51. package/src/props.jsx +69 -3
  52. package/src/room.jsx +14 -35
  53. package/src/scene-asset-cache.js +125 -0
  54. package/src/scene-assets.js +288 -0
  55. package/src/scene-objects.js +245 -7
  56. package/src/scenes.js +207 -26
  57. package/src/shot-authoring.js +55 -13
  58. package/src/styles.css +1296 -129
  59. package/tools/ardy/BRIDGE.md +3 -2
  60. package/tools/ardy/README.md +9 -5
  61. package/tools/ardy/bridge.mjs +57 -1
  62. package/tools/ardy/bvh-cskel27.mjs +1209 -0
  63. package/tools/ardy/cclay_constrained_generate.py +123 -11
  64. package/tools/ardy/cclay_sequence_generate.py +49 -0
  65. package/tools/ardy/extract.mjs +367 -0
  66. package/tools/ardy/footage.mjs +462 -0
  67. package/tools/ardy/npz.mjs +74 -9
  68. package/tools/ardy/run-on-box.sh +25 -0
  69. package/tools/ardy/run-sequence-on-box.sh +15 -0
  70. package/tools/ardy/runners/remote.mjs +8 -2
  71. package/dist/assets/app-Cgpk2hwX.js +0 -4803
  72. package/dist/assets/app-DgZvaAE1.css +0 -1
package/src/scenes.js CHANGED
@@ -2,21 +2,43 @@
2
2
  // A Scene is the set; shotDocument and stage are sealed department envelopes.
3
3
  // This module stores and copies those envelopes but never opens or validates them.
4
4
 
5
- export const SCENES_VERSION = 2;
6
- export const SCENES_STORAGE_KEY = "cozyclay.scenes.v2";
7
- export const SCENES_QUARANTINE_KEY = "cozyclay.scenes.v2.quarantine";
8
- export const PREVIOUS_SCENES_STORAGE_KEY = "cozyclay.scenes.v1";
5
+ export const SCENES_VERSION = 4;
6
+ export const SCENES_STORAGE_KEY = "cozyclay.scenes.v4";
7
+ export const SCENES_QUARANTINE_KEY = "cozyclay.scenes.v4.quarantine";
8
+ export const PREVIOUS_SCENES_STORAGE_KEY = "cozyclay.scenes.v3";
9
+ export const V2_SCENES_STORAGE_KEY = "cozyclay.scenes.v2";
10
+ export const V1_SCENES_STORAGE_KEY = "cozyclay.scenes.v1";
9
11
  export const LEGACY_SCENE_STORAGE_KEY = "cozyclay.scene.v1";
12
+ /** Newest-first fallback chain: a user arriving from any older build still
13
+ * finds their scenes, and the reader migrates whatever body it lands on. */
14
+ export const LEGACY_SCENES_STORAGE_KEYS = Object.freeze([
15
+ PREVIOUS_SCENES_STORAGE_KEY,
16
+ V2_SCENES_STORAGE_KEY,
17
+ V1_SCENES_STORAGE_KEY,
18
+ ]);
19
+
20
+ /** v3 and older authored every frame number on ARDY's 20 fps clock; v4 reads
21
+ * them on the 24 fps production clock. The numbers are MULTIPLIED, never
22
+ * reinterpreted: a waypoint at frame 40 meant 2.0 s and must still mean 2.0 s,
23
+ * which is frame 48 — reinterpreting it would silently speed the scene up. */
24
+ export const LEGACY_FRAME_FPS = 20;
25
+ export const TIMELINE_FRAME_FPS = 24;
26
+ export const toTimelineFrame = (frame) =>
27
+ Math.round((frame * TIMELINE_FRAME_FPS) / LEGACY_FRAME_FPS);
28
+
29
+ /** Rigged character models shipped in public/models. The id is both the FBX
30
+ * file stem and the wire `source.rig` value sent to ARDY. */
31
+ export const CHARACTER_MODEL_IDS = Object.freeze(["y-bot-tpose", "x-bot-tpose"]);
32
+ export const DEFAULT_CHARACTER_MODEL = "y-bot-tpose";
33
+ export const DEFAULT_SUBJECT_ONE = "a young woman in a tan coat";
34
+ export const DEFAULT_SUBJECT_TWO = "a man in a dark coat";
10
35
 
11
36
  export const DEFAULT_SCENE_STAGE = Object.freeze({
12
- charA: Object.freeze({ x: 0, z: 0, rot: 0 }),
13
- charB: Object.freeze({ x: 1.15, z: 0.1, rot: -14 }),
14
- showB: false,
15
- poseA: null,
16
- poseB: null,
37
+ characters: Object.freeze([
38
+ Object.freeze({ id: "char-a", model: DEFAULT_CHARACTER_MODEL, x: 0, z: 0, rot: 0, hidden: false, pose: null, subject: DEFAULT_SUBJECT_ONE }),
39
+ ]),
17
40
  hasCharSheet: false,
18
- subject: "a young woman in a tan coat",
19
- subject2: "a man in a dark coat",
41
+ shotAspect: "16:9",
20
42
  });
21
43
 
22
44
  let sceneSequence = 1;
@@ -49,17 +71,167 @@ function cloneValue(value, copies = new WeakMap()) {
49
71
  return copy;
50
72
  }
51
73
 
74
+ const finiteOr = (value, fallback) => (Number.isFinite(value) ? value : fallback);
75
+
76
+ /** Stature band for a cast member. Wider than ardy/npz.js's mocap band
77
+ * (0.6-1.5, a sanity clamp on ESTIMATED statures): the gizmo's scale handles
78
+ * are a deliberate artistic ask, and a previs giant or child is legitimate. */
79
+ export const CHARACTER_SCALE_MIN = 0.2;
80
+ export const CHARACTER_SCALE_MAX = 3;
81
+ const clampScale = (value) => (Number.isFinite(value) && value > 0
82
+ ? Math.max(CHARACTER_SCALE_MIN, Math.min(CHARACTER_SCALE_MAX, value))
83
+ : 1);
84
+
85
+ /** Where an extra extraction take's performer stands: the filmed offset from
86
+ * person 0, rotated into the ACTIVE character's facing. Pure geometry, so the
87
+ * placement can be proven without a renderer. */
88
+ export function takeAnchor(active, offsetX, offsetZ) {
89
+ const rad = (finiteOr(active?.rot, 0) * Math.PI) / 180;
90
+ const dx = finiteOr(offsetX, 0);
91
+ const dz = finiteOr(offsetZ, 0);
92
+ return {
93
+ x: finiteOr(active?.x, 0) + dx * Math.cos(rad) + dz * Math.sin(rad),
94
+ z: finiteOr(active?.z, 0) - dx * Math.sin(rad) + dz * Math.cos(rad),
95
+ };
96
+ }
97
+
98
+ /** One character entry in the stage envelope. `source` may be a v3 entry, a
99
+ * partial (spawn dialog), or null — every field falls back to a sane default
100
+ * and every object is freshly owned by the caller. */
101
+ export function createCharacterEntry(source = null, index = 0) {
102
+ const s = plainObject(source) ? source : {};
103
+ return {
104
+ id: typeof s.id === "string" && s.id ? s.id : `char-${index + 1}`,
105
+ model: CHARACTER_MODEL_IDS.includes(s.model) ? s.model : DEFAULT_CHARACTER_MODEL,
106
+ x: finiteOr(s.x, 0),
107
+ y: finiteOr(s.y, 0),
108
+ z: finiteOr(s.z, 0),
109
+ rot: finiteOr(s.rot, 0),
110
+ hidden: s.hidden === true,
111
+ // User-picked body tint; null means "model default" (y-bot clay, x-bot
112
+ // whiter clay) so the entry survives future default tweaks.
113
+ tint: typeof s.tint === "string" && /^#[0-9a-fA-F]{6}$/.test(s.tint) ? s.tint : null,
114
+ pose: plainObject(s.pose) ? cloneValue(s.pose) : null,
115
+ // Stature multiplier: 1 is the canonical body, an extracted take carries
116
+ // the FILMED person's leg ratio. It persists with the entry because the
117
+ // take's root travel was authored against it — see the render path.
118
+ scale: clampScale(s.scale),
119
+ subject: typeof s.subject === "string" ? s.subject : index === 0 ? DEFAULT_SUBJECT_ONE : DEFAULT_SUBJECT_TWO,
120
+ // The character's animation layer: its own root path and prompt-block
121
+ // schedule. The generated clip itself is heavy, so only a lightweight
122
+ // reference (bridge URL + generation params) is persisted — enough to
123
+ // re-fetch and decode it on the next session.
124
+ layer: normalizeLayer(s.layer),
125
+ motionRef: normalizeMotionRef(s.motionRef),
126
+ };
127
+ }
128
+
129
+ function normalizeMotionRef(ref) {
130
+ if (!plainObject(ref) || typeof ref.url !== "string" || !ref.url) return null;
131
+ return {
132
+ url: ref.url,
133
+ prompt: typeof ref.prompt === "string" ? ref.prompt : "",
134
+ rotationDeg: Number.isFinite(ref.rotationDeg) ? ref.rotationDeg : 0,
135
+ anchorX: Number.isFinite(ref.anchorX) ? ref.anchorX : 0,
136
+ anchorZ: Number.isFinite(ref.anchorZ) ? ref.anchorZ : 0,
137
+ };
138
+ }
139
+
140
+ function normalizeLayer(layer) {
141
+ const source = plainObject(layer) ? layer : {};
142
+ return {
143
+ waypoints: Array.isArray(source.waypoints) ? cloneValue(source.waypoints) : [],
144
+ promptClips: Array.isArray(source.promptClips) ? cloneValue(source.promptClips) : [],
145
+ };
146
+ }
147
+
148
+ export function createCharacterLayer() {
149
+ return { waypoints: [], promptClips: [] };
150
+ }
151
+
152
+ /** Retime one animation layer from the 20 fps authoring clock onto the 24 fps
153
+ * production clock. Frame numbers only: positions, headings and prompt text
154
+ * are untouched. The mapping is strictly increasing, so ascending order and
155
+ * the gaps between prompt blocks survive it — the guards below re-establish
156
+ * both anyway, because a hand-edited body may never have had them. Prompt
157
+ * ranges are half-open [start, end), matching movePromptClipFrames: touching
158
+ * blocks are legal, overlapping ones are not. */
159
+ function migrateLayerFrames(layer) {
160
+ if (!plainObject(layer)) return layer;
161
+ const next = { ...cloneValue(layer) };
162
+ if (Array.isArray(layer.waypoints)) {
163
+ const byFrame = new Map();
164
+ for (const waypoint of layer.waypoints) {
165
+ if (!plainObject(waypoint) || !Number.isFinite(waypoint.frame)) continue;
166
+ const frame = Math.max(0, toTimelineFrame(Math.round(waypoint.frame)));
167
+ byFrame.set(frame, { ...cloneValue(waypoint), frame });
168
+ }
169
+ next.waypoints = [...byFrame.values()].sort((a, b) => a.frame - b.frame);
170
+ }
171
+ if (Array.isArray(layer.promptClips)) {
172
+ const ordered = layer.promptClips
173
+ .filter((clip) => plainObject(clip) && Number.isFinite(clip.startFrame) && Number.isFinite(clip.endFrame))
174
+ .sort((a, b) => a.startFrame - b.startFrame);
175
+ const clips = [];
176
+ let floor = 0;
177
+ for (const clip of ordered) {
178
+ const startFrame = Math.max(floor, toTimelineFrame(Math.round(clip.startFrame)));
179
+ const endFrame = Math.max(startFrame, toTimelineFrame(Math.round(clip.endFrame)));
180
+ clips.push({ ...cloneValue(clip), startFrame, endFrame });
181
+ floor = endFrame;
182
+ }
183
+ next.promptClips = clips;
184
+ }
185
+ return next;
186
+ }
187
+
188
+ /** v3 → v4 for one stage envelope: every frame-bearing number in every cast
189
+ * member's layer moves onto the production clock. Exported because a project
190
+ * FILE carries its own scene document and needs the same migration. */
191
+ export function migrateStageFrames(stage) {
192
+ if (!plainObject(stage) || !Array.isArray(stage.characters)) return stage;
193
+ return {
194
+ ...stage,
195
+ characters: stage.characters.map((entry) => (plainObject(entry) && plainObject(entry.layer)
196
+ ? { ...entry, layer: migrateLayerFrames(entry.layer) }
197
+ : entry)),
198
+ };
199
+ }
200
+
201
+ /** Stage envelopes before v3 stored a fixed cast (charA/charB/showB/poseA/
202
+ * poseB/subject/subject2). Fold that cast into the characters list so the
203
+ * rest of the app only ever sees one shape. */
204
+ function migrateLegacyCast(source) {
205
+ const cast = [createCharacterEntry({ ...plainObject(source.charA) ? source.charA : {}, id: "char-a", pose: source.poseA ?? null, subject: source.subject ?? DEFAULT_SUBJECT_ONE }, 0)];
206
+ if (source.showB === true) {
207
+ cast.push(createCharacterEntry({ ...plainObject(source.charB) ? source.charB : {}, id: "char-b", pose: source.poseB ?? null, subject: source.subject2 ?? DEFAULT_SUBJECT_TWO }, 1));
208
+ }
209
+ return cast;
210
+ }
211
+
212
+ const STAGE_ENVELOPE_KEYS = new Set([
213
+ "characters", "hasCharSheet", "shotAspect",
214
+ "charA", "charB", "showB", "poseA", "poseB", "subject", "subject2",
215
+ ]);
216
+
52
217
  export function createSceneStage(stage = null) {
53
218
  const source = plainObject(stage) ? stage : {};
219
+ const characters = Array.isArray(source.characters) && source.characters.length
220
+ ? source.characters.map((entry, index) => createCharacterEntry(entry, index))
221
+ : migrateLegacyCast(source);
222
+ // Unknown extra keys (shotAspect was one, once) ride along so the envelope
223
+ // stays sealed-but-lossless; legacy cast keys are deliberately dropped.
224
+ const extras = {};
225
+ for (const [key, value] of Object.entries(source)) {
226
+ if (!STAGE_ENVELOPE_KEYS.has(key)) extras[key] = cloneValue(value);
227
+ }
54
228
  return {
55
- ...cloneValue(DEFAULT_SCENE_STAGE),
56
- ...cloneValue(source),
57
- charA: { ...cloneValue(DEFAULT_SCENE_STAGE.charA), ...cloneValue(plainObject(source.charA) ? source.charA : {}) },
58
- charB: { ...cloneValue(DEFAULT_SCENE_STAGE.charB), ...cloneValue(plainObject(source.charB) ? source.charB : {}) },
59
- showB: source.showB === true,
229
+ ...extras,
230
+ characters,
60
231
  hasCharSheet: source.hasCharSheet === true,
61
- subject: typeof source.subject === "string" ? source.subject : DEFAULT_SCENE_STAGE.subject,
62
- subject2: typeof source.subject2 === "string" ? source.subject2 : DEFAULT_SCENE_STAGE.subject2,
232
+ shotAspect: ["16:9", "9:16", "1:1", "4:3"].includes(source.shotAspect)
233
+ ? source.shotAspect
234
+ : DEFAULT_SCENE_STAGE.shotAspect,
63
235
  };
64
236
  }
65
237
 
@@ -139,23 +311,26 @@ export function createSceneDocument() {
139
311
  return { version: SCENES_VERSION, activeSceneId: scene.id, scenes: [scene] };
140
312
  }
141
313
 
142
- function repairScene(record, existing, fallbackNumber, fallbackStage) {
314
+ function repairScene(record, existing, fallbackNumber, fallbackStage, legacyClock = false) {
143
315
  if (!plainObject(record) || typeof record.id !== "string" || !record.id || !Array.isArray(record.objects)) return null;
144
316
  if (existing.some((scene) => scene.id === record.id)) return null;
317
+ const stage = cloneValue(plainObject(record.stage) ? record.stage : fallbackStage);
145
318
  return {
146
319
  id: record.id,
147
320
  name: uniqueName(typeof record.name === "string" ? record.name : `SCENE ${String(fallbackNumber).padStart(2, "0")}`, existing),
148
321
  objects: record.objects.filter(plainObject).map((object) => cloneValue(object)),
322
+ // The shot document is a sealed envelope with its OWN version, and it
323
+ // migrates its own frames when its reader opens it.
149
324
  shotDocument: cloneValue(record.shotDocument ?? null),
150
- stage: cloneValue(plainObject(record.stage) ? record.stage : fallbackStage),
325
+ stage: legacyClock ? migrateStageFrames(stage) : stage,
151
326
  };
152
327
  }
153
328
 
154
- function repairDocument(payload, fallbackStage = DEFAULT_SCENE_STAGE) {
329
+ function repairDocument(payload, fallbackStage = DEFAULT_SCENE_STAGE, legacyClock = false) {
155
330
  const scenes = [];
156
331
  let dropped = 0;
157
332
  for (const record of payload.scenes) {
158
- const scene = repairScene(record, scenes, scenes.length + 1, fallbackStage);
333
+ const scene = repairScene(record, scenes, scenes.length + 1, fallbackStage, legacyClock);
159
334
  if (scene) scenes.push(scene);
160
335
  else dropped += 1;
161
336
  }
@@ -197,7 +372,11 @@ export function readSceneDocument(raw, legacyRaw = null) {
197
372
  return { status: "corrupt", document: createSceneDocument(), dropped: 0, quarantineRaw: raw };
198
373
  }
199
374
  const migrating = payload.version < SCENES_VERSION;
200
- const repaired = repairDocument(payload, createSceneStage(payload.stage));
375
+ // Anything below v4 was authored while the timeline ran at 20 fps. The
376
+ // fallback stage (a v1 global cast) rides the same conversion, applied
377
+ // once, inside repairScene — whichever stage that scene ends up with.
378
+ const legacyClock = payload.version < 4;
379
+ const repaired = repairDocument(payload, createSceneStage(payload.stage), legacyClock);
201
380
  return { status: migrating ? "migrated" : "valid", ...repaired };
202
381
  }
203
382
 
@@ -208,9 +387,11 @@ export function serializeSceneDocument(document) {
208
387
  /** Storage adapter: quarantine corrupt bytes, persist successful migration,
209
388
  * and never write over a future document. The legacy key remains as a backup. */
210
389
  export function loadSceneDocumentFromStorage(storage) {
211
- const currentRaw = storage.getItem(SCENES_STORAGE_KEY);
212
- const previousRaw = currentRaw ? null : storage.getItem(PREVIOUS_SCENES_STORAGE_KEY);
213
- const raw = currentRaw || previousRaw;
390
+ let raw = storage.getItem(SCENES_STORAGE_KEY);
391
+ for (const key of LEGACY_SCENES_STORAGE_KEYS) {
392
+ if (raw) break;
393
+ raw = storage.getItem(key);
394
+ }
214
395
  const legacyRaw = raw ? null : storage.getItem(LEGACY_SCENE_STORAGE_KEY);
215
396
  const result = readSceneDocument(raw, legacyRaw);
216
397
  if (result.status === "corrupt" && result.quarantineRaw !== undefined) {
@@ -6,24 +6,62 @@
6
6
 
7
7
  import { createShot } from "./cuts.js";
8
8
 
9
- export const SHOT_AUTHORING_VERSION = 3;
10
- export const SHOT_AUTHORING_KEY = "cozyclay.shot-authoring.v3";
9
+ export const SHOT_AUTHORING_VERSION = 4;
10
+ export const SHOT_AUTHORING_KEY = "cozyclay.shot-authoring.v4";
11
11
  // The single-key alias points at the newest legacy body for older callers.
12
12
  // New readers should walk the list so a user can still arrive directly from v1.
13
- export const SHOT_AUTHORING_LEGACY_KEY = "cozyclay.shot-authoring.v2";
13
+ export const SHOT_AUTHORING_LEGACY_KEY = "cozyclay.shot-authoring.v3";
14
14
  export const SHOT_AUTHORING_LEGACY_KEYS = Object.freeze([
15
15
  SHOT_AUTHORING_LEGACY_KEY,
16
+ "cozyclay.shot-authoring.v2",
16
17
  "cozyclay.shot-authoring.v1",
17
18
  ]);
18
- export const SHOT_AUTHORING_QUARANTINE_KEY = "cozyclay.shot-authoring.v3.quarantine";
19
+ export const SHOT_AUTHORING_QUARANTINE_KEY = "cozyclay.shot-authoring.v4.quarantine";
19
20
 
20
- /** clip length sanity bounds, frames @ 20 fps: 1 s .. 20 min */
21
- const FRAME_COUNT_MIN = 20;
22
- const FRAME_COUNT_MAX = 24000;
23
- const DEFAULT_FRAME_COUNT = 120;
21
+ /** clip length sanity bounds, frames @ 24 fps: 1 s .. 20 min */
22
+ const FRAME_COUNT_MIN = 24;
23
+ const FRAME_COUNT_MAX = 28800;
24
+ const DEFAULT_FRAME_COUNT = 144;
24
25
  const RAIL_MAX_POINTS = 512;
25
26
  const finite = Number.isFinite;
26
27
 
28
+ /** v3 and older bodies counted frames on ARDY's 20 fps clock; v4 counts them
29
+ * on the 24 fps production clock. Every frame-bearing number is multiplied so
30
+ * a saved roll keeps its DURATION: a 300-frame clip was 15 s and stays 15 s as
31
+ * 360 frames. Reinterpreting instead would silently shorten it to 12.5 s. */
32
+ const LEGACY_FRAME_FPS = 20;
33
+ const TIMELINE_FRAME_FPS = 24;
34
+ const toTimelineFrame = (frame) => Math.round((frame * TIMELINE_FRAME_FPS) / LEGACY_FRAME_FPS);
35
+ const rescaleFrame = (value) => (finite(value) ? toTimelineFrame(value) : value);
36
+ const rescaleKeys = (entries) => (Array.isArray(entries)
37
+ ? entries.map((key) => (key && typeof key === "object" ? { ...key, frame: rescaleFrame(key.frame) } : key))
38
+ : entries);
39
+ const rescaleRailFollow = (value) => (value && typeof value === "object" && !Array.isArray(value)
40
+ ? { ...value, startFrame: rescaleFrame(value.startFrame), endFrame: rescaleFrame(value.endFrame) }
41
+ : value);
42
+
43
+ /** Retime a whole parsed body before the ordinary repair runs, so the version
44
+ * migrations below only ever see production-clock numbers. Rail geometry,
45
+ * follow gains and framings carry no frames and are passed through. */
46
+ function rescaleShotAuthoringFrames(parsed) {
47
+ const next = { ...parsed, frameCount: rescaleFrame(parsed.frameCount) };
48
+ if (Array.isArray(parsed.cameraKeys)) next.cameraKeys = rescaleKeys(parsed.cameraKeys);
49
+ if (Array.isArray(parsed.waypoints)) next.waypoints = rescaleKeys(parsed.waypoints);
50
+ if (parsed.railFollow !== undefined) next.railFollow = rescaleRailFollow(parsed.railFollow);
51
+ if (Array.isArray(parsed.shots)) {
52
+ next.shots = parsed.shots.map((shot) => {
53
+ if (!shot || typeof shot !== "object") return shot;
54
+ const moved = { ...shot, startFrame: rescaleFrame(shot.startFrame), endFrame: rescaleFrame(shot.endFrame) };
55
+ if (Array.isArray(shot.cameraKeys)) moved.cameraKeys = rescaleKeys(shot.cameraKeys);
56
+ if (shot.camera && typeof shot.camera === "object" && !Array.isArray(shot.camera)) {
57
+ moved.camera = { ...shot.camera, railFollow: rescaleRailFollow(shot.camera.railFollow) };
58
+ }
59
+ return moved;
60
+ });
61
+ }
62
+ return next;
63
+ }
64
+
27
65
  const FOLLOW_BOUNDS = {
28
66
  distance: [0.5, 15],
29
67
  height: [0.2, 6],
@@ -187,13 +225,15 @@ export function createShotAuthoringDocument({ shots = [], waypoints = [], frameC
187
225
  }
188
226
 
189
227
  /** Pure object reader; storage adapters decide how bytes become this object. */
190
- export function readShotAuthoringDocument(parsed) {
191
- if (parsed === undefined) return { status: "absent", state: null };
192
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { status: "corrupt", state: null };
193
- const version = parsed.version === undefined ? 1 : parsed.version;
228
+ export function readShotAuthoringDocument(raw) {
229
+ if (raw === undefined) return { status: "absent", state: null };
230
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { status: "corrupt", state: null };
231
+ const version = raw.version === undefined ? 1 : raw.version;
194
232
  if (!Number.isInteger(version) || version < 1) return { status: "corrupt", state: null };
195
233
  if (version > SHOT_AUTHORING_VERSION) return { status: "future", state: null };
196
234
 
235
+ // One conversion at the door: everything below reads one clock.
236
+ const parsed = version < 4 ? rescaleShotAuthoringFrames(raw) : raw;
197
237
  const frameCount = repairFrameCount(parsed.frameCount);
198
238
  const effectiveFrameCount = frameCount ?? DEFAULT_FRAME_COUNT;
199
239
  if (version === 1) {
@@ -215,7 +255,9 @@ export function readShotAuthoringDocument(parsed) {
215
255
  };
216
256
  }
217
257
  return {
218
- status: "valid",
258
+ // A v3 body is structurally current but was authored on the old clock;
259
+ // it is rewritten, so it reports as migrated, not valid.
260
+ status: version < SHOT_AUTHORING_VERSION ? "migrated" : "valid",
219
261
  state: { ...repairShared(parsed, frameCount), shots: repairShots(parsed.shots, effectiveFrameCount) },
220
262
  };
221
263
  }