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.
- package/CHANGELOG.md +85 -0
- package/README.md +31 -0
- package/THIRD_PARTY_NOTICES.md +37 -1
- package/bin/cozyclay.mjs +51 -2
- package/dist/ai-camera-control/index.html +406 -0
- package/dist/app/index.html +5 -5
- package/dist/assets/app-B3U5aut1.js +4811 -0
- package/dist/assets/app-BrRF0wso.css +1 -0
- package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
- package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
- package/dist/fonts/Inter-OFL.txt +92 -0
- package/dist/fonts/README.md +15 -0
- package/dist/index.html +60 -16
- package/dist/sitemap.xml +7 -1
- package/mcp/LIVE-PROTOCOL.md +63 -0
- package/mcp/README.md +142 -0
- package/mcp/ardy-prompts.mjs +170 -0
- package/mcp/live-hub.mjs +105 -0
- package/mcp/package.json +24 -0
- package/mcp/server.mjs +1394 -0
- package/package.json +122 -90
- package/src/App.jsx +2957 -514
- package/src/ardy/cskel27.js +7 -2
- package/src/ardy/ik.js +25 -15
- package/src/ardy/npz.js +64 -3
- package/src/ardy/playback.js +31 -1
- package/src/ardy/prompt-clips.js +7 -2
- package/src/ardy/retime.js +211 -0
- package/src/ardy/timeline-coordinates.js +13 -0
- package/src/ardy/timeline.jsx +124 -5
- package/src/ardy/to-cskel27.js +34 -12
- package/src/ardy/trim.js +33 -0
- package/src/asset-pane.jsx +36 -0
- package/src/dualview.jsx +14 -8
- package/src/hierarchy-model.js +95 -13
- package/src/hierarchy-panel.jsx +139 -6
- package/src/live-control.js +122 -0
- package/src/matte-editor.js +543 -0
- package/src/matte.js +503 -0
- package/src/multimodel-ingest.js +344 -0
- package/src/object-gizmo.jsx +43 -15
- package/src/planview.jsx +43 -32
- package/src/pose-extract/detector.js +75 -0
- package/src/pose-extract/index.js +3 -0
- package/src/pose-extract/take.js +87 -0
- package/src/pose-extract/video-frames.js +91 -0
- package/src/pose-thumbs.js +152 -0
- package/src/posestudio.jsx +361 -11
- package/src/project-browser.jsx +135 -0
- package/src/project.js +289 -0
- package/src/props.jsx +69 -3
- package/src/room.jsx +14 -35
- package/src/scene-asset-cache.js +125 -0
- package/src/scene-assets.js +288 -0
- package/src/scene-objects.js +245 -7
- package/src/scenes.js +207 -26
- package/src/shot-authoring.js +55 -13
- package/src/styles.css +1296 -129
- package/tools/ardy/BRIDGE.md +3 -2
- package/tools/ardy/README.md +9 -5
- package/tools/ardy/bridge.mjs +57 -1
- package/tools/ardy/bvh-cskel27.mjs +1209 -0
- package/tools/ardy/cclay_constrained_generate.py +123 -11
- package/tools/ardy/cclay_sequence_generate.py +49 -0
- package/tools/ardy/extract.mjs +367 -0
- package/tools/ardy/footage.mjs +462 -0
- package/tools/ardy/npz.mjs +74 -9
- package/tools/ardy/run-on-box.sh +25 -0
- package/tools/ardy/run-sequence-on-box.sh +15 -0
- package/tools/ardy/runners/remote.mjs +8 -2
- package/dist/assets/app-Cgpk2hwX.js +0 -4803
- 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 =
|
|
6
|
-
export const SCENES_STORAGE_KEY = "cozyclay.scenes.
|
|
7
|
-
export const SCENES_QUARANTINE_KEY = "cozyclay.scenes.
|
|
8
|
-
export const PREVIOUS_SCENES_STORAGE_KEY = "cozyclay.scenes.
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
56
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
const
|
|
213
|
-
|
|
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) {
|
package/src/shot-authoring.js
CHANGED
|
@@ -6,24 +6,62 @@
|
|
|
6
6
|
|
|
7
7
|
import { createShot } from "./cuts.js";
|
|
8
8
|
|
|
9
|
-
export const SHOT_AUTHORING_VERSION =
|
|
10
|
-
export const SHOT_AUTHORING_KEY = "cozyclay.shot-authoring.
|
|
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.
|
|
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.
|
|
19
|
+
export const SHOT_AUTHORING_QUARANTINE_KEY = "cozyclay.shot-authoring.v4.quarantine";
|
|
19
20
|
|
|
20
|
-
/** clip length sanity bounds, frames @
|
|
21
|
-
const FRAME_COUNT_MIN =
|
|
22
|
-
const FRAME_COUNT_MAX =
|
|
23
|
-
const DEFAULT_FRAME_COUNT =
|
|
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(
|
|
191
|
-
if (
|
|
192
|
-
if (!
|
|
193
|
-
const 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
|
-
|
|
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
|
}
|