cozyclay 1.2.0 → 1.5.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 (113) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/LICENSE +67 -80
  3. package/LICENSES/GPL-3.0-or-later.txt +674 -0
  4. package/LICENSING.md +32 -0
  5. package/README.md +48 -6
  6. package/THIRD_PARTY_NOTICES.md +47 -1
  7. package/bin/cozyclay.mjs +120 -50
  8. package/bin/mcp-runtime.mjs +115 -0
  9. package/bin/open-browser.mjs +10 -0
  10. package/bin/update-check.mjs +158 -0
  11. package/dist/ai-camera-control/index.html +406 -0
  12. package/dist/app/index.html +5 -5
  13. package/dist/ardy/assembled/run-jump-15s.npz +0 -0
  14. package/dist/assets/app-CqOk49Bk.css +1 -0
  15. package/dist/assets/app-zu633VCI.js +4811 -0
  16. package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
  17. package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
  18. package/dist/fonts/Inter-OFL.txt +92 -0
  19. package/dist/fonts/README.md +15 -0
  20. package/dist/index.html +62 -17
  21. package/dist/sitemap.xml +7 -1
  22. package/mcp/LIVE-PROTOCOL.md +144 -0
  23. package/mcp/README.md +160 -0
  24. package/mcp/ardy-prompts.mjs +188 -0
  25. package/mcp/live-hub.mjs +319 -0
  26. package/mcp/package.json +27 -0
  27. package/mcp/runtime/package-lock.json +1211 -0
  28. package/mcp/runtime/package.json +16 -0
  29. package/mcp/server.mjs +2008 -0
  30. package/package.json +142 -90
  31. package/src/App.jsx +5475 -1028
  32. package/src/ardy/cskel27.js +7 -2
  33. package/src/ardy/ik.js +25 -15
  34. package/src/ardy/motion-edit.js +172 -0
  35. package/src/ardy/npz.js +64 -3
  36. package/src/ardy/playback.js +31 -1
  37. package/src/ardy/pose-pin.js +97 -0
  38. package/src/ardy/prompt-clips.js +7 -2
  39. package/src/ardy/retime.js +211 -0
  40. package/src/ardy/root-drop.js +180 -0
  41. package/src/ardy/timeline-coordinates.js +13 -0
  42. package/src/ardy/timeline-resize.js +3 -1
  43. package/src/ardy/timeline.jsx +471 -63
  44. package/src/ardy/to-cskel27.js +34 -12
  45. package/src/ardy/trim.js +33 -0
  46. package/src/ardy/waypoints.js +4 -1
  47. package/src/asset-pane.jsx +354 -0
  48. package/src/asset-shelf.js +82 -0
  49. package/src/camera-block.js +42 -0
  50. package/src/camera-follow.js +149 -27
  51. package/src/camera-move.js +45 -19
  52. package/src/camera-rail-schedule.js +6 -0
  53. package/src/cuts.js +67 -54
  54. package/src/dualview.jsx +58 -12
  55. package/src/generation/generation-request.js +17 -0
  56. package/src/generation/use-generation.js +3 -14
  57. package/src/hierarchy-model.js +100 -16
  58. package/src/hierarchy-panel.jsx +139 -6
  59. package/src/live-control.js +142 -0
  60. package/src/matte-editor.js +543 -0
  61. package/src/matte.js +503 -0
  62. package/src/mp4-muxer.js +52 -0
  63. package/src/multimodel-ingest.js +344 -0
  64. package/src/object-gizmo.jsx +248 -25
  65. package/src/offscreen-export.js +166 -0
  66. package/src/otio.js +217 -0
  67. package/src/planview.jsx +57 -38
  68. package/src/pose-extract/detector.js +80 -0
  69. package/src/pose-extract/image-frame.js +40 -0
  70. package/src/pose-extract/index.js +4 -0
  71. package/src/pose-extract/take.js +114 -0
  72. package/src/pose-extract/video-frames.js +91 -0
  73. package/src/pose-thumbs.js +152 -0
  74. package/src/poses.js +19 -265
  75. package/src/posestudio.jsx +441 -17
  76. package/src/project-browser.jsx +135 -0
  77. package/src/project-poses.js +9 -0
  78. package/src/project.js +373 -0
  79. package/src/props.jsx +69 -3
  80. package/src/room.jsx +89 -37
  81. package/src/sample-at.js +136 -0
  82. package/src/scene-asset-cache.js +166 -0
  83. package/src/scene-assets.js +378 -0
  84. package/src/scene-objects.js +308 -7
  85. package/src/scenes.js +235 -26
  86. package/src/shot-authoring.js +120 -41
  87. package/src/shot.js +53 -13
  88. package/src/source-offer.jsx +12 -0
  89. package/src/stable-items.js +83 -0
  90. package/src/styles.css +2283 -349
  91. package/src/ui.jsx +32 -4
  92. package/src/usd-camera.js +145 -0
  93. package/tools/ardy/BRIDGE.md +11 -7
  94. package/tools/ardy/README.md +24 -5
  95. package/tools/ardy/artifacts.mjs +34 -0
  96. package/tools/ardy/bridge.mjs +89 -12
  97. package/tools/ardy/bvh-cskel27.mjs +1209 -0
  98. package/tools/ardy/cclay_constrained_generate.py +123 -11
  99. package/tools/ardy/cclay_sequence_generate.py +49 -0
  100. package/tools/ardy/extract.mjs +372 -0
  101. package/tools/ardy/footage.mjs +467 -0
  102. package/tools/ardy/npz.mjs +74 -9
  103. package/tools/ardy/run-on-box.sh +25 -0
  104. package/tools/ardy/run-sequence-on-box.sh +15 -0
  105. package/tools/ardy/runners/local.mjs +4 -1
  106. package/tools/ardy/runners/remote.mjs +8 -2
  107. package/tools/ardy/setup-text-encoder.py +14 -4
  108. package/tools/dev-full.mjs +63 -5
  109. package/tools/generation/bridge.mjs +14 -1
  110. package/tools/process-supervisor.mjs +97 -1
  111. package/tools/run-tests.mjs +169 -0
  112. package/dist/assets/app-Cgpk2hwX.js +0 -4803
  113. package/dist/assets/app-DgZvaAE1.css +0 -1
package/src/scenes.js CHANGED
@@ -2,21 +2,47 @@
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
+ import { DEFAULT_SENSOR_FORMAT, SENSOR_FORMATS } from "./shot.js";
6
+ import { normalizeStableItems } from "./stable-items.js";
7
+
8
+ export const SCENES_VERSION = 4;
9
+ export const SCENES_STORAGE_KEY = "cozyclay.scenes.v4";
10
+ export const SCENES_QUARANTINE_KEY = "cozyclay.scenes.v4.quarantine";
11
+ export const PREVIOUS_SCENES_STORAGE_KEY = "cozyclay.scenes.v3";
12
+ export const V2_SCENES_STORAGE_KEY = "cozyclay.scenes.v2";
13
+ export const V1_SCENES_STORAGE_KEY = "cozyclay.scenes.v1";
9
14
  export const LEGACY_SCENE_STORAGE_KEY = "cozyclay.scene.v1";
15
+ /** Newest-first fallback chain: a user arriving from any older build still
16
+ * finds their scenes, and the reader migrates whatever body it lands on. */
17
+ export const LEGACY_SCENES_STORAGE_KEYS = Object.freeze([
18
+ PREVIOUS_SCENES_STORAGE_KEY,
19
+ V2_SCENES_STORAGE_KEY,
20
+ V1_SCENES_STORAGE_KEY,
21
+ ]);
22
+
23
+ /** v3 and older authored every frame number on ARDY's 20 fps clock; v4 reads
24
+ * them on the 24 fps production clock. The numbers are MULTIPLIED, never
25
+ * reinterpreted: a waypoint at frame 40 meant 2.0 s and must still mean 2.0 s,
26
+ * which is frame 48 — reinterpreting it would silently speed the scene up. */
27
+ export const LEGACY_FRAME_FPS = 20;
28
+ export const TIMELINE_FRAME_FPS = 24;
29
+ export const toTimelineFrame = (frame) =>
30
+ Math.round((frame * TIMELINE_FRAME_FPS) / LEGACY_FRAME_FPS);
31
+
32
+ /** Rigged character models shipped in public/models. The id is both the FBX
33
+ * file stem and the wire `source.rig` value sent to ARDY. */
34
+ export const CHARACTER_MODEL_IDS = Object.freeze(["y-bot-tpose", "x-bot-tpose"]);
35
+ export const DEFAULT_CHARACTER_MODEL = "y-bot-tpose";
36
+ export const DEFAULT_SUBJECT_ONE = "a young woman in a tan coat";
37
+ export const DEFAULT_SUBJECT_TWO = "a man in a dark coat";
10
38
 
11
39
  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,
40
+ characters: Object.freeze([
41
+ Object.freeze({ id: "char-a", model: DEFAULT_CHARACTER_MODEL, x: 0, z: 0, rot: 0, hidden: false, pose: null, subject: DEFAULT_SUBJECT_ONE }),
42
+ ]),
17
43
  hasCharSheet: false,
18
- subject: "a young woman in a tan coat",
19
- subject2: "a man in a dark coat",
44
+ shotAspect: "16:9",
45
+ sensorId: DEFAULT_SENSOR_FORMAT,
20
46
  });
21
47
 
22
48
  let sceneSequence = 1;
@@ -49,17 +75,191 @@ function cloneValue(value, copies = new WeakMap()) {
49
75
  return copy;
50
76
  }
51
77
 
78
+ const finiteOr = (value, fallback) => (Number.isFinite(value) ? value : fallback);
79
+
80
+ /** Stature band for a cast member. Wider than ardy/npz.js's mocap band
81
+ * (0.6-1.5, a sanity clamp on ESTIMATED statures): the gizmo's scale handles
82
+ * are a deliberate artistic ask, and a previs giant or child is legitimate. */
83
+ export const CHARACTER_SCALE_MIN = 0.2;
84
+ export const CHARACTER_SCALE_MAX = 3;
85
+ const clampScale = (value) => (Number.isFinite(value) && value > 0
86
+ ? Math.max(CHARACTER_SCALE_MIN, Math.min(CHARACTER_SCALE_MAX, value))
87
+ : 1);
88
+
89
+ /** Where an extra extraction take's performer stands: the filmed offset from
90
+ * person 0, rotated into the ACTIVE character's facing. Pure geometry, so the
91
+ * placement can be proven without a renderer. */
92
+ export function takeAnchor(active, offsetX, offsetZ) {
93
+ const rad = (finiteOr(active?.rot, 0) * Math.PI) / 180;
94
+ const dx = finiteOr(offsetX, 0);
95
+ const dz = finiteOr(offsetZ, 0);
96
+ return {
97
+ x: finiteOr(active?.x, 0) + dx * Math.cos(rad) + dz * Math.sin(rad),
98
+ z: finiteOr(active?.z, 0) - dx * Math.sin(rad) + dz * Math.cos(rad),
99
+ };
100
+ }
101
+
102
+ /** One character entry in the stage envelope. `source` may be a v3 entry, a
103
+ * partial (spawn dialog), or null — every field falls back to a sane default
104
+ * and every object is freshly owned by the caller. */
105
+ export function createCharacterEntry(source = null, index = 0) {
106
+ const s = plainObject(source) ? source : {};
107
+ return {
108
+ id: typeof s.id === "string" && s.id ? s.id : `char-${index + 1}`,
109
+ model: CHARACTER_MODEL_IDS.includes(s.model) ? s.model : DEFAULT_CHARACTER_MODEL,
110
+ x: finiteOr(s.x, 0),
111
+ // Lift floors at the deck; there is deliberately no ceiling, so every
112
+ // writer (inspector field, viewport gizmo, load path) shares max(0, y).
113
+ y: Math.max(0, finiteOr(s.y, 0)),
114
+ z: finiteOr(s.z, 0),
115
+ rot: finiteOr(s.rot, 0),
116
+ hidden: s.hidden === true,
117
+ // User-picked body tint; null means "model default" (y-bot clay, x-bot
118
+ // whiter clay) so the entry survives future default tweaks.
119
+ tint: typeof s.tint === "string" && /^#[0-9a-fA-F]{6}$/.test(s.tint) ? s.tint : null,
120
+ pose: plainObject(s.pose) ? cloneValue(s.pose) : null,
121
+ // Stature multiplier: 1 is the canonical body, an extracted take carries
122
+ // the FILMED person's leg ratio. It persists with the entry because the
123
+ // take's root travel was authored against it — see the render path.
124
+ scale: clampScale(s.scale),
125
+ subject: typeof s.subject === "string" ? s.subject : index === 0 ? DEFAULT_SUBJECT_ONE : DEFAULT_SUBJECT_TWO,
126
+ // The character's animation layer: its own root path and prompt-block
127
+ // schedule. The generated clip itself is heavy, so only a lightweight
128
+ // reference (bridge URL + generation params) is persisted — enough to
129
+ // re-fetch and decode it on the next session.
130
+ layer: normalizeLayer(s.layer),
131
+ motionRef: normalizeMotionRef(s.motionRef),
132
+ };
133
+ }
134
+
135
+ function normalizeMotionRef(ref) {
136
+ if (!plainObject(ref) || typeof ref.url !== "string" || !ref.url) return null;
137
+ return {
138
+ url: ref.url,
139
+ prompt: typeof ref.prompt === "string" ? ref.prompt : "",
140
+ rotationDeg: Number.isFinite(ref.rotationDeg) ? ref.rotationDeg : 0,
141
+ anchorX: Number.isFinite(ref.anchorX) ? ref.anchorX : 0,
142
+ anchorZ: Number.isFinite(ref.anchorZ) ? ref.anchorZ : 0,
143
+ };
144
+ }
145
+
146
+ function normalizeLayer(layer) {
147
+ const source = plainObject(layer) ? layer : {};
148
+ return {
149
+ waypoints: normalizeStableItems(cloneValue(source.waypoints), "waypoint"),
150
+ promptClips: normalizeStableItems(cloneValue(source.promptClips), "prompt-clip"),
151
+ };
152
+ }
153
+
154
+ function normalizeStageLayers(stage) {
155
+ const source = plainObject(stage) ? stage : {};
156
+ const ids = new Set();
157
+ return {
158
+ ...source,
159
+ characters: Array.isArray(source.characters)
160
+ ? source.characters.map((character) => ({
161
+ ...character,
162
+ layer: {
163
+ waypoints: normalizeStableItems(cloneValue(character.layer?.waypoints), "waypoint", ids),
164
+ promptClips: normalizeStableItems(cloneValue(character.layer?.promptClips), "prompt-clip", ids),
165
+ },
166
+ }))
167
+ : source.characters,
168
+ };
169
+ }
170
+
171
+ export function createCharacterLayer() {
172
+ return { waypoints: [], promptClips: [] };
173
+ }
174
+
175
+ /** Retime one animation layer from the 20 fps authoring clock onto the 24 fps
176
+ * production clock. Frame numbers only: positions, headings and prompt text
177
+ * are untouched. The mapping is strictly increasing, so ascending order and
178
+ * the gaps between prompt blocks survive it — the guards below re-establish
179
+ * both anyway, because a hand-edited body may never have had them. Prompt
180
+ * ranges are half-open [start, end), matching movePromptClipFrames: touching
181
+ * blocks are legal, overlapping ones are not. */
182
+ function migrateLayerFrames(layer) {
183
+ if (!plainObject(layer)) return layer;
184
+ const next = { ...cloneValue(layer) };
185
+ if (Array.isArray(layer.waypoints)) {
186
+ const byFrame = new Map();
187
+ for (const waypoint of layer.waypoints) {
188
+ if (!plainObject(waypoint) || !Number.isFinite(waypoint.frame)) continue;
189
+ const frame = Math.max(0, toTimelineFrame(Math.round(waypoint.frame)));
190
+ byFrame.set(frame, { ...cloneValue(waypoint), frame });
191
+ }
192
+ next.waypoints = [...byFrame.values()].sort((a, b) => a.frame - b.frame);
193
+ }
194
+ if (Array.isArray(layer.promptClips)) {
195
+ const ordered = layer.promptClips
196
+ .filter((clip) => plainObject(clip) && Number.isFinite(clip.startFrame) && Number.isFinite(clip.endFrame))
197
+ .sort((a, b) => a.startFrame - b.startFrame);
198
+ const clips = [];
199
+ let floor = 0;
200
+ for (const clip of ordered) {
201
+ const startFrame = Math.max(floor, toTimelineFrame(Math.round(clip.startFrame)));
202
+ const endFrame = Math.max(startFrame, toTimelineFrame(Math.round(clip.endFrame)));
203
+ clips.push({ ...cloneValue(clip), startFrame, endFrame });
204
+ floor = endFrame;
205
+ }
206
+ next.promptClips = clips;
207
+ }
208
+ return next;
209
+ }
210
+
211
+ /** v3 → v4 for one stage envelope: every frame-bearing number in every cast
212
+ * member's layer moves onto the production clock. Exported because a project
213
+ * FILE carries its own scene document and needs the same migration. */
214
+ export function migrateStageFrames(stage) {
215
+ if (!plainObject(stage) || !Array.isArray(stage.characters)) return stage;
216
+ return {
217
+ ...stage,
218
+ characters: stage.characters.map((entry) => (plainObject(entry) && plainObject(entry.layer)
219
+ ? { ...entry, layer: migrateLayerFrames(entry.layer) }
220
+ : entry)),
221
+ };
222
+ }
223
+
224
+ /** Stage envelopes before v3 stored a fixed cast (charA/charB/showB/poseA/
225
+ * poseB/subject/subject2). Fold that cast into the characters list so the
226
+ * rest of the app only ever sees one shape. */
227
+ function migrateLegacyCast(source) {
228
+ const cast = [createCharacterEntry({ ...plainObject(source.charA) ? source.charA : {}, id: "char-a", pose: source.poseA ?? null, subject: source.subject ?? DEFAULT_SUBJECT_ONE }, 0)];
229
+ if (source.showB === true) {
230
+ cast.push(createCharacterEntry({ ...plainObject(source.charB) ? source.charB : {}, id: "char-b", pose: source.poseB ?? null, subject: source.subject2 ?? DEFAULT_SUBJECT_TWO }, 1));
231
+ }
232
+ return cast;
233
+ }
234
+
235
+ const STAGE_ENVELOPE_KEYS = new Set([
236
+ "characters", "hasCharSheet", "shotAspect", "sensorId",
237
+ "charA", "charB", "showB", "poseA", "poseB", "subject", "subject2",
238
+ ]);
239
+
52
240
  export function createSceneStage(stage = null) {
53
241
  const source = plainObject(stage) ? stage : {};
242
+ const characters = Array.isArray(source.characters) && source.characters.length
243
+ ? source.characters.map((entry, index) => createCharacterEntry(entry, index))
244
+ : migrateLegacyCast(source);
245
+ // Unknown extra keys (shotAspect was one, once) ride along so the envelope
246
+ // stays sealed-but-lossless; legacy cast keys are deliberately dropped.
247
+ const extras = {};
248
+ for (const [key, value] of Object.entries(source)) {
249
+ if (!STAGE_ENVELOPE_KEYS.has(key)) extras[key] = cloneValue(value);
250
+ }
54
251
  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,
252
+ ...extras,
253
+ characters,
60
254
  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,
255
+ shotAspect: ["16:9", "2.39:1", "9:16", "1:1", "4:3"].includes(source.shotAspect)
256
+ ? source.shotAspect
257
+ : DEFAULT_SCENE_STAGE.shotAspect,
258
+ // `sensorFormat` was this field's name for one unreleased day; read it so
259
+ // a stage saved in that window still loads with its camera intact.
260
+ sensorId: Object.hasOwn(SENSOR_FORMATS, source.sensorId ?? source.sensorFormat)
261
+ ? (source.sensorId ?? source.sensorFormat)
262
+ : DEFAULT_SCENE_STAGE.sensorId,
63
263
  };
64
264
  }
65
265
 
@@ -139,23 +339,26 @@ export function createSceneDocument() {
139
339
  return { version: SCENES_VERSION, activeSceneId: scene.id, scenes: [scene] };
140
340
  }
141
341
 
142
- function repairScene(record, existing, fallbackNumber, fallbackStage) {
342
+ function repairScene(record, existing, fallbackNumber, fallbackStage, legacyClock = false) {
143
343
  if (!plainObject(record) || typeof record.id !== "string" || !record.id || !Array.isArray(record.objects)) return null;
144
344
  if (existing.some((scene) => scene.id === record.id)) return null;
345
+ const stage = normalizeStageLayers(createSceneStage(cloneValue(plainObject(record.stage) ? record.stage : fallbackStage)));
145
346
  return {
146
347
  id: record.id,
147
348
  name: uniqueName(typeof record.name === "string" ? record.name : `SCENE ${String(fallbackNumber).padStart(2, "0")}`, existing),
148
349
  objects: record.objects.filter(plainObject).map((object) => cloneValue(object)),
350
+ // The shot document is a sealed envelope with its OWN version, and it
351
+ // migrates its own frames when its reader opens it.
149
352
  shotDocument: cloneValue(record.shotDocument ?? null),
150
- stage: cloneValue(plainObject(record.stage) ? record.stage : fallbackStage),
353
+ stage: legacyClock ? migrateStageFrames(stage) : stage,
151
354
  };
152
355
  }
153
356
 
154
- function repairDocument(payload, fallbackStage = DEFAULT_SCENE_STAGE) {
357
+ function repairDocument(payload, fallbackStage = DEFAULT_SCENE_STAGE, legacyClock = false) {
155
358
  const scenes = [];
156
359
  let dropped = 0;
157
360
  for (const record of payload.scenes) {
158
- const scene = repairScene(record, scenes, scenes.length + 1, fallbackStage);
361
+ const scene = repairScene(record, scenes, scenes.length + 1, fallbackStage, legacyClock);
159
362
  if (scene) scenes.push(scene);
160
363
  else dropped += 1;
161
364
  }
@@ -197,7 +400,11 @@ export function readSceneDocument(raw, legacyRaw = null) {
197
400
  return { status: "corrupt", document: createSceneDocument(), dropped: 0, quarantineRaw: raw };
198
401
  }
199
402
  const migrating = payload.version < SCENES_VERSION;
200
- const repaired = repairDocument(payload, createSceneStage(payload.stage));
403
+ // Anything below v4 was authored while the timeline ran at 20 fps. The
404
+ // fallback stage (a v1 global cast) rides the same conversion, applied
405
+ // once, inside repairScene — whichever stage that scene ends up with.
406
+ const legacyClock = payload.version < 4;
407
+ const repaired = repairDocument(payload, createSceneStage(payload.stage), legacyClock);
201
408
  return { status: migrating ? "migrated" : "valid", ...repaired };
202
409
  }
203
410
 
@@ -208,9 +415,11 @@ export function serializeSceneDocument(document) {
208
415
  /** Storage adapter: quarantine corrupt bytes, persist successful migration,
209
416
  * and never write over a future document. The legacy key remains as a backup. */
210
417
  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;
418
+ let raw = storage.getItem(SCENES_STORAGE_KEY);
419
+ for (const key of LEGACY_SCENES_STORAGE_KEYS) {
420
+ if (raw) break;
421
+ raw = storage.getItem(key);
422
+ }
214
423
  const legacyRaw = raw ? null : storage.getItem(LEGACY_SCENE_STORAGE_KEY);
215
424
  const result = readSceneDocument(raw, legacyRaw);
216
425
  if (result.status === "corrupt" && result.quarantineRaw !== undefined) {
@@ -5,32 +5,72 @@
5
5
  */
6
6
 
7
7
  import { createShot } from "./cuts.js";
8
+ import { normalizeStableItems } from "./stable-items.js";
8
9
 
9
- export const SHOT_AUTHORING_VERSION = 3;
10
- export const SHOT_AUTHORING_KEY = "cozyclay.shot-authoring.v3";
10
+ export const SHOT_AUTHORING_VERSION = 4;
11
+ export const SHOT_AUTHORING_KEY = "cozyclay.shot-authoring.v4";
11
12
  // The single-key alias points at the newest legacy body for older callers.
12
13
  // 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";
14
+ export const SHOT_AUTHORING_LEGACY_KEY = "cozyclay.shot-authoring.v3";
14
15
  export const SHOT_AUTHORING_LEGACY_KEYS = Object.freeze([
15
16
  SHOT_AUTHORING_LEGACY_KEY,
17
+ "cozyclay.shot-authoring.v2",
16
18
  "cozyclay.shot-authoring.v1",
17
19
  ]);
18
- export const SHOT_AUTHORING_QUARANTINE_KEY = "cozyclay.shot-authoring.v3.quarantine";
20
+ export const SHOT_AUTHORING_QUARANTINE_KEY = "cozyclay.shot-authoring.v4.quarantine";
19
21
 
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;
22
+ /** clip length sanity bounds, frames @ 24 fps: 1 s .. 20 min */
23
+ const FRAME_COUNT_MIN = 24;
24
+ const FRAME_COUNT_MAX = 28800;
25
+ const DEFAULT_FRAME_COUNT = 144;
24
26
  const RAIL_MAX_POINTS = 512;
25
27
  const finite = Number.isFinite;
26
28
 
29
+ /** v3 and older bodies counted frames on ARDY's 20 fps clock; v4 counts them
30
+ * on the 24 fps production clock. Every frame-bearing number is multiplied so
31
+ * a saved roll keeps its DURATION: a 300-frame clip was 15 s and stays 15 s as
32
+ * 360 frames. Reinterpreting instead would silently shorten it to 12.5 s. */
33
+ const LEGACY_FRAME_FPS = 20;
34
+ const TIMELINE_FRAME_FPS = 24;
35
+ const toTimelineFrame = (frame) => Math.round((frame * TIMELINE_FRAME_FPS) / LEGACY_FRAME_FPS);
36
+ const rescaleFrame = (value) => (finite(value) ? toTimelineFrame(value) : value);
37
+ const rescaleKeys = (entries) => (Array.isArray(entries)
38
+ ? entries.map((key) => (key && typeof key === "object" ? { ...key, frame: rescaleFrame(key.frame) } : key))
39
+ : entries);
40
+ const rescaleRailFollow = (value) => (value && typeof value === "object" && !Array.isArray(value)
41
+ ? { ...value, startFrame: rescaleFrame(value.startFrame), endFrame: rescaleFrame(value.endFrame) }
42
+ : value);
43
+
44
+ /** Retime a whole parsed body before the ordinary repair runs, so the version
45
+ * migrations below only ever see production-clock numbers. Rail geometry,
46
+ * follow gains and framings carry no frames and are passed through. */
47
+ function rescaleShotAuthoringFrames(parsed) {
48
+ const next = { ...parsed, frameCount: rescaleFrame(parsed.frameCount) };
49
+ if (Array.isArray(parsed.cameraKeys)) next.cameraKeys = rescaleKeys(parsed.cameraKeys);
50
+ if (Array.isArray(parsed.waypoints)) next.waypoints = rescaleKeys(parsed.waypoints);
51
+ if (parsed.railFollow !== undefined) next.railFollow = rescaleRailFollow(parsed.railFollow);
52
+ if (Array.isArray(parsed.shots)) {
53
+ next.shots = parsed.shots.map((shot) => {
54
+ if (!shot || typeof shot !== "object") return shot;
55
+ const moved = { ...shot, startFrame: rescaleFrame(shot.startFrame), endFrame: rescaleFrame(shot.endFrame) };
56
+ if (Array.isArray(shot.cameraKeys)) moved.cameraKeys = rescaleKeys(shot.cameraKeys);
57
+ if (shot.camera && typeof shot.camera === "object" && !Array.isArray(shot.camera)) {
58
+ moved.camera = { ...shot.camera, railFollow: rescaleRailFollow(shot.camera.railFollow) };
59
+ }
60
+ return moved;
61
+ });
62
+ }
63
+ return next;
64
+ }
65
+
27
66
  const FOLLOW_BOUNDS = {
28
67
  distance: [0.5, 15],
29
- height: [0.2, 6],
68
+ height: [0.2, Number.POSITIVE_INFINITY],
30
69
  response: [0.1, 3],
31
70
  lead: [0, 1],
32
71
  maxDollySpeed: [0.2, 8],
33
- pitchOffsetDeg: [-30, 30],
72
+ pitchOffsetDeg: [-170, 170],
73
+ orbitOffsetDeg: [-180, 180],
34
74
  };
35
75
  const FOLLOW_DEFAULTS = {
36
76
  distance: 3,
@@ -40,6 +80,7 @@ const FOLLOW_DEFAULTS = {
40
80
  railStartMode: "head",
41
81
  maxDollySpeed: 4,
42
82
  pitchOffsetDeg: 0,
83
+ orbitOffsetDeg: 0,
43
84
  };
44
85
  const CAMERA_MODES = new Set(["keys", "follow", "rail"]);
45
86
 
@@ -55,12 +96,13 @@ function repairFrameCount(value) {
55
96
  return finite(value) ? Math.max(FRAME_COUNT_MIN, Math.min(FRAME_COUNT_MAX, Math.round(value))) : null;
56
97
  }
57
98
 
58
- function repairKeys(entries, minFrame, maxFrame) {
99
+ function repairKeys(entries, minFrame, maxFrame, ids = new Set()) {
59
100
  const byFrame = new Map();
60
- for (const key of Array.isArray(entries) ? entries : []) {
61
- if (!key || !finite(key.frame) || !validFraming(key.framing)) continue;
101
+ for (const key of normalizeStableItems(entries, "camera-key", ids)) {
102
+ if (!finite(key.frame) || !validFraming(key.framing)) continue;
62
103
  const frame = Math.max(minFrame, Math.min(maxFrame, Math.round(key.frame)));
63
104
  byFrame.set(frame, {
105
+ id: key.id,
64
106
  frame,
65
107
  framing: {
66
108
  pos: { x: key.framing.pos.x, y: key.framing.pos.y, z: key.framing.pos.z },
@@ -73,7 +115,7 @@ function repairKeys(entries, minFrame, maxFrame) {
73
115
  return [...byFrame.values()].sort((a, b) => a.frame - b.frame);
74
116
  }
75
117
 
76
- function repairShots(entries, frameCount, inheritedCamera = null) {
118
+ function repairShots(entries, frameCount, inheritedCamera = null, ids = new Set()) {
77
119
  const candidates = [];
78
120
  for (const entry of Array.isArray(entries) ? entries : []) {
79
121
  if (!entry || typeof entry !== "object" || !finite(entry.startFrame)) continue;
@@ -85,34 +127,35 @@ function repairShots(entries, frameCount, inheritedCamera = null) {
85
127
 
86
128
  // One boundary per frame. The later stored entry wins, just like re-keying.
87
129
  const byStart = new Map(candidates.map((candidate) => [candidate.startFrame, candidate.entry]));
88
- const ordered = [...byStart.entries()].sort((a, b) => a[0] - b[0]);
89
- const seenIds = new Set();
90
- return ordered.map(([startFrame, entry], index) => {
91
- const nextStart = ordered[index + 1]?.[0] ?? frameCount;
130
+ const ordered = normalizeStableItems(
131
+ [...byStart.entries()].sort((a, b) => a[0] - b[0]).map(([startFrame, entry]) => ({ ...entry, startFrame })),
132
+ "shot",
133
+ ids,
134
+ );
135
+ return ordered.map((entry, index) => {
136
+ const startFrame = entry.startFrame;
137
+ const nextStart = ordered[index + 1]?.startFrame ?? frameCount;
92
138
  // Pre-overlay v3 bodies had no endFrame and were gapless by definition.
93
139
  // Infer their old boundary exactly; new bodies persist an explicit end.
94
140
  const storedEnd = finite(entry.endFrame) ? Math.round(entry.endFrame) : nextStart - 1;
95
141
  const endFrame = Math.max(startFrame, Math.min(frameCount - 1, nextStart - 1, storedEnd));
96
- const fallback = createShot(`Shot ${index + 1}`, startFrame, endFrame);
97
- const storedId = typeof entry.id === "string" && entry.id.trim() && !seenIds.has(entry.id) ? entry.id : fallback.id;
98
- seenIds.add(storedId);
99
142
  return {
100
- id: storedId,
143
+ id: entry.id,
101
144
  name: typeof entry.name === "string" && entry.name.trim() ? entry.name.trim() : `Shot ${index + 1}`,
102
145
  startFrame,
103
146
  endFrame,
104
- cameraKeys: repairKeys(entry.cameraKeys, startFrame, endFrame),
147
+ cameraKeys: repairKeys(entry.cameraKeys, startFrame, endFrame, ids),
105
148
  camera: repairCamera(inheritedCamera ?? entry.camera),
106
149
  };
107
150
  });
108
151
  }
109
152
 
110
- function repairWaypoints(entries) {
153
+ function repairWaypoints(entries, ids = new Set()) {
111
154
  const byFrame = new Map();
112
- for (const waypoint of Array.isArray(entries) ? entries : []) {
113
- if (!waypoint || !finite(waypoint.frame) || waypoint.frame < 0 || !finite(waypoint.x) || !finite(waypoint.z)) continue;
155
+ for (const waypoint of normalizeStableItems(entries, "waypoint", ids)) {
156
+ if (!finite(waypoint.frame) || waypoint.frame < 0 || !finite(waypoint.x) || !finite(waypoint.z)) continue;
114
157
  const frame = Math.round(waypoint.frame);
115
- byFrame.set(frame, { frame, x: waypoint.x, z: waypoint.z, heading: finite(waypoint.heading) ? waypoint.heading : null });
158
+ byFrame.set(frame, { id: waypoint.id, frame, x: waypoint.x, z: waypoint.z, heading: finite(waypoint.heading) ? waypoint.heading : null });
116
159
  }
117
160
  return [...byFrame.values()].sort((a, b) => a.frame - b.frame);
118
161
  }
@@ -143,6 +186,27 @@ function repairRailFollow(value) {
143
186
  return startFrame <= endFrame ? { mode: "range", startFrame, endFrame } : null;
144
187
  }
145
188
 
189
+ /** The crane axis rides the rail's arc; mirrors camera-block.js's normalization. */
190
+ function repairCraneHeight(value) {
191
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
192
+ const raw = Array.isArray(value.points)
193
+ ? value.points
194
+ : finite(value.start) && finite(value.end)
195
+ ? [{ t: 0, height: value.start }, { t: 1, height: value.end }]
196
+ : null;
197
+ if (!raw) return null;
198
+ const cleaned = raw
199
+ .filter((point) => point && finite(point.t) && finite(point.height))
200
+ .map((point) => ({ t: Math.max(0, Math.min(1, point.t)), height: Math.max(0.1, point.height) }))
201
+ .sort((a, b) => a.t - b.t)
202
+ .filter((point, i, arr) => i === arr.length - 1 || arr[i + 1].t - point.t > 1e-6)
203
+ .slice(0, 8);
204
+ if (cleaned.length < 2) return null;
205
+ cleaned[0] = { ...cleaned[0], t: 0 };
206
+ cleaned[cleaned.length - 1] = { ...cleaned[cleaned.length - 1], t: 1 };
207
+ return { points: cleaned };
208
+ }
209
+
146
210
  function repairCamera(value) {
147
211
  const source = value && typeof value === "object" && !Array.isArray(value) ? value : {};
148
212
  const cameraRail = repairRail(source.cameraRail);
@@ -150,7 +214,14 @@ function repairCamera(value) {
150
214
  // A rail block without a usable two-point rail behaves like ordinary
151
215
  // follow, never like a mysteriously enabled but motionless dolly.
152
216
  if (mode === "rail" && !cameraRail) mode = "follow";
153
- return { mode, followCam: repairFollowCam(source.followCam), cameraRail, railFollow: repairRailFollow(source.railFollow) };
217
+ return {
218
+ mode,
219
+ followCam: repairFollowCam(source.followCam),
220
+ cameraRail,
221
+ railFollow: repairRailFollow(source.railFollow),
222
+ // A crane cannot outlive the rail it rides.
223
+ craneHeight: cameraRail ? repairCraneHeight(source.craneHeight) : null,
224
+ };
154
225
  }
155
226
 
156
227
  function migratedCamera(followCam, cameraRail, railFollow = null) {
@@ -164,10 +235,10 @@ function migratedCamera(followCam, cameraRail, railFollow = null) {
164
235
  });
165
236
  }
166
237
 
167
- function repairShared(parsed, frameCount) {
238
+ function repairShared(parsed, frameCount, ids) {
168
239
  return {
169
240
  frameCount,
170
- waypoints: repairWaypoints(parsed.waypoints),
241
+ waypoints: repairWaypoints(parsed.waypoints, ids),
171
242
  };
172
243
  }
173
244
 
@@ -178,45 +249,53 @@ function repairShared(parsed, frameCount) {
178
249
  export function createShotAuthoringDocument({ shots = [], waypoints = [], frameCount = null } = {}) {
179
250
  const repairedFrameCount = repairFrameCount(frameCount);
180
251
  const effectiveFrameCount = repairedFrameCount ?? DEFAULT_FRAME_COUNT;
252
+ const ids = new Set();
181
253
  return {
182
254
  version: SHOT_AUTHORING_VERSION,
183
255
  frameCount: repairedFrameCount,
184
- shots: repairShots(shots, effectiveFrameCount),
185
- waypoints: repairWaypoints(waypoints),
256
+ waypoints: repairWaypoints(waypoints, ids),
257
+ shots: repairShots(shots, effectiveFrameCount, null, ids),
186
258
  };
187
259
  }
188
260
 
189
261
  /** 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;
262
+ export function readShotAuthoringDocument(raw) {
263
+ if (raw === undefined) return { status: "absent", state: null };
264
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { status: "corrupt", state: null };
265
+ const version = raw.version === undefined ? 1 : raw.version;
194
266
  if (!Number.isInteger(version) || version < 1) return { status: "corrupt", state: null };
195
267
  if (version > SHOT_AUTHORING_VERSION) return { status: "future", state: null };
196
268
 
269
+ // One conversion at the door: everything below reads one clock.
270
+ const parsed = version < 4 ? rescaleShotAuthoringFrames(raw) : raw;
197
271
  const frameCount = repairFrameCount(parsed.frameCount);
198
272
  const effectiveFrameCount = frameCount ?? DEFAULT_FRAME_COUNT;
199
273
  if (version === 1) {
200
- const keys = repairKeys(parsed.cameraKeys, 0, effectiveFrameCount - 1);
274
+ const ids = new Set();
275
+ const keys = repairKeys(parsed.cameraKeys, 0, effectiveFrameCount - 1, ids);
201
276
  const camera = migratedCamera(parsed.followCam, parsed.cameraRail, parsed.railFollow);
202
277
  // v1 was one whole-timeline camera roll even when it had no keys.
203
278
  const shots = [createShot("Shot 1", 0, effectiveFrameCount - 1, keys, repairCamera(camera))];
204
279
  return {
205
280
  status: "migrated",
206
- state: { ...repairShared(parsed, frameCount), shots },
281
+ state: { ...repairShared(parsed, frameCount, ids), shots },
207
282
  };
208
283
  }
209
284
  if (!Array.isArray(parsed.shots)) return { status: "corrupt", state: null };
210
285
  if (version === 2) {
286
+ const ids = new Set();
211
287
  const camera = migratedCamera(parsed.followCam, parsed.cameraRail, parsed.railFollow);
212
288
  return {
213
289
  status: "migrated",
214
- state: { ...repairShared(parsed, frameCount), shots: repairShots(parsed.shots, effectiveFrameCount, camera) },
290
+ state: { ...repairShared(parsed, frameCount, ids), shots: repairShots(parsed.shots, effectiveFrameCount, camera, ids) },
215
291
  };
216
292
  }
293
+ const ids = new Set();
217
294
  return {
218
- status: "valid",
219
- state: { ...repairShared(parsed, frameCount), shots: repairShots(parsed.shots, effectiveFrameCount) },
295
+ // A v3 body is structurally current but was authored on the old clock;
296
+ // it is rewritten, so it reports as migrated, not valid.
297
+ status: version < SHOT_AUTHORING_VERSION ? "migrated" : "valid",
298
+ state: { ...repairShared(parsed, frameCount, ids), shots: repairShots(parsed.shots, effectiveFrameCount, null, ids) },
220
299
  };
221
300
  }
222
301