cozyclay 3.1.4 → 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 +86 -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 +16 -64
- 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-KXdgBC-S.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/semantic-edit.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createCameraBlock } from "./camera-block.js";
|
|
2
2
|
import { isBusRunActive } from "./command-bus.js";
|
|
3
3
|
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/shell/BoardSlot.jsx
CHANGED
|
@@ -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 {
|
|
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} />}
|
package/src/shell/BottomDock.jsx
CHANGED
|
@@ -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)}
|
package/src/shell/MenuBar.jsx
CHANGED
|
@@ -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);
|
package/src/shell/TopBar.jsx
CHANGED
|
@@ -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
|
+
}
|