cozyclay 3.1.3 → 3.1.5
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 +98 -0
- package/INSTALL_FOR_AGENTS.md +3 -0
- package/README.md +5 -0
- package/bin/agent/agent-failure-codes.mjs +31 -0
- package/bin/agent/agent-routes.mjs +5 -10
- package/bin/agent/agent-runner.mjs +5 -3
- package/bin/cozyclay.mjs +18 -112
- package/bin/star-prompt.mjs +123 -0
- package/bin/studio-routes.mjs +69 -0
- package/bin/update-check.mjs +53 -5
- package/bin/update-install.mjs +136 -0
- package/bin/update-routes.mjs +80 -0
- package/dist/ai-camera-control/index.html +1 -1
- package/dist/app/index.html +5 -6
- package/dist/assets/{TaskBoard-U1Yl-4F1.js → TaskBoard-DwXhsJXz.js} +1 -1
- package/dist/assets/app-CEP7jzXB.css +1 -0
- package/dist/assets/app-CTKcyPM-.js +5144 -0
- package/dist/assets/compile-worker-or7djI2v.js +6 -0
- package/dist/assets/{motion3-compile-DDG3wklD.js → motion3-compile-Du7UXrWn.js} +1 -1
- package/dist/assets/scene-asset-sync-t45pWCyq.js +13 -0
- package/dist/assets/task-board-CkLZGPkv.js +15 -0
- package/dist/assets/workflow-DJwOUzqJ.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/greybox-to-video/index.html +3 -3
- package/dist/index.html +533 -264
- package/dist/media/cozyclay-mark.png +0 -0
- package/dist/media/cozyclay-truck-demo-poster.jpg +0 -0
- package/dist/media/step-move-poster.jpg +0 -0
- package/dist/media/step-shoot-poster.jpg +0 -0
- package/dist/media/step-stage-poster.jpg +0 -0
- package/dist/media/truck-greybox-poster.jpg +0 -0
- package/dist/media/truck-seedance-poster.jpg +0 -0
- package/dist/previs-software/index.html +4 -4
- package/dist/privacy/index.html +46 -19
- package/dist/seedance-camera-control/index.html +3 -3
- package/dist/sitemap.xml +6 -6
- package/dist/workflow/index.html +3 -4
- package/package.json +1 -1
- package/src/App.jsx +59 -70
- package/src/analytics-controls.js +17 -0
- package/src/analytics-friction.js +124 -0
- package/src/analytics-strict.js +139 -0
- package/src/analytics-values.js +49 -0
- package/src/analytics.js +37 -133
- package/src/asset-pane.jsx +4 -5
- package/src/click-friction.js +101 -0
- package/src/command-bus-telemetry.js +155 -0
- package/src/command-bus.js +10 -8
- package/src/commands/motion3.js +7 -2
- package/src/controls.jsx +3 -2
- package/src/crash-telemetry.js +45 -0
- package/src/domains/cast.js +2 -11
- package/src/domains/motion.js +49 -38
- package/src/domains/objects.js +3 -0
- package/src/domains/scene-undo.js +27 -0
- package/src/domains/scenes.js +1 -1
- package/src/error-boundary.jsx +2 -0
- package/src/export-attempt.js +100 -0
- package/src/export-workspace.js +12 -1
- package/src/first-success-guide.jsx +11 -7
- package/src/guide-telemetry.js +60 -0
- package/src/hierarchy-panel.jsx +2 -1
- package/src/live-control.js +2 -9
- package/src/locale-zh/domains.js +1 -0
- package/src/main.jsx +5 -0
- package/src/motion/timeline.jsx +9 -5
- package/src/motion-draft-telemetry.js +62 -0
- package/src/motion3/compiler/channels.js +7 -5
- package/src/motion3/compiler/compile.js +6 -2
- package/src/motion3/compiler/finger-close.js +84 -7
- package/src/motion3/compiler/head-lead.js +70 -0
- package/src/motion3/compiler/head-steady.js +1 -1
- package/src/motion3/compiler/limbs.js +10 -3
- package/src/motion3/compiler/pose.js +1 -1
- package/src/motion3/compiler/validate.js +5 -3
- package/src/motion3/pipeline.js +4 -0
- package/src/motion3/qa/explain.js +2 -1
- package/src/motion3/qa/finger-checks.js +80 -0
- package/src/motion3/qa/gait-observe.js +88 -0
- package/src/motion3/qa/grasp-checks.js +3 -0
- package/src/motion3/qa/hints-grasp.js +8 -0
- package/src/motion3/qa/index.js +2 -1
- package/src/motion3/qa/motion-life.js +26 -3
- package/src/motion3/schema/topics.js +1 -1
- package/src/playback-telemetry.js +107 -0
- package/src/post-turn-edits.js +62 -0
- package/src/search-friction.js +49 -0
- package/src/semantic-edit.js +19 -6
- package/src/shell/BoardSlot.jsx +3 -3
- package/src/shell/BottomDock.jsx +1 -1
- package/src/shell/DetailsSlot.jsx +1 -1
- package/src/shell/MenuBar.jsx +13 -13
- package/src/shell/StudioShell.jsx +7 -1
- package/src/shell/TopBar.jsx +2 -0
- package/src/shell/ViewportToolbar.jsx +2 -2
- package/src/shortcut-friction.js +122 -0
- package/src/stage-canvas-rigs.jsx +3 -4
- package/src/studio-telemetry.js +97 -0
- package/src/thrash-friction.js +90 -0
- package/src/update/UpdateChip.jsx +86 -0
- package/src/update/UpdateDialog.jsx +147 -0
- package/src/update/update-client.js +188 -0
- package/src/update/update-telemetry.js +78 -0
- package/src/update/update.css +94 -0
- package/src/use-webgl-available.js +35 -0
- package/src/workflow/AgentPanel.jsx +29 -30
- package/src/workflow/CozySceneNode.jsx +1 -1
- package/src/workflow/LoginPanel.jsx +15 -5
- package/src/workflow/agent-client.js +10 -91
- package/src/workflow/agent-failure-codes.js +47 -0
- package/src/workflow/agent-turn-telemetry.js +222 -0
- package/src/workflow/connect-telemetry.js +86 -0
- package/src/workflow/prompt-telemetry.js +105 -0
- package/tools/dev/pages/privacy.html +44 -17
- package/tools/loc-baseline.json +10 -10
- package/tools/run-tests.mjs +8 -8
- package/dist/assets/app-BdRlMf4J.css +0 -1
- package/dist/assets/app-DwETmoQZ.js +0 -5144
- package/dist/assets/compile-worker-oy_P9HHh.js +0 -6
- package/dist/assets/landing-y3EqDgSZ.js +0 -1
- package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +0 -1
- package/dist/assets/scene-asset-sync-Bz59Ghdz.js +0 -13
- package/dist/assets/task-board-DFfNa0xg.js +0 -15
- package/dist/assets/workflow-BMJGd_gZ.js +0 -179
- package/dist/media/playground-poster.jpg +0 -0
package/src/App.jsx
CHANGED
|
@@ -30,7 +30,9 @@ import { applyTakeFrame } from "./motion3/playback.js";
|
|
|
30
30
|
|
|
31
31
|
import { FlyControls, aimAt, forwardFrom } from "./controls.jsx";
|
|
32
32
|
import { createLiveControl, loadLiveWorkspaceId, mintLiveWorkspaceId } from "./live-control.js";
|
|
33
|
-
import {
|
|
33
|
+
import { startUserEdits } from "./command-bus-telemetry.js";
|
|
34
|
+
import { noteHistoryKey, noteShortcutIgnored, noteUnselectedKey } from "./shortcut-friction.js";
|
|
35
|
+
import { noteNavSwitch } from "./thrash-friction.js";
|
|
34
36
|
|
|
35
37
|
import AgentPanel from "./workflow/AgentPanel.jsx";
|
|
36
38
|
import SculptPanel from "./panels/SculptPanel.jsx";
|
|
@@ -118,6 +120,7 @@ import {
|
|
|
118
120
|
} from "./project.js";
|
|
119
121
|
import ProjectBrowser, { ProjectNameDialog } from "./project-browser.jsx";
|
|
120
122
|
import FirstSuccessGuide from "./first-success-guide.jsx";
|
|
123
|
+
import { noteDeleteUndone, notePanelToggled } from "./studio-telemetry.js";
|
|
121
124
|
import UseCaseQuestion from "./use-case-question.jsx";
|
|
122
125
|
import ObjectGizmo from "./object-gizmo.jsx";
|
|
123
126
|
import { useHeldObjectEdit } from "./held-object-edit.jsx";
|
|
@@ -140,19 +143,17 @@ import {
|
|
|
140
143
|
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
144
|
import { routeOwnerFor } from "./route-owner.js";
|
|
142
145
|
import {
|
|
143
|
-
analyticsActive,
|
|
144
146
|
exportFailureCode,
|
|
145
|
-
recordSemanticEdit,
|
|
146
147
|
recordUseCase,
|
|
147
148
|
sculptRunTelemetry,
|
|
148
149
|
shouldAskUseCase,
|
|
149
|
-
startExportAttempt,
|
|
150
150
|
track,
|
|
151
151
|
trackActivation,
|
|
152
152
|
trackFeature,
|
|
153
153
|
} from "./analytics.js";
|
|
154
154
|
import { ko, isKo, isZh } from "./locale.js";
|
|
155
155
|
import { saveExport } from "./export-workspace.js";
|
|
156
|
+
import { startExportAttempt } from "./export-attempt.js";
|
|
156
157
|
import { characterCapabilities, kindRefusal, proxyFigureBounds } from "./character-kind.js";
|
|
157
158
|
import { STARTER_SCENES, resolveSceneParam } from "./starter-scenes.js";
|
|
158
159
|
import { PART_COLOURS } from "./part-colours.js";
|
|
@@ -181,6 +182,7 @@ import {
|
|
|
181
182
|
Toast,
|
|
182
183
|
} from "./ui.jsx";
|
|
183
184
|
import { useRenderActivity } from "./use-render-activity.js";
|
|
185
|
+
import { useWebGlAvailable } from "./use-webgl-available.js";
|
|
184
186
|
|
|
185
187
|
import {
|
|
186
188
|
CUSTOM_MOVE,
|
|
@@ -442,33 +444,20 @@ export default function App() {
|
|
|
442
444
|
if (typeof window !== "undefined") window.__cozyclayRenders = (window.__cozyclayRenders || 0) + 1;
|
|
443
445
|
const sceneRevisionRef = useRef(0);
|
|
444
446
|
const studioBindingRef = useRef(null);
|
|
445
|
-
const
|
|
446
|
-
if (!
|
|
447
|
-
|
|
448
|
-
//
|
|
449
|
-
|
|
450
|
-
|
|
447
|
+
const semanticEditRef = useRef(null);
|
|
448
|
+
if (!semanticEditRef.current) {
|
|
449
|
+
// A new mount boundary for the first edit. User edits are counted from the
|
|
450
|
+
// command bus's own receipts (command-bus-telemetry.js), not from here.
|
|
451
|
+
startUserEdits(playgroundMode ? "playground" : "craft");
|
|
452
|
+
semanticEditRef.current = (domain, before, after) => {
|
|
451
453
|
if (before !== after) sceneRevisionRef.current += 1;
|
|
452
454
|
studioBindingRef.current?.invalidate(domain, before, after);
|
|
453
455
|
studioBindingRef.current?.publishSemantic(domain, after);
|
|
454
|
-
// Every authored edit is counted by its closed kind for the session
|
|
455
|
-
// summary (#466); classification only runs while telemetry is on.
|
|
456
|
-
if (before !== after && analyticsActive()) {
|
|
457
|
-
try {
|
|
458
|
-
const kind = semanticEditKind(domain, before, after);
|
|
459
|
-
if (kind) recordSemanticEdit(kind);
|
|
460
|
-
} catch {
|
|
461
|
-
// Telemetry classification must never affect editing.
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
return firstEdit(surface, domain, before, after);
|
|
465
456
|
};
|
|
466
457
|
}
|
|
467
458
|
// One semantic hook for authored UI and programmatic mutations. Passive
|
|
468
459
|
// setters intentionally bypass it (navigation, load, seed, restore, history).
|
|
469
|
-
const markSemanticEdit = (domain, before, after) =>
|
|
470
|
-
return firstEditRef.current(playgroundMode ? "playground" : "craft", domain, before, after);
|
|
471
|
-
};
|
|
460
|
+
const markSemanticEdit = (domain, before, after) => semanticEditRef.current(domain, before, after);
|
|
472
461
|
const craftActionTrackedRef = useRef(false);
|
|
473
462
|
const markCraftAction = (actionKind) => {
|
|
474
463
|
if (craftActionTrackedRef.current) return;
|
|
@@ -1148,29 +1137,29 @@ export default function App() {
|
|
|
1148
1137
|
// the "load a character" one, which would read as a loading problem.
|
|
1149
1138
|
const activeCapabilities = characterCapabilities(activeChar);
|
|
1150
1139
|
const poseRefusal = !activeCapabilities.pose ? kindRefusal("pose", isKo, isZh) : ikChains ? null : ko("Load a character to pose it — Pose mode edits its rig with IK", "캐릭터를 불러와야 포즈를 잡을 수 있어요 — 포즈 모드는 IK로 리그를 편집합니다", "加载角色后才能摆姿势 — 姿势模式用 IK 编辑其骨骼");
|
|
1140
|
+
// False when the mode refuses (its reason shows as a toast): the 1-5 keys report that as shortcut:ignored.
|
|
1151
1141
|
function selectWorkflowMode(next) {
|
|
1152
1142
|
if (next === "pose" && poseRefusal) {
|
|
1153
1143
|
setToast(poseRefusal);
|
|
1154
|
-
return;
|
|
1144
|
+
return false;
|
|
1155
1145
|
}
|
|
1156
1146
|
// A storyboard is stills: there is no Motion department to open.
|
|
1157
1147
|
if (next === "motion" && storyboardProject) {
|
|
1158
1148
|
setToast(ko(STORYBOARD_MOTION_REFUSAL.en, STORYBOARD_MOTION_REFUSAL.ko, STORYBOARD_MOTION_REFUSAL.zh));
|
|
1159
|
-
return;
|
|
1149
|
+
return false;
|
|
1160
1150
|
}
|
|
1161
1151
|
setWorkflowMode(next);
|
|
1152
|
+
// A user's switch (tab, key, a shot or camera pick) toward nav:thrash; the agent's view.setMode does not come here.
|
|
1153
|
+
if (next !== workflowMode) noteNavSwitch("mode");
|
|
1162
1154
|
// Picking a department leaves the chrome-free player. Shot-look is
|
|
1163
1155
|
// camera work, so it only yields when the operator leaves Camera.
|
|
1164
1156
|
if (preview) exitPreview();
|
|
1165
1157
|
else if (lookThroughShot && next !== "camera") exitPreview();
|
|
1166
1158
|
if (next === "camera") setSelectedHierarchyId("camera");
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
setSelectedHierarchyId(rowIdForCharIndex(activeCharIndex));
|
|
1172
|
-
|
|
1173
|
-
}
|
|
1159
|
+
// The active character's ROW, not the group: the placement gizmo only
|
|
1160
|
+
// renders for a specific cast member, so selecting the group used to
|
|
1161
|
+
// drop the operator into Motion with nothing to drag.
|
|
1162
|
+
else if (next === "motion") setSelectedHierarchyId(rowIdForCharIndex(activeCharIndex));
|
|
1174
1163
|
// Pose lands on the active character's rig: its Rig Control panel holds
|
|
1175
1164
|
// the IK tools that W/E/R switch between.
|
|
1176
1165
|
else if (next === "pose") setSelectedHierarchyId(`${rowIdForCharIndex(activeCharIndex)}.rig`);
|
|
@@ -1605,7 +1594,7 @@ export default function App() {
|
|
|
1605
1594
|
setToast(ko("A newer edit comes after this deletion. Use Undo history instead.", "삭제 이후의 편집이 있어요. 실행 취소 기록을 사용해 주세요."));
|
|
1606
1595
|
return;
|
|
1607
1596
|
}
|
|
1608
|
-
undoScene();
|
|
1597
|
+
undoScene("button");
|
|
1609
1598
|
}
|
|
1610
1599
|
|
|
1611
1600
|
useEffect(() => {
|
|
@@ -1626,8 +1615,8 @@ export default function App() {
|
|
|
1626
1615
|
// selected, and mid-drag the store's settle commits then steps.
|
|
1627
1616
|
if (event.code === "KeyZ" && (event.ctrlKey || event.metaKey)) {
|
|
1628
1617
|
event.preventDefault();
|
|
1629
|
-
|
|
1630
|
-
else undoScene();
|
|
1618
|
+
// Nothing to undo or redo, or a refused undo, is shortcut:ignored: the bus receipt says which.
|
|
1619
|
+
if (event.shiftKey) noteHistoryKey("redo", redoScene()); else noteHistoryKey("undo", undoScene("key"));
|
|
1631
1620
|
return;
|
|
1632
1621
|
}
|
|
1633
1622
|
// 1-4 pick the workflow mode; pose is refused (with its reason) when
|
|
@@ -1637,7 +1626,7 @@ export default function App() {
|
|
|
1637
1626
|
// A storyboard has no key 4: it does nothing, not even a refusal.
|
|
1638
1627
|
if (modeKey === "motion" && storyboardProject) return;
|
|
1639
1628
|
event.preventDefault();
|
|
1640
|
-
if (modeKey !== workflowMode
|
|
1629
|
+
if (modeKey !== workflowMode && selectWorkflowMode(modeKey) === false) noteShortcutIgnored("mode", "workflow_mode");
|
|
1641
1630
|
return;
|
|
1642
1631
|
}
|
|
1643
1632
|
// Sculpt mode's W/E/R swing the turntable to front, side and back.
|
|
@@ -1650,9 +1639,9 @@ export default function App() {
|
|
|
1650
1639
|
// buttons: the range pin needs a take.
|
|
1651
1640
|
if (workflowMode === "pose" && GIZMO_HOTKEYS[event.code]) {
|
|
1652
1641
|
event.preventDefault();
|
|
1642
|
+
// E has no IK tool: a pose-mode E does nothing (shortcut:ignored).
|
|
1653
1643
|
if (event.code === "KeyW") setIkEditTool("ik");
|
|
1654
|
-
|
|
1655
|
-
else if (event.code === "KeyR" && motion) { setIkEditTool("pin"); setRangePinPartPick(null); }
|
|
1644
|
+
else if (event.code === "KeyR" && motion) { setIkEditTool("pin"); setRangePinPartPick(null); } else if (event.code === "KeyE") noteShortcutIgnored("transform", "wrong_mode");
|
|
1656
1645
|
return;
|
|
1657
1646
|
}
|
|
1658
1647
|
if (GIZMO_HOTKEYS[event.code]) {
|
|
@@ -1703,7 +1692,8 @@ export default function App() {
|
|
|
1703
1692
|
dropSelectedSceneObject();
|
|
1704
1693
|
return;
|
|
1705
1694
|
}
|
|
1706
|
-
|
|
1695
|
+
// F, Ctrl/Cmd+D and Delete with nothing to act on reach here: shortcut:ignored, no_selection.
|
|
1696
|
+
if (!selectedSceneObjectId) return noteUnselectedKey(event);
|
|
1707
1697
|
if (event.key !== "Delete" && event.key !== "Backspace") return;
|
|
1708
1698
|
// A selected path point owns Delete: the route's handles are the
|
|
1709
1699
|
// finer target, and deleting the whole prop out from under a point
|
|
@@ -1809,6 +1799,7 @@ export default function App() {
|
|
|
1809
1799
|
return () => window.removeEventListener(PWA_UPDATE_EVENT, onUpdate);
|
|
1810
1800
|
}, []);
|
|
1811
1801
|
const [glContextLost, setGlContextLost] = useState(false);
|
|
1802
|
+
const webGlAvailable = useWebGlAvailable();
|
|
1812
1803
|
|
|
1813
1804
|
|
|
1814
1805
|
|
|
@@ -2245,14 +2236,14 @@ export default function App() {
|
|
|
2245
2236
|
try { const value = studioBindingRef.current.context(); setStudioAgentError(null); return value; }
|
|
2246
2237
|
catch (error) { setStudioAgentError(`${error.code ?? "INVALID_CONTEXT"}: ${error.message}`); return null; }
|
|
2247
2238
|
};
|
|
2239
|
+
const agentCollapsedRef = useRef(agentCollapsed);
|
|
2240
|
+
useEffect(() => { agentCollapsedRef.current = agentCollapsed; }, [agentCollapsed]);
|
|
2248
2241
|
useEffect(() => {
|
|
2249
2242
|
if (embedMode) return;
|
|
2250
|
-
|
|
2251
|
-
const
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
window.addEventListener("cozyclay:agent-panel-toggle", toggle);
|
|
2255
|
-
window.addEventListener("keydown", key);
|
|
2243
|
+
// The toolbar control and Workflow's button dispatch the event; Cmd/Ctrl+B calls toggle with the keydown.
|
|
2244
|
+
const toggle = (event) => { notePanelToggled(agentCollapsedRef.current, event?.type === "keydown" ? "key" : "button"); noteNavSwitch("agent_panel"); setAgentCollapsed(value => !value); };
|
|
2245
|
+
const key = event => { if ((event.metaKey || event.ctrlKey) && event.code === "KeyB") { event.preventDefault(); toggle(event); } };
|
|
2246
|
+
window.addEventListener("cozyclay:agent-panel-toggle", toggle); window.addEventListener("keydown", key);
|
|
2256
2247
|
return () => { window.removeEventListener("cozyclay:agent-panel-toggle", toggle); window.removeEventListener("keydown", key); };
|
|
2257
2248
|
}, [embedMode]);
|
|
2258
2249
|
const projectHandleRef = useRef(null);
|
|
@@ -2783,7 +2774,7 @@ export default function App() {
|
|
|
2783
2774
|
|
|
2784
2775
|
function updateExportStatus(job, phase, details = {}) {
|
|
2785
2776
|
if (recRef.current !== job) return;
|
|
2786
|
-
job.cancellable = details.cancellable ?? false;
|
|
2777
|
+
job.cancellable = details.cancellable ?? false; if (!job.controller.signal.aborted) job.attempt?.note(phase, details);
|
|
2787
2778
|
setExportStatus({ kind: job.request.kind, phase, label: job.label, ...details });
|
|
2788
2779
|
}
|
|
2789
2780
|
|
|
@@ -2805,11 +2796,10 @@ export default function App() {
|
|
|
2805
2796
|
if (request.external) throw new Error(ko("An export is already running", "이미 내보내기 중입니다"));
|
|
2806
2797
|
return null;
|
|
2807
2798
|
}
|
|
2808
|
-
const job = { request, controller: new AbortController(), capture: null, cancellable: false };
|
|
2799
|
+
const job = { request, controller: new AbortController(), capture: null, cancellable: false, attempt: request.external ? null : startExportAttempt({ export_kind: request.kind, format: request.format, surface: embedMode ? "embed" : "studio", objects: sceneObjects.length, request }) };
|
|
2809
2800
|
recRef.current = job;
|
|
2810
2801
|
retryExportRef.current = request;
|
|
2811
2802
|
setRecState("recording");
|
|
2812
|
-
const attempt = request.external ? null : startExportAttempt({ export_kind: request.kind, format: request.format, surface: embedMode ? "embed" : "studio" });
|
|
2813
2803
|
updateExportStatus(job, "preparing", { cancellable: request.kind !== "frame" });
|
|
2814
2804
|
try {
|
|
2815
2805
|
await exportBoundary(job);
|
|
@@ -2819,7 +2809,7 @@ export default function App() {
|
|
|
2819
2809
|
}
|
|
2820
2810
|
const output = await request.run(job);
|
|
2821
2811
|
job.controller.signal.throwIfAborted();
|
|
2822
|
-
attempt?.succeed();
|
|
2812
|
+
job.attempt?.succeed();
|
|
2823
2813
|
updateExportStatus(job, "completed", { message: request.download
|
|
2824
2814
|
? ko("Export completed. Check your export folder or browser downloads; the OS save is not confirmed.", "내보내기를 완료했어요. 내보내기 폴더나 브라우저 다운로드를 확인하세요. OS 저장 완료는 확인할 수 없어요.")
|
|
2825
2815
|
: ko("Export pipeline completed. The requesting tool handles the file handoff.", "내보내기 처리를 완료했어요. 요청한 도구에서 파일 전달을 처리합니다.") });
|
|
@@ -2842,7 +2832,7 @@ export default function App() {
|
|
|
2842
2832
|
return output;
|
|
2843
2833
|
} catch (error) {
|
|
2844
2834
|
const code = exportFailureCode(error, request.kind === "depth_video" ? "render_failed" : "unknown");
|
|
2845
|
-
attempt?.fail(error, code);
|
|
2835
|
+
job.attempt?.fail(error, code, request.handedOff.size);
|
|
2846
2836
|
const message = exportRecovery(code);
|
|
2847
2837
|
updateExportStatus(job, code === "aborted" ? "cancelled" : "failed", {
|
|
2848
2838
|
code, message, retryable: !request.external, handedOff: request.handedOff.size,
|
|
@@ -3097,6 +3087,7 @@ export default function App() {
|
|
|
3097
3087
|
setToast(ko("Add at least one Shot before exporting OTIO", "OTIO를 내보내려면 샷을 하나 이상 추가하세요"));
|
|
3098
3088
|
return;
|
|
3099
3089
|
}
|
|
3090
|
+
const attempt = startExportAttempt({ export_kind: "otio", format: "otio", surface: embedMode ? "embed" : "studio", objects: sceneObjects.length });
|
|
3100
3091
|
try {
|
|
3101
3092
|
const activeScene = scenes.find((scene) => scene.id === activeSceneId);
|
|
3102
3093
|
const exportScene = {
|
|
@@ -3111,17 +3102,14 @@ export default function App() {
|
|
|
3111
3102
|
};
|
|
3112
3103
|
const serialized = serializeOtio(exportScene, shots);
|
|
3113
3104
|
const blob = new Blob([serialized], { type: "application/json" });
|
|
3114
|
-
const slug = (activeScene?.name ?? "cut-list")
|
|
3115
|
-
|
|
3116
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
3117
|
-
.replace(/^-+|-+$/g, "") || "cut-list";
|
|
3118
|
-
await saveExport(blob, `cozyclay-${slug}.otio`);
|
|
3105
|
+
const slug = (activeScene?.name ?? "cut-list").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "cut-list";
|
|
3106
|
+
await saveExport(blob, `cozyclay-${slug}.otio`); attempt.succeed();
|
|
3119
3107
|
const frameCount = shots.reduce((total, shot) => total + shot.endFrame - shot.startFrame + 1, 0);
|
|
3120
3108
|
setToast(ko(`OTIO saved · ${shots.length} shots · ${frameCount} frames`,
|
|
3121
3109
|
`OTIO 저장됨 · ${shots.length}샷 · ${frameCount}프레임`,
|
|
3122
3110
|
`OTIO 已保存 · ${shots.length} 个镜头 · ${frameCount} 帧`));
|
|
3123
3111
|
} catch (error) {
|
|
3124
|
-
setToast(error?.message || String(error));
|
|
3112
|
+
attempt.fail(error); setToast(error?.message || String(error));
|
|
3125
3113
|
}
|
|
3126
3114
|
}
|
|
3127
3115
|
|
|
@@ -3273,13 +3261,15 @@ export default function App() {
|
|
|
3273
3261
|
|
|
3274
3262
|
/** Depth and normal conditioning passes of the framing on screen now. */
|
|
3275
3263
|
async function exportRenderPasses() {
|
|
3264
|
+
const attempt = startExportAttempt({ export_kind: "render_pass", format: "png", surface: embedMode ? "embed" : "studio", objects: sceneObjects.length });
|
|
3265
|
+
let handedOff = 0;
|
|
3276
3266
|
try {
|
|
3277
3267
|
const dataUrls = renderPassDataUrls();
|
|
3278
|
-
for (const kind of ["depth", "normal"]) await saveExport(dataUrls[kind], passFileName(kind));
|
|
3268
|
+
for (const kind of ["depth", "normal"]) { await saveExport(dataUrls[kind], passFileName(kind)); handedOff += 1; }
|
|
3279
3269
|
setToast(ko("Depth and normal passes exported", "뎁스·노멀 패스를 내보냈어요"));
|
|
3280
|
-
trackFeature("export_render_pass");
|
|
3270
|
+
attempt.succeed(); trackFeature("export_render_pass");
|
|
3281
3271
|
} catch (error) {
|
|
3282
|
-
setToast(error?.message || String(error));
|
|
3272
|
+
attempt.fail(error, "render_failed", handedOff); setToast(error?.message || String(error));
|
|
3283
3273
|
}
|
|
3284
3274
|
}
|
|
3285
3275
|
|
|
@@ -3308,6 +3298,7 @@ export default function App() {
|
|
|
3308
3298
|
|
|
3309
3299
|
/** Contact sheet of the whole cut: one thumbnail and prompt per shot. */
|
|
3310
3300
|
async function exportStoryboard() {
|
|
3301
|
+
const attempt = startExportAttempt({ export_kind: "storyboard", format: "png", surface: embedMode ? "embed" : "studio", objects: sceneObjects.length });
|
|
3311
3302
|
try {
|
|
3312
3303
|
if (!shots.length) throw new Error(ko("Add at least one Shot before exporting a storyboard", "스토리보드를 내보내려면 샷을 하나 이상 추가하세요"));
|
|
3313
3304
|
setToast(ko("Composing the storyboard…", "스토리보드 구성 중…"));
|
|
@@ -3337,9 +3328,9 @@ export default function App() {
|
|
|
3337
3328
|
});
|
|
3338
3329
|
await saveExport(canvas.toDataURL("image/png"), "cozyclay-storyboard.png");
|
|
3339
3330
|
setToast(ko(`Storyboard saved · ${cells.length} shots`, `스토리보드 저장됨 · ${cells.length}샷`, `故事板已保存 · ${cells.length} 个镜头`));
|
|
3340
|
-
trackFeature("export_storyboard");
|
|
3331
|
+
attempt.succeed(); trackFeature("export_storyboard");
|
|
3341
3332
|
} catch (error) {
|
|
3342
|
-
setToast(error?.message || String(error));
|
|
3333
|
+
attempt.fail(error); setToast(error?.message || String(error));
|
|
3343
3334
|
}
|
|
3344
3335
|
}
|
|
3345
3336
|
|
|
@@ -3625,7 +3616,9 @@ export default function App() {
|
|
|
3625
3616
|
// nothing to apply — pure FK posing / pure motion playback stays
|
|
3626
3617
|
// untouched.
|
|
3627
3618
|
useEffect(() => {
|
|
3628
|
-
|
|
3619
|
+
// On a selection switch this runs once before the resolve effect's
|
|
3620
|
+
// setIkChains lands: the chains still pose the previous character.
|
|
3621
|
+
if (!ikChains || !activeRig || posing || ikChains.values().next().value?.rig !== activeRig) return;
|
|
3629
3622
|
// Preview changes may re-run this effect without a playhead change.
|
|
3630
3623
|
// Always re-establish the motion base before adding a correction.
|
|
3631
3624
|
if (motion) poseMemberAtFrame(activeRig, motion, null, tlFrame);
|
|
@@ -3645,7 +3638,7 @@ export default function App() {
|
|
|
3645
3638
|
useEffect(() => {
|
|
3646
3639
|
const frameChanged = ikPrevFrameRef.current !== tlFrame;
|
|
3647
3640
|
ikPrevFrameRef.current = tlFrame;
|
|
3648
|
-
if (!ikMode || !ikChains || !activeRig || posing) return;
|
|
3641
|
+
if (!ikMode || !ikChains || !activeRig || posing || ikChains.values().next().value?.rig !== activeRig) return;
|
|
3649
3642
|
if (!frameChanged) return; // pure toggle-on: evaluate already ran
|
|
3650
3643
|
ikSeedTargets(ikChains, ikStateRef.current);
|
|
3651
3644
|
}, [ikMode, ikChains, activeRig, motion, posing, tlFrame, ikTick, ikFkJoints]);
|
|
@@ -4271,7 +4264,7 @@ export default function App() {
|
|
|
4271
4264
|
const restored = storeRef.current.objects;
|
|
4272
4265
|
if (before !== restored) {
|
|
4273
4266
|
if (!redo && objectDeleteUndo?.id && restored.some(object => object.id === objectDeleteUndo.id)) {
|
|
4274
|
-
setSelectedHierarchyId(`object:${objectDeleteUndo.id}`); setObjectDeleteUndo(null);
|
|
4267
|
+
setSelectedHierarchyId(`object:${objectDeleteUndo.id}`); setObjectDeleteUndo(null); noteDeleteUndone(objectDeleteUndo.id);
|
|
4275
4268
|
} else if (selectedSceneObjectId && !restored.some(object => object.id === selectedSceneObjectId)) setSelectedHierarchyId("props");
|
|
4276
4269
|
}
|
|
4277
4270
|
setToast(redo ? ko("Redone", "다시 실행됨") : ko("Undone", "실행 취소됨"));
|
|
@@ -5121,11 +5114,7 @@ export default function App() {
|
|
|
5121
5114
|
— orbit, pan, click-to-select — reaches the WebGL canvas
|
|
5122
5115
|
untouched while the mode is on. */}
|
|
5123
5116
|
|
|
5124
|
-
{!
|
|
5125
|
-
if (typeof document === "undefined") return true;
|
|
5126
|
-
const canvas = document.createElement("canvas");
|
|
5127
|
-
return Boolean(canvas.getContext("webgl") || canvas.getContext("experimental-webgl"));
|
|
5128
|
-
})()) && (
|
|
5117
|
+
{!webGlAvailable && (
|
|
5129
5118
|
<div className="gl-lost-overlay" role="alert" data-testid="webgl-unavailable">
|
|
5130
5119
|
<div className="gl-lost-card">
|
|
5131
5120
|
<strong>{ko("3D graphics (WebGL) is turned off in this browser", "이 브라우저에서 3D 그래픽(WebGL)이 꺼져 있어요")}</strong>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The closed list of primary Studio controls that carry `data-fid`, for
|
|
2
|
+
// ui:rage_click and ui:dead_click. A click on any other control reports "other".
|
|
3
|
+
// Ids name a control, never a scene object, a name or any text.
|
|
4
|
+
export const CONTROL_IDS = Object.freeze([
|
|
5
|
+
"dock_tab_board", "dock_tab_animation", "dock_tab_assets",
|
|
6
|
+
"mode_scene", "mode_pose", "mode_camera", "mode_motion", "mode_sculpt",
|
|
7
|
+
"tool_move", "tool_rotate", "tool_scale", "tool_ik", "tool_pin", "tool_front", "tool_side", "tool_back",
|
|
8
|
+
"tl_play", "shot_add", "shot_delete", "camera_key_add",
|
|
9
|
+
"export_animation_project", "export_keyframe_pack", "export_video", "export_video_shot",
|
|
10
|
+
"export_render_passes", "export_depth_video", "export_storyboard", "export_otio",
|
|
11
|
+
"menu_undo", "menu_redo",
|
|
12
|
+
"agent_send", "agent_stop", "agent_steer", "agent_retry", "agent_connect", "agent_panel",
|
|
13
|
+
"board_export", "board_stylize",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
/** The closed control id of a `data-fid` value, else "other". */
|
|
17
|
+
export const controlOf = (fid) => (CONTROL_IDS.includes(fid) ? fid : "other");
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Session friction counters for app:friction_summary, sent once at session end.
|
|
2
|
+
// Detectors bump a counter on every occurrence; a detector that also has its
|
|
3
|
+
// own event asks admit() first, so past its per-session cap only the counter
|
|
4
|
+
// here moves. Counts leave only as closed buckets. Nothing here reads input
|
|
5
|
+
// elements or text: callers pass counts and durations, never content.
|
|
6
|
+
import { SESSION_DURATION_BUCKETS } from "./analytics-values.js";
|
|
7
|
+
|
|
8
|
+
// Counter sources: refusals_ui/refusals_agent and undos (command bus), errors
|
|
9
|
+
// (app:error listener, ErrorBoundary), long_tasks (longtask observer), compiles
|
|
10
|
+
// (every motion.compile draft, motion-draft-telemetry.js), rage_clicks/dead_clicks
|
|
11
|
+
// (click-friction.js), shortcut_ignored (shortcut-friction.js), search_empty
|
|
12
|
+
// (search-friction.js), nav_thrash/camera_thrash (thrash-friction.js).
|
|
13
|
+
const COUNTERS = Object.freeze([
|
|
14
|
+
"refusals_ui", "refusals_agent", "undos", "errors", "rage_clicks", "dead_clicks",
|
|
15
|
+
"shortcut_ignored", "search_empty", "nav_thrash", "camera_thrash", "long_tasks", "compiles",
|
|
16
|
+
]);
|
|
17
|
+
export const FRICTION_SUMMARY_KEYS = Object.freeze(["active_time", ...COUNTERS, "long_task_max", "late_tick_ratio"]);
|
|
18
|
+
const COUNT_BUCKETS = new Set(["0", "1", "2", "3", "4-5", "6-10", "gte11"]);
|
|
19
|
+
const LONG_TASK_BUCKETS = new Set(["lt50", "50-150", "150-500", "500-2000", "gte2000"]);
|
|
20
|
+
const LATE_TICK_BUCKETS = new Set(["0", "lt5", "5-20", "20-50", "gte50"]);
|
|
21
|
+
|
|
22
|
+
const counts = new Map(COUNTERS.map((name) => [name, 0]));
|
|
23
|
+
const admitted = new Map();
|
|
24
|
+
let longTaskMax = 0;
|
|
25
|
+
let lateTicks = 0;
|
|
26
|
+
let totalTicks = 0;
|
|
27
|
+
let observing = false;
|
|
28
|
+
|
|
29
|
+
/** 0, 1, 2, 3, 4-5, 6-10, gte11: small counts stay distinguishable. */
|
|
30
|
+
export function bucketFrictionCount(value) {
|
|
31
|
+
const count = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
|
|
32
|
+
if (count <= 3) return String(count);
|
|
33
|
+
if (count <= 5) return "4-5";
|
|
34
|
+
if (count <= 10) return "6-10";
|
|
35
|
+
return "gte11";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** lt50, 50-150, 150-500, 500-2000, gte2000 ms: long tasks and compile times. */
|
|
39
|
+
export function bucketShortMs(ms) {
|
|
40
|
+
if (!(ms >= 50)) return "lt50";
|
|
41
|
+
if (ms < 150) return "50-150";
|
|
42
|
+
if (ms < 500) return "150-500";
|
|
43
|
+
if (ms < 2000) return "500-2000";
|
|
44
|
+
return "gte2000";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Share of playback ticks that ran late, as a closed percent bucket. */
|
|
48
|
+
export function bucketLateTicks(late, total) {
|
|
49
|
+
if (!(total > 0) || !(late > 0)) return "0";
|
|
50
|
+
const percent = (late / total) * 100;
|
|
51
|
+
if (percent < 5) return "lt5";
|
|
52
|
+
if (percent < 20) return "5-20";
|
|
53
|
+
if (percent < 50) return "20-50";
|
|
54
|
+
return "gte50";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Count n occurrences of a known counter. Unknown names are ignored. */
|
|
58
|
+
export function bump(name, n = 1) {
|
|
59
|
+
if (!counts.has(name) || !Number.isFinite(n) || n < 1) return false;
|
|
60
|
+
counts.set(name, counts.get(name) + Math.floor(n));
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** True while `event` has been admitted fewer than `cap` times this session. */
|
|
65
|
+
export function admit(event, cap) {
|
|
66
|
+
const seen = admitted.get(event) ?? 0;
|
|
67
|
+
if (!(seen < cap)) return false;
|
|
68
|
+
admitted.set(event, seen + 1);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function noteLongTask(ms) {
|
|
73
|
+
if (!Number.isFinite(ms) || ms < 0) return;
|
|
74
|
+
bump("long_tasks");
|
|
75
|
+
longTaskMax = Math.max(longTaskMax, ms);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One playback run's ticks: how many ran late out of how many. */
|
|
79
|
+
export function noteTick(late, total) {
|
|
80
|
+
if (!Number.isInteger(late) || !Number.isInteger(total) || late < 0 || total < late) return;
|
|
81
|
+
lateTicks += late;
|
|
82
|
+
totalTicks += total;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Feed long tasks into the counters where the browser reports them; a no-op elsewhere. */
|
|
86
|
+
export function observeLongTasks(Observer = globalThis.PerformanceObserver) {
|
|
87
|
+
if (observing) return true;
|
|
88
|
+
try {
|
|
89
|
+
if (typeof Observer !== "function" || !Observer.supportedEntryTypes?.includes("longtask")) return false;
|
|
90
|
+
new Observer((list) => {
|
|
91
|
+
for (const entry of list.getEntries()) noteLongTask(entry.duration);
|
|
92
|
+
}).observe({ type: "longtask" });
|
|
93
|
+
observing = true;
|
|
94
|
+
return true;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The session's counters as closed buckets, without active_time. */
|
|
101
|
+
export function snapshot() {
|
|
102
|
+
return {
|
|
103
|
+
...Object.fromEntries([...counts].map(([name, count]) => [name, bucketFrictionCount(count)])),
|
|
104
|
+
long_task_max: bucketShortMs(longTaskMax),
|
|
105
|
+
late_tick_ratio: bucketLateTicks(lateTicks, totalTicks),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** One app:friction_summary property, checked against its closed set. */
|
|
110
|
+
export function frictionPropAllowed(key, value) {
|
|
111
|
+
if (key === "active_time") return SESSION_DURATION_BUCKETS.has(value);
|
|
112
|
+
if (key === "long_task_max") return LONG_TASK_BUCKETS.has(value);
|
|
113
|
+
if (key === "late_tick_ratio") return LATE_TICK_BUCKETS.has(value);
|
|
114
|
+
return counts.has(key) && COUNT_BUCKETS.has(value);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Tests only: a fresh session. The long-task observer stays installed. */
|
|
118
|
+
export function reset() {
|
|
119
|
+
for (const name of counts.keys()) counts.set(name, 0);
|
|
120
|
+
admitted.clear();
|
|
121
|
+
longTaskMax = 0;
|
|
122
|
+
lateTicks = 0;
|
|
123
|
+
totalTicks = 0;
|
|
124
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Events whose every property is checked against its own closed set, never the
|
|
2
|
+
// generic "short string" rule. analytics.js asks strictPropValidator(event) once
|
|
3
|
+
// per event; a property its validator refuses is dropped.
|
|
4
|
+
import { STUDIO_ERROR_CODES } from "./studio-error-codes.js";
|
|
5
|
+
import { ERROR_TYPE_VALUES, sessionEndedPropAllowed } from "./analytics-values.js";
|
|
6
|
+
import { FRICTION_SUMMARY_KEYS, frictionPropAllowed } from "./analytics-friction.js";
|
|
7
|
+
import { CONTROL_IDS } from "./analytics-controls.js";
|
|
8
|
+
|
|
9
|
+
const TURN_ID_PATTERN = /^[a-f0-9]{32}$/;
|
|
10
|
+
// The friction count vocabulary (analytics-friction.js bucketFrictionCount).
|
|
11
|
+
const COUNT_BUCKETS = new Set(["0", "1", "2", "3", "4-5", "6-10", "gte11"]);
|
|
12
|
+
const LONG_BUCKETS = new Set(["lt10s", "10-30s", "30-60s", "1-2m", "2-5m", "gte5m"]);
|
|
13
|
+
const AGE_BUCKETS = new Set(["lt2s", "2-10s", "10-60s", "gte60s"]);
|
|
14
|
+
const SHORT_MS_BUCKETS = new Set(["lt50", "50-150", "150-500", "500-2000", "gte2000"]);
|
|
15
|
+
const REFUSAL_CODES = new Set([...STUDIO_ERROR_CODES, "other"]);
|
|
16
|
+
// The providers CozyClay configures itself (bin/agent/providers.mjs PROVIDERS); the
|
|
17
|
+
// connect funnel reports any other id the login list offers as "other".
|
|
18
|
+
export const CONNECT_PROVIDERS = Object.freeze(["openai-codex", "anthropic", "anthropic-subscription", "openai", "google",
|
|
19
|
+
"minimax", "minimax-cn", "openrouter", "cliproxy", "openai-compatible"]);
|
|
20
|
+
|
|
21
|
+
/** A Studio refusal code as sent: one of STUDIO_ERROR_CODES, else "other". */
|
|
22
|
+
export const refusalCodeOf = (code) => (STUDIO_ERROR_CODES.includes(code) ? code : "other");
|
|
23
|
+
|
|
24
|
+
/** lt10s, 10-30s, 30-60s, 1-2m, 2-5m, gte5m: agent turns and edit windows outlast bucketMs. */
|
|
25
|
+
export function bucketLong(ms) {
|
|
26
|
+
if (!Number.isFinite(ms) || ms < 10_000) return "lt10s";
|
|
27
|
+
if (ms < 30_000) return "10-30s";
|
|
28
|
+
if (ms < 60_000) return "30-60s";
|
|
29
|
+
if (ms < 120_000) return "1-2m";
|
|
30
|
+
if (ms < 300_000) return "2-5m";
|
|
31
|
+
return "gte5m";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** lt2s, 2-10s, 10-60s, gte60s: how old an edit was when it was undone. */
|
|
35
|
+
export function bucketAge(ms) {
|
|
36
|
+
if (!Number.isFinite(ms) || ms < 2000) return "lt2s";
|
|
37
|
+
if (ms < 10_000) return "2-10s";
|
|
38
|
+
if (ms < 60_000) return "10-60s";
|
|
39
|
+
return "gte60s";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const oneOf = (...values) => (value) => values.includes(value);
|
|
43
|
+
const inSet = (set) => (value) => set.has(value);
|
|
44
|
+
const turnId = (value) => typeof value === "string" && TURN_ID_PATTERN.test(value);
|
|
45
|
+
const bool = (value) => typeof value === "boolean";
|
|
46
|
+
const count = inSet(COUNT_BUCKETS);
|
|
47
|
+
const long = inSet(LONG_BUCKETS);
|
|
48
|
+
|
|
49
|
+
// One closed check per property. The keys are the allowlist (EVENT_PROPERTIES).
|
|
50
|
+
const SCHEMAS = Object.freeze({
|
|
51
|
+
"studio:refused": {
|
|
52
|
+
code: inSet(REFUSAL_CODES), origin: oneOf("ui", "agent"), phase: oneOf("admission", "commit"),
|
|
53
|
+
group: oneOf("object", "shot", "cast", "scene", "motion", "edit", "task", "agent", "other"),
|
|
54
|
+
},
|
|
55
|
+
// error_type: the app:error label set (analytics.js describeError); never a message or stack.
|
|
56
|
+
"app:render_crash": { error_type: inSet(ERROR_TYPE_VALUES) },
|
|
57
|
+
"app:gl_lost": { kind: oneOf("unavailable", "context_lost") },
|
|
58
|
+
"studio:ready": { boot: oneOf("lt2s", "2-5s", "5-15s", "gte15s") },
|
|
59
|
+
// late_ticks (the late_tick_ratio set) is an estimate: timer ticks that caught up more than one frame,
|
|
60
|
+
// not measured render drops. measured_fps is device:profile's frame_rate_bucket set (bucketFrameRate).
|
|
61
|
+
"playback:summary": {
|
|
62
|
+
duration: long, late_ticks: oneOf("0", "lt5", "5-20", "20-50", "gte50"), max_catchup: oneOf("1", "2", "3-5", "gte6"),
|
|
63
|
+
fps: oneOf("12", "24", "30", "60", "other"), speed: oneOf("0.5", "1", "2", "other"),
|
|
64
|
+
measured_fps: oneOf("lt20", "20-40", "40-55", "gte55", "unknown"),
|
|
65
|
+
},
|
|
66
|
+
// mode and step: first-success-guide.jsx's STEPS_BY_MODE keys and its 4 steps per mode.
|
|
67
|
+
"guide:opened": { mode: oneOf("animation", "storyboard") },
|
|
68
|
+
"guide:step": { mode: oneOf("animation", "storyboard"), step: oneOf("1", "2", "3", "4"), step_ms: long },
|
|
69
|
+
"guide:dismissed": { mode: oneOf("animation", "storyboard"), reached: oneOf("1", "2", "3", "4") },
|
|
70
|
+
"guide:completed": { mode: oneOf("animation", "storyboard") },
|
|
71
|
+
"agent:panel_toggled": { open: bool, via: oneOf("key", "button") },
|
|
72
|
+
"object:deleted_soon": { age: inSet(AGE_BUCKETS) },
|
|
73
|
+
"object:delete_undone": { age: inSet(AGE_BUCKETS) },
|
|
74
|
+
"edit:undone": {
|
|
75
|
+
age: inSet(AGE_BUCKETS), kind: oneOf("pose", "camera", "object", "shot", "prompt", "agent_turn", "ik_preview", "other"),
|
|
76
|
+
via: oneOf("key", "menu", "button"), result: oneOf("done", "noop", "refused"),
|
|
77
|
+
},
|
|
78
|
+
"agent:turn_summary": {
|
|
79
|
+
turn_id: turnId, outcome: oneOf("succeeded", "failed", "cancelled"), wall: long, first_tool: long,
|
|
80
|
+
tool_calls: count, tool_failed: count, refused: count, refusal_top: (value) => value === "none" || REFUSAL_CODES.has(value),
|
|
81
|
+
stale_retry: count, authored: bool, drafts: count, qa_first: oneOf("clean", "problems"), qa_last: oneOf("clean", "problems"),
|
|
82
|
+
qa_trend: oneOf("improved", "flat", "worse", "na"), stuck: bool, cancel_phase: oneOf("before_tool", "during_tool", "after_tool", "na"),
|
|
83
|
+
},
|
|
84
|
+
"motion:draft_compiled": {
|
|
85
|
+
draft_index: count, qa_clean: bool, qa_problems: count, qa_frames: oneOf("0", "1-10", "11-50", "51-200", "gte201"),
|
|
86
|
+
compile: inSet(SHORT_MS_BUCKETS), installed: bool, stuck: bool, repaired: bool, searched: bool, adjusted: bool,
|
|
87
|
+
frames: oneOf("lt30", "30-120", "121-480", "gte481"),
|
|
88
|
+
},
|
|
89
|
+
// Shortcut, search and thrash friction (shortcut-friction.js, search-friction.js, thrash-friction.js).
|
|
90
|
+
// A key class or combo name, never the key character; a query length bucket, never the query.
|
|
91
|
+
"shortcut:ignored": {
|
|
92
|
+
key_class: oneOf("undo", "redo", "mode", "transform", "frame", "duplicate", "delete"),
|
|
93
|
+
reason: oneOf("no_selection", "wrong_mode", "workflow_mode", "busy", "empty_history"),
|
|
94
|
+
},
|
|
95
|
+
"shortcut:unbound": { combo: oneOf("copy", "paste", "cut", "select_all", "group", "find", "rename", "help") },
|
|
96
|
+
"help:shortcuts_opened": {},
|
|
97
|
+
"search:no_results": { surface: oneOf("assets", "outliner"), query_len: oneOf("1", "2-3", "4-8", "gte9") },
|
|
98
|
+
"nav:thrash": { surface: oneOf("dock_tab", "mode", "agent_panel"), switches: oneOf("4-5", "6-10", "gte11"), window_s: oneOf(10) },
|
|
99
|
+
"camera:thrash": { gestures: oneOf("6-10", "gte11"), kind_mix: oneOf("orbit", "fly", "pan", "mixed"), window_s: oneOf(20) },
|
|
100
|
+
"agent:connect_opened": { entry: oneOf("first_run", "failure_card", "signed_out", "menu", "other") },
|
|
101
|
+
"agent:connect_started": { provider: oneOf(...CONNECT_PROVIDERS, "other"), method: oneOf("oauth", "api_key", "chatgpt") },
|
|
102
|
+
"agent:connect_finished": {
|
|
103
|
+
provider: oneOf(...CONNECT_PROVIDERS, "other"), method: oneOf("oauth", "api_key", "chatgpt"),
|
|
104
|
+
outcome: oneOf("ok", "failed", "cancelled", "abandoned"), step: oneOf("list", "prompt", "waiting", "done"), duration: long,
|
|
105
|
+
},
|
|
106
|
+
"agent:model_changed": { provider: oneOf(...CONNECT_PROVIDERS, "other"), after_failure: bool },
|
|
107
|
+
"agent:prompt_sent": {
|
|
108
|
+
turn_id: turnId, provider: oneOf(...CONNECT_PROVIDERS, "other"), via: oneOf("typed", "retry_button", "regenerate", "chip", "supervisor"),
|
|
109
|
+
prompt_len: oneOf("1-20", "21-80", "81-240", "gte241"), compose_ms: long, similarity: oneOf("first", "same", "near", "different"),
|
|
110
|
+
gap: (value) => value === "first" || long(value), prev_outcome: oneOf("succeeded", "failed", "cancelled", "none"), turn_index: count,
|
|
111
|
+
attached: bool, task_mode: bool,
|
|
112
|
+
},
|
|
113
|
+
"ui:rage_click": { control: oneOf(...CONTROL_IDS, "other"), clicks: oneOf("3", "4-5", "gte6"), changed: bool },
|
|
114
|
+
"ui:dead_click": { control: oneOf(...CONTROL_IDS, "other"), reason: oneOf("aria_disabled", "refused", "no_effect") },
|
|
115
|
+
// The update notice (src/update/update-telemetry.js): an install kind and buckets, never a version string or npm output.
|
|
116
|
+
"update:notice_shown": { kind: oneOf("global", "npx"), behind: oneOf("patch", "minor", "major") },
|
|
117
|
+
"update:clicked": { action: oneOf("update_now", "copy_command", "later", "dismiss", "release_notes"), kind: oneOf("global", "npx") },
|
|
118
|
+
"update:finished": {
|
|
119
|
+
outcome: oneOf("ok", "failed"), error_class: oneOf("none", "permission", "network", "node_version", "npm_missing", "other"), duration: long,
|
|
120
|
+
},
|
|
121
|
+
"agent:post_turn_edit": {
|
|
122
|
+
// prompt_block, not prompt: "prompt" is a denied property key (analytics.js), whatever its value.
|
|
123
|
+
turn_id: turnId, edited: bool, pose: count, camera: count, object: count, shot: count, prompt_block: count, first_delay: long,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/** EVENT_PROPERTIES rows for the strict events defined here (app:session_ended keeps its own row). */
|
|
128
|
+
export const STRICT_EVENT_KEYS = Object.freeze({
|
|
129
|
+
"app:friction_summary": FRICTION_SUMMARY_KEYS,
|
|
130
|
+
...Object.fromEntries(Object.entries(SCHEMAS).map(([event, schema]) => [event, Object.keys(schema)])),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
/** (key, value) => allowed, for an event with a closed schema; null for every other event. */
|
|
134
|
+
export function strictPropValidator(event) {
|
|
135
|
+
if (event === "app:session_ended") return sessionEndedPropAllowed;
|
|
136
|
+
if (event === "app:friction_summary") return frictionPropAllowed;
|
|
137
|
+
const schema = SCHEMAS[event];
|
|
138
|
+
return schema ? (key, value) => Object.hasOwn(schema, key) && schema[key](value) : null;
|
|
139
|
+
}
|