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.
- package/CHANGELOG.md +193 -0
- package/LICENSE +67 -80
- package/LICENSES/GPL-3.0-or-later.txt +674 -0
- package/LICENSING.md +32 -0
- package/README.md +48 -6
- package/THIRD_PARTY_NOTICES.md +47 -1
- package/bin/cozyclay.mjs +120 -50
- package/bin/mcp-runtime.mjs +115 -0
- package/bin/open-browser.mjs +10 -0
- package/bin/update-check.mjs +158 -0
- package/dist/ai-camera-control/index.html +406 -0
- package/dist/app/index.html +5 -5
- package/dist/ardy/assembled/run-jump-15s.npz +0 -0
- package/dist/assets/app-CqOk49Bk.css +1 -0
- package/dist/assets/app-zu633VCI.js +4811 -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 +62 -17
- package/dist/sitemap.xml +7 -1
- package/mcp/LIVE-PROTOCOL.md +144 -0
- package/mcp/README.md +160 -0
- package/mcp/ardy-prompts.mjs +188 -0
- package/mcp/live-hub.mjs +319 -0
- package/mcp/package.json +27 -0
- package/mcp/runtime/package-lock.json +1211 -0
- package/mcp/runtime/package.json +16 -0
- package/mcp/server.mjs +2008 -0
- package/package.json +142 -90
- package/src/App.jsx +5475 -1028
- package/src/ardy/cskel27.js +7 -2
- package/src/ardy/ik.js +25 -15
- package/src/ardy/motion-edit.js +172 -0
- package/src/ardy/npz.js +64 -3
- package/src/ardy/playback.js +31 -1
- package/src/ardy/pose-pin.js +97 -0
- package/src/ardy/prompt-clips.js +7 -2
- package/src/ardy/retime.js +211 -0
- package/src/ardy/root-drop.js +180 -0
- package/src/ardy/timeline-coordinates.js +13 -0
- package/src/ardy/timeline-resize.js +3 -1
- package/src/ardy/timeline.jsx +471 -63
- package/src/ardy/to-cskel27.js +34 -12
- package/src/ardy/trim.js +33 -0
- package/src/ardy/waypoints.js +4 -1
- package/src/asset-pane.jsx +354 -0
- package/src/asset-shelf.js +82 -0
- package/src/camera-block.js +42 -0
- package/src/camera-follow.js +149 -27
- package/src/camera-move.js +45 -19
- package/src/camera-rail-schedule.js +6 -0
- package/src/cuts.js +67 -54
- package/src/dualview.jsx +58 -12
- package/src/generation/generation-request.js +17 -0
- package/src/generation/use-generation.js +3 -14
- package/src/hierarchy-model.js +100 -16
- package/src/hierarchy-panel.jsx +139 -6
- package/src/live-control.js +142 -0
- package/src/matte-editor.js +543 -0
- package/src/matte.js +503 -0
- package/src/mp4-muxer.js +52 -0
- package/src/multimodel-ingest.js +344 -0
- package/src/object-gizmo.jsx +248 -25
- package/src/offscreen-export.js +166 -0
- package/src/otio.js +217 -0
- package/src/planview.jsx +57 -38
- package/src/pose-extract/detector.js +80 -0
- package/src/pose-extract/image-frame.js +40 -0
- package/src/pose-extract/index.js +4 -0
- package/src/pose-extract/take.js +114 -0
- package/src/pose-extract/video-frames.js +91 -0
- package/src/pose-thumbs.js +152 -0
- package/src/poses.js +19 -265
- package/src/posestudio.jsx +441 -17
- package/src/project-browser.jsx +135 -0
- package/src/project-poses.js +9 -0
- package/src/project.js +373 -0
- package/src/props.jsx +69 -3
- package/src/room.jsx +89 -37
- package/src/sample-at.js +136 -0
- package/src/scene-asset-cache.js +166 -0
- package/src/scene-assets.js +378 -0
- package/src/scene-objects.js +308 -7
- package/src/scenes.js +235 -26
- package/src/shot-authoring.js +120 -41
- package/src/shot.js +53 -13
- package/src/source-offer.jsx +12 -0
- package/src/stable-items.js +83 -0
- package/src/styles.css +2283 -349
- package/src/ui.jsx +32 -4
- package/src/usd-camera.js +145 -0
- package/tools/ardy/BRIDGE.md +11 -7
- package/tools/ardy/README.md +24 -5
- package/tools/ardy/artifacts.mjs +34 -0
- package/tools/ardy/bridge.mjs +89 -12
- 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 +372 -0
- package/tools/ardy/footage.mjs +467 -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/local.mjs +4 -1
- package/tools/ardy/runners/remote.mjs +8 -2
- package/tools/ardy/setup-text-encoder.py +14 -4
- package/tools/dev-full.mjs +63 -5
- package/tools/generation/bridge.mjs +14 -1
- package/tools/process-supervisor.mjs +97 -1
- package/tools/run-tests.mjs +169 -0
- package/dist/assets/app-Cgpk2hwX.js +0 -4803
- 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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export const
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
...
|
|
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,
|
|
252
|
+
...extras,
|
|
253
|
+
characters,
|
|
60
254
|
hasCharSheet: source.hasCharSheet === true,
|
|
61
|
-
|
|
62
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
const
|
|
213
|
-
|
|
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) {
|
package/src/shot-authoring.js
CHANGED
|
@@ -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 =
|
|
10
|
-
export const SHOT_AUTHORING_KEY = "cozyclay.shot-authoring.
|
|
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.
|
|
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.
|
|
20
|
+
export const SHOT_AUTHORING_QUARANTINE_KEY = "cozyclay.shot-authoring.v4.quarantine";
|
|
19
21
|
|
|
20
|
-
/** clip length sanity bounds, frames @
|
|
21
|
-
const FRAME_COUNT_MIN =
|
|
22
|
-
const FRAME_COUNT_MAX =
|
|
23
|
-
const DEFAULT_FRAME_COUNT =
|
|
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,
|
|
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: [-
|
|
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
|
|
61
|
-
if (!
|
|
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 =
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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:
|
|
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
|
|
113
|
-
if (!
|
|
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 {
|
|
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
|
-
|
|
185
|
-
|
|
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(
|
|
191
|
-
if (
|
|
192
|
-
if (!
|
|
193
|
-
const 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
|
|
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
|
-
|
|
219
|
-
|
|
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
|
|