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.
Files changed (125) hide show
  1. package/CHANGELOG.md +98 -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 +18 -112
  8. package/bin/star-prompt.mjs +123 -0
  9. package/bin/studio-routes.mjs +69 -0
  10. package/bin/update-check.mjs +53 -5
  11. package/bin/update-install.mjs +136 -0
  12. package/bin/update-routes.mjs +80 -0
  13. package/dist/ai-camera-control/index.html +1 -1
  14. package/dist/app/index.html +5 -6
  15. package/dist/assets/{TaskBoard-U1Yl-4F1.js → TaskBoard-DwXhsJXz.js} +1 -1
  16. package/dist/assets/app-CEP7jzXB.css +1 -0
  17. package/dist/assets/app-CTKcyPM-.js +5144 -0
  18. package/dist/assets/compile-worker-or7djI2v.js +6 -0
  19. package/dist/assets/{motion3-compile-DDG3wklD.js → motion3-compile-Du7UXrWn.js} +1 -1
  20. package/dist/assets/scene-asset-sync-t45pWCyq.js +13 -0
  21. package/dist/assets/task-board-CkLZGPkv.js +15 -0
  22. package/dist/assets/workflow-DJwOUzqJ.js +179 -0
  23. package/dist/cozyclay-package.json +1 -1
  24. package/dist/greybox-to-video/index.html +3 -3
  25. package/dist/index.html +533 -264
  26. package/dist/media/cozyclay-mark.png +0 -0
  27. package/dist/media/cozyclay-truck-demo-poster.jpg +0 -0
  28. package/dist/media/step-move-poster.jpg +0 -0
  29. package/dist/media/step-shoot-poster.jpg +0 -0
  30. package/dist/media/step-stage-poster.jpg +0 -0
  31. package/dist/media/truck-greybox-poster.jpg +0 -0
  32. package/dist/media/truck-seedance-poster.jpg +0 -0
  33. package/dist/previs-software/index.html +4 -4
  34. package/dist/privacy/index.html +46 -19
  35. package/dist/seedance-camera-control/index.html +3 -3
  36. package/dist/sitemap.xml +6 -6
  37. package/dist/workflow/index.html +3 -4
  38. package/package.json +1 -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/dist/assets/app-BdRlMf4J.css +0 -1
  118. package/dist/assets/app-DwETmoQZ.js +0 -5144
  119. package/dist/assets/compile-worker-oy_P9HHh.js +0 -6
  120. package/dist/assets/landing-y3EqDgSZ.js +0 -1
  121. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +0 -1
  122. package/dist/assets/scene-asset-sync-Bz59Ghdz.js +0 -13
  123. package/dist/assets/task-board-DFfNa0xg.js +0 -15
  124. package/dist/assets/workflow-BMJGd_gZ.js +0 -179
  125. package/dist/media/playground-poster.jpg +0 -0
@@ -1,7 +1,9 @@
1
1
  import { createCameraBlock } from "./camera-block.js";
2
2
  import { isBusRunActive } from "./command-bus.js";
3
3
 
4
- export const FIRST_EDIT_VERSION = 1;
4
+ // 2: counted from the user's own command-bus receipts (#922); 1 diffed domain values and
5
+ // missed shot, camera and object edits in the real Studio.
6
+ export const FIRST_EDIT_VERSION = 2;
5
7
  export const FIRST_EDIT_KINDS = Object.freeze([
6
8
  "pose_edit", "object_insert", "cutout_insert", "object_transform",
7
9
  "shot_add", "shot_edit", "camera_key_record", "rail_edit",
@@ -64,18 +66,29 @@ export function semanticEditKind(domain, before, after) {
64
66
  return null;
65
67
  }
66
68
 
67
- export function createFirstEditTracker(emit) {
69
+ /** (surface, kind) -> the surface's first edit, once: `${surface}:first_edit` with a closed kind. */
70
+ export function createFirstEditRecorder(emit) {
68
71
  // Dedupe once per editor App mount, independently for Studio and Playground.
69
72
  // Scene/project switches and history do not reset it; no install persistence.
70
73
  const seen = new Set();
71
- return (surface, domain, before, after) => {
72
- if (!["craft", "playground"].includes(surface) || seen.has(surface)) return false;
73
- const kind = semanticEditKind(domain, before, after);
74
- if (!kind) return false;
74
+ const record = (surface, kind) => {
75
+ if (!["craft", "playground"].includes(surface) || seen.has(surface) || !FIRST_EDIT_KINDS.includes(kind)) return false;
75
76
  seen.add(surface);
76
77
  emit(`${surface}:first_edit`, { edit_kind: kind, definition_version: FIRST_EDIT_VERSION });
77
78
  return true;
78
79
  };
80
+ record.seen = surface => seen.has(surface);
81
+ return record;
82
+ }
83
+
84
+ /** The same dedupe over a domain diff (semanticEditKind), for callers that hold before/after values. */
85
+ export function createFirstEditTracker(emit) {
86
+ const record = createFirstEditRecorder(emit);
87
+ return (surface, domain, before, after) => {
88
+ if (record.seen(surface)) return false;
89
+ const kind = semanticEditKind(domain, before, after);
90
+ return kind ? record(surface, kind) : false;
91
+ };
79
92
  }
80
93
 
81
94
  // Synchronous mutation owner shared by UI and live commands. Keeping the before
@@ -4,7 +4,7 @@ import { ko } from "../locale.js";
4
4
  import { assetRecord, rememberAsset } from "../scene-asset-cache.js";
5
5
  import { ASSET_MAX_SOURCE_BYTES, importImageFile } from "../scene-assets.js";
6
6
  import { STILL_HOLD_MAX } from "../shot-authoring.js";
7
- import { startExportAttempt, track } from "../analytics.js";
7
+ import { track } from "../analytics.js"; import { startExportAttempt } from "../export-attempt.js";
8
8
  import { bytesToDataUrl, panelVideoPrompt, sendPanelToWorkflow } from "../workflow-send.js";
9
9
  import { panelStylizeRequest, requestAgentImage, stylizedPngBytes, stylizeErrorMessage } from "../agent-image-client.js";
10
10
 
@@ -266,7 +266,7 @@ function PanelCard({ shot, index, fps, thumb, stylizing, onStylize, onUnstylize,
266
266
  <div className="dock-board-actions">
267
267
  <button type="button" data-action="duplicate" title={ko("Duplicate this panel after itself", "이 패널을 바로 뒤에 복제")} onClick={(event) => { stop(event); onDuplicate(); }}>{ko("Duplicate", "복제")}</button>
268
268
  <button type="button" data-action="delete" className="danger" title={ko("Delete this panel (Delete)", "이 패널 삭제 (Delete)")} onClick={(event) => { stop(event); onRemove(); }}>{ko("Delete", "삭제")}</button>
269
- <button type="button" data-action="stylize" disabled={stylizing}
269
+ <button type="button" data-action="stylize" data-fid="board_stylize" disabled={stylizing}
270
270
  title={shot.stylizedAssetId
271
271
  ? ko("Generate a new stylized picture from this panel's frame and caption (replaces the current one)", "이 패널의 프레임과 캡션으로 스타일화 그림을 새로 생성 (지금 그림을 바꿔요)")
272
272
  : ko("Generate a stylized picture from this panel's frame and caption", "이 패널의 프레임과 캡션으로 스타일화 그림 생성")}
@@ -295,7 +295,7 @@ function PanelCard({ shot, index, fps, thumb, stylizing, onStylize, onUnstylize,
295
295
  <button type="button" role="menuitem" data-send-source="greybox" onClick={(event) => { stop(event); setSendMenu(false); onSendToWorkflow("greybox"); }}>{ko("Greybox", "그레이박스")}</button>
296
296
  </div>
297
297
  )}
298
- <button type="button" data-action="export" disabled={exporting} title={exporting ? ko("Export — another export is running", "내보내기 — 다른 내보내기가 진행 중입니다") : ko("Export this panel as a pack (zip)", "이 패널을 팩(zip)으로 내보내기")} data-disabled-reason={exporting ? "export-running" : undefined} onClick={(event) => { stop(event); onExport(); }}>{ko("Export", "내보내기")}</button>
298
+ <button type="button" data-action="export" data-fid="board_export" disabled={exporting} title={exporting ? ko("Export — another export is running", "내보내기 — 다른 내보내기가 진행 중입니다") : ko("Export this panel as a pack (zip)", "이 패널을 팩(zip)으로 내보내기")} data-disabled-reason={exporting ? "export-running" : undefined} onClick={(event) => { stop(event); onExport(); }}>{ko("Export", "내보내기")}</button>
299
299
  </div>
300
300
  <div className="dock-board-thumb" data-stylized={stylized ? "true" : undefined}>
301
301
  {stylized ? <img className="dock-board-image" src={stylized} alt="" draggable={false} /> : thumb && <img className="dock-board-image" src={thumb} alt="" draggable={false} />}
@@ -194,7 +194,7 @@ export default function BottomDock({ tab: requestedTab = "animation", onTabChang
194
194
  type="button"
195
195
  role="tab"
196
196
  key={entry.key}
197
- data-testid={`dock-tab-${entry.key}`}
197
+ data-testid={`dock-tab-${entry.key}`} data-fid={`dock_tab_${entry.key}`}
198
198
  aria-selected={tab === entry.key}
199
199
  onClick={() => onTabChange?.(entry.key)}
200
200
  >
@@ -271,7 +271,7 @@ export default function DetailsSlot() {
271
271
  <button
272
272
  type="button"
273
273
  className="studio-agent-bar"
274
- data-testid="studio-agent-bar"
274
+ data-testid="studio-agent-bar" data-fid="agent_panel"
275
275
  aria-expanded="false"
276
276
  title={ko("Open Agent (Cmd/Ctrl+B)", "에이전트 열기 (Cmd/Ctrl+B)")}
277
277
  onClick={() => setStudioAgentMode(true)}
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
3
3
  import { useStudioShell } from "./studio-shell-context.js";
4
4
  import SourceOffer from "../source-offer.jsx";
5
5
  import { ko } from "../locale.js";
6
+ import { noteShortcutsOpened } from "../shortcut-friction.js";
6
7
 
7
8
  const MAC = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform ?? "");
8
9
  const MOD = MAC ? "⌘" : "Ctrl+";
@@ -351,7 +352,7 @@ export default function MenuBar({ preferences }) {
351
352
  type="button"
352
353
  role="menuitem"
353
354
  className="menubar-item"
354
- data-testid="export-animation-project"
355
+ data-testid="export-animation-project" data-fid="export_animation_project"
355
356
  onClick={item(() => runStudioAction("project.exportAsAnimation"))}
356
357
  >
357
358
  <span className="menubar-item-label">{ko("Export as Animation project…", "애니메이션 프로젝트로 내보내기…")}</span>
@@ -389,7 +390,7 @@ export default function MenuBar({ preferences }) {
389
390
  type="button"
390
391
  role="menuitem"
391
392
  className="menubar-item export-menu-primary"
392
- data-testid="export-keyframe-pack"
393
+ data-testid="export-keyframe-pack" data-fid="export_keyframe_pack"
393
394
  disabled={!shots.length || recording}
394
395
  data-disabled-reason={shots.length ? undefined : "no-shots"}
395
396
  title={shots.length
@@ -410,7 +411,7 @@ export default function MenuBar({ preferences }) {
410
411
  type="button"
411
412
  role="menuitem"
412
413
  className="menubar-item"
413
- data-testid="export-video"
414
+ data-testid="export-video" data-fid="export_video"
414
415
  disabled={recording}
415
416
  title={ko("Render the whole timeline to one MP4 — every shot back to back, camera and motion, no editor chrome", "타임라인 전체를 MP4 하나로 — 모든 샷을 이어서, 카메라·모션만, 편집 UI 제외")}
416
417
  onClick={() => {
@@ -426,7 +427,7 @@ export default function MenuBar({ preferences }) {
426
427
  type="button"
427
428
  role="menuitem"
428
429
  className="menubar-item"
429
- data-testid="export-video-shot"
430
+ data-testid="export-video-shot" data-fid="export_video_shot"
430
431
  disabled={recording}
431
432
  title={ko("Render one shot to its own MP4 — the shot you opened the menu from, or the one under the playhead", "샷 하나만 MP4로 — 메뉴를 연 샷, 아니면 재생 위치의 샷")}
432
433
  onClick={() => {
@@ -442,7 +443,7 @@ export default function MenuBar({ preferences }) {
442
443
  type="button"
443
444
  role="menuitem"
444
445
  className="menubar-item"
445
- data-testid="export-render-passes"
446
+ data-testid="export-render-passes" data-fid="export_render_passes"
446
447
  disabled={recording}
447
448
  title={ko("Depth and normal conditioning plates of the current framing", "현재 프레이밍의 뎁스·노멀 컨디션 플레이트")}
448
449
  onClick={item(exportRenderPasses)}
@@ -453,7 +454,7 @@ export default function MenuBar({ preferences }) {
453
454
  type="button"
454
455
  role="menuitem"
455
456
  className="menubar-item"
456
- data-testid="export-depth-video"
457
+ data-testid="export-depth-video" data-fid="export_depth_video"
457
458
  disabled={!shots.length || recording}
458
459
  data-disabled-reason={shots.length ? undefined : "no-shots"}
459
460
  title={ko("Depth pass of the whole shot as an mp4 for video-model conditioning", "샷 전체의 뎁스 패스를 mp4로 — 영상 모델 컨디셔닝용")}
@@ -469,7 +470,7 @@ export default function MenuBar({ preferences }) {
469
470
  type="button"
470
471
  role="menuitem"
471
472
  className="menubar-item"
472
- data-testid="export-storyboard"
473
+ data-testid="export-storyboard" data-fid="export_storyboard"
473
474
  disabled={!shots.length || recording}
474
475
  data-disabled-reason={shots.length ? undefined : "no-shots"}
475
476
  title={shots.length
@@ -484,7 +485,7 @@ export default function MenuBar({ preferences }) {
484
485
  type="button"
485
486
  role="menuitem"
486
487
  className="menubar-item"
487
- data-testid="export-otio"
488
+ data-testid="export-otio" data-fid="export_otio"
488
489
  title={ko("Download OTIO cut list", "OTIO 컷 목록 다운로드")}
489
490
  onClick={item(downloadOtioCutList)}
490
491
  >
@@ -506,11 +507,11 @@ export default function MenuBar({ preferences }) {
506
507
  ),
507
508
  edit: (
508
509
  <>
509
- <button type="button" role="menuitem" className="menubar-item" onClick={item(() => castDomain.undoScene())}>
510
+ <button type="button" role="menuitem" className="menubar-item" data-fid="menu_undo" onClick={item(() => castDomain.undoScene("menu"))}>
510
511
  <span className="menubar-item-label">{ko("Undo", "실행 취소")}</span>
511
512
  <Shortcut keys={`${MOD}Z`} />
512
513
  </button>
513
- <button type="button" role="menuitem" className="menubar-item" onClick={item(() => castDomain.redoScene())}>
514
+ <button type="button" role="menuitem" className="menubar-item" data-fid="menu_redo" onClick={item(() => castDomain.redoScene())}>
514
515
  <span className="menubar-item-label">{ko("Redo", "다시 실행")}</span>
515
516
  <Shortcut keys={`${SHIFT}${MOD}Z`} />
516
517
  </button>
@@ -534,9 +535,8 @@ export default function MenuBar({ preferences }) {
534
535
  ),
535
536
  help: (
536
537
  <>
537
- <button type="button" role="menuitem" className="menubar-item" onClick={item(() => setDialog("shortcuts"))}>
538
- <span className="menubar-item-label">{ko("Keyboard shortcuts", "단축키")}</span>
539
- </button>
538
+ <button type="button" role="menuitem" className="menubar-item" onClick={item(() => { setDialog("shortcuts"); noteShortcutsOpened(); })}>
539
+ <span className="menubar-item-label">{ko("Keyboard shortcuts", "단축키")}</span></button>
540
540
  <button type="button" role="menuitem" className="menubar-item" onClick={item(() => setDialog("about"))}>
541
541
  <span className="menubar-item-label">{ko("About Cozy Clay", "Cozy Clay 정보")}</span>
542
542
  </button>
@@ -1,5 +1,7 @@
1
- import { useState } from "react";
1
+ import { useEffect, useState } from "react";
2
+ import { installClickFriction } from "../click-friction.js";
2
3
  import { ko } from "../locale.js";
4
+ import { noteNavSwitch } from "../thrash-friction.js";
3
5
  import { useStudioShell } from "./studio-shell-context.js";
4
6
  import TopBar, { ProjectHead } from "./TopBar.jsx";
5
7
  import OutlinerSlot from "./OutlinerSlot.jsx";
@@ -32,7 +34,11 @@ export default function StudioShell({ viewport, children, ...props }) {
32
34
  // Embeds (playview, playground) keep the Sequencer-only dock.
33
35
  const embedded = Boolean(props["data-embed-mode"]);
34
36
  const shownTab = embedded ? "animation" : dockTab;
37
+ // Rage and dead clicks on the primary controls (data-fid): one capture-phase listener for the whole shell.
38
+ useEffect(() => installClickFriction(), []);
35
39
  function changeDockTab(next) {
40
+ // A user's tab flip toward nav:thrash (thrash-friction.js).
41
+ if (next !== dockTab) noteNavSwitch("dock_tab");
36
42
  setDockTab(next);
37
43
  try {
38
44
  globalThis.localStorage?.setItem(DOCK_TAB_KEY, next);
@@ -3,6 +3,7 @@ import ProjectPanel from "../panels/ProjectPanel.jsx";
3
3
  import MenuBar from "./MenuBar.jsx";
4
4
  import { SaveState } from "./StatusBar.jsx";
5
5
  import { ko } from "../locale.js";
6
+ import UpdateChip from "../update/UpdateChip.jsx";
6
7
  import "./topbar.css";
7
8
 
8
9
  // 2a top bar: File/Edit/Window/Help, then the MCP state. Motion is authored by the
@@ -15,6 +16,7 @@ export default function TopBar({ preferences }) {
15
16
  <header className="topbar v2-topbar" data-rec-state={recState}>
16
17
  <MenuBar preferences={preferences} />
17
18
  <div className="topbar-spacer" />
19
+ <UpdateChip />
18
20
  <span
19
21
  className={"topbar-mcp" + (liveWorkspaceHandle ? " live-workspace-handle" : "")}
20
22
  data-state={connected ? "connected" : "disconnected"}
@@ -309,7 +309,7 @@ export default function ViewportToolbar() {
309
309
  type="button"
310
310
  key={tool.key}
311
311
  data-tool-key={tool.key}
312
- data-tool={tool.id}
312
+ data-tool={tool.id} data-fid={`tool_${tool.id}`}
313
313
  className={"vp-tool-key" + (tool.active ? " active" : "")}
314
314
  aria-pressed={tool.active}
315
315
  aria-disabled={tool.refused ? true : undefined}
@@ -341,7 +341,7 @@ export default function ViewportToolbar() {
341
341
  role="tab"
342
342
  key={mode.id}
343
343
  data-mode-key={mode.key}
344
- data-mode={mode.id}
344
+ data-mode={mode.id} data-fid={`mode_${mode.id}`}
345
345
  className={"vp-mode-key" + (active ? " active" : "")}
346
346
  aria-selected={active}
347
347
  aria-disabled={refused ? true : undefined}
@@ -0,0 +1,122 @@
1
+ // Shortcut friction: shortcut:ignored (a bound Studio key that did nothing),
2
+ // shortcut:unbound (a common combo the Studio has no binding for) and
3
+ // help:shortcuts_opened. Only a closed key class, reason or combo name leaves,
4
+ // never the key character. Keys typed into an input, textarea, select or
5
+ // contenteditable element, and keys of an IME composition, are never looked at.
6
+ import { track } from "./analytics.js";
7
+ import { admit, bump } from "./analytics-friction.js";
8
+ import { isImeComposing } from "./ime.js";
9
+
10
+ const IGNORED_PER_CLASS = 5, UNBOUND_PER_COMBO = 3, HELP_PER_SESSION = 3;
11
+ let send = track, defer = (run) => setTimeout(run, 0), listening = false;
12
+
13
+ const emit = (event, props) => {
14
+ try { send(event, props); } catch { /* telemetry is advisory */ }
15
+ };
16
+
17
+ /** A target the user types into: its keys are text, never shortcuts. */
18
+ function isTypingTarget(target) {
19
+ return Boolean(target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(String(target.tagName ?? ""))));
20
+ }
21
+
22
+ /**
23
+ * A bound shortcut that did nothing. `keyClass` is one of undo, redo, mode,
24
+ * transform, frame, duplicate, delete; `reason` one of no_selection, wrong_mode,
25
+ * workflow_mode, busy, empty_history (analytics-strict.js). The friction
26
+ * counter moves every time; the event goes out 5 times per class per session.
27
+ */
28
+ export function noteShortcutIgnored(keyClass, reason) {
29
+ try {
30
+ bump("shortcut_ignored");
31
+ if (admit(`shortcut:ignored:${keyClass}`, IGNORED_PER_CLASS)) emit("shortcut:ignored", { key_class: keyClass, reason });
32
+ } catch { /* telemetry is advisory */ }
33
+ }
34
+
35
+ /** Ctrl/Cmd+Z (undo) or Ctrl/Cmd+Shift+Z (redo) from the keyboard: its bus receipt says whether anything moved. */
36
+ export function noteHistoryKey(keyClass, receipt) {
37
+ // A refused undo names a running task that owns the last edit (scene-undo.js).
38
+ if (receipt?.ok === false) noteShortcutIgnored(keyClass, "busy");
39
+ else if (receipt?.status === "noop") noteShortcutIgnored(keyClass, "empty_history");
40
+ }
41
+
42
+ /**
43
+ * A key that reached the global handler's "nothing selected" exit (App.jsx):
44
+ * F, Ctrl/Cmd+D and Delete/Backspace need a selection there. Another handler
45
+ * (a path point, a cut, a board panel) may own the key, so only a press no
46
+ * handler claimed (defaultPrevented false after dispatch) counts.
47
+ */
48
+ export function noteUnselectedKey(event) {
49
+ if (!event || event.repeat || isImeComposing(event)) return;
50
+ const mod = event.ctrlKey || event.metaKey;
51
+ const keyClass = event.code === "KeyF" && !mod && !event.altKey ? "frame"
52
+ : event.code === "KeyD" && mod && !event.altKey && !event.shiftKey ? "duplicate"
53
+ : (event.key === "Delete" || event.key === "Backspace") && !mod ? "delete" : null;
54
+ if (!keyClass) return;
55
+ defer(() => { if (!event.defaultPrevented) noteShortcutIgnored(keyClass, "no_selection"); });
56
+ }
57
+
58
+ const modOnly = (event) => (event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey;
59
+ const bare = (event) => !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey;
60
+ // The closed combo list, by physical key code (a keyboard layout or an IME never
61
+ // changes it). F2 (rename) is the one bare key; every other combo is modifier + key.
62
+ // Save, zoom-to-fit, focus-selected and deselect are left out: Ctrl/Cmd+S is bound
63
+ // (MenuBar.jsx), and the others have no modifier combo common enough to name.
64
+ const COMBOS = [
65
+ ["copy", (event) => modOnly(event) && event.code === "KeyC"],
66
+ ["paste", (event) => modOnly(event) && event.code === "KeyV"],
67
+ ["cut", (event) => modOnly(event) && event.code === "KeyX"],
68
+ ["select_all", (event) => modOnly(event) && event.code === "KeyA"],
69
+ ["group", (event) => modOnly(event) && event.code === "KeyG"],
70
+ ["find", (event) => modOnly(event) && event.code === "KeyF"],
71
+ ["rename", (event) => bare(event) && event.code === "F2"],
72
+ ["help", (event) => event.code === "Slash" && !event.altKey && (event.shiftKey || event.ctrlKey || event.metaKey)],
73
+ ];
74
+ export const UNBOUND_COMBOS = Object.freeze(COMBOS.map(([name]) => name));
75
+
76
+ /** The closed combo name of a keydown, or null. */
77
+ export function comboOf(event) {
78
+ return COMBOS.find(([, matches]) => matches(event))?.[0] ?? null;
79
+ }
80
+
81
+ // Copying or cutting page text the user selected is the browser's own job.
82
+ const hasTextSelection = () => {
83
+ try { return globalThis.getSelection?.()?.isCollapsed === false; } catch { return false; }
84
+ };
85
+
86
+ /**
87
+ * A keydown on the window, in the bubble phase. It is judged after dispatch
88
+ * (deferred), so every Studio handler has run: a combo none of them claimed
89
+ * (defaultPrevented false) is unbound. Capture-phase handlers that stop the
90
+ * event (controls.jsx onEscapeCapture, object-gizmo.jsx's drag Escape) are
91
+ * invisible here; none of them handles a combo on the list.
92
+ */
93
+ export function noteUnboundKey(event) {
94
+ if (!event || event.repeat || isImeComposing(event) || isTypingTarget(event.target)) return null;
95
+ const combo = comboOf(event);
96
+ if (!combo || ((combo === "copy" || combo === "cut") && hasTextSelection())) return null;
97
+ defer(() => {
98
+ if (event.defaultPrevented) return;
99
+ if (admit(`shortcut:unbound:${combo}`, UNBOUND_PER_COMBO)) emit("shortcut:unbound", { combo });
100
+ });
101
+ return combo;
102
+ }
103
+
104
+ /** Install the unbound-combo listener once (main.jsx). */
105
+ export function listenForUnboundShortcuts(target = globalThis.window) {
106
+ if (listening || typeof target?.addEventListener !== "function") return false;
107
+ target.addEventListener("keydown", noteUnboundKey);
108
+ listening = true;
109
+ return true;
110
+ }
111
+
112
+ /** Help > Keyboard shortcuts opened: 3 per session. */
113
+ export function noteShortcutsOpened() {
114
+ if (admit("help:shortcuts_opened", HELP_PER_SESSION)) emit("help:shortcuts_opened", {});
115
+ }
116
+
117
+ /** Tests only: capture what is sent and run deferred checks by hand; returns the restore. */
118
+ export function shortcutFrictionSeam({ track: capture = track, defer: later = defer } = {}) {
119
+ const previous = [send, defer];
120
+ send = capture; defer = later;
121
+ return () => { [send, defer] = previous; };
122
+ }
@@ -7,6 +7,7 @@ import { shotAtFrame } from "./cuts.js";
7
7
  import { sampleAt } from "./sample-at.js";
8
8
  import { playbackProbe, probeVisibility } from "./motion/playback-probe.js";
9
9
  import { RENDER_ACTIVITY_EVENT } from "./use-render-activity.js";
10
+ import { reportGlLost } from "./crash-telemetry.js";
10
11
  import { PRESETS, editorFrameForSubject } from "./stage-startup-defaults.js";
11
12
 
12
13
  /** Selection marker for the picked cast member: a Unity-style XYZ tripod
@@ -371,11 +372,9 @@ export function ContextLossGuard({ onLostChange }) {
371
372
  const onLost = (event) => {
372
373
  event.preventDefault();
373
374
  onLostChange(true);
375
+ reportGlLost("context_lost");
374
376
  };
375
- const onRestored = () => {
376
- onLostChange(false);
377
- invalidate();
378
- };
377
+ const onRestored = () => { onLostChange(false); invalidate(); };
379
378
  canvas.addEventListener("webglcontextlost", onLost);
380
379
  canvas.addEventListener("webglcontextrestored", onRestored);
381
380
  return () => {
@@ -0,0 +1,97 @@
1
+ // Studio-shell signals with closed values only:
2
+ // - studio:ready, once per session: navigation start to the first frame after the
3
+ // first React commit (StudioReadyMark in main.jsx). It never delays the render.
4
+ // - agent:panel_toggled, at most 10 per session: Cmd/Ctrl+B or the toolbar control.
5
+ // - object:deleted_soon and object:delete_undone, at most 10 each per session.
6
+ // Object ids are slugs a re-created object of the same kind can reuse, so only an
7
+ // id this session created counts, and the age is an ESTIMATE.
8
+ // Never object ids, names, kinds or anything the user typed.
9
+ import { useEffect } from "react";
10
+ import { initAnalytics, track } from "./analytics.js";
11
+ import { admit } from "./analytics-friction.js";
12
+ import { bucketAge } from "./analytics-strict.js";
13
+
14
+ // Mirrors OBJECT_DELETE_UNDO_MS (stage-startup-defaults.js); test/verify-analytics.mjs checks both.
15
+ export const DELETE_UNDO_WINDOW_MS = 7000;
16
+ const PANEL_TOGGLES_PER_SESSION = 10, DELETES_PER_SESSION = 10;
17
+
18
+ let send = track, clock = () => Date.now();
19
+ // studio:ready lands before the SDK has loaded; initAnalytics() is idempotent (main.jsx started it).
20
+ let sendWhenReady = (event, props) => { initAnalytics().then(() => send(event, props), () => {}); };
21
+ const created = new Map(), deleted = new Map();
22
+ const guarded = (run) => { try { run(); } catch { /* Telemetry must never affect the studio. */ } };
23
+
24
+ /** lt2s, 2-5s, 5-15s, gte15s from navigation start. */
25
+ export function bucketBoot(ms) {
26
+ if (!Number.isFinite(ms) || ms < 2000) return "lt2s";
27
+ if (ms < 5000) return "2-5s";
28
+ if (ms < 15_000) return "5-15s";
29
+ return "gte15s";
30
+ }
31
+
32
+ /** The first frame after the first render; `ms` is performance.now() (navigation start origin). */
33
+ export function noteStudioReady(ms) {
34
+ guarded(() => {
35
+ if (Number.isFinite(ms) && ms >= 0 && admit("studio:ready", 1)) sendWhenReady("studio:ready", { boot: bucketBoot(ms) });
36
+ });
37
+ }
38
+
39
+ /** Renders nothing; after the first commit it waits one frame and reports studio:ready. */
40
+ export function StudioReadyMark() {
41
+ useEffect(() => {
42
+ const raf = globalThis.requestAnimationFrame;
43
+ if (typeof raf !== "function") return undefined;
44
+ const id = raf(() => noteStudioReady(performance.now()));
45
+ return () => globalThis.cancelAnimationFrame?.(id);
46
+ }, []);
47
+ return null;
48
+ }
49
+
50
+ /** The agent panel is about to flip; `wasCollapsed` is its state before the toggle. */
51
+ export function notePanelToggled(wasCollapsed, via) {
52
+ guarded(() => {
53
+ if (typeof wasCollapsed !== "boolean" || !["key", "button"].includes(via)) return;
54
+ if (admit("agent:panel_toggled", PANEL_TOGGLES_PER_SESSION)) send("agent:panel_toggled", { open: wasCollapsed, via });
55
+ });
56
+ }
57
+
58
+ /** The user added object `id` (Add menu, Assets shelf). */
59
+ export function noteObjectCreated(id) {
60
+ guarded(() => { if (id) created.set(id, clock()); });
61
+ }
62
+
63
+ /** The user deleted object `id`; object:deleted_soon only when this session created that id. */
64
+ export function noteObjectDeleted(id) {
65
+ guarded(() => {
66
+ if (!id) return;
67
+ const now = clock(), bornAt = created.get(id);
68
+ deleted.set(id, now);
69
+ if (bornAt === undefined) return;
70
+ created.delete(id);
71
+ if (admit("object:deleted_soon", DELETES_PER_SESSION)) send("object:deleted_soon", { age: bucketAge(now - bornAt) });
72
+ });
73
+ }
74
+
75
+ /** The deletion of `id` was undone; counts only inside the delete-undo window. */
76
+ export function noteDeleteUndone(id) {
77
+ guarded(() => {
78
+ const deletedAt = deleted.get(id);
79
+ if (deletedAt === undefined) return;
80
+ deleted.delete(id);
81
+ const age = clock() - deletedAt;
82
+ if (age >= 0 && age <= DELETE_UNDO_WINDOW_MS && admit("object:delete_undone", DELETES_PER_SESSION)) {
83
+ send("object:delete_undone", { age: bucketAge(age) });
84
+ }
85
+ });
86
+ }
87
+
88
+ /** Tests only: capture what is sent (studio:ready too) and drive the clock; returns the restore. */
89
+ export function studioTelemetrySeam(capture, now) {
90
+ const previous = [send, clock, sendWhenReady];
91
+ send = capture;
92
+ sendWhenReady = capture;
93
+ if (now) clock = now;
94
+ created.clear();
95
+ deleted.clear();
96
+ return () => { [send, clock, sendWhenReady] = previous; created.clear(); deleted.clear(); };
97
+ }
@@ -0,0 +1,90 @@
1
+ // nav:thrash and camera:thrash: flipping between panels or modes, or moving the
2
+ // camera again and again, with no edit in between. "No edit" is the command
3
+ // bus's user-edit path (command-bus-telemetry.js lastUserEditAt): agent, MCP,
4
+ // undo and redo receipts never count as an edit. Only closed values leave: a
5
+ // surface, a count bucket, a gesture-kind mix and the fixed window.
6
+ import { track } from "./analytics.js";
7
+ import { bucketFrictionCount, bump } from "./analytics-friction.js";
8
+ import { lastUserEditAt } from "./command-bus-telemetry.js";
9
+
10
+ const COOLDOWN_MS = 30_000;
11
+ let send = track, clock = () => Date.now(), lastEdit = lastUserEditAt;
12
+ let timers = { set: (run, ms) => setTimeout(run, ms), clear: (id) => clearTimeout(id) };
13
+
14
+ /** 4-5, 6-10, gte11: how many switches a thrash window held (4 is the threshold). */
15
+ const bucketSwitches = (count) => (count <= 5 ? "4-5" : count <= 10 ? "6-10" : "gte11");
16
+ const kindMix = (kinds) => (new Set(kinds).size === 1 ? kinds[0] : "mixed");
17
+
18
+ /**
19
+ * `threshold` events inside `windowMs`, all after the last user edit, open a
20
+ * window that closes `windowMs` after its first event; at the close the event
21
+ * goes out with everything that window held, and a 30 s cooldown starts. A
22
+ * window that opens during the cooldown only moves the counter. A user edit after
23
+ * a window opened cancels it: the next event starts over from the events after
24
+ * that edit, so a fresh run of switches is judged on its own window.
25
+ */
26
+ function createDetector({ event, counter, threshold, windowMs, props }) {
27
+ let stamps = [], open = null, cooledUntil = -Infinity;
28
+ const fresh = (now) => {
29
+ const edit = lastEdit();
30
+ return stamps.filter(({ at }) => at > edit && at > now - windowMs);
31
+ };
32
+ const close = (start) => {
33
+ open = null;
34
+ const edit = lastEdit();
35
+ const held = stamps.filter(({ at }) => at > edit && at >= start && at <= start + windowMs);
36
+ stamps = [];
37
+ if (held.length < threshold) return;
38
+ bump(counter);
39
+ cooledUntil = clock() + COOLDOWN_MS;
40
+ try { send(event, props(held)); } catch { /* telemetry is advisory */ }
41
+ };
42
+ return {
43
+ note(kind) {
44
+ if (open && lastEdit() >= open.start) { timers.clear(open.id); open = null; }
45
+ if (open) { stamps.push({ at: clock(), kind }); return; }
46
+ const now = clock();
47
+ stamps = [...fresh(now), { at: now, kind }];
48
+ if (stamps.length < threshold) return;
49
+ if (now < cooledUntil) { bump(counter); stamps = []; return; }
50
+ const start = stamps[0].at;
51
+ open = { start, id: timers.set(() => close(start), Math.max(0, start + windowMs - now)) };
52
+ },
53
+ reset() { if (open) timers.clear(open.id); stamps = []; open = null; cooledUntil = -Infinity; },
54
+ };
55
+ }
56
+
57
+ const NAV_SURFACES = ["dock_tab", "mode", "agent_panel"];
58
+ const nav = Object.fromEntries(NAV_SURFACES.map((surface) => [surface, createDetector({
59
+ event: "nav:thrash", counter: "nav_thrash", threshold: 4, windowMs: 10_000,
60
+ props: (held) => ({ surface, switches: bucketSwitches(held.length), window_s: 10 }),
61
+ })]));
62
+ const camera = createDetector({
63
+ event: "camera:thrash", counter: "camera_thrash", threshold: 6, windowMs: 20_000,
64
+ props: (held) => ({ gestures: bucketFrictionCount(held.length), kind_mix: kindMix(held.map(({ kind }) => kind)), window_s: 20 }),
65
+ });
66
+
67
+ /**
68
+ * One user switch on `surface`: dock_tab (StudioShell.jsx), mode (App.jsx
69
+ * selectWorkflowMode) or agent_panel (App.jsx, the agent panel toggle). 4 or
70
+ * more in 10 s with no edit is a thrash; 30 s cooldown per surface.
71
+ */
72
+ export function noteNavSwitch(surface) {
73
+ try { nav[surface]?.note(surface); } catch { /* telemetry is advisory */ }
74
+ }
75
+
76
+ /** One finished camera gesture that moved the view (controls.jsx): orbit, fly or pan. 6 or more in 20 s with no edit. */
77
+ export function noteCameraGesture(kind) {
78
+ try { if (kind === "orbit" || kind === "fly" || kind === "pan") camera.note(kind); } catch { /* telemetry is advisory */ }
79
+ }
80
+
81
+ /** Tests only: capture what is sent, drive the clock, timers and last edit; returns the restore. */
82
+ export function thrashSeam({ track: capture = track, now = clock, set = timers.set, clear = timers.clear, lastEditAt = lastEdit } = {}) {
83
+ const previous = [send, clock, timers, lastEdit];
84
+ send = capture; clock = now; timers = { set, clear }; lastEdit = lastEditAt;
85
+ for (const detector of [...Object.values(nav), camera]) detector.reset();
86
+ return () => {
87
+ for (const detector of [...Object.values(nav), camera]) detector.reset();
88
+ [send, clock, timers, lastEdit] = previous;
89
+ };
90
+ }