cozyclay 3.1.4 → 3.1.6

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.
Files changed (126) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/INSTALL_FOR_AGENTS.md +3 -0
  3. package/README.md +5 -0
  4. package/bin/agent/agent-failure-codes.mjs +31 -0
  5. package/bin/agent/agent-routes.mjs +5 -10
  6. package/bin/agent/agent-runner.mjs +5 -3
  7. package/bin/cozyclay.mjs +16 -64
  8. package/bin/studio-routes.mjs +69 -0
  9. package/bin/update-check.mjs +53 -5
  10. package/bin/update-install.mjs +136 -0
  11. package/bin/update-routes.mjs +80 -0
  12. package/dist/ai-camera-control/index.html +1 -1
  13. package/dist/app/index.html +5 -6
  14. package/dist/assets/{TaskBoard-U1Yl-4F1.js → TaskBoard-DwXhsJXz.js} +1 -1
  15. package/dist/assets/app-BLS1Zaru.js +5144 -0
  16. package/dist/assets/app-CEP7jzXB.css +1 -0
  17. package/dist/assets/compile-worker-or7djI2v.js +6 -0
  18. package/dist/assets/{motion3-compile-DDG3wklD.js → motion3-compile-Du7UXrWn.js} +1 -1
  19. package/dist/assets/scene-asset-sync-t45pWCyq.js +13 -0
  20. package/dist/assets/task-board-CkLZGPkv.js +15 -0
  21. package/dist/assets/workflow-DJwOUzqJ.js +179 -0
  22. package/dist/cozyclay-package.json +1 -1
  23. package/dist/greybox-to-video/index.html +3 -3
  24. package/dist/index.html +533 -264
  25. package/dist/media/cozyclay-mark.png +0 -0
  26. package/dist/media/cozyclay-truck-demo-poster.jpg +0 -0
  27. package/dist/media/step-move-poster.jpg +0 -0
  28. package/dist/media/step-shoot-poster.jpg +0 -0
  29. package/dist/media/step-stage-poster.jpg +0 -0
  30. package/dist/media/truck-greybox-poster.jpg +0 -0
  31. package/dist/media/truck-seedance-poster.jpg +0 -0
  32. package/dist/previs-software/index.html +4 -4
  33. package/dist/privacy/index.html +46 -19
  34. package/dist/seedance-camera-control/index.html +3 -3
  35. package/dist/sitemap.xml +6 -6
  36. package/dist/workflow/index.html +3 -4
  37. package/mcp/project-camera.mjs +43 -0
  38. package/package.json +2 -1
  39. package/src/App.jsx +59 -70
  40. package/src/analytics-controls.js +17 -0
  41. package/src/analytics-friction.js +124 -0
  42. package/src/analytics-strict.js +139 -0
  43. package/src/analytics-values.js +49 -0
  44. package/src/analytics.js +37 -133
  45. package/src/asset-pane.jsx +4 -5
  46. package/src/click-friction.js +101 -0
  47. package/src/command-bus-telemetry.js +155 -0
  48. package/src/command-bus.js +10 -8
  49. package/src/commands/motion3.js +7 -2
  50. package/src/controls.jsx +3 -2
  51. package/src/crash-telemetry.js +45 -0
  52. package/src/domains/cast.js +2 -11
  53. package/src/domains/motion.js +49 -38
  54. package/src/domains/objects.js +3 -0
  55. package/src/domains/scene-undo.js +27 -0
  56. package/src/domains/scenes.js +1 -1
  57. package/src/error-boundary.jsx +2 -0
  58. package/src/export-attempt.js +100 -0
  59. package/src/export-workspace.js +12 -1
  60. package/src/first-success-guide.jsx +11 -7
  61. package/src/guide-telemetry.js +60 -0
  62. package/src/hierarchy-panel.jsx +2 -1
  63. package/src/live-control.js +2 -9
  64. package/src/locale-zh/domains.js +1 -0
  65. package/src/main.jsx +5 -0
  66. package/src/motion/timeline.jsx +9 -5
  67. package/src/motion-draft-telemetry.js +62 -0
  68. package/src/motion3/compiler/channels.js +7 -5
  69. package/src/motion3/compiler/compile.js +6 -2
  70. package/src/motion3/compiler/finger-close.js +84 -7
  71. package/src/motion3/compiler/head-lead.js +70 -0
  72. package/src/motion3/compiler/head-steady.js +1 -1
  73. package/src/motion3/compiler/limbs.js +10 -3
  74. package/src/motion3/compiler/pose.js +1 -1
  75. package/src/motion3/compiler/validate.js +5 -3
  76. package/src/motion3/pipeline.js +4 -0
  77. package/src/motion3/qa/explain.js +2 -1
  78. package/src/motion3/qa/finger-checks.js +80 -0
  79. package/src/motion3/qa/gait-observe.js +88 -0
  80. package/src/motion3/qa/grasp-checks.js +3 -0
  81. package/src/motion3/qa/hints-grasp.js +8 -0
  82. package/src/motion3/qa/index.js +2 -1
  83. package/src/motion3/qa/motion-life.js +26 -3
  84. package/src/motion3/schema/topics.js +1 -1
  85. package/src/playback-telemetry.js +107 -0
  86. package/src/post-turn-edits.js +62 -0
  87. package/src/search-friction.js +49 -0
  88. package/src/semantic-edit.js +19 -6
  89. package/src/shell/BoardSlot.jsx +3 -3
  90. package/src/shell/BottomDock.jsx +1 -1
  91. package/src/shell/DetailsSlot.jsx +1 -1
  92. package/src/shell/MenuBar.jsx +13 -13
  93. package/src/shell/StudioShell.jsx +7 -1
  94. package/src/shell/TopBar.jsx +2 -0
  95. package/src/shell/ViewportToolbar.jsx +2 -2
  96. package/src/shortcut-friction.js +122 -0
  97. package/src/stage-canvas-rigs.jsx +3 -4
  98. package/src/studio-telemetry.js +97 -0
  99. package/src/thrash-friction.js +90 -0
  100. package/src/update/UpdateChip.jsx +86 -0
  101. package/src/update/UpdateDialog.jsx +147 -0
  102. package/src/update/update-client.js +188 -0
  103. package/src/update/update-telemetry.js +78 -0
  104. package/src/update/update.css +94 -0
  105. package/src/use-webgl-available.js +35 -0
  106. package/src/workflow/AgentPanel.jsx +29 -30
  107. package/src/workflow/CozySceneNode.jsx +1 -1
  108. package/src/workflow/LoginPanel.jsx +15 -5
  109. package/src/workflow/agent-client.js +10 -91
  110. package/src/workflow/agent-failure-codes.js +47 -0
  111. package/src/workflow/agent-turn-telemetry.js +222 -0
  112. package/src/workflow/connect-telemetry.js +86 -0
  113. package/src/workflow/prompt-telemetry.js +105 -0
  114. package/tools/dev/pages/privacy.html +44 -17
  115. package/tools/loc-baseline.json +10 -10
  116. package/tools/run-tests.mjs +8 -8
  117. package/tools/verify-package-tarball.mjs +29 -1
  118. package/dist/assets/app-BdRlMf4J.css +0 -1
  119. package/dist/assets/app-KXdgBC-S.js +0 -5144
  120. package/dist/assets/compile-worker-oy_P9HHh.js +0 -6
  121. package/dist/assets/landing-y3EqDgSZ.js +0 -1
  122. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +0 -1
  123. package/dist/assets/scene-asset-sync-Bz59Ghdz.js +0 -13
  124. package/dist/assets/task-board-DFfNa0xg.js +0 -15
  125. package/dist/assets/workflow-BMJGd_gZ.js +0 -179
  126. 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 { createFirstEditTracker, semanticEditKind } from "./semantic-edit.js";
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 firstEditRef = useRef(null);
446
- if (!firstEditRef.current) {
447
- const firstEdit = createFirstEditTracker(track);
448
- // Observe the existing semantic boundary once, even after its telemetry
449
- // gate is satisfied. The semantic hook itself stays unchanged.
450
- firstEditRef.current = (surface, domain, before, after) => {
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
- else if (next === "motion") {
1168
- // The active character's ROW, not the group: the placement gizmo only
1169
- // renders for a specific cast member, so selecting the group used to
1170
- // drop the operator into Motion with nothing to drag.
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
- if (event.shiftKey) redoScene();
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) selectWorkflowMode(modeKey);
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
- if (!selectedSceneObjectId) return;
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
- const toggle = () => setAgentCollapsed(value => !value);
2251
- const key = event => {
2252
- if ((event.metaKey || event.ctrlKey) && event.code === "KeyB") { event.preventDefault(); toggle(); }
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
- .toLowerCase()
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
- if (!ikChains || !activeRig || posing) return;
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
- {!(globalThis.__cozyclayWebGLAvailable ??= (() => {
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
+ }