cozyclay 2.0.2 → 2.1.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 +56 -0
- package/README.md +17 -2
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/bin/agent/agent-routes.mjs +211 -36
- package/bin/agent/agent-runner.mjs +98 -4
- package/bin/agent/codex-client.mjs +2 -1
- package/bin/agent/fal-contract.mjs +0 -17
- package/bin/agent/h3-preservation.mjs +6 -8
- package/bin/agent/provider-keys.mjs +1 -1
- package/bin/agent/providers.mjs +124 -3
- package/bin/agent/script-tool.mjs +74 -0
- package/bin/agent/session-store.mjs +5 -5
- package/bin/agent/studio-prompt.mjs +2 -1
- package/bin/agent/studio-rebase.mjs +314 -0
- package/bin/agent/studio-tools.mjs +21 -14
- package/bin/agent/supervisor.mjs +311 -0
- package/bin/agent/video-adapters.mjs +0 -7
- package/bin/codex-auth.mjs +4 -4
- package/bin/live/cli.mjs +2 -3
- package/bin/live/client.mjs +1 -1
- package/bin/live/studio.mjs +5 -2
- package/bin/mcp-runtime.mjs +2 -2
- package/bin/telemetry-state.mjs +3 -3
- package/dist/app/index.html +6 -6
- package/dist/assets/analytics-B41FmPYI.js +1 -0
- package/dist/assets/app-Do-Og5Pu.css +1 -0
- package/dist/assets/app-DwP-tl2x.js +4920 -0
- package/dist/assets/{demo-ByeYp-dY.js → demo-Dt0qZfS7.js} +1 -1
- package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-BlT33CGf.js} +1 -1
- package/dist/assets/{landing-BaXkpFip.js → landing-BSesrCpL.js} +1 -1
- package/dist/assets/scene-asset-sync-BCWDJB1d.js +19 -0
- package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
- package/dist/assets/{ticket-BcmsnHn5.js → ticket-jqMhqL88.js} +1 -1
- package/dist/assets/workflow-Br-K7Srs.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +328 -222
- package/dist/sitemap.xml +1 -1
- package/dist/workflow/index.html +4 -4
- package/mcp/LIVE-PROTOCOL.md +23 -2
- package/mcp/README.md +37 -2
- package/mcp/ardy-prompts.mjs +1 -1
- package/mcp/image-file.mjs +132 -0
- package/mcp/live-hub.mjs +2 -2
- package/mcp/package.json +1 -0
- package/mcp/runtime/package-lock.json +7 -0
- package/mcp/runtime/package.json +1 -0
- package/mcp/server.mjs +0 -6
- package/mcp/tool-handlers.mjs +289 -87
- package/package.json +11 -2
- package/src/App.jsx +789 -263
- package/src/agent-image-client.js +94 -0
- package/src/analytics.js +47 -3
- package/src/app-stage.jsx +129 -2745
- package/src/ardy/auto-physics.js +6 -16
- package/src/ardy/collision-blockers.js +3 -7
- package/src/ardy/fix-collisions.js +5 -35
- package/src/ardy/ik-drag.js +86 -5
- package/src/ardy/ik-key-json.js +3 -1
- package/src/ardy/ik.js +15 -13
- package/src/ardy/motion-calibration.js +1 -1
- package/src/ardy/npz.js +0 -8
- package/src/ardy/physics-panel.jsx +3 -2
- package/src/ardy/physics-review.js +3 -2
- package/src/ardy/physics-support.js +1 -1
- package/src/ardy/physics-surface.js +1 -1
- package/src/ardy/platform-fit-panel.jsx +2 -1
- package/src/ardy/playback.js +1 -5
- package/src/ardy/range-pin.js +2 -2
- package/src/ardy/root-drop.js +0 -1
- package/src/ardy/timeline.css +184 -6
- package/src/ardy/timeline.jsx +113 -28
- package/src/ardy/to-cskel27.js +0 -2
- package/src/ardy/waypoints.js +0 -3
- package/src/ardy-bridge-contract.js +281 -0
- package/src/asset-pane.jsx +19 -9
- package/src/auto-color.js +1 -1
- package/src/burn-in.js +1 -1
- package/src/camera-block.js +1 -1
- package/src/camera-follow.js +75 -32
- package/src/camera-move.js +0 -6
- package/src/camera-tutorial.jsx +6 -7
- package/src/character-kind.js +79 -0
- package/src/command-bus.js +76 -17
- package/src/commands/cast.js +31 -7
- package/src/commands/elements/object.js +1 -1
- package/src/commands/elements/shot.js +1 -1
- package/src/commands/elements.js +1 -1
- package/src/commands/export.js +3 -3
- package/src/commands/motion.js +2 -2
- package/src/commands/objects.js +105 -10
- package/src/commands/project.js +15 -2
- package/src/commands/sculpt-images.js +53 -0
- package/src/commands/shot.js +88 -10
- package/src/commands/view.js +10 -4
- package/src/coplanar-depth.js +216 -0
- package/src/cuts.js +7 -6
- package/src/domains/cast.js +60 -43
- package/src/domains/motion/motion-intent-codec.js +50 -0
- package/src/domains/motion/motion-range-pin-rules.js +23 -0
- package/src/domains/motion/motion-video-capture.js +664 -0
- package/src/domains/motion.js +253 -742
- package/src/domains/objects.js +106 -5
- package/src/domains/scenes.js +79 -28
- package/src/domains/shots.js +33 -7
- package/src/error-boundary.jsx +1 -1
- package/src/execution-telemetry.js +5 -5
- package/src/facing-marks.js +14 -1
- package/src/first-success-guide.jsx +35 -4
- package/src/gizmo-claim.js +3 -1
- package/src/grid-floor.jsx +1 -4
- package/src/hierarchy-model.js +48 -35
- package/src/hierarchy-panel.css +1 -0
- package/src/hierarchy-panel.jsx +54 -102
- package/src/history.js +0 -2
- package/src/i2v-motion-client.js +1 -4
- package/src/ime.js +6 -0
- package/src/keyframe-pack.js +15 -6
- package/src/line-edit.js +4 -4
- package/src/live-control.js +6 -6
- package/src/matte-editor.js +2 -8
- package/src/matte.js +0 -19
- package/src/mesh-graph-clone.js +16 -2
- package/src/mesh-sniff.js +0 -1
- package/src/monotone-cubic.js +40 -0
- package/src/motion-store.js +4 -4
- package/src/motion-trail.js +5 -11
- package/src/multimodel-ingest.js +1 -3
- package/src/object-catalog.jsx +7 -5
- package/src/object-gizmo.jsx +45 -31
- package/src/object-path.js +310 -67
- package/src/object-travel.js +224 -0
- package/src/otio.js +8 -3
- package/src/panels/CharacterTransformPanel.jsx +24 -6
- package/src/panels/ObjectTransformPanel.jsx +4 -3
- package/src/panels/PoseImagePanel.jsx +205 -0
- package/src/panels/PromptBlocksPanel.jsx +3 -1
- package/src/panels/ProxyFigurePanel.jsx +56 -0
- package/src/panels/ReferenceImageField.jsx +1 -1
- package/src/panels/RigControlPanel.jsx +24 -3
- package/src/panels/RigPanel.jsx +2 -1
- package/src/panels/SculptPanel.jsx +405 -0
- package/src/panels/SubjectBox.jsx +1 -1
- package/src/panels/SubjectsPanel.jsx +0 -2
- package/src/panels/VideoCapturePanel.jsx +2 -1
- package/src/panels/pose-image.css +42 -0
- package/src/panels/pose.css +3 -0
- package/src/panels/sculpt.css +162 -0
- package/src/plan-diagram.js +184 -0
- package/src/planview.jsx +127 -80
- package/src/playground.js +3 -5
- package/src/pose-fade.js +77 -0
- package/src/pose-gizmo.css +23 -0
- package/src/pose-gizmo.js +96 -0
- package/src/posestudio.jsx +134 -83
- package/src/project-browser.css +53 -0
- package/src/project-browser.jsx +49 -4
- package/src/project.js +55 -24
- package/src/props.jsx +177 -36
- package/src/proxy-figure.jsx +69 -0
- package/src/range-pin-panel.jsx +0 -1
- package/src/receipt-findings.js +20 -0
- package/src/render-passes.js +0 -1
- package/src/resource-status.jsx +1 -1
- package/src/root-path.js +48 -0
- package/src/route-owner.js +52 -0
- package/src/scene-asset-cache.js +1 -2
- package/src/scene-assets.js +12 -4
- package/src/scene-fbx.js +1 -1
- package/src/scene-mesh-cache.js +0 -1
- package/src/scene-objects.js +319 -19
- package/src/scenes.js +58 -9
- package/src/sculpt-blueprint.js +65 -0
- package/src/sculpt-compare.js +384 -0
- package/src/sculpt-geometry.js +141 -0
- package/src/sculpt-mode.js +168 -0
- package/src/sculpt-recipe.js +385 -0
- package/src/sculpt-run.js +351 -0
- package/src/sculpt-stage.jsx +221 -0
- package/src/settings-menu.jsx +1 -1
- package/src/shell/BoardSlot.jsx +341 -0
- package/src/shell/BottomDock.jsx +58 -14
- package/src/shell/DetailsSlot.jsx +26 -6
- package/src/shell/MenuBar.jsx +78 -6
- package/src/shell/OutlinerSlot.jsx +3 -1
- package/src/shell/TopBar.jsx +17 -3
- package/src/shell/ViewportToolbar.jsx +18 -3
- package/src/shell/dock.css +239 -0
- package/src/shell/glass-regions.css +3 -2
- package/src/shell/log-store.js +4 -4
- package/src/shell/mode.css +20 -0
- package/src/shell/studio-shell-context.js +4 -1
- package/src/shot-authoring.js +69 -6
- package/src/stable-items.js +1 -1
- package/src/stage-attach-frames.js +169 -0
- package/src/stage-canvas-rigs.jsx +402 -0
- package/src/stage-capture.jsx +216 -0
- package/src/stage-characters.jsx +302 -0
- package/src/stage-editor-gizmos.jsx +1229 -0
- package/src/stage-labels-ko.js +204 -0
- package/src/stage-startup-defaults.js +182 -0
- package/src/starter-scenes.js +1 -1
- package/src/storyboard.js +24 -5
- package/src/studio-actions.js +42 -9
- package/src/studio-agent-commands.js +186 -23
- package/src/studio-agent-context.js +11 -9
- package/src/studio-agent-motion.js +2 -1
- package/src/studio-agent-protocol.js +56 -23
- package/src/studio-app-binding.js +52 -23
- package/src/studio-contact-sheet.js +3 -3
- package/src/studio-elements.js +11 -2
- package/src/studio-geometry-facts.js +114 -0
- package/src/styles.css +92 -9
- package/src/ui-scrub.js +4 -4
- package/src/ui.jsx +6 -4
- package/src/use-asset-shelf.js +60 -0
- package/src/use-camera-tutorial.js +50 -0
- package/src/use-embed-bridge.js +12 -0
- package/src/use-first-success-prompt.js +7 -0
- package/src/use-i2v-capture-modal.js +15 -0
- package/src/use-sculpt-mode.js +95 -0
- package/src/workflow/AgentPanel.jsx +106 -18
- package/src/workflow/WorkflowBuilder.jsx +1 -1
- package/src/workflow/agent-client.js +113 -45
- package/src/workflow/agent-panel.css +35 -0
- package/src/workflow/attachment-image.js +1 -1
- package/src/workflow/canvas-commands.js +3 -3
- package/src/workflow/cozy-scene-node.js +3 -3
- package/src/workflow/local-workflow.js +2 -2
- package/src/workflow/motion-input.js +3 -3
- package/src/workflow/node-schema.js +1 -1
- package/src/workflow/scene-asset-sync.js +2 -9
- package/src/workflow/shot-prompt-node.js +1 -1
- package/src/workflow/video-contract.js +1 -1
- package/src/workflow/workflow-resources.js +0 -8
- package/src/workflow-send.js +87 -0
- package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
- package/tools/check-eslint.mjs +57 -0
- package/tools/check-knip.mjs +60 -0
- package/tools/check-loc.mjs +54 -0
- package/tools/demo-worker/api-client.mjs +7 -13
- package/tools/demo-worker/generate.mjs +2 -4
- package/tools/eslint-baseline.json +23 -0
- package/tools/kimodo/effector-constraints.mjs +4 -4
- package/tools/kimodo/generate.mjs +1 -1
- package/tools/knip-baseline.json +331 -0
- package/tools/loc-baseline.json +126 -0
- package/tools/process-supervisor.mjs +1 -1
- package/tools/projflow/replay.mjs +4 -4
- package/tools/projflow/runner.mjs +3 -3
- package/tools/qa/studio-control-count.mjs +2 -0
- package/tools/run-tests.mjs +94 -7
- package/dist/assets/analytics-MVZOEW8O.js +0 -1
- package/dist/assets/app-BxBLC6GT.js +0 -4876
- package/dist/assets/app-CJdxvnrl.css +0 -1
- package/dist/assets/shot-prompt-hN0j_O-p.js +0 -17
- package/dist/assets/workflow-B5XZwVfe.js +0 -179
package/src/commands/project.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { studioActionDeclaration } from "../studio-actions.js";
|
|
2
2
|
import { fail } from "./shared.js";
|
|
3
|
+
import { PREVIS_MODES } from "../project.js";
|
|
3
4
|
|
|
4
5
|
const input = (properties = {}, required = []) => ({ type: "object", properties, required, additionalProperties: false });
|
|
5
6
|
const name = { type: "string", minLength: 1, maxLength: 240 };
|
|
@@ -8,12 +9,13 @@ const document = (id, label, properties = {}) => ({ id, label, description: labe
|
|
|
8
9
|
const rename = { id: "project.rename", label: "Rename project", description: "Rename the project in one retained undo entry.", kind: "mutation", undoDomain: "scenes", input: input({ name }, ["name"]) };
|
|
9
10
|
const save = { ...studioActionDeclaration("project.save"), input: input({ name }) };
|
|
10
11
|
const saveAs = { ...save, id: "project.saveAs", label: "Save project as" };
|
|
12
|
+
const exportAsAnimation = studioActionDeclaration("project.exportAsAnimation");
|
|
11
13
|
const opening = document("project.open", "Open a project file", { serialized: { type: "string", maxLength: 400_000_000 }, handleToken: token, projectToken: token });
|
|
12
|
-
const fresh = document("project.new", "Start a new project", { name });
|
|
14
|
+
const fresh = document("project.new", "Start a new project", { name, previsMode: { type: "string", enum: [...PREVIS_MODES] } });
|
|
13
15
|
const starter = document("project.openStarter", "Open a starter project", { id: token, source: { type: "string", enum: ["starter", "tutorial", "launch"] }, name });
|
|
14
16
|
const restore = { ...document("project.restore", "Restore the remembered project", { handleToken: token }), exposure: "ui-only" };
|
|
15
17
|
const browse = { id: "project.browse", label: "Browse projects", description: "Show the project browser.", kind: "transient", exposure: "ui-only", input: input() };
|
|
16
|
-
export const declarations = Object.freeze([rename, save, saveAs, opening, fresh, starter, restore, browse]);
|
|
18
|
+
export const declarations = Object.freeze([rename, save, saveAs, exportAsAnimation, opening, fresh, starter, restore, browse]);
|
|
17
19
|
|
|
18
20
|
export function register(registry, ports) {
|
|
19
21
|
const owner = () => ports.storeDomain?.("scenes");
|
|
@@ -44,6 +46,17 @@ export function register(registry, ports) {
|
|
|
44
46
|
? `This browser has no file access, so the project ${saved.name} was downloaded as ${saved.fileName}.`
|
|
45
47
|
: `Saved the project ${saved.name} to ${saved.fileName}.` };
|
|
46
48
|
} });
|
|
49
|
+
registry.register({ ...exportAsAnimation, available: () => true,
|
|
50
|
+
run: async (_args, context) => {
|
|
51
|
+
const saved = await owner().exportAsAnimationProject(context);
|
|
52
|
+
if (saved?.cancelled) fail("TARGET_NOT_READY", "Not exported: the user closed the file picker.");
|
|
53
|
+
if (!saved?.saved) fail("TARGET_NOT_READY", saved?.failure === "not-storyboard"
|
|
54
|
+
? "Export as Animation project is available only for storyboard projects."
|
|
55
|
+
: "Not exported: writing the animation project file failed.");
|
|
56
|
+
return { affectedIds: [], output: { fileName: saved.fileName }, summary: saved.downloaded
|
|
57
|
+
? `This browser has no file access, so the animation project ${saved.name} was downloaded as ${saved.fileName}.`
|
|
58
|
+
: `Exported the storyboard as ${saved.name} to ${saved.fileName}.` };
|
|
59
|
+
} });
|
|
47
60
|
for (const declaration of [opening, fresh, starter, restore, browse]) registry.register({ ...declaration,
|
|
48
61
|
available, run: async (args, context) => {
|
|
49
62
|
const result = await owner().projectAction(declaration.id, args, context);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The image port behind sculpt.compare: a reference ({ assetId } from the asset
|
|
2
|
+
// store, or { dataUrl }) decoded to RGBA, and an RGBA sheet encoded as a PNG data
|
|
3
|
+
// URL. The editor uses the browser one below; a Node test hands in its own as
|
|
4
|
+
// `ports.sculptImages`, so the scoring runs on the same pixels in both.
|
|
5
|
+
import { getAsset, openAssetDb } from "../scene-assets.js";
|
|
6
|
+
|
|
7
|
+
function canvas(width, height) {
|
|
8
|
+
if (typeof OffscreenCanvas === "function") return new OffscreenCanvas(width, height);
|
|
9
|
+
if (globalThis.document?.createElement) return Object.assign(document.createElement("canvas"), { width, height });
|
|
10
|
+
throw new Error("this browser cannot draw images: no canvas is available");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function referenceBlob(reference) {
|
|
14
|
+
if (reference.dataUrl !== undefined) return (await fetch(reference.dataUrl)).blob();
|
|
15
|
+
const asset = await getAsset(await openAssetDb(), reference.assetId);
|
|
16
|
+
if (!asset || asset.width === undefined) throw new Error(`no image asset ${reference.assetId} in this project`);
|
|
17
|
+
return new Blob([asset.bytes], { type: asset.type });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const browserSculptImages = Object.freeze({
|
|
21
|
+
async decode(reference) {
|
|
22
|
+
if (typeof createImageBitmap !== "function") throw new Error("this browser cannot decode images: createImageBitmap is unavailable");
|
|
23
|
+
const bitmap = await createImageBitmap(await referenceBlob(reference));
|
|
24
|
+
try {
|
|
25
|
+
const context = canvas(bitmap.width, bitmap.height).getContext("2d", { willReadFrequently: true });
|
|
26
|
+
context.drawImage(bitmap, 0, 0);
|
|
27
|
+
const { width, height, data } = context.getImageData(0, 0, bitmap.width, bitmap.height);
|
|
28
|
+
return { width, height, data };
|
|
29
|
+
} finally {
|
|
30
|
+
bitmap.close?.();
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
async encodePng({ width, height, data }) {
|
|
34
|
+
const surface = canvas(width, height);
|
|
35
|
+
surface.getContext("2d").putImageData(new ImageData(new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength), width, height), 0, 0);
|
|
36
|
+
if (!surface.convertToBlob) return surface.toDataURL("image/png");
|
|
37
|
+
const bytes = new Uint8Array(await (await surface.convertToBlob({ type: "image/png" })).arrayBuffer());
|
|
38
|
+
let binary = "";
|
|
39
|
+
for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
40
|
+
return `data:image/png;base64,${btoa(binary)}`;
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** Nearest-neighbour shrink of an RGBA image by an integer factor. */
|
|
45
|
+
export function shrinkRgba({ width, height, data }, factor) {
|
|
46
|
+
const w = Math.max(1, Math.floor(width / factor)), h = Math.max(1, Math.floor(height / factor));
|
|
47
|
+
const out = new Uint8Array(w * h * 4);
|
|
48
|
+
for (let y = 0; y < h; y += 1) for (let x = 0; x < w; x += 1) {
|
|
49
|
+
const s = ((y * factor) * width + x * factor) * 4;
|
|
50
|
+
out.set(data.subarray(s, s + 4), (y * w + x) * 4);
|
|
51
|
+
}
|
|
52
|
+
return { width: w, height: h, data: out };
|
|
53
|
+
}
|
package/src/commands/shot.js
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
// One semantic entry point for timeline, camera controls and agent framing.
|
|
2
2
|
import { createCameraBlock, updateCameraBlock, removeCameraRail } from '../camera-block.js';
|
|
3
|
-
import { addShotAtFrame, cutAtFrame, duplicateShot, removeShot, reorderShot, resizeShot, renameShot, moveCameraKey, removeCameraKey } from '../cuts.js';
|
|
3
|
+
import { addShotAtFrame, createShot, cutAtFrame, duplicateShot, removeShot, reorderShot, resizeShot, renameShot, moveCameraKey, removeCameraKey } from '../cuts.js';
|
|
4
|
+
import { reflowStillShots, stillFrameCount, STILL_FRAME_COUNT_MAX, STILL_HOLD_DEFAULT } from '../shot-authoring.js';
|
|
5
|
+
import { ko } from '../locale.js';
|
|
6
|
+
import { track } from '../analytics.js';
|
|
7
|
+
import { isImageAssetId } from '../scene-assets.js';
|
|
4
8
|
import { createStableItemId, updateStableItem } from '../stable-items.js';
|
|
5
9
|
import { railFollowForNewGeometry } from '../camera-rail-schedule.js';
|
|
6
|
-
import { studioActionDeclaration } from '../studio-actions.js';
|
|
10
|
+
import { studioActionDeclaration, studioActionRefusal } from '../studio-actions.js';
|
|
7
11
|
import { STUDIO_TOOL_SCHEMAS, StudioSchemas } from '../studio-agent-protocol.js';
|
|
8
12
|
import { elementSetSchema, registerElementSet } from './elements.js';
|
|
9
13
|
import './elements/shot.js';
|
|
10
14
|
import { changedIds, fail, shotLabel } from './shared.js';
|
|
11
15
|
|
|
12
16
|
const id = StudioSchemas.TargetGuard.properties.targetId;
|
|
17
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
13
18
|
const number = { type: 'number' };
|
|
14
19
|
const frame = { type: 'integer', minimum: 0 };
|
|
15
20
|
const input = (properties, required = Object.keys(properties)) => ({ type: 'object', properties, required, additionalProperties: false });
|
|
16
21
|
const mutation = (id, label, schema) => ({ id, label, description: label, kind: 'mutation', undoDomain: 'shot', input: schema });
|
|
17
|
-
const existing = ['shot.create', 'shot.split', 'shot.duplicate', 'shot.remove', 'shot.setRange', 'shot.setCameraRail', 'shot.clearCameraRail', 'shot.reorder'].map(studioActionDeclaration);
|
|
22
|
+
const existing = ['shot.create', 'shot.createStill', 'shot.setCaption', 'shot.setCastOverride', 'shot.setHold', 'shot.split', 'shot.duplicate', 'shot.remove', 'shot.setRange', 'shot.setCameraRail', 'shot.clearCameraRail', 'shot.reorder'].map(studioActionDeclaration);
|
|
18
23
|
const extra = [
|
|
19
24
|
mutation('shot.set', 'Set shot fields', elementSetSchema('shot')),
|
|
20
25
|
mutation('shot.rename', 'Rename shot', input({ shotId: id, name: { type: 'string', maxLength: 240 } })),
|
|
@@ -28,6 +33,9 @@ const extra = [
|
|
|
28
33
|
mutation('shot.frame', 'Frame the shot', { ...input({ ...STUDIO_TOOL_SCHEMAS.frame_shot.properties, preset: { type: 'string' } }, []), oneOf: [STUDIO_TOOL_SCHEMAS.frame_shot, input({ preset: { type: 'string' } })] }),
|
|
29
34
|
{ ...mutation('shot.replace', 'Replace shot authoring', input({ shots: { type: 'array', items: { type: 'object', properties: {}, additionalProperties: true } } })), exposure: 'ui-only' },
|
|
30
35
|
{ ...mutation('shot.captureCamera', 'Capture camera framing', input({ shotId: id }, [])), exposure: 'ui-only' },
|
|
36
|
+
// The Board's Stylize result (or null to clear it). UI-only: the picture has
|
|
37
|
+
// to be in the asset store first, which only the Stylize flow does.
|
|
38
|
+
{ ...mutation('shot.setStylized', 'Set stylized panel image', input({ shotId: id, assetId: { oneOf: [{ type: 'string' }, { type: 'null' }] } })), exposure: 'ui-only' },
|
|
31
39
|
{ ...mutation('shot.placeCamera', 'Place camera', input(Object.fromEntries(['x', 'y', 'z', 'lookAtX', 'lookAtY', 'lookAtZ', 'focalMm'].map(key => [key, number])), [])), exposure: 'ui-only' },
|
|
32
40
|
];
|
|
33
41
|
export const declarations = Object.freeze([extra[0], ...existing, ...extra.slice(1).map(entry => entry.id === 'shot.frame' ? entry : { ...entry, exposure: 'ui-only' })]);
|
|
@@ -37,16 +45,75 @@ export function register(registry, ports) {
|
|
|
37
45
|
const mounted = () => Boolean(ports.storeDomain?.('shot')) || 'The shots document owner is not mounted.';
|
|
38
46
|
const shotOf = shotId => owner().read().find(shot => shot.id === shotId) ?? fail('STALE_TARGET', `Shot ${shotId} is not in this scene.`);
|
|
39
47
|
const patchShot = (shotId, update) => { shotOf(shotId); owner().write(current => updateStableItem(current, shotId, update, 'shots')); };
|
|
48
|
+
const storyboard = () => ports.storeDomain?.('scenes')?.metadata?.().previsMode === 'storyboard';
|
|
49
|
+
const stillOf = shotId => {
|
|
50
|
+
const shot = shotOf(shotId);
|
|
51
|
+
return shot.kind === 'still' ? shot : fail('INVALID_ARGUMENT', `${shot.name} is a clip shot; only stills have a hold and a place in the still order.`);
|
|
52
|
+
};
|
|
53
|
+
// Every still edit is one write: the stills (in their new array order) are
|
|
54
|
+
// laid end to end and the timeline is sized to them, or nothing changes.
|
|
55
|
+
function writeStills(shots) {
|
|
56
|
+
const next = reflowStillShots(shots), frameCount = stillFrameCount(next);
|
|
57
|
+
if (frameCount > STILL_FRAME_COUNT_MAX) {
|
|
58
|
+
const en = `Storyboard is full (${STILL_FRAME_COUNT_MAX} frames); shorten a hold or remove a panel.`;
|
|
59
|
+
throw studioActionRefusal('INVALID_ARGUMENT', en, ko(en, `스토리보드가 가득 찼습니다 (${STILL_FRAME_COUNT_MAX}프레임). 홀드를 줄이거나 패널을 지우세요.`));
|
|
60
|
+
}
|
|
61
|
+
const clip = next.find(shot => shot.kind !== 'still' && next.some(still => still.kind === 'still' && still.startFrame <= shot.endFrame && still.endFrame >= shot.startFrame));
|
|
62
|
+
if (clip) fail('TARGET_NOT_READY', `The stills would overlap the clip shot ${clip.name} (frames ${clip.startFrame}-${clip.endFrame}); move or remove it first.`);
|
|
63
|
+
owner().writeState(before => ({ ...before, shots: next, frameCount }));
|
|
64
|
+
return next;
|
|
65
|
+
}
|
|
66
|
+
function createStill({ caption = '', hold = STILL_HOLD_DEFAULT } = {}) {
|
|
67
|
+
const current = owner().read(), framing = owner().capture(), last = current.findLastIndex(shot => shot.kind === 'still');
|
|
68
|
+
const keys = framing ? [{ id: createStableItemId('camera-key'), frame: 0, framing }] : [];
|
|
69
|
+
const still = { ...createShot(`Shot ${current.length + 1}`, 0, hold - 1, keys), kind: 'still', caption };
|
|
70
|
+
const written = writeStills([...current.slice(0, last + 1), still, ...current.slice(last + 1)]);
|
|
71
|
+
// Land the playhead in the new panel (the timeline.seek path) so the placement
|
|
72
|
+
// actions that follow write this panel's cast override, not the previous one's.
|
|
73
|
+
const created = written.find(shot => shot.id === still.id), { selection, view } = ports.readView();
|
|
74
|
+
ports.publishView({ selection, view: { ...view, frame: created.startFrame }, shotId: created.id });
|
|
75
|
+
}
|
|
76
|
+
function duplicate(shotId) {
|
|
77
|
+
const source = shotOf(shotId), copied = { kind: source.kind, caption: source.caption, cast: structuredClone(source.cast), stylizedAssetId: source.stylizedAssetId };
|
|
78
|
+
if (source.kind !== 'still') {
|
|
79
|
+
const before = owner().read(), next = duplicateShot(before, shotId, owner().state().frameCount);
|
|
80
|
+
return owner().write(next.map(shot => before.includes(shot) ? shot : { ...shot, ...copied }));
|
|
81
|
+
}
|
|
82
|
+
const current = owner().read(), index = current.indexOf(source);
|
|
83
|
+
const copy = { ...createShot(`${source.name} copy`, source.startFrame, source.endFrame,
|
|
84
|
+
source.cameraKeys.map(key => ({ ...key, id: createStableItemId('camera-key') })), source.camera), ...copied };
|
|
85
|
+
writeStills([...current.slice(0, index + 1), copy, ...current.slice(index + 1)]);
|
|
86
|
+
}
|
|
87
|
+
function reorder({ shotId, startFrame, index }) {
|
|
88
|
+
if ((startFrame === undefined) === (index === undefined)) fail('INVALID_ARGUMENT', 'shot.reorder takes exactly one of startFrame or index.');
|
|
89
|
+
const shot = shotOf(shotId);
|
|
90
|
+
if (shot.kind !== 'still') {
|
|
91
|
+
if (index !== undefined) stillOf(shotId);
|
|
92
|
+
return owner().write(current => reorderShot(current, shotId, startFrame, owner().state().frameCount));
|
|
93
|
+
}
|
|
94
|
+
const current = owner().read(), others = current.filter(row => row.kind === 'still' && row.id !== shotId);
|
|
95
|
+
const target = index ?? others.filter(row => row.startFrame < startFrame).length;
|
|
96
|
+
const order = [...others.slice(0, target), shot, ...others.slice(target)];
|
|
97
|
+
let next = 0;
|
|
98
|
+
writeStills(current.map(row => row.kind === 'still' ? order[next++] : row));
|
|
99
|
+
}
|
|
40
100
|
const methods = {
|
|
41
|
-
'shot.create': () => owner().write(current => addShotAtFrame(current, ports.state().frame, owner().state().frameCount, owner().capture())),
|
|
101
|
+
'shot.create': () => storyboard() ? createStill() : owner().write(current => addShotAtFrame(current, ports.state().frame, owner().state().frameCount, owner().capture())),
|
|
102
|
+
'shot.createStill': args => createStill(args),
|
|
103
|
+
'shot.setCaption': ({ shotId, caption }) => patchShot(shotId, shot => ({ ...shot, caption })),
|
|
104
|
+
'shot.setCastOverride': ({ shotId, characterId, override }) => owner().setCastOverride(shotId, characterId, override),
|
|
105
|
+
'shot.setHold': ({ shotId, hold }) => {
|
|
106
|
+
const shot = stillOf(shotId), endFrame = shot.startFrame + hold - 1;
|
|
107
|
+
writeStills(owner().read().map(row => row === shot ? { ...row, endFrame, cameraKeys: row.cameraKeys.filter(key => key.frame <= endFrame) } : row));
|
|
108
|
+
},
|
|
42
109
|
'shot.split': ({ shotId }) => {
|
|
43
110
|
const shot = shotOf(shotId), { frame } = ports.state();
|
|
44
111
|
if (frame <= shot.startFrame || frame > shot.endFrame) fail('TARGET_NOT_READY', `The playhead (frame ${frame}) is not inside ${shot.name} after its first frame.`);
|
|
45
112
|
owner().write(current => cutAtFrame(current, shotId, frame, owner().capture()));
|
|
46
113
|
},
|
|
47
|
-
'shot.duplicate': ({ shotId }) =>
|
|
114
|
+
'shot.duplicate': ({ shotId }) => duplicate(shotId),
|
|
48
115
|
'shot.remove': ({ shotId }) => { shotOf(shotId); owner().write(current => removeShot(current, shotId)); },
|
|
49
|
-
'shot.reorder':
|
|
116
|
+
'shot.reorder': args => reorder(args),
|
|
50
117
|
'shot.setRange': ({ shotId, range }) => {
|
|
51
118
|
const shot = shotOf(shotId), startFrame = range.startFrame, endFrame = range.endFrameExclusive - 1;
|
|
52
119
|
const edges = startFrame > shot.endFrame ? [['end', endFrame], ['start', startFrame]] : [['start', startFrame], ['end', endFrame]];
|
|
@@ -77,24 +144,35 @@ export function register(registry, ports) {
|
|
|
77
144
|
'shot.setLens': ({ fovDeg }) => owner().setLens(fovDeg),
|
|
78
145
|
'shot.replace': ({ shots }) => owner().write(shots),
|
|
79
146
|
'shot.captureCamera': args => owner().captureCamera(args.shotId),
|
|
147
|
+
'shot.setStylized': ({ shotId, assetId }) => {
|
|
148
|
+
if (assetId !== null && !isImageAssetId(assetId)) fail('INVALID_ARGUMENT', `${assetId} is not an image asset id.`);
|
|
149
|
+
patchShot(shotId, shot => ({ ...shot, stylizedAssetId: assetId }));
|
|
150
|
+
},
|
|
80
151
|
'shot.placeCamera': args => owner().placeCamera(args),
|
|
81
152
|
};
|
|
82
153
|
const hasShots = state => state.shots.length > 0 || 'There are no shots yet; add one with shot.create.';
|
|
83
154
|
const availability = {
|
|
84
|
-
'shot.
|
|
155
|
+
'shot.createStill': () => true,
|
|
156
|
+
'shot.create': state => storyboard() || addShotAtFrame(state.shots, state.frame, state.frameCount, null) !== state.shots || `There is no free room for a new shot at the playhead (frame ${state.frame}); move it with operate_studio { frame } or shorten a shot.`,
|
|
85
157
|
'shot.split': state => state.shots.some(shot => state.frame > shot.startFrame && state.frame <= shot.endFrame) || `The playhead (frame ${state.frame}) is not inside a shot after its first frame; move it with operate_studio { frame }.`,
|
|
86
158
|
'shot.clearCameraRail': state => state.shots.some(shot => createCameraBlock(shot.camera).cameraRail) || 'No shot has a camera rail; lay one with shot.setCameraRail.',
|
|
87
159
|
};
|
|
88
160
|
registerElementSet(registry, ports, extra[0]);
|
|
89
161
|
for (const declaration of declarations.filter(row => row.id !== 'shot.set')) registry.register({ ...declaration,
|
|
90
162
|
available: state => mounted() === true ? (availability[declaration.id] ?? (existing.includes(declaration) ? hasShots : () => true))(state) : mounted(),
|
|
91
|
-
run(args) {
|
|
163
|
+
run(args, context) {
|
|
92
164
|
if (declaration.id === 'shot.frame') {
|
|
93
165
|
const plan = owner().frame(args);
|
|
94
166
|
return { affectedIds: plan.affectedIds, summary: 'Framed the shot.' };
|
|
95
167
|
}
|
|
96
|
-
const before = owner().read()
|
|
97
|
-
|
|
168
|
+
const before = owner().read(), newPanel = declaration.id === 'shot.createStill' || (declaration.id === 'shot.create' && storyboard());
|
|
169
|
+
methods[declaration.id](args);
|
|
170
|
+
// Only the origin travels: never the caption, name or id of the panel.
|
|
171
|
+
if (newPanel) track('storyboard:panel_created', { source: context?.origin && context.origin !== 'ui' ? 'agent' : 'manual' });
|
|
172
|
+
// The owner re-normalizes every shot on write, so compare content, not
|
|
173
|
+
// identity; a storyboard reflow can still move more shots than a receipt
|
|
174
|
+
// names (100), and the first 100 stand for the batch.
|
|
175
|
+
const after = owner().read(), changed = changedIds(before.map(row => after.find(next => next.id === row.id && same(next, row)) ?? row), after).slice(0, 100);
|
|
98
176
|
const affectedIds = changed.length ? changed : [args.shotId ?? ports.state().activeSceneId];
|
|
99
177
|
return { affectedIds, summary: changed.length ? `${declaration.label}: ${changed.map(id => after.find(row => row.id === id)).filter(Boolean).map(shotLabel).join('; ')}.` : `${declaration.label}.` };
|
|
100
178
|
},
|
package/src/commands/view.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Viewer preferences: transient, like the View menu they mirror.
|
|
2
|
-
import { studioActionDeclaration } from "../studio-actions.js";
|
|
3
|
-
import {
|
|
2
|
+
import { studioActionDeclaration, studioActionRefusal } from "../studio-actions.js";
|
|
3
|
+
import { STUDIO_TOOL_SCHEMAS } from '../studio-agent-protocol.js';
|
|
4
|
+
import { ko } from '../locale.js';
|
|
4
5
|
import { shotAtFrame } from '../cuts.js';
|
|
6
|
+
import { fail } from './shared.js';
|
|
5
7
|
|
|
6
8
|
const fields = STUDIO_TOOL_SCHEMAS.operate_studio.properties;
|
|
7
9
|
const groups = { 'view.select': ['selection', 'shotId'], 'timeline.seek': ['frame'], 'timeline.play': ['playing'], 'view.setMode': ['mode', 'view'] };
|
|
@@ -10,16 +12,18 @@ const operations = Object.entries({ ...groups, 'view.update': Object.keys(fields
|
|
|
10
12
|
input: { type: 'object', properties: Object.fromEntries(keys.map(key => [key, fields[key]])), required: [], additionalProperties: false },
|
|
11
13
|
}));
|
|
12
14
|
export const declarations = Object.freeze([...operations, ...["view.setPartColours", "view.setGuideMode", "view.setInset"].map(studioActionDeclaration)]);
|
|
15
|
+
// A storyboard project has no Motion department; every door says so the same way.
|
|
16
|
+
export const STORYBOARD_MOTION_REFUSAL = Object.freeze({ en: 'Motion tools are not part of a Storyboard project.', ko: '모션 도구는 스토리보드 프로젝트에 없어요.' });
|
|
13
17
|
export const viewCommand = args => Object.entries(groups).find(([, keys]) => Object.keys(args).every(key => keys.includes(key)))?.[0] ?? 'view.update';
|
|
14
18
|
|
|
15
19
|
export function register(registry, ports) {
|
|
16
20
|
registry.registerToolAlias('operate_studio', viewCommand);
|
|
17
21
|
for (const declaration of operations) registry.register({ ...declaration, available: () => true, run(args) {
|
|
18
|
-
const fail = (code, message) => { throw new StudioProtocolError(code, message); };
|
|
19
22
|
if (!Object.keys(args).length || (args.view && !Object.keys(args.view).length)) fail('INVALID_ARGUMENT', 'Specify a view operation.');
|
|
20
23
|
const state = ports.readView();
|
|
21
24
|
// Pose mode is IK editing: without a rig to solve there is nothing to pose.
|
|
22
25
|
if (args.mode === 'pose' && args.mode !== state.view.mode && !ports.canPose()) fail('TARGET_NOT_READY', 'Pose mode needs a loaded character rig to edit with IK.');
|
|
26
|
+
if (args.mode === 'motion' && state.previsMode === 'storyboard') throw studioActionRefusal('TARGET_NOT_READY', STORYBOARD_MOTION_REFUSAL.en, ko(STORYBOARD_MOTION_REFUSAL.en, STORYBOARD_MOTION_REFUSAL.ko));
|
|
23
27
|
const selection = args.selection === undefined ? state.selection : args.selection;
|
|
24
28
|
if (selection) {
|
|
25
29
|
const found = selection.kind === 'scene' ? selection.id === state.host.sceneId : selection.kind === 'camera' ? selection.id === 'camera'
|
|
@@ -31,7 +35,9 @@ export function register(registry, ports) {
|
|
|
31
35
|
const frame = args.frame ?? shot?.startFrame ?? state.view.frame;
|
|
32
36
|
if (frame >= Math.max(1, state.frameCount)) fail('INVALID_RANGE', 'Frame is outside the timeline.');
|
|
33
37
|
const view = { ...state.view, ...args.view, frame, mode: args.mode ?? state.view.mode, playing: args.playing ?? state.view.playing };
|
|
34
|
-
|
|
38
|
+
// modeSet: only a command that names a mode moves the editor out of a
|
|
39
|
+
// UI-only mode (Sculpt, #739), which the published view reads as Stage.
|
|
40
|
+
ports.publishView({ selection, view, shotId: args.shotId ?? shotAtFrame(state.shots, frame)?.id ?? null, modeSet: args.mode !== undefined });
|
|
35
41
|
return { affectedIds: [state.host.sceneId], summary: 'Updated editor view.' };
|
|
36
42
|
} });
|
|
37
43
|
const viewAction = (id, run) => registry.register({ ...studioActionDeclaration(id), available: () => true,
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Depth ranks for coplanar faces of scene-object primitives.
|
|
5
|
+
*
|
|
6
|
+
* Agent-built assemblies stack parts whose same-facing faces lie in exactly
|
|
7
|
+
* one plane (a hood and a grille sharing a top at y=1.05). The depth test
|
|
8
|
+
* cannot order them, so as the camera moves the winner flips every frame and
|
|
9
|
+
* the strip sparkles. The ranks computed here let the renderer pull the
|
|
10
|
+
* later-created part toward the camera by a polygon offset, which holds
|
|
11
|
+
* whatever the camera does. The records are never edited.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const DEG = Math.PI / 180;
|
|
15
|
+
/** Planes closer than this are one plane. */
|
|
16
|
+
const PLANE_EPS = 1e-3;
|
|
17
|
+
/** Normals this aligned (cosine) face the same way. */
|
|
18
|
+
const NORMAL_DOT = 0.9999;
|
|
19
|
+
/** Overlap shallower than this is two faces touching along an edge. */
|
|
20
|
+
const OVERLAP_EPS = 1e-4;
|
|
21
|
+
/** Bounds the offset: a deeper chain shares the top rank. */
|
|
22
|
+
export const MAX_DEPTH_RANK = 6;
|
|
23
|
+
|
|
24
|
+
// Planar faces per kind, in the primitive's local frame BEFORE scale: the axis
|
|
25
|
+
// the face is perpendicular to, which side, and where along that axis it sits
|
|
26
|
+
// (cube/cylinder/cone sit on y in [0, 1] — see Primitive in props.jsx).
|
|
27
|
+
const BOX_FACES = [
|
|
28
|
+
{ axis: 0, side: 1, at: 0.5 }, { axis: 0, side: -1, at: -0.5 },
|
|
29
|
+
{ axis: 1, side: 1, at: 1 }, { axis: 1, side: -1, at: 0 },
|
|
30
|
+
{ axis: 2, side: 1, at: 0.5 }, { axis: 2, side: -1, at: -0.5 },
|
|
31
|
+
];
|
|
32
|
+
const CAP_TOP = { axis: 1, side: 1, at: 1 };
|
|
33
|
+
const CAP_BASE = { axis: 1, side: -1, at: 0 };
|
|
34
|
+
const FACES_BY_KIND = {
|
|
35
|
+
cube: BOX_FACES,
|
|
36
|
+
cylinder: [CAP_TOP, CAP_BASE],
|
|
37
|
+
cone: [CAP_BASE],
|
|
38
|
+
// 2x2, a hair above the floor, drawn double-sided
|
|
39
|
+
plane: [{ axis: 1, side: 1, at: 0.004, flat: true }, { axis: 1, side: -1, at: 0.004, flat: true }],
|
|
40
|
+
};
|
|
41
|
+
// Local centre of the box the faces live on, per axis (x/z centred, y from 0).
|
|
42
|
+
const MID = [0, 0.5, 0];
|
|
43
|
+
const HALF = [0.5, 0.5, 0.5];
|
|
44
|
+
const PLANE_HALF = [1, 0, 1];
|
|
45
|
+
|
|
46
|
+
const rot = new THREE.Matrix4();
|
|
47
|
+
const euler = new THREE.Euler();
|
|
48
|
+
|
|
49
|
+
/** World-space rectangular faces of one object, or null when it has none. */
|
|
50
|
+
function facesOf(object) {
|
|
51
|
+
const kind = object.renderer ?? object.kind;
|
|
52
|
+
const table = FACES_BY_KIND[kind];
|
|
53
|
+
if (!table || object.attach || object.hidden === true) return null;
|
|
54
|
+
euler.set((object.rotX ?? 0) * DEG, (object.rot ?? 0) * DEG, (object.rotZ ?? 0) * DEG);
|
|
55
|
+
rot.makeRotationFromEuler(euler);
|
|
56
|
+
const e = rot.elements;
|
|
57
|
+
// column a of the rotation = where local axis a points in the world
|
|
58
|
+
const axes = [[e[0], e[1], e[2]], [e[4], e[5], e[6]], [e[8], e[9], e[10]]];
|
|
59
|
+
const scale = [object.scaleX ?? 1, object.scaleY ?? 1, object.scaleZ ?? 1];
|
|
60
|
+
const pos = [object.x ?? 0, object.y ?? 0, object.z ?? 0];
|
|
61
|
+
if (![...scale, ...pos, ...axes[0]].every(Number.isFinite)) return null;
|
|
62
|
+
const faces = [];
|
|
63
|
+
for (const face of table) {
|
|
64
|
+
const a = face.axis;
|
|
65
|
+
const u = (a + 1) % 3;
|
|
66
|
+
const v = (a + 2) % 3;
|
|
67
|
+
// a mirrored scale turns the outward normal around with it
|
|
68
|
+
const flip = face.side * (scale[a] < 0 ? -1 : 1);
|
|
69
|
+
const half = face.flat ? PLANE_HALF : HALF;
|
|
70
|
+
const centre = [0, 0, 0];
|
|
71
|
+
for (let i = 0; i < 3; i += 1) {
|
|
72
|
+
const local = i === a ? face.at : face.flat ? 0 : MID[i];
|
|
73
|
+
for (let k = 0; k < 3; k += 1) centre[k] += axes[i][k] * local * scale[i];
|
|
74
|
+
}
|
|
75
|
+
faces.push({
|
|
76
|
+
n: [axes[a][0] * flip, axes[a][1] * flip, axes[a][2] * flip],
|
|
77
|
+
c: [centre[0] + pos[0], centre[1] + pos[1], centre[2] + pos[2]],
|
|
78
|
+
u: axes[u],
|
|
79
|
+
v: axes[v],
|
|
80
|
+
hu: half[u] * Math.abs(scale[u]),
|
|
81
|
+
hv: half[v] * Math.abs(scale[v]),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
// world box of the faces, for the pair prefilter
|
|
85
|
+
const min = [Infinity, Infinity, Infinity];
|
|
86
|
+
const max = [-Infinity, -Infinity, -Infinity];
|
|
87
|
+
for (const f of faces) {
|
|
88
|
+
for (let k = 0; k < 3; k += 1) {
|
|
89
|
+
const reach = Math.abs(f.u[k]) * f.hu + Math.abs(f.v[k]) * f.hv;
|
|
90
|
+
min[k] = Math.min(min[k], f.c[k] - reach);
|
|
91
|
+
max[k] = Math.max(max[k], f.c[k] + reach);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { faces, min, max };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const dot = (p, q) => p[0] * q[0] + p[1] * q[1] + p[2] * q[2];
|
|
98
|
+
|
|
99
|
+
// Overlap of two rectangles lying in one plane, by separating axes in the
|
|
100
|
+
// plane: the rectangles' own edge directions are the only candidates.
|
|
101
|
+
function overlaps(a, b) {
|
|
102
|
+
const rel = [b.c[0] - a.c[0], b.c[1] - a.c[1], b.c[2] - a.c[2]];
|
|
103
|
+
for (const axis of [a.u, a.v, b.u, b.v]) {
|
|
104
|
+
const reachA = Math.abs(dot(axis, a.u)) * a.hu + Math.abs(dot(axis, a.v)) * a.hv;
|
|
105
|
+
const reachB = Math.abs(dot(axis, b.u)) * b.hu + Math.abs(dot(axis, b.v)) * b.hv;
|
|
106
|
+
if (Math.abs(dot(axis, rel)) >= reachA + reachB - OVERLAP_EPS) return false;
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Every pair of same-facing faces of a and b that share a plane and overlap. */
|
|
112
|
+
function conflictingFaces(a, b) {
|
|
113
|
+
const found = [];
|
|
114
|
+
for (const fa of a.faces) {
|
|
115
|
+
for (const fb of b.faces) {
|
|
116
|
+
if (dot(fa.n, fb.n) <= NORMAL_DOT) continue;
|
|
117
|
+
const gap = dot(fa.n, [fb.c[0] - fa.c[0], fb.c[1] - fa.c[1], fb.c[2] - fa.c[2]]);
|
|
118
|
+
if (Math.abs(gap) > PLANE_EPS) continue;
|
|
119
|
+
if (overlaps(fa, fb)) found.push(fa);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return found;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function conflict(a, b) {
|
|
126
|
+
for (const fa of a.faces) {
|
|
127
|
+
for (const fb of b.faces) {
|
|
128
|
+
if (dot(fa.n, fb.n) <= NORMAL_DOT) continue;
|
|
129
|
+
const gap = dot(fa.n, [fb.c[0] - fa.c[0], fb.c[1] - fa.c[1], fb.c[2] - fa.c[2]]);
|
|
130
|
+
if (Math.abs(gap) > PLANE_EPS) continue;
|
|
131
|
+
if (overlaps(fa, fb)) return true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Which objects need to win the depth test over an earlier one.
|
|
139
|
+
*
|
|
140
|
+
* Two planar faces conflict when they face the same way, share a plane to
|
|
141
|
+
* within a millimetre and overlap with real area; touching boxes (opposite
|
|
142
|
+
* facing) never do. The later object in `objects` is the later-made detail, so
|
|
143
|
+
* each conflict is an edge earlier -> later, and an object's rank is the length
|
|
144
|
+
* of the longest chain ending at it (capped). Only ranked objects (> 0) get an
|
|
145
|
+
* entry. Run it on the AUTHORED records: a routed group moves rigidly, so
|
|
146
|
+
* relative geometry inside it does not change with the frame.
|
|
147
|
+
*
|
|
148
|
+
* Skipped: spheres, capsules, models, cutouts, props carried by a character
|
|
149
|
+
* and hidden props. Cylinder and cone caps are approximated by their bounding
|
|
150
|
+
* rectangle, which can over-report a conflict near a rim; that costs a
|
|
151
|
+
* sub-pixel pull on a prop and nothing else.
|
|
152
|
+
*
|
|
153
|
+
* @param {Array<object>} objects scene-object records, in creation order
|
|
154
|
+
* @returns {Map<string, number>} id -> rank
|
|
155
|
+
*/
|
|
156
|
+
export function coplanarDepthRanks(objects) {
|
|
157
|
+
const ranks = new Map();
|
|
158
|
+
if (!Array.isArray(objects) || objects.length < 2) return ranks;
|
|
159
|
+
const shapes = objects.map((object) => (object && typeof object === "object" ? facesOf(object) : null));
|
|
160
|
+
const rank = new Array(objects.length).fill(0);
|
|
161
|
+
for (let j = 1; j < objects.length; j += 1) {
|
|
162
|
+
const b = shapes[j];
|
|
163
|
+
if (!b) continue;
|
|
164
|
+
for (let i = 0; i < j; i += 1) {
|
|
165
|
+
const a = shapes[i];
|
|
166
|
+
if (!a || rank[i] + 1 <= rank[j]) continue;
|
|
167
|
+
if (
|
|
168
|
+
a.min[0] > b.max[0] + PLANE_EPS || b.min[0] > a.max[0] + PLANE_EPS ||
|
|
169
|
+
a.min[1] > b.max[1] + PLANE_EPS || b.min[1] > a.max[1] + PLANE_EPS ||
|
|
170
|
+
a.min[2] > b.max[2] + PLANE_EPS || b.min[2] > a.max[2] + PLANE_EPS
|
|
171
|
+
) continue;
|
|
172
|
+
if (conflict(a, b)) rank[j] = Math.min(MAX_DEPTH_RANK, rank[i] + 1);
|
|
173
|
+
}
|
|
174
|
+
if (rank[j] > 0) ranks.set(objects[j].id, rank[j]);
|
|
175
|
+
}
|
|
176
|
+
return ranks;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const AXES = ["x", "y", "z"];
|
|
180
|
+
const FACE_NAMES = { "y+": "tops", "y-": "bottoms" };
|
|
181
|
+
const round = (value) => Number(value.toFixed(4));
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The coplanar conflicts coplanarDepthRanks resolves by offset, named: for
|
|
185
|
+
* each pair of objects with same-facing faces in one plane, the shared planes
|
|
186
|
+
* ("tops at y=1.05"). Informational for a receipt; it does not decide ranks.
|
|
187
|
+
*
|
|
188
|
+
* @param {Array<object>} objects scene-object records
|
|
189
|
+
* @returns {Array<{a: string, b: string, planes: Array<{face: string, axis: string|null, at: number}>}>}
|
|
190
|
+
*/
|
|
191
|
+
export function coplanarConflicts(objects) {
|
|
192
|
+
const pairs = [];
|
|
193
|
+
if (!Array.isArray(objects) || objects.length < 2) return pairs;
|
|
194
|
+
const shapes = objects.map((object) => (object && typeof object === "object" ? facesOf(object) : null));
|
|
195
|
+
for (let j = 1; j < objects.length; j += 1) {
|
|
196
|
+
const b = shapes[j];
|
|
197
|
+
if (!b) continue;
|
|
198
|
+
for (let i = 0; i < j; i += 1) {
|
|
199
|
+
const a = shapes[i];
|
|
200
|
+
if (!a) continue;
|
|
201
|
+
if (
|
|
202
|
+
a.min[0] > b.max[0] + PLANE_EPS || b.min[0] > a.max[0] + PLANE_EPS ||
|
|
203
|
+
a.min[1] > b.max[1] + PLANE_EPS || b.min[1] > a.max[1] + PLANE_EPS ||
|
|
204
|
+
a.min[2] > b.max[2] + PLANE_EPS || b.min[2] > a.max[2] + PLANE_EPS
|
|
205
|
+
) continue;
|
|
206
|
+
const planes = conflictingFaces(a, b).map((face) => {
|
|
207
|
+
const axis = [0, 1, 2].find((k) => Math.abs(face.n[k]) > NORMAL_DOT);
|
|
208
|
+
if (axis === undefined) return { face: "tilted faces", axis: null, at: round(dot(face.n, face.c)) };
|
|
209
|
+
const sign = face.n[axis] > 0 ? "+" : "-";
|
|
210
|
+
return { face: FACE_NAMES[`${AXES[axis]}${sign}`] ?? `${sign}${AXES[axis]} faces`, axis: AXES[axis], at: round(face.c[axis]) };
|
|
211
|
+
});
|
|
212
|
+
if (planes.length) pairs.push({ a: objects[i].id, b: objects[j].id, planes });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return pairs;
|
|
216
|
+
}
|
package/src/cuts.js
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
updateStableItem,
|
|
12
12
|
} from "./stable-items.js";
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
const DEFAULT_SHOT_FRAMES = 40;
|
|
15
15
|
|
|
16
16
|
const frameNumber = (value, fallback = 0) => Number.isFinite(value) ? Math.round(value) : fallback;
|
|
17
17
|
|
|
@@ -48,6 +48,12 @@ export function createShot(name = "Shot", startFrame = 0, endFrame = startFrame
|
|
|
48
48
|
endFrame: end,
|
|
49
49
|
cameraKeys: uniqueKeys(cameraKeys, start, end),
|
|
50
50
|
camera: createCameraBlock(camera),
|
|
51
|
+
// v5 shot-document defaults, so a fresh shot already has the shape the
|
|
52
|
+
// document normalizer gives it (and an undo restores).
|
|
53
|
+
kind: "clip",
|
|
54
|
+
caption: "",
|
|
55
|
+
cast: {},
|
|
56
|
+
stylizedAssetId: null,
|
|
51
57
|
};
|
|
52
58
|
}
|
|
53
59
|
|
|
@@ -133,11 +139,6 @@ export function resizeShot(shots, shotId, edge, rawFrame, frameCount) {
|
|
|
133
139
|
.sort((a, b) => a.startFrame - b.startFrame);
|
|
134
140
|
}
|
|
135
141
|
|
|
136
|
-
/** Compatibility name for callers that move the left edge. */
|
|
137
|
-
export function moveBoundary(shots, shotId, newStartFrame, frameCount) {
|
|
138
|
-
return resizeShot(shots, shotId, "start", newStartFrame, frameCount);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
142
|
export function removeShot(shots, shotId) {
|
|
142
143
|
return removeStableItem(shots, shotId, "shots");
|
|
143
144
|
}
|