cozyclay 3.1.0 → 3.1.2
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 +65 -0
- package/bin/agent/agent-routes.mjs +12 -11
- package/bin/agent/main-agent.mjs +21 -7
- package/bin/agent/main-prompt.mjs +7 -6
- package/bin/agent/plan-tool.mjs +114 -0
- package/bin/agent/script-tool.mjs +4 -1
- package/bin/agent/sculpt-sketch.mjs +190 -0
- package/bin/agent/session-store.mjs +2 -1
- package/bin/agent/sketch-code.mjs +151 -0
- package/bin/agent/sketch-sandbox.mjs +102 -0
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-rebase.mjs +5 -4
- package/bin/agent/studio-task-routes.mjs +1 -1
- package/bin/agent/studio-tasks.mjs +116 -25
- package/bin/agent/studio-tools.mjs +3 -2
- package/bin/agent/turn-frames.mjs +4 -0
- package/bin/cozyclay.mjs +4 -2
- package/bin/live-endpoint.mjs +2 -0
- package/dist/app/index.html +6 -6
- package/dist/assets/{TaskBoard-DdDXMgZm.js → TaskBoard-GR8xZm5c.js} +1 -1
- package/dist/assets/{app-BlwOpest.css → app-Bhe0TpGz.css} +1 -1
- package/dist/assets/app-C4QTcDcb.js +5144 -0
- package/dist/assets/compile-worker-oy_P9HHh.js +6 -0
- package/dist/assets/motion3-compile-dgGhrvPK.js +1 -0
- package/dist/assets/scene-asset-sync-BEjjhNEo.css +1 -0
- package/dist/assets/scene-asset-sync-qoxcdrhj.js +13 -0
- package/dist/assets/task-board-BqReHzOi.js +15 -0
- package/dist/assets/workflow-C87p6E1E.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +1 -1
- package/dist/sitemap.xml +1 -1
- package/dist/workflow/index.html +4 -4
- package/mcp/README.md +1 -1
- package/mcp/live-hub.mjs +5 -3
- package/mcp/tool-handlers.mjs +7 -7
- package/package.json +3 -1
- package/src/App.jsx +44 -66
- package/src/bus-task-scopes.js +59 -11
- package/src/bus-task-targets.js +35 -9
- package/src/bus-tasks.js +23 -10
- package/src/command-bus.js +7 -4
- package/src/commands/ai.js +1 -1
- package/src/commands/elements.js +4 -3
- package/src/commands/export.js +1 -1
- package/src/commands/motion3.js +27 -7
- package/src/commands/objects.js +1 -1
- package/src/export-workspace.js +244 -0
- package/src/keyframe-pack.js +5 -1
- package/src/live-control.js +14 -7
- package/src/material-look.js +189 -0
- package/src/material-presets.js +78 -0
- package/src/material-textures.js +157 -0
- package/src/motion3/compile-job.js +15 -3
- package/src/motion3/compiler/channels.js +74 -24
- package/src/motion3/compiler/compile.js +2 -1
- package/src/motion3/compiler/idle.js +2 -0
- package/src/motion3/compiler/overlap.js +180 -0
- package/src/motion3/compiler/pose.js +6 -4
- package/src/motion3/compiler/validate-common.js +1 -0
- package/src/motion3/compiler/validate-parts.js +5 -1
- package/src/motion3/compiler/validate.js +30 -10
- package/src/motion3/correct.js +236 -0
- package/src/motion3/docs.js +8 -6
- package/src/motion3/face-lean.js +31 -0
- package/src/motion3/fix/index.js +5 -1
- package/src/motion3/fix/reach-body.js +172 -0
- package/src/motion3/hint-check.js +44 -0
- package/src/motion3/json-patch.js +120 -12
- package/src/motion3/lenient.js +46 -4
- package/src/motion3/object-keys.js +17 -5
- package/src/motion3/pipeline.js +46 -9
- package/src/motion3/qa/arm-checks.js +96 -0
- package/src/motion3/qa/asked.js +113 -0
- package/src/motion3/qa/goals.js +37 -8
- package/src/motion3/qa/hints-goal.js +6 -5
- package/src/motion3/qa/index.js +5 -2
- package/src/motion3/qa/key-checks.js +4 -3
- package/src/motion3/qa/motion-life.js +243 -0
- package/src/motion3/qa/spec-checks.js +15 -9
- package/src/motion3/qa/verdict.js +50 -0
- package/src/motion3/refs.js +38 -14
- package/src/motion3/repair-watch.js +132 -0
- package/src/motion3/repair.js +52 -19
- package/src/motion3/report.js +50 -9
- package/src/motion3/schema/topics.js +6 -4
- package/src/motion3/schema/types.js +1 -1
- package/src/motion3/skills/basic.js +15 -8
- package/src/motion3/skills/index.js +52 -18
- package/src/motion3/skills/info.js +1 -1
- package/src/motion3/skills/stance.js +6 -0
- package/src/motion3/skills/walk-fit.js +170 -0
- package/src/object-look.js +9 -5
- package/src/panels/PoseImagePanel.jsx +3 -2
- package/src/panels/SculptPanel.jsx +2 -2
- package/src/primitive-look.js +38 -0
- package/src/props.jsx +20 -28
- package/src/render-passes.js +7 -1
- package/src/result-modal.jsx +3 -3
- package/src/scene-objects.js +2 -2
- package/src/sculpt-colours.js +65 -0
- package/src/sculpt-compare.js +13 -50
- package/src/sculpt-csg.js +112 -0
- package/src/sculpt-curve.js +123 -0
- package/src/sculpt-cutters.js +43 -0
- package/src/sculpt-detail.js +207 -0
- package/src/sculpt-geometry-loft-convex.js +100 -0
- package/src/sculpt-geometry-shapes.js +250 -0
- package/src/sculpt-geometry.js +33 -15
- package/src/sculpt-loft-rings.js +225 -0
- package/src/sculpt-noise.js +65 -0
- package/src/sculpt-part-fields.js +27 -0
- package/src/sculpt-recipe-read.js +65 -0
- package/src/sculpt-recipe.js +33 -75
- package/src/sculpt-run.js +1 -1
- package/src/sculpt-scatter-items.js +183 -0
- package/src/sculpt-scatter-shape.js +57 -0
- package/src/sculpt-scatter.js +124 -0
- package/src/sculpt-shapes-curved.js +211 -0
- package/src/sculpt-shapes-loft-convex.js +218 -0
- package/src/sculpt-shapes.js +138 -0
- package/src/sculpt-sketch-state.js +76 -0
- package/src/sculpt-sketch.jsx +124 -0
- package/src/sculpt-surface-fields.js +65 -0
- package/src/shell/ExportFolderPreference.jsx +103 -0
- package/src/shell/PreferencesDialog.jsx +3 -166
- package/src/shell/StatusBar.jsx +1 -1
- package/src/shell/preference-controls.jsx +114 -0
- package/src/shell/preference-pages.jsx +72 -0
- package/src/shell/preferences.css +46 -0
- package/src/shot-prompt.js +29 -9
- package/src/studio-actions.js +2 -2
- package/src/studio-agent-motion.js +19 -9
- package/src/studio-agent-protocol.js +1 -1
- package/src/studio-app-binding.js +3 -2
- package/src/studio-elements.js +5 -1
- package/src/workflow/AgentPanel.jsx +6 -6
- package/src/workflow/AgentPlanCard.jsx +41 -0
- package/src/workflow/CozySceneNode.jsx +1 -1
- package/src/workflow/WorkflowBuilder.jsx +2 -2
- package/src/workflow/agent-client.js +14 -11
- package/src/workflow/agent-plan.css +93 -0
- package/src/workflow/agent-plan.js +59 -0
- package/src/workflow/keyframe-pack-request.js +3 -10
- package/src/workflow/task-board.css +1 -1
- package/src/workflow/task-board.js +7 -5
- package/tools/loc-baseline.json +1 -2
- package/tools/run-tests.mjs +8 -8
- package/dist/assets/app-B2BpfCl2.js +0 -5088
- package/dist/assets/compile-worker-D0pKioa0.js +0 -5
- package/dist/assets/motion3-compile-B887n77J.js +0 -1
- package/dist/assets/scene-asset-sync-COGOlF5T.css +0 -1
- package/dist/assets/scene-asset-sync-CQzEdsqu.js +0 -13
- package/dist/assets/task-board-BKaM7d-9.js +0 -14
- package/dist/assets/workflow-B8Dhzexj.js +0 -179
|
@@ -1 +1 @@
|
|
|
1
|
-
{"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"3.1.
|
|
1
|
+
{"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"3.1.2\",\"repository\":\"NomaDamas/CozyClay\",\"content_sha256\":\"4d8d2ca94381f4bdb9a2983a6c3f0378ea513c73e1408c6c18baef2b2db6fc05\"}","signature":"bb1tql+IuEnYxCIbq+Xh/MJa3bL9WYcnI3Bw7STxCTwRoi7NB/L2VNd0/jR1d3qVEK4E3MGLA5GavrN5E+/7DA=="}
|
package/dist/index.html
CHANGED
|
@@ -394,7 +394,7 @@
|
|
|
394
394
|
<div class="wrap start">
|
|
395
395
|
<div class="start-head">
|
|
396
396
|
<h2>Quick start</h2>
|
|
397
|
-
<p>Node.js 22.
|
|
397
|
+
<p>Node.js 22.19+ and a Chromium browser.</p>
|
|
398
398
|
<a href="#controls" id="controls-link">Full control list</a>
|
|
399
399
|
</div>
|
|
400
400
|
<div class="start-body">
|
package/dist/sitemap.xml
CHANGED
package/dist/workflow/index.html
CHANGED
|
@@ -8,13 +8,13 @@
|
|
|
8
8
|
<meta name="theme-color" content="#101116" />
|
|
9
9
|
<meta name="description" content="CozyClay visual workflow builder" />
|
|
10
10
|
<title>CozyClay Workflow</title>
|
|
11
|
-
<script type="module" crossorigin src="/assets/workflow-
|
|
11
|
+
<script type="module" crossorigin src="/assets/workflow-C87p6E1E.js"></script>
|
|
12
12
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
|
|
13
13
|
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-P2Xu9kJm.js">
|
|
14
|
-
<link rel="modulepreload" crossorigin href="/assets/task-board-
|
|
14
|
+
<link rel="modulepreload" crossorigin href="/assets/task-board-BqReHzOi.js">
|
|
15
15
|
<link rel="modulepreload" crossorigin href="/assets/fflate.module-KbGj5-H6.js">
|
|
16
|
-
<link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-
|
|
17
|
-
<link rel="stylesheet" crossorigin href="/assets/scene-asset-sync-
|
|
16
|
+
<link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-qoxcdrhj.js">
|
|
17
|
+
<link rel="stylesheet" crossorigin href="/assets/scene-asset-sync-BEjjhNEo.css">
|
|
18
18
|
<link rel="stylesheet" crossorigin href="/assets/workflow-DRdwoDBT.css">
|
|
19
19
|
</head>
|
|
20
20
|
<body>
|
package/mcp/README.md
CHANGED
|
@@ -35,7 +35,7 @@ Then point a client at it. For Claude Desktop, in `claude_desktop_config.json`:
|
|
|
35
35
|
}
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
Restart the client;
|
|
38
|
+
Restart the client; 27 tools appear.
|
|
39
39
|
|
|
40
40
|
Prefer a long-lived endpoint? `node server.mjs --http 5183` serves Streamable HTTP at
|
|
41
41
|
`http://127.0.0.1:5183/mcp` (one isolated session per client), with a plain status page at `/`.
|
package/mcp/live-hub.mjs
CHANGED
|
@@ -436,10 +436,12 @@ export class LiveHub {
|
|
|
436
436
|
void this.serveController(socket, frame);
|
|
437
437
|
return;
|
|
438
438
|
}
|
|
439
|
-
|
|
440
|
-
|
|
439
|
+
// Meta and event frames both act for this socket's workspace. A meta frame
|
|
440
|
+
// relabels the tab after it opens another project; the handle stays.
|
|
441
|
+
const workspaceHandle = [...this.editors.entries()].find(([, editor]) => editor === socket)?.[0];
|
|
442
|
+
if (frame?.type === "meta" && workspaceHandle && frame.meta && typeof frame.meta === "object") this.workspaceMeta.set(workspaceHandle, frame.meta);
|
|
443
|
+
else if (frame?.type === "event" && typeof frame.name === "string" && frame.payload && typeof frame.payload === "object") {
|
|
441
444
|
if (workspaceHandle) this.onEvent?.({ workspaceHandle, workspaceId: this.workspaceId(workspaceHandle), name: frame.name, payload: frame.payload });
|
|
442
|
-
return;
|
|
443
445
|
}
|
|
444
446
|
if (frame?.type !== "result" || typeof frame.id !== "string") return;
|
|
445
447
|
const pending = this.pending.get(frame.id);
|
package/mcp/tool-handlers.mjs
CHANGED
|
@@ -71,6 +71,7 @@ import {
|
|
|
71
71
|
import { classifyMove, captureFraming, moveSlate } from "../src/camera-move.js";
|
|
72
72
|
import { DEFAULT_PREVIS_MODE, PREVIS_MODES, createProjectDocument, readProjectDocument } from "../src/project.js";
|
|
73
73
|
import { kindRefusal } from "../src/character-kind.js";
|
|
74
|
+
import { documentWithCamera, splitSavedCamera } from "./project-camera.mjs";
|
|
74
75
|
|
|
75
76
|
/** The authoring state. One scene document, one camera, one project name. */
|
|
76
77
|
export const state = {
|
|
@@ -1155,10 +1156,10 @@ export const createToolHandlers = ({ projectRootPromise } = {}) => {
|
|
|
1155
1156
|
title: "Sculpt a clay prop from a recipe of soft parts",
|
|
1156
1157
|
description:
|
|
1157
1158
|
"Unlike place_object, import_mesh and compare_sculpt, sculpt_object builds or changes a clay prop from a recipe of parts you write, typically after reading a reference image. It needs no file and no catalogue entry. " +
|
|
1158
|
-
"recipe = { version: 1, parts: [{ id, shape, size: [x, y, z], position?, rotation?, color?, parent?, mirror?,
|
|
1159
|
+
"recipe = { version: 1, parts: [{ id, shape, size: [x, y, z], position?, rotation?, color?, parent?, mirror?, ...shape fields, material?, finish?, detail?, gradient?, op? }] }. " +
|
|
1159
1160
|
"Units are metres and degrees. The floor is y = 0, front is +z, and the subject's own left is +x. size is the full extent; position is the part's centre, in its parent part's frame when parent is set. " +
|
|
1160
1161
|
"shape: blob (soft superellipsoid: roundness 0 is an ellipsoid, 1 is a rounded box; taper narrows the top when positive and the bottom when negative), box (roundness bevels the edges), " +
|
|
1161
|
-
"cylinder (taper 1 is a cone), torus (size [outer, outer, tube]), frame (a rectangular ring in x/y with a border width, for glasses rims, windows and picture frames). " +
|
|
1162
|
+
"cylinder (taper 1 is a cone), torus (size [outer, outer, tube]), frame (a rectangular ring in x/y with a border width, for glasses rims, windows and picture frames), " + "lathe { profile: [[r 0..1 of half-width, y 0..1 of height], ...] bottom to top, smooth? } for turned objects (pot, vase, bottle, bowl, lampshade, mushroom), " + "tube { path: [[x, y, z] each -0.5..0.5 of size], thickness (m, diameter), taper?, closed? } for ropes, vines, cables, handles, branches and bike frames, " + "extrude { outline: [[x, y] -0.5..0.5 of size], holes?, roundness? } for flat cut shapes pushed through size z (sign, leaf, gable, gear, star), " + "loft { sections: [{ y 0..1, points: [[x, z]...] } or { y, w, d, x?, z?, roundness? }] bottom to top (first y 0, last y 1), smooth? } for cross-sections that change (hull, car body, shoe, loaf; lay it down with rotation [-90,0,0]), " + "convex { points: [[x, y, z]...] or seed + facetCount?, facets? } for rocks, boulders, gems and crystals, " + "scatter { item: blade|tuft|flower|leaf|pebble|twig, count <= 4000, spread: ground|shell, seed? } for thousands of small items as ONE part (grass, flower beds, gravel; shell covers a crown or bush it is parented to; 12000 items per recipe). " + "Any part also takes material clay|wood|straw|stone|brick|concrete|metal|glass|ceramic|plastic|rubber|fabric|paper|foliage (real texture and shine; color tints it; never fake glass with opacity or thatch with stripes) and finish matte|satin|gloss; detail { amount 0..1, scale?, seed? } for a lumpy surface (clouds, bushes, rocks, hay, bread), gradient { color, axis? y|x|z, from?, to? } to blend into a second colour (sky-lit clouds, fruit, sunset walls), " + "and op: subtract|intersect, which makes the part an undrawn cutter of its parent (windows, doors, arches; hollow a lathe with a smaller lathe of the same profile; cut deeper than the wall; mirror cuts both sides). " +
|
|
1162
1163
|
"mirror: true adds a copy reflected across x = 0 of the parent frame, with its children; author the subject's left (+x) side once. color is #rrggbb. At most 48 parts after mirrors. " +
|
|
1163
1164
|
"To fix a sculpt, pass its id with edits instead of a whole recipe: [{ set: partId, ...fields }, { add: part }, { remove: partId }] (parent: null and mirror: false clear; remove takes the part's children). " +
|
|
1164
1165
|
"capture: true also returns the editor's frame (connected editor only), so one call is one round. " +
|
|
@@ -1697,7 +1698,7 @@ export const createToolHandlers = ({ projectRootPromise } = {}) => {
|
|
|
1697
1698
|
// the current stage shape rather than trusted as-is.
|
|
1698
1699
|
const scenes = readSceneDocument(serializeSceneDocument(result.project.scenesDocument));
|
|
1699
1700
|
if (!scenes.document) return text(`That project was written by a newer CozyClay: ${full}`);
|
|
1700
|
-
const nextDocument = scenes.document;
|
|
1701
|
+
const { document: nextDocument, camera: savedCamera } = splitSavedCamera(scenes.document);
|
|
1701
1702
|
if (liveHub?.connected) {
|
|
1702
1703
|
try {
|
|
1703
1704
|
const loaded = await executeStudioCommand({ ...admission, action: "load_scenes", args: { document: nextDocument } });
|
|
@@ -1713,11 +1714,10 @@ export const createToolHandlers = ({ projectRootPromise } = {}) => {
|
|
|
1713
1714
|
}
|
|
1714
1715
|
}
|
|
1715
1716
|
state.doc = nextDocument;
|
|
1717
|
+
Object.assign(state.camera, savedCamera);
|
|
1716
1718
|
state.previsMode = result.project.previsMode ?? DEFAULT_PREVIS_MODE;
|
|
1717
1719
|
state.name = result.project.name;
|
|
1718
|
-
state
|
|
1719
|
-
state.focusLocked = false;
|
|
1720
|
-
state.markedFraming = null;
|
|
1720
|
+
Object.assign(state, { focus: null, focusLocked: false, markedFraming: null });
|
|
1721
1721
|
return text(`Opened ${full}.\n\n${sceneReport()}`);
|
|
1722
1722
|
},
|
|
1723
1723
|
),
|
|
@@ -1753,7 +1753,7 @@ export const createToolHandlers = ({ projectRootPromise } = {}) => {
|
|
|
1753
1753
|
return text(`Could not write project: ${error.message}`);
|
|
1754
1754
|
}
|
|
1755
1755
|
const project = createProjectDocument({
|
|
1756
|
-
scenesDocument: state.doc,
|
|
1756
|
+
scenesDocument: documentWithCamera(state.doc, state.camera),
|
|
1757
1757
|
workspaceLayout: null,
|
|
1758
1758
|
customPoses: [],
|
|
1759
1759
|
name: state.name,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cozyclay",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.2",
|
|
4
4
|
"description": "Open source previs software in the browser: block a scene, pose characters, author camera moves and cuts, then take the same shots to an AI video model.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "node tools/dev-full.mjs --host 127.0.0.1 --port 5180",
|
|
@@ -82,6 +82,8 @@
|
|
|
82
82
|
"react-icons": "^5.7.0",
|
|
83
83
|
"reactflow": "^11.11.4",
|
|
84
84
|
"three": "^0.185.1",
|
|
85
|
+
"three-bvh-csg": "^0.0.18",
|
|
86
|
+
"three-mesh-bvh": "^0.9.16",
|
|
85
87
|
"vite": "^8.2.0"
|
|
86
88
|
},
|
|
87
89
|
"bin": {
|
package/src/App.jsx
CHANGED
|
@@ -137,7 +137,7 @@ import {
|
|
|
137
137
|
pathMetrics,
|
|
138
138
|
strokeToPathPoints,
|
|
139
139
|
} from "./object-path.js";
|
|
140
|
-
import { carriedPointAt, sceneObjectsAt } from "./object-travel.js"; import { objectTracksOf } from "./motion3/object-tracks.js"; import { ViewGhosts } from "./view-ghosts.jsx"; import { DraftGhost } from "./draft-ghost.jsx";
|
|
140
|
+
import { carriedPointAt, sceneObjectsAt } from "./object-travel.js"; import { objectTracksOf } from "./motion3/object-tracks.js"; import { ViewGhosts } from "./view-ghosts.jsx"; import { DraftGhost } from "./draft-ghost.jsx"; import { SculptSketch } from "./sculpt-sketch.jsx";
|
|
141
141
|
import { routeOwnerFor } from "./route-owner.js";
|
|
142
142
|
import {
|
|
143
143
|
analyticsActive,
|
|
@@ -152,6 +152,7 @@ import {
|
|
|
152
152
|
trackFeature,
|
|
153
153
|
} from "./analytics.js";
|
|
154
154
|
import { ko, isKo } from "./locale.js";
|
|
155
|
+
import { saveExport } from "./export-workspace.js";
|
|
155
156
|
import { characterCapabilities, kindRefusal, proxyFigureBounds } from "./character-kind.js";
|
|
156
157
|
import { STARTER_SCENES, resolveSceneParam } from "./starter-scenes.js";
|
|
157
158
|
import { PART_COLOURS } from "./part-colours.js";
|
|
@@ -2463,6 +2464,17 @@ export default function App() {
|
|
|
2463
2464
|
capture_plan_png: (args = {}) => appContext.live.state.capturePlanPng(requestedCaptureOutput(args) ?? undefined),
|
|
2464
2465
|
};
|
|
2465
2466
|
|
|
2467
|
+
const liveSceneName = scenes.find((entry) => entry.id === activeSceneId)?.name ?? "";
|
|
2468
|
+
const liveMeta = () => ({
|
|
2469
|
+
project: projectName ?? "Untitled", scene: liveSceneName, cast: appContext.live.characters.length,
|
|
2470
|
+
// The Workflow page's embedded preview is a live editor too; mark it apart from the authoring tab.
|
|
2471
|
+
// Commands: what this editor answers; a stale tab from an older build answers a different set.
|
|
2472
|
+
...(embedMode ? { embed: true } : {}), commands: [...Object.keys(liveQueries), ...Object.keys(studioBindingRef.current.handlers)],
|
|
2473
|
+
});
|
|
2474
|
+
// The client is created once; opening a project must still relabel the tab
|
|
2475
|
+
// in live status and the --workspace <project> selector (#864).
|
|
2476
|
+
useEffect(() => { liveControlRef.current?.setMeta(liveMeta()); }, [projectName, liveSceneName, appContext.live.characters.length]);
|
|
2477
|
+
|
|
2466
2478
|
useEffect(() => {
|
|
2467
2479
|
const enabled = import.meta.env.DEV || window.__COZYCLAY_LIVE__ === true;
|
|
2468
2480
|
if (!enabled) return undefined;
|
|
@@ -2474,19 +2486,7 @@ export default function App() {
|
|
|
2474
2486
|
// Tapped so a sculpt run hears the agent's object.sculpt / sculpt.compare.
|
|
2475
2487
|
handlers: sculptTap.wrap({ ...liveQueries, ...studioBindingRef.current.handlers }),
|
|
2476
2488
|
workspaceId: liveWorkspaceIdRef.current,
|
|
2477
|
-
meta:
|
|
2478
|
-
project: projectName ?? "Untitled",
|
|
2479
|
-
scene: scenes.find((entry) => entry.id === activeSceneId)?.name ?? "",
|
|
2480
|
-
cast: appContext.live.characters.length,
|
|
2481
|
-
// The Workflow page embeds this same Studio as a preview. It is a
|
|
2482
|
-
// live editor too, so an agent choosing a workspace must be able to
|
|
2483
|
-
// tell the preview apart from the tab the user is authoring in.
|
|
2484
|
-
...(embedMode ? { embed: true } : {}),
|
|
2485
|
-
// Which live commands this editor answers. A stale tab from an
|
|
2486
|
-
// older build (or a different app on the same port) answers a
|
|
2487
|
-
// different set; the agent picks a workspace that has what it needs.
|
|
2488
|
-
commands: [...Object.keys(liveQueries), ...Object.keys(studioBindingRef.current.handlers)],
|
|
2489
|
-
},
|
|
2489
|
+
meta: liveMeta(),
|
|
2490
2490
|
// The shared client intentionally has no disconnect UI callback.
|
|
2491
2491
|
// Observe only this owned socket; a stale handle must never be sent.
|
|
2492
2492
|
WebSocketImpl: class extends WebSocket {
|
|
@@ -2776,7 +2776,7 @@ export default function App() {
|
|
|
2776
2776
|
case "unsupported_codec": return ko("MP4 encoding is unavailable. Use a current browser with H.264 WebCodecs support (such as Chrome or Edge), enable hardware acceleration, then retry.", "MP4 인코딩을 사용할 수 없어요. H.264 WebCodecs를 지원하는 최신 Chrome·Edge 등에서 하드웨어 가속을 켠 뒤 다시 시도하세요.");
|
|
2777
2777
|
case "encode_failed": return ko("Video encoding failed. Retry the same request. If resources are tight, shorten the shot range or lower output resolution before starting a new export.", "영상 인코딩에 실패했어요. 같은 요청을 다시 시도하세요. 리소스가 부족하면 샷 범위를 줄이거나 출력 해상도를 낮춘 뒤 새로 내보내세요.");
|
|
2778
2778
|
case "render_failed": return ko("Frame rendering failed. Let the scene finish loading, then retry. If it repeats, shorten the range or reduce output resolution for a new export.", "프레임 렌더링에 실패했어요. 장면 로딩이 끝난 뒤 다시 시도하세요. 반복되면 범위나 출력 해상도를 줄여 새로 내보내세요.");
|
|
2779
|
-
case "aborted": return ko("Export cancelled. No further
|
|
2779
|
+
case "aborted": return ko("Export cancelled. No further files will be handed off.", "내보내기를 취소했어요. 추가 파일은 전달하지 않아요.");
|
|
2780
2780
|
default: return ko("Export could not finish. Retry the same request. For memory or resource problems, close other heavy tabs or use a shorter range / lower output resolution in a new export.", "내보내기를 완료하지 못했어요. 같은 요청을 다시 시도하세요. 메모리·리소스 문제라면 무거운 탭을 닫거나 범위·출력 해상도를 줄여 새로 내보내세요.");
|
|
2781
2781
|
}
|
|
2782
2782
|
}
|
|
@@ -2821,7 +2821,7 @@ export default function App() {
|
|
|
2821
2821
|
job.controller.signal.throwIfAborted();
|
|
2822
2822
|
attempt?.succeed();
|
|
2823
2823
|
updateExportStatus(job, "completed", { message: request.download
|
|
2824
|
-
? ko("Export completed.
|
|
2824
|
+
? ko("Export completed. Check your export folder or browser downloads; the OS save is not confirmed.", "내보내기를 완료했어요. 내보내기 폴더나 브라우저 다운로드를 확인하세요. OS 저장 완료는 확인할 수 없어요.")
|
|
2825
2825
|
: ko("Export pipeline completed. The requesting tool handles the file handoff.", "내보내기 처리를 완료했어요. 요청한 도구에서 파일 전달을 처리합니다.") });
|
|
2826
2826
|
if (!request.external) {
|
|
2827
2827
|
if (request.kind === "video") { track("export:video_succeeded", { format: "mp4" }); trackFeature("export_video"); }
|
|
@@ -3010,11 +3010,9 @@ export default function App() {
|
|
|
3010
3010
|
const slate = (moveSequence?.slate ?? "shot").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "shot";
|
|
3011
3011
|
// Default plate filename: const name = `cozyclay-${slate}.mp4`
|
|
3012
3012
|
const name = fileName ?? `cozyclay-${slate}.mp4`;
|
|
3013
|
-
|
|
3014
|
-
try { saveDownload(url, name); }
|
|
3015
|
-
finally { setTimeout(() => URL.revokeObjectURL(url), 10_000); }
|
|
3013
|
+
await saveExport(result.blob, name);
|
|
3016
3014
|
setRecordedVideoName(name);
|
|
3017
|
-
setToast((isKo, ko) => isKo ? `${name}
|
|
3015
|
+
setToast((isKo, ko) => isKo ? `${name} 내보내기 완료 · ${result.frameCount}프레임` : `${name} exported · ${result.frameCount} frames`);
|
|
3018
3016
|
return { ...result, fileName: name };
|
|
3019
3017
|
}
|
|
3020
3018
|
return result;
|
|
@@ -3079,13 +3077,13 @@ export default function App() {
|
|
|
3079
3077
|
{busy && !message && <p>{stage === "mux"
|
|
3080
3078
|
? ko("Finalizing MP4. Progress is indeterminate; this stage cannot be interrupted.", "MP4 마무리 중이에요. 진행률은 알 수 없으며 이 단계는 중단할 수 없어요.")
|
|
3081
3079
|
: stage === "flush" ? ko("Finishing encoder output. Progress is indeterminate.", "인코더 출력을 마무리하고 있어요. 진행률은 알 수 없어요.")
|
|
3082
|
-
: kind === "frame" ? ko("Handing off existing PNGs.
|
|
3083
|
-
: stage === "archive" ? ko("Building the ZIP. Cancel takes effect after the current work unit, before
|
|
3084
|
-
: stage === "depth" || stage === "frames" ? ko("Cancel stops at the next frame boundary, before
|
|
3080
|
+
: kind === "frame" ? ko("Handing off existing PNGs. The file handoff cannot be cancelled.", "기존 PNG를 전달하고 있어요. 파일 전달은 취소할 수 없어요.")
|
|
3081
|
+
: stage === "archive" ? ko("Building the ZIP. Cancel takes effect after the current work unit, before the file handoff.", "ZIP을 만드는 중이에요. 취소는 현재 작업이 끝난 뒤 파일 전달 전에 적용돼요.")
|
|
3082
|
+
: stage === "depth" || stage === "frames" ? ko("Cancel stops at the next frame boundary, before the file handoff.", "취소하면 파일 전달 전 다음 프레임 경계에서 멈춰요.")
|
|
3085
3083
|
: phase === "preparing" ? ko("Preparing the renderer and checking MP4 support…", "렌더러를 준비하고 MP4 지원을 확인하고 있어요…")
|
|
3086
3084
|
: ko("Encoding the requested frames, not yet a completed file.", "요청한 프레임을 인코딩 중이에요. 아직 파일이 완성되지 않았어요.")}</p>}
|
|
3087
3085
|
{retryable && <p>{ko("Retry keeps the original shot, camera, range and settings; it does not change your edits.", "다시 시도하면 원래 샷·카메라·범위·설정을 사용하며 편집 내용은 바꾸지 않아요.")}</p>}
|
|
3088
|
-
{handedOff > 0 && <p>{ko(`${handedOff} file(s) already handed off. Retry skips those
|
|
3086
|
+
{handedOff > 0 && <p>{ko(`${handedOff} file(s) already handed off. Retry skips those files; check your export folder or browser downloads because OS saves cannot be confirmed.`, `파일 ${handedOff}개는 이미 전달했어요. 다시 시도할 때 해당 파일은 건너뛰어요. OS 저장은 확인할 수 없으니 내보내기 폴더나 브라우저 다운로드를 확인하세요.`)}</p>}
|
|
3089
3087
|
<div className="export-status-actions">
|
|
3090
3088
|
{busy && cancellable && <button type="button" data-testid="export-cancel" onClick={stopShotRecording}>{ko("Cancel export", "내보내기 취소")}</button>}
|
|
3091
3089
|
{retryable && <button type="button" data-testid="export-retry" disabled={recState === "recording"} onClick={() => void retryExport()}>{ko("Retry same export", "같은 내보내기 재시도")}</button>}
|
|
@@ -3094,7 +3092,7 @@ export default function App() {
|
|
|
3094
3092
|
);
|
|
3095
3093
|
}
|
|
3096
3094
|
|
|
3097
|
-
function downloadOtioCutList() {
|
|
3095
|
+
async function downloadOtioCutList() {
|
|
3098
3096
|
if (!shots.length) {
|
|
3099
3097
|
setToast(ko("Add at least one Shot before exporting OTIO", "OTIO를 내보내려면 샷을 하나 이상 추가하세요"));
|
|
3100
3098
|
return;
|
|
@@ -3113,16 +3111,11 @@ export default function App() {
|
|
|
3113
3111
|
};
|
|
3114
3112
|
const serialized = serializeOtio(exportScene, shots);
|
|
3115
3113
|
const blob = new Blob([serialized], { type: "application/json" });
|
|
3116
|
-
const url = URL.createObjectURL(blob);
|
|
3117
|
-
const anchor = document.createElement("a");
|
|
3118
3114
|
const slug = (activeScene?.name ?? "cut-list")
|
|
3119
3115
|
.toLowerCase()
|
|
3120
3116
|
.replace(/[^a-z0-9]+/g, "-")
|
|
3121
3117
|
.replace(/^-+|-+$/g, "") || "cut-list";
|
|
3122
|
-
|
|
3123
|
-
anchor.download = `cozyclay-${slug}.otio`;
|
|
3124
|
-
anchor.click();
|
|
3125
|
-
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
|
3118
|
+
await saveExport(blob, `cozyclay-${slug}.otio`);
|
|
3126
3119
|
const frameCount = shots.reduce((total, shot) => total + shot.endFrame - shot.startFrame + 1, 0);
|
|
3127
3120
|
setToast(isKo
|
|
3128
3121
|
? `OTIO 저장됨 · ${shots.length}샷 · ${frameCount}프레임`
|
|
@@ -3141,15 +3134,6 @@ export default function App() {
|
|
|
3141
3134
|
* - a storyboard contact sheet of the whole cut.
|
|
3142
3135
|
*/
|
|
3143
3136
|
|
|
3144
|
-
function saveDownload(href, name) {
|
|
3145
|
-
const anchor = document.createElement("a");
|
|
3146
|
-
anchor.href = href;
|
|
3147
|
-
anchor.download = name;
|
|
3148
|
-
document.body.appendChild(anchor);
|
|
3149
|
-
anchor.click();
|
|
3150
|
-
anchor.remove();
|
|
3151
|
-
}
|
|
3152
|
-
|
|
3153
3137
|
function loadImage(dataUrl) {
|
|
3154
3138
|
return new Promise((resolve, reject) => {
|
|
3155
3139
|
const image = new Image();
|
|
@@ -3279,21 +3263,20 @@ export default function App() {
|
|
|
3279
3263
|
job.label = ko(`Shot ${order + 1} of ${targets.length}`, `샷 ${order + 1} / ${targets.length}`);
|
|
3280
3264
|
const pack = await buildShotKeyframePack(job.request.context.shots[index], index, null, job);
|
|
3281
3265
|
job.controller.signal.throwIfAborted();
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
setToast(isKo ? `${pack.name} 다운로드 요청 · 파일 ${pack.entries.length}개` : `Download requested: ${pack.name} · ${pack.entries.length} files`);
|
|
3266
|
+
await saveExport(new Blob([pack.bytes], { type: "application/zip" }), pack.name);
|
|
3267
|
+
job.request.handedOff.add(index);
|
|
3268
|
+
setToast(isKo ? `${pack.name} 내보내기 완료 · 파일 ${pack.entries.length}개` : `${pack.name} exported · ${pack.entries.length} files`);
|
|
3286
3269
|
await exportBoundary(job);
|
|
3287
3270
|
}
|
|
3288
3271
|
}));
|
|
3289
3272
|
}
|
|
3290
3273
|
|
|
3291
3274
|
/** Depth and normal conditioning passes of the framing on screen now. */
|
|
3292
|
-
function exportRenderPasses() {
|
|
3275
|
+
async function exportRenderPasses() {
|
|
3293
3276
|
try {
|
|
3294
3277
|
const dataUrls = renderPassDataUrls();
|
|
3295
|
-
for (const kind of ["depth", "normal"])
|
|
3296
|
-
setToast(ko("Depth and normal passes
|
|
3278
|
+
for (const kind of ["depth", "normal"]) await saveExport(dataUrls[kind], passFileName(kind));
|
|
3279
|
+
setToast(ko("Depth and normal passes exported", "뎁스·노멀 패스를 내보냈어요"));
|
|
3297
3280
|
trackFeature("export_render_pass");
|
|
3298
3281
|
} catch (error) {
|
|
3299
3282
|
setToast(error?.message || String(error));
|
|
@@ -3352,7 +3335,7 @@ export default function App() {
|
|
|
3352
3335
|
cell: { width: 480, height: 300 },
|
|
3353
3336
|
createCanvas: storyboardCanvas,
|
|
3354
3337
|
});
|
|
3355
|
-
|
|
3338
|
+
await saveExport(canvas.toDataURL("image/png"), "cozyclay-storyboard.png");
|
|
3356
3339
|
setToast(isKo ? `스토리보드 저장됨 · ${cells.length}샷` : `Storyboard saved · ${cells.length} shots`);
|
|
3357
3340
|
trackFeature("export_storyboard");
|
|
3358
3341
|
} catch (error) {
|
|
@@ -3443,8 +3426,8 @@ export default function App() {
|
|
|
3443
3426
|
}
|
|
3444
3427
|
const canvas = composeStoryboard({ shots: cells, columns: Math.min(3, cells.length), cell: { width: 480, height: 300 }, createCanvas: storyboardCanvas });
|
|
3445
3428
|
job.controller.signal.throwIfAborted();
|
|
3446
|
-
|
|
3447
|
-
setToast(isKo ? `콘택트 시트
|
|
3429
|
+
await saveExport(canvas.toDataURL("image/png"), "cozyclay-storyboard.png");
|
|
3430
|
+
setToast(isKo ? `콘택트 시트 내보내기 완료 · 패널 ${cells.length}개` : `Contact sheet exported · ${cells.length} panels`);
|
|
3448
3431
|
return { fileName: "cozyclay-storyboard.png", panelCount: cells.length };
|
|
3449
3432
|
}));
|
|
3450
3433
|
}
|
|
@@ -3497,10 +3480,8 @@ export default function App() {
|
|
|
3497
3480
|
const bytes = every ? buildZip(entries) : buildKeyframePack(packs[0]);
|
|
3498
3481
|
const name = every ? "cozyclay-panels.zip" : `cozyclay-${packs[0].folder}-${slugifyTitle(target.name)}.zip`;
|
|
3499
3482
|
await exportBoundary(job);
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
finally { setTimeout(() => URL.revokeObjectURL(url), 10_000); }
|
|
3503
|
-
setToast(isKo ? `${name} 다운로드 요청 · 파일 ${entries.length}개` : `Download requested: ${name} · ${entries.length} files`);
|
|
3483
|
+
await saveExport(new Blob([bytes], { type: "application/zip" }), name);
|
|
3484
|
+
setToast(isKo ? `${name} 내보내기 완료 · 파일 ${entries.length}개` : `${name} exported · ${entries.length} files`);
|
|
3504
3485
|
return { fileName: name, entries: entries.map((entry) => entry.name) };
|
|
3505
3486
|
}));
|
|
3506
3487
|
}
|
|
@@ -4186,27 +4167,24 @@ export default function App() {
|
|
|
4186
4167
|
}
|
|
4187
4168
|
const snapshot = structuredClone(result);
|
|
4188
4169
|
const frameJob = { source: result, done: false, request: null };
|
|
4189
|
-
frameJob.request = exportRequest("frame", (job) => {
|
|
4170
|
+
frameJob.request = exportRequest("frame", async (job) => {
|
|
4190
4171
|
if (!snapshot?.frame) throw Object.assign(new Error("The captured frame is not ready"), { exportFailureCode: "render_failed" });
|
|
4191
4172
|
updateExportStatus(job, "finalizing", { stage: "download", cancellable: false });
|
|
4192
|
-
const save = (
|
|
4173
|
+
const save = async (source, name) => {
|
|
4193
4174
|
if (job.request.handedOff.has(name)) return;
|
|
4194
|
-
|
|
4175
|
+
await saveExport(source, name);
|
|
4195
4176
|
job.request.handedOff.add(name);
|
|
4196
4177
|
};
|
|
4197
4178
|
if (snapshot.partColours && !job.request.handedOff.has("blocking-frame-palette.json")) {
|
|
4198
|
-
|
|
4199
|
-
const url = URL.createObjectURL(blob);
|
|
4200
|
-
try { save(url, "blocking-frame-palette.json"); }
|
|
4201
|
-
finally { setTimeout(() => URL.revokeObjectURL(url), 1000); }
|
|
4179
|
+
await save(new Blob([JSON.stringify({ partColours: snapshot.partColours }, null, 2)], { type: "application/json" }), "blocking-frame-palette.json");
|
|
4202
4180
|
}
|
|
4203
4181
|
if (snapshot.frameB) {
|
|
4204
|
-
save(snapshot.frame, "blocking-frame-A-start.png");
|
|
4205
|
-
save(snapshot.frameB, "blocking-frame-B-end.png");
|
|
4206
|
-
} else save(snapshot.frame, "blocking-frame.png");
|
|
4182
|
+
await save(snapshot.frame, "blocking-frame-A-start.png");
|
|
4183
|
+
await save(snapshot.frameB, "blocking-frame-B-end.png");
|
|
4184
|
+
} else await save(snapshot.frame, "blocking-frame.png");
|
|
4207
4185
|
frameJob.done = true;
|
|
4208
4186
|
setResult((current) => current === frameJob.source ? { ...current, downloaded: true } : current);
|
|
4209
|
-
setToast(ko("Frame
|
|
4187
|
+
setToast(ko("Frame export finished. Check your export folder or browser downloads.", "프레임 내보내기가 끝났어요. 내보내기 폴더나 브라우저 다운로드를 확인하세요."));
|
|
4210
4188
|
});
|
|
4211
4189
|
frameExportRef.current = frameJob;
|
|
4212
4190
|
return executeExportRequest(frameJob.request);
|
|
@@ -4791,7 +4769,7 @@ export default function App() {
|
|
|
4791
4769
|
/>
|
|
4792
4770
|
</ObjectCarrier>
|
|
4793
4771
|
))}
|
|
4794
|
-
<ViewGhosts rig={rigs[activeChar.id]} take={motion} frame={tlFrame} objects={sceneObjects} propTake={{ frameCount: tlFrameCount, fps: tlFps, objectTracks }} /><DraftGhost rigs={rigs} objects={sceneObjects} />
|
|
4772
|
+
<ViewGhosts rig={rigs[activeChar.id]} take={motion} frame={tlFrame} objects={sceneObjects} propTake={{ frameCount: tlFrameCount, fps: tlFps, objectTracks }} /><DraftGhost rigs={rigs} objects={sceneObjects} /><SculptSketch objects={sceneObjects} />
|
|
4795
4773
|
|
|
4796
4774
|
{/* Selection marker: XYZ tripod + ring on the picked cast
|
|
4797
4775
|
member; the X/Z arrows drag it across the deck. */}
|
package/src/bus-task-scopes.js
CHANGED
|
@@ -1,18 +1,64 @@
|
|
|
1
1
|
// The document targets a command writes, known before it runs, for tasks that edit one scene at once (parallel Studio
|
|
2
|
-
// tasks, P1). A key names one target: object:<id> (a group carries its subtree), character:<id> (
|
|
3
|
-
//
|
|
4
|
-
// timeline (frame count, shot order and ranges) and scene (documents, the cast library, a replace of
|
|
5
|
-
// timeline and scene are exclusive: no other task runs beside them. A command that only creates
|
|
6
|
-
// character.add) needs no key: what it makes joins its task. A command this table does not know
|
|
7
|
-
// new command is safe until it is named here.
|
|
2
|
+
// tasks, P1). A key names one target: object:<id> (a group carries its subtree), character:<id>:<layer> (one owned
|
|
3
|
+
// layer of a character, below), shot:<id> (its camera, rail and keys), or one of the singletons stage (light,
|
|
4
|
+
// environment, lens), timeline (frame count, shot order and ranges) and scene (documents, the cast library, a replace of
|
|
5
|
+
// a whole kind). timeline and scene are exclusive: no other task runs beside them. A command that only creates
|
|
6
|
+
// (object.add, a kit, character.add) needs no key: what it makes joins its task. A command this table does not know
|
|
7
|
+
// writes scene, so a new command is safe until it is named here.
|
|
8
|
+
// A character is four layers, so one task can place her while another makes her motion: placement (where she stands,
|
|
9
|
+
// her scale, parent, visibility, tint, static pose: every cast field the others do not own), take (the motion take, its
|
|
10
|
+
// timing edit and the root path), ik (the IK keys and range pins on top of the take) and props (what she holds; the
|
|
11
|
+
// held object's own key goes with it). Playback already stacks them placement < take < ik; the split only says who may
|
|
12
|
+
// write which. A plain character:<id> is all four (the scope a task names; a receipt's id): it overlaps every layer.
|
|
8
13
|
const EXCLUSIVE_KEYS = new Set(['timeline', 'scene']);
|
|
9
|
-
const
|
|
14
|
+
const CHARACTER_LAYERS = Object.freeze(['placement', 'take', 'ik', 'props']);
|
|
15
|
+
const ID = '[A-Za-z0-9][A-Za-z0-9._:-]*';
|
|
16
|
+
const KEY = new RegExp(`^(?:(?:object|character|shot):${ID}|stage|timeline|scene)$`);
|
|
10
17
|
export const isScopeKey = key => typeof key === 'string' && key.length <= 140 && KEY.test(key);
|
|
18
|
+
const LAYERED = /^character:(.+?)(?::(placement|take|ik|props))?$/s;
|
|
19
|
+
/** { id, layer } of a character key (layer null for the plain key), or null for any other key. */
|
|
20
|
+
export function layerOf(key) {
|
|
21
|
+
const match = LAYERED.exec(key);
|
|
22
|
+
return match ? { id: match[1], layer: match[2] ?? null } : null;
|
|
23
|
+
}
|
|
24
|
+
const layers = (id, names = CHARACTER_LAYERS) => names.map(name => `character:${id}:${name}`);
|
|
25
|
+
/** The keys a scope names, with a plain character:<id> spelled out as its four layers. */
|
|
26
|
+
export const expandScope = keys => [...new Set(keys.flatMap(key => { const of = layerOf(key); return of && !of.layer ? layers(of.id) : [key]; }))];
|
|
27
|
+
/** True when one key's target covers part of the other's: the same key, or a plain character key and one of its layers. */
|
|
28
|
+
export function keysOverlap(a, b) {
|
|
29
|
+
if (a === b) return true;
|
|
30
|
+
const x = layerOf(a), y = layerOf(b);
|
|
31
|
+
return Boolean(x && y && x.id === y.id && (!x.layer || !y.layer));
|
|
32
|
+
}
|
|
11
33
|
|
|
12
34
|
const SCENE = ['cast.replace', 'cast.setCustomPoses', 'cast.showExtras', 'cast.removePose', 'objects.replace', 'shot.replace', 'ai.prepareShot'];
|
|
13
35
|
const TIMELINE = ['shot.create', 'shot.createStill', 'shot.split', 'shot.duplicate', 'shot.remove', 'shot.reorder', 'shot.setRange', 'shot.setTimeline'];
|
|
14
36
|
const CREATE = ['object.add', 'object.createKit', 'asset.import', 'character.add'];
|
|
15
37
|
const CHARACTER = /^(character\.|characters?\b|cast\.(savePose|setLayer)$|ik\.|motion\.)/;
|
|
38
|
+
// The layers each character command writes, read from what it changes: a compile installs a take and clears the IK
|
|
39
|
+
// keys on it (motion domain replace), a timing edit moves the IK keys with the frames they address, a pose that clears
|
|
40
|
+
// motion clears both, and attaching a prop changes what she holds. A command this table does not name writes all four.
|
|
41
|
+
const WRITES = {
|
|
42
|
+
'character.move': ['placement'], 'characters.arrange': ['placement'], 'cast.setLayer': ['take'], 'motion.set': ['take'],
|
|
43
|
+
'motion.compile': ['take', 'ik'], 'motion.clear': ['take', 'ik'], 'motion.keyObject': ['take', 'ik', 'props'], 'object.attach': ['props'],
|
|
44
|
+
...Object.fromEntries(['addWaypoint', 'moveWaypoint', 'removeWaypoint', 'clearWaypoints'].map(verb => [`character.${verb}`, ['take']])),
|
|
45
|
+
...Object.fromEntries(['cut', 'setSegmentSpeed', 'removeSegment', 'trim', 'resetTiming'].map(verb => [`motion.${verb}`, ['take', 'ik']])),
|
|
46
|
+
...Object.fromEntries(['character.setIkKey', 'character.removeIkKey', 'character.clearIkKeys', 'ik.setKey', 'ik.removeKey', 'ik.clearKeys', 'ik.applyPose',
|
|
47
|
+
'motion.rangePin.apply', 'motion.rangePin.remove'].map(id => [id, ['ik']])),
|
|
48
|
+
};
|
|
49
|
+
// A cast field the take or the IK layer owns (the rest is placement); a model swap is a different body under all four.
|
|
50
|
+
const FIELD_LAYER = { layer: 'take', motionTake: 'take', waypoints: 'take', ikEdits: 'ik', ikKeys: 'ik', model: null };
|
|
51
|
+
const fieldLayers = fields => fields.some(field => FIELD_LAYER[field] === null) ? CHARACTER_LAYERS
|
|
52
|
+
: [...new Set(fields.map(field => FIELD_LAYER[field] ?? 'placement'))];
|
|
53
|
+
const poseLayers = args => args.clearMotion ? ['placement', 'take', 'ik'] : ['placement'];
|
|
54
|
+
/** The layer keys a character command writes on character `id`. */
|
|
55
|
+
function characterKeys(id, args) {
|
|
56
|
+
if (WRITES[id]) return WRITES[id];
|
|
57
|
+
if (id === 'character.update') return fieldLayers(Object.keys(args.patch ?? {}));
|
|
58
|
+
if (id === 'character.set') return fieldLayers(Object.keys(args.set ?? {}));
|
|
59
|
+
if (id === 'character.setPose' || id === 'cast.savePose') return poseLayers(args);
|
|
60
|
+
return CHARACTER_LAYERS;
|
|
61
|
+
}
|
|
16
62
|
|
|
17
63
|
/** object:<id> and every object under it. */
|
|
18
64
|
function objectKeys(ids, state) {
|
|
@@ -34,7 +80,9 @@ export function commandKeys(id, args = {}, state = {}, entry = {}) {
|
|
|
34
80
|
if (CREATE.includes(id)) return [];
|
|
35
81
|
if (id === 'objects.batch') return [...new Set(args.ops.flatMap(op => commandKeys(op.name, op.args, state)))];
|
|
36
82
|
if (id === 'objects.arrange') return [...new Set(arrangeKeys(args.ops, state))];
|
|
37
|
-
if (id === 'characters.arrange') return args.ops.flatMap(op => op.op === 'create' ? [] :
|
|
83
|
+
if (id === 'characters.arrange') return args.ops.flatMap(op => op.op === 'create' ? [] : layers(op.characterId, characterKeys(id, op)));
|
|
84
|
+
// A set or a take edit of several characters at once names each one (an op is { id, set }).
|
|
85
|
+
if ((id === 'character.set' || id === 'motion.set') && args.ops) return [...new Set(args.ops.flatMap(op => layers(op.id, characterKeys(id, op))))];
|
|
38
86
|
if (id.startsWith('stage.') || id === 'shot.setLens') return ['stage'];
|
|
39
87
|
if (TIMELINE.includes(id)) return ['timeline'];
|
|
40
88
|
if (id.startsWith('shot.')) {
|
|
@@ -44,9 +92,9 @@ export function commandKeys(id, args = {}, state = {}, entry = {}) {
|
|
|
44
92
|
if (id === 'object.group') return objectKeys([args.parent, ...args.children], state);
|
|
45
93
|
if (id === 'object.ungroup') return objectKeys(args.children, state);
|
|
46
94
|
if (id === 'object.remove') return objectKeys(args.ids, state);
|
|
47
|
-
if (id === 'object.attach' || id === 'motion.keyObject') return [
|
|
95
|
+
if (id === 'object.attach' || id === 'motion.keyObject') return [...layers(args.characterId, characterKeys(id, args)), ...objectKeys([args.objectId], state)];
|
|
48
96
|
if (id.startsWith('object.')) return objectKeys([args.id ?? args.objectId], state);
|
|
49
|
-
if (CHARACTER.test(id) && (args.characterId ?? args.id)) return
|
|
97
|
+
if (CHARACTER.test(id) && (args.characterId ?? args.id)) return layers(args.characterId ?? args.id, characterKeys(id, args));
|
|
50
98
|
return ['scene'];
|
|
51
99
|
}
|
|
52
100
|
|
|
@@ -62,7 +110,7 @@ export function keyOfId(id, state = {}) {
|
|
|
62
110
|
export function scopeConflict(taskId, keys, tasks) {
|
|
63
111
|
for (const other of tasks.values()) {
|
|
64
112
|
if (other.taskId === taskId) continue;
|
|
65
|
-
const held = keys.find(key =>
|
|
113
|
+
const held = keys.find(key => EXCLUSIVE_KEYS.has(key) || [...other.keys].some(k => EXCLUSIVE_KEYS.has(k) || keysOverlap(key, k)));
|
|
66
114
|
if (held) return `${held} is held by task ${other.taskId}${other.title ? ` (${other.title})` : ''}`;
|
|
67
115
|
}
|
|
68
116
|
return null;
|
package/src/bus-task-targets.js
CHANGED
|
@@ -2,20 +2,37 @@
|
|
|
2
2
|
// key is its rows (an object's row; a character's cast row and motion layer; a shot's row) or, for stage and timeline,
|
|
3
3
|
// the whole slices; restoring puts every row back where it stood (or removes a row the task created) in one composed
|
|
4
4
|
// entry, so undo, history and saves see an ordinary edit. scene is not restorable: documents have no undo.
|
|
5
|
+
// A character layer (bus-task-scopes.js) is the fields of those two rows it owns: restoring it writes only them back
|
|
6
|
+
// into the row as it stands now, so cancelling the task that placed her keeps the take another task made meanwhile.
|
|
5
7
|
import { StudioProtocolError } from './studio-agent-protocol.js';
|
|
8
|
+
import { layerOf } from './bus-task-scopes.js';
|
|
6
9
|
|
|
7
10
|
const ROWS = { object: [['objects', null]], character: [['cast', 'characters'], ['motion', null]], shot: [['shot', 'shots']] };
|
|
8
11
|
const WHOLE = { stage: ['stage'], timeline: ['shot', 'motion'] };
|
|
9
12
|
const split = key => { const at = key.indexOf(':'); return at < 0 ? [key] : [key.slice(0, at), key.slice(at + 1)]; };
|
|
13
|
+
// The fields of the cast row (scenes.js createCharacterEntry) and the motion layer (emptyMotionLayer) each layer owns;
|
|
14
|
+
// placement is every other cast field, props no field of either (what she holds lives on the objects).
|
|
15
|
+
const LAYER_FIELDS = {
|
|
16
|
+
cast: { take: ['layer', 'motionTake'], ik: ['ikEdits'], props: [] },
|
|
17
|
+
motion: { placement: [], take: ['take', 'fullTake', 'takeRecipe', 'takeVersions'], ik: ['ikKeys', 'ikPins', 'ikPinResiduals', 'committedIkEdits'], props: [] },
|
|
18
|
+
};
|
|
19
|
+
const CAST_OWNED = new Set(['id', ...Object.values(LAYER_FIELDS.cast).flat()]);
|
|
20
|
+
const fieldsOf = (domain, layer, row) => LAYER_FIELDS[domain][layer] ?? Object.keys(row).filter(field => !CAST_OWNED.has(field));
|
|
21
|
+
// A character with no motion layer yet reads as the empty one (motion-intent-codec.js emptyMotionLayer).
|
|
22
|
+
const EMPTY = { motion: { take: null, fullTake: null, takeRecipe: null, takeVersions: [], ikKeys: [], ikPins: [], ikPinResiduals: [], committedIkEdits: [] } };
|
|
23
|
+
const pick = (row, fields) => Object.fromEntries(fields.map(field => [field, row[field]]));
|
|
10
24
|
|
|
11
25
|
function put(slice, part) {
|
|
12
26
|
if ('whole' in part) return part.whole;
|
|
13
|
-
const
|
|
14
|
-
|
|
27
|
+
const all = part.path ? slice[part.path] : slice, at = all.findIndex(row => row.id === part.id), rows = all.filter(row => row.id !== part.id);
|
|
28
|
+
// A layer goes back into the row as it is now; a row removed since comes back whole; a layer never removes a row.
|
|
29
|
+
if ('fields' in part && at >= 0) rows.splice(at, 0, { ...all[at], ...part.fields });
|
|
30
|
+
else if (part.row) rows.splice(Math.min(part.index, rows.length), 0, part.row);
|
|
31
|
+
else if ('fields' in part) return slice;
|
|
15
32
|
return part.path ? { ...slice, [part.path]: rows } : rows;
|
|
16
33
|
}
|
|
17
34
|
|
|
18
|
-
/** snapshotTarget(key)
|
|
35
|
+
/** snapshotTarget(key), restoreTargets([{ key, value }]) and readLayer(key) over the app's store domains (ports.storeDomain, ports.beginAction). */
|
|
19
36
|
export function taskTargetPorts(ports) {
|
|
20
37
|
const store = domain => ports.storeDomain?.(domain)?.documentStore;
|
|
21
38
|
const rowParts = (kind, id, read) => ROWS[kind].filter(([domain]) => store(domain)).map(([domain, path]) => {
|
|
@@ -23,17 +40,26 @@ export function taskTargetPorts(ports) {
|
|
|
23
40
|
const index = read ? rows.findIndex(row => row.id === id) : -1;
|
|
24
41
|
return { domain, path, id, index, row: index < 0 ? undefined : rows[index] };
|
|
25
42
|
});
|
|
43
|
+
// A layer's parts: its fields of each row that has some, beside the whole row (to put back a row removed since).
|
|
44
|
+
const layerParts = ({ id, layer }) => rowParts('character', id, true).flatMap(part => {
|
|
45
|
+
const row = part.row ?? EMPTY[part.domain], fields = row ? fieldsOf(part.domain, layer, row) : [];
|
|
46
|
+
return fields.length ? [{ ...part, fields: pick(row, fields) }] : [];
|
|
47
|
+
});
|
|
48
|
+
const snapshotTarget = key => {
|
|
49
|
+
const [kind, id] = split(key), of = layerOf(key);
|
|
50
|
+
if (of?.layer) return layerParts(of);
|
|
51
|
+
if (WHOLE[kind]) return WHOLE[kind].filter(store).map(domain => ({ domain, whole: store(domain).read(domain) }));
|
|
52
|
+
return ROWS[kind] ? rowParts(kind, id, true) : null;
|
|
53
|
+
};
|
|
26
54
|
return {
|
|
27
|
-
snapshotTarget
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return ROWS[kind] ? rowParts(kind, id, true) : null;
|
|
31
|
-
},
|
|
55
|
+
snapshotTarget,
|
|
56
|
+
/** A layer's token: its own fields only, so an edit of another layer of the same character leaves it as it was. */
|
|
57
|
+
readLayer: key => JSON.stringify(snapshotTarget(key).map(part => part.fields)),
|
|
32
58
|
restoreTargets(values) {
|
|
33
59
|
const parts = values.flatMap(({ key, value }) => {
|
|
34
60
|
if (value === null) throw new StudioProtocolError('CAPABILITY_MISSING', `${key} cannot be restored: a document change has no undo.`);
|
|
35
61
|
const [kind, id] = split(key);
|
|
36
|
-
return value ?? rowParts(kind, id, false);
|
|
62
|
+
return value ?? rowParts(kind, layerOf(key)?.id ?? id, false);
|
|
37
63
|
});
|
|
38
64
|
const domains = [...new Set(parts.map(part => part.domain))];
|
|
39
65
|
if (!domains.length) return { historyEntryId: null };
|