cozyclay 3.1.3 → 3.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +98 -0
- package/INSTALL_FOR_AGENTS.md +3 -0
- package/README.md +5 -0
- package/bin/agent/agent-failure-codes.mjs +31 -0
- package/bin/agent/agent-routes.mjs +5 -10
- package/bin/agent/agent-runner.mjs +5 -3
- package/bin/cozyclay.mjs +18 -112
- package/bin/star-prompt.mjs +123 -0
- package/bin/studio-routes.mjs +69 -0
- package/bin/update-check.mjs +53 -5
- package/bin/update-install.mjs +136 -0
- package/bin/update-routes.mjs +80 -0
- package/dist/ai-camera-control/index.html +1 -1
- package/dist/app/index.html +5 -6
- package/dist/assets/{TaskBoard-U1Yl-4F1.js → TaskBoard-DwXhsJXz.js} +1 -1
- package/dist/assets/app-CEP7jzXB.css +1 -0
- package/dist/assets/app-CTKcyPM-.js +5144 -0
- package/dist/assets/compile-worker-or7djI2v.js +6 -0
- package/dist/assets/{motion3-compile-DDG3wklD.js → motion3-compile-Du7UXrWn.js} +1 -1
- package/dist/assets/scene-asset-sync-t45pWCyq.js +13 -0
- package/dist/assets/task-board-CkLZGPkv.js +15 -0
- package/dist/assets/workflow-DJwOUzqJ.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/greybox-to-video/index.html +3 -3
- package/dist/index.html +533 -264
- package/dist/media/cozyclay-mark.png +0 -0
- package/dist/media/cozyclay-truck-demo-poster.jpg +0 -0
- package/dist/media/step-move-poster.jpg +0 -0
- package/dist/media/step-shoot-poster.jpg +0 -0
- package/dist/media/step-stage-poster.jpg +0 -0
- package/dist/media/truck-greybox-poster.jpg +0 -0
- package/dist/media/truck-seedance-poster.jpg +0 -0
- package/dist/previs-software/index.html +4 -4
- package/dist/privacy/index.html +46 -19
- package/dist/seedance-camera-control/index.html +3 -3
- package/dist/sitemap.xml +6 -6
- package/dist/workflow/index.html +3 -4
- package/package.json +1 -1
- package/src/App.jsx +59 -70
- package/src/analytics-controls.js +17 -0
- package/src/analytics-friction.js +124 -0
- package/src/analytics-strict.js +139 -0
- package/src/analytics-values.js +49 -0
- package/src/analytics.js +37 -133
- package/src/asset-pane.jsx +4 -5
- package/src/click-friction.js +101 -0
- package/src/command-bus-telemetry.js +155 -0
- package/src/command-bus.js +10 -8
- package/src/commands/motion3.js +7 -2
- package/src/controls.jsx +3 -2
- package/src/crash-telemetry.js +45 -0
- package/src/domains/cast.js +2 -11
- package/src/domains/motion.js +49 -38
- package/src/domains/objects.js +3 -0
- package/src/domains/scene-undo.js +27 -0
- package/src/domains/scenes.js +1 -1
- package/src/error-boundary.jsx +2 -0
- package/src/export-attempt.js +100 -0
- package/src/export-workspace.js +12 -1
- package/src/first-success-guide.jsx +11 -7
- package/src/guide-telemetry.js +60 -0
- package/src/hierarchy-panel.jsx +2 -1
- package/src/live-control.js +2 -9
- package/src/locale-zh/domains.js +1 -0
- package/src/main.jsx +5 -0
- package/src/motion/timeline.jsx +9 -5
- package/src/motion-draft-telemetry.js +62 -0
- package/src/motion3/compiler/channels.js +7 -5
- package/src/motion3/compiler/compile.js +6 -2
- package/src/motion3/compiler/finger-close.js +84 -7
- package/src/motion3/compiler/head-lead.js +70 -0
- package/src/motion3/compiler/head-steady.js +1 -1
- package/src/motion3/compiler/limbs.js +10 -3
- package/src/motion3/compiler/pose.js +1 -1
- package/src/motion3/compiler/validate.js +5 -3
- package/src/motion3/pipeline.js +4 -0
- package/src/motion3/qa/explain.js +2 -1
- package/src/motion3/qa/finger-checks.js +80 -0
- package/src/motion3/qa/gait-observe.js +88 -0
- package/src/motion3/qa/grasp-checks.js +3 -0
- package/src/motion3/qa/hints-grasp.js +8 -0
- package/src/motion3/qa/index.js +2 -1
- package/src/motion3/qa/motion-life.js +26 -3
- package/src/motion3/schema/topics.js +1 -1
- package/src/playback-telemetry.js +107 -0
- package/src/post-turn-edits.js +62 -0
- package/src/search-friction.js +49 -0
- package/src/semantic-edit.js +19 -6
- package/src/shell/BoardSlot.jsx +3 -3
- package/src/shell/BottomDock.jsx +1 -1
- package/src/shell/DetailsSlot.jsx +1 -1
- package/src/shell/MenuBar.jsx +13 -13
- package/src/shell/StudioShell.jsx +7 -1
- package/src/shell/TopBar.jsx +2 -0
- package/src/shell/ViewportToolbar.jsx +2 -2
- package/src/shortcut-friction.js +122 -0
- package/src/stage-canvas-rigs.jsx +3 -4
- package/src/studio-telemetry.js +97 -0
- package/src/thrash-friction.js +90 -0
- package/src/update/UpdateChip.jsx +86 -0
- package/src/update/UpdateDialog.jsx +147 -0
- package/src/update/update-client.js +188 -0
- package/src/update/update-telemetry.js +78 -0
- package/src/update/update.css +94 -0
- package/src/use-webgl-available.js +35 -0
- package/src/workflow/AgentPanel.jsx +29 -30
- package/src/workflow/CozySceneNode.jsx +1 -1
- package/src/workflow/LoginPanel.jsx +15 -5
- package/src/workflow/agent-client.js +10 -91
- package/src/workflow/agent-failure-codes.js +47 -0
- package/src/workflow/agent-turn-telemetry.js +222 -0
- package/src/workflow/connect-telemetry.js +86 -0
- package/src/workflow/prompt-telemetry.js +105 -0
- package/tools/dev/pages/privacy.html +44 -17
- package/tools/loc-baseline.json +10 -10
- package/tools/run-tests.mjs +8 -8
- package/dist/assets/app-BdRlMf4J.css +0 -1
- package/dist/assets/app-DwETmoQZ.js +0 -5144
- package/dist/assets/compile-worker-oy_P9HHh.js +0 -6
- package/dist/assets/landing-y3EqDgSZ.js +0 -1
- package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +0 -1
- package/dist/assets/scene-asset-sync-Bz59Ghdz.js +0 -13
- package/dist/assets/task-board-DFfNa0xg.js +0 -15
- package/dist/assets/workflow-BMJGd_gZ.js +0 -179
- package/dist/media/playground-poster.jpg +0 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Closed property values for the export lifecycle and the session-end beacon.
|
|
2
|
+
// Kept out of analytics.js so these vocabularies can grow without growing the
|
|
3
|
+
// core sanitizer; nothing here imports, so analytics.js can depend on it.
|
|
4
|
+
|
|
5
|
+
export const EXPORT_FAILURE_CODES = new Set(["unsupported_codec", "encode_failed", "render_failed", "aborted", "unknown"]);
|
|
6
|
+
const COUNT_BUCKETS = new Set(["0", "1-3", "4-10", "gte11"]);
|
|
7
|
+
const EXPORT_PROPERTY_VALUES = Object.freeze({
|
|
8
|
+
export_kind: new Set(["video", "depth_video", "frame", "keyframe_pack", "workflow_send", "animation_project", "contact_sheet", "panel_pack", "animatic", "storyboard", "render_pass", "otio"]),
|
|
9
|
+
format: new Set(["mp4", "png", "zip", "cclayproject", "otio"]),
|
|
10
|
+
surface: new Set(["studio", "workflow", "embed"]),
|
|
11
|
+
duration_bucket: new Set(["lt1s", "1-3s", "3-10s", "10-30s", "gte30s"]),
|
|
12
|
+
failure_code: EXPORT_FAILURE_CODES,
|
|
13
|
+
objects: COUNT_BUCKETS,
|
|
14
|
+
// Where a failed or cancelled export stood: the step and its frame share.
|
|
15
|
+
stage: new Set(["preparing", "encoding", "finalizing", "handoff"]),
|
|
16
|
+
progress: new Set(["0", "1-25", "26-50", "51-75", "76-99"]),
|
|
17
|
+
// export:saved: where the file went, and why it fell back to a download.
|
|
18
|
+
method: new Set(["workspace", "download"]),
|
|
19
|
+
reason: new Set(["none", "unsupported", "no_workspace", "permission", "write_failed"]),
|
|
20
|
+
});
|
|
21
|
+
const ATTEMPT_ID_PATTERN = /^[a-f0-9]{32}$/;
|
|
22
|
+
|
|
23
|
+
/** One export:attempt_* or export:saved property, checked against its closed set. */
|
|
24
|
+
export function exportPropAllowed(key, value) {
|
|
25
|
+
if (key === "attempt_id" || key === "retry_of") return typeof value === "string" && ATTEMPT_ID_PATTERN.test(value);
|
|
26
|
+
if (key === "is_retry") return typeof value === "boolean";
|
|
27
|
+
return EXPORT_PROPERTY_VALUES[key]?.has(value) ?? false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The thrown-value type labels app:error and app:render_crash share (analytics.js
|
|
31
|
+
// errorType): built-in Error and DOMException names, else "non_error" or "other".
|
|
32
|
+
export const ERROR_TYPE_VALUES = new Set([
|
|
33
|
+
"Error", "TypeError", "RangeError", "ReferenceError", "SyntaxError", "EvalError", "URIError",
|
|
34
|
+
"AggregateError", "AbortError", "TimeoutError", "NotAllowedError", "NotSupportedError", "NotFoundError",
|
|
35
|
+
"NotReadableError", "InvalidStateError", "QuotaExceededError", "SecurityError", "NetworkError",
|
|
36
|
+
"DataCloneError", "OperationError", "EncodingError", "UnknownError", "DOMException", "non_error", "other",
|
|
37
|
+
]);
|
|
38
|
+
export const SESSION_DURATION_BUCKETS = new Set(["lt1m", "1-5m", "5-15m", "15-30m", "gte30m"]);
|
|
39
|
+
// The SDK's session id is a random UUID; nothing else may ride in that slot.
|
|
40
|
+
const SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
41
|
+
const SCENES_TOUCHED_MAX = 20;
|
|
42
|
+
|
|
43
|
+
/** One app:session_ended property: code-made buckets, a capped count, the SDK session id. */
|
|
44
|
+
export function sessionEndedPropAllowed(key, value) {
|
|
45
|
+
if (key === "duration_bucket") return SESSION_DURATION_BUCKETS.has(value);
|
|
46
|
+
if (key === "scenes_touched") return Number.isInteger(value) && value >= 0 && value <= SCENES_TOUCHED_MAX;
|
|
47
|
+
if (key === "session_id") return typeof value === "string" && SESSION_ID_PATTERN.test(value);
|
|
48
|
+
return key.endsWith("_bucket") && COUNT_BUCKETS.has(value);
|
|
49
|
+
}
|
package/src/analytics.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { FIRST_EDIT_KINDS, FIRST_EDIT_VERSION } from "./semantic-edit.js";
|
|
2
|
+
import { ERROR_TYPE_VALUES, EXPORT_FAILURE_CODES, exportPropAllowed } from "./analytics-values.js";
|
|
3
|
+
import { bump as bumpFriction, observeLongTasks, snapshot as frictionSnapshot } from "./analytics-friction.js";
|
|
4
|
+
import { STRICT_EVENT_KEYS, strictPropValidator } from "./analytics-strict.js";
|
|
2
5
|
export const MCP_TOOL_CATEGORIES = Object.freeze([
|
|
3
6
|
"read",
|
|
4
7
|
"camera",
|
|
@@ -94,10 +97,12 @@ const DEFAULT_ALLOWED_ORIGINS = Object.freeze([
|
|
|
94
97
|
"https://cozyclay.org",
|
|
95
98
|
"https://www.cozyclay.org",
|
|
96
99
|
]);
|
|
100
|
+
const EXPORT_ATTEMPT_KEYS = ["attempt_id", "export_kind", "format", "surface", "is_retry", "retry_of", "objects"];
|
|
97
101
|
const EVENT_PROPERTIES = Object.freeze({
|
|
98
102
|
"install:first_launch": ["heard_from"],
|
|
99
103
|
"app:session_started": [],
|
|
100
|
-
"app:session_ended": ["duration_bucket", "action_count_bucket", "scenes_touched", ...EDIT_BUCKET_KEYS],
|
|
104
|
+
"app:session_ended": ["duration_bucket", "action_count_bucket", "scenes_touched", ...EDIT_BUCKET_KEYS, "session_id"],
|
|
105
|
+
...STRICT_EVENT_KEYS,
|
|
101
106
|
"app:error": ["error_kind", "error_type", "error_source", "error_file", "error_line", "error_col"],
|
|
102
107
|
"device:profile": ["gpu_vendor", "gpu_class", "webgl", "cpu_bucket", "memory_bucket", "frame_rate_bucket"],
|
|
103
108
|
"survey:use_case": ["use_case", "team"],
|
|
@@ -116,21 +121,14 @@ const EVENT_PROPERTIES = Object.freeze({
|
|
|
116
121
|
"project:opened": ["age_bucket"],
|
|
117
122
|
"craft:first_action": ["action_kind"],
|
|
118
123
|
"craft:first_edit": ["edit_kind", "definition_version"],
|
|
119
|
-
"motion:generate_requested": ["surface", "input_mode", "request_id"],
|
|
120
|
-
"motion:preflight_blocked": ["reason", "surface", "request_id"],
|
|
121
|
-
"motion:preflight_passed": ["backend", "surface", "request_id"],
|
|
122
|
-
"motion:job_started": ["backend", "input_mode", "request_id"],
|
|
123
|
-
"motion:job_succeeded": ["backend", "duration_bucket", "input_mode", "request_id"],
|
|
124
|
-
"motion:job_failed": ["backend", "duration_bucket", "input_mode", "error_code", "request_id"],
|
|
125
|
-
"motion:result_applied": ["request_id", "backend"],
|
|
126
124
|
"export:blocking_frame_succeeded": ["format"],
|
|
127
125
|
"export:video_succeeded": ["format"],
|
|
128
126
|
"export:keyframe_pack": ["entries", "source"],
|
|
129
|
-
"export:attempt_started":
|
|
130
|
-
"export:attempt_succeeded": [
|
|
131
|
-
"export:attempt_failed": [
|
|
132
|
-
"export:attempt_cancelled": [
|
|
133
|
-
"
|
|
127
|
+
"export:attempt_started": EXPORT_ATTEMPT_KEYS,
|
|
128
|
+
"export:attempt_succeeded": [...EXPORT_ATTEMPT_KEYS, "duration_bucket"],
|
|
129
|
+
"export:attempt_failed": [...EXPORT_ATTEMPT_KEYS, "duration_bucket", "failure_code", "stage", "progress"],
|
|
130
|
+
"export:attempt_cancelled": [...EXPORT_ATTEMPT_KEYS, "duration_bucket", "failure_code", "stage", "progress"],
|
|
131
|
+
"export:saved": ["method", "reason"],
|
|
134
132
|
"playground:opened": [],
|
|
135
133
|
"playground:first_action": ["action_kind"],
|
|
136
134
|
"playground:first_edit": ["edit_kind", "definition_version"],
|
|
@@ -140,35 +138,15 @@ const EVENT_PROPERTIES = Object.freeze({
|
|
|
140
138
|
});
|
|
141
139
|
const FEATURE_NAMES = new Set([
|
|
142
140
|
"pose_edit", "pose_save", "camera_fly", "orbit", "dolly_rail", "crane_graph", "timeline_scrub",
|
|
143
|
-
"
|
|
144
|
-
"export_depth_video", "export_keyframe_pack",
|
|
145
|
-
"
|
|
141
|
+
"export_frame", "export_video",
|
|
142
|
+
"export_depth_video", "export_keyframe_pack", "export_render_pass", "export_storyboard",
|
|
143
|
+
"auto_color",
|
|
146
144
|
]);
|
|
147
145
|
// `site` and `playground` arrive through `npx cozyclay --via <source>` from the
|
|
148
146
|
// landing page's copy commands; the others are the first-launch prompt answers.
|
|
149
147
|
export const HEARD_FROM_SOURCES = Object.freeze(["x", "hn", "reddit", "github", "friend", "other", "site", "playground"]);
|
|
150
148
|
const HEARD_FROM_VALUES = new Set(HEARD_FROM_SOURCES);
|
|
151
149
|
const DENIED_PROPERTY_KEYS = new Set(["prompt", "text", "url", "path", "file"]);
|
|
152
|
-
const MOTION_ERROR_CODES = new Set(["aborted", "unsupported_route", "generation_failed", "quota", "unknown"]);
|
|
153
|
-
const MOTION_PROPERTY_VALUES = Object.freeze({
|
|
154
|
-
// `fal` is the Studio's AI video route (H3 Max Turbo through api.cozyclay.org).
|
|
155
|
-
backend: new Set(["none", "local_kimodo", "hosted", "fal"]),
|
|
156
|
-
surface: new Set(["timeline", "line_edit", "trail", "mcp", "fal_card", "agent"]),
|
|
157
|
-
input_mode: new Set(["prompt", "pose", "edit", "a_to_b", "still"]),
|
|
158
|
-
// `locked`: the AI video route is not enabled for this account yet.
|
|
159
|
-
// `missing_input`: A/B stills, segmentation or a shared camera are missing.
|
|
160
|
-
reason: new Set(["unconfigured", "unreachable", "unsupported_route", "locked", "missing_input"]),
|
|
161
|
-
error_code: MOTION_ERROR_CODES,
|
|
162
|
-
duration_bucket: new Set(["lt1s", "1-3s", "3-10s", "10-30s", "gte30s"]),
|
|
163
|
-
});
|
|
164
|
-
const EXPORT_FAILURE_CODES = new Set(["unsupported_codec", "encode_failed", "render_failed", "aborted", "unknown"]);
|
|
165
|
-
const EXPORT_PROPERTY_VALUES = Object.freeze({
|
|
166
|
-
export_kind: new Set(["video", "depth_video", "frame", "keyframe_pack", "workflow_send", "animation_project", "contact_sheet", "panel_pack", "animatic"]),
|
|
167
|
-
format: new Set(["mp4", "png", "zip", "cclayproject"]),
|
|
168
|
-
surface: new Set(["studio", "workflow", "embed"]),
|
|
169
|
-
duration_bucket: new Set(["lt1s", "1-3s", "3-10s", "10-30s", "gte30s"]),
|
|
170
|
-
failure_code: EXPORT_FAILURE_CODES,
|
|
171
|
-
});
|
|
172
150
|
// Sculpt mode (#739): one sculpt_run per finished run. Numbers only: no
|
|
173
151
|
// reference image, recipe, part name or sculpt id ever rides along.
|
|
174
152
|
export const SCULPT_RUN_OUTCOMES = Object.freeze(["done", "cancelled", "failed"]);
|
|
@@ -176,18 +154,13 @@ const SCULPT_RUN_MAX_ROUNDS = 10;
|
|
|
176
154
|
const SCULPT_RUN_MAX_DURATION_MS = 86_400_000;
|
|
177
155
|
const EXECUTION_FAILURE_CODES = Object.freeze({
|
|
178
156
|
workflow: new Set(["aborted", "capture_failed", "generation_failed", "unknown"]),
|
|
179
|
-
agent: new Set(["aborted", "auth", "rate_limited", "tool_failed", "upstream", "unknown"]),
|
|
157
|
+
agent: new Set(["aborted", "auth", "rate_limited", "tool_failed", "upstream", "unknown", "key_refused", "stale_scene", "capability_missing", "busy", "invalid_request"]),
|
|
180
158
|
});
|
|
181
159
|
// Only a type label and a same-origin script location cross the boundary; the
|
|
182
160
|
// error message, stack text, URLs and anything a user typed never do.
|
|
183
161
|
const ERROR_PROPERTY_VALUES = Object.freeze({
|
|
184
162
|
error_kind: new Set(["error", "unhandled_rejection"]),
|
|
185
|
-
error_type:
|
|
186
|
-
"Error", "TypeError", "RangeError", "ReferenceError", "SyntaxError", "EvalError", "URIError",
|
|
187
|
-
"AggregateError", "AbortError", "TimeoutError", "NotAllowedError", "NotSupportedError", "NotFoundError",
|
|
188
|
-
"NotReadableError", "InvalidStateError", "QuotaExceededError", "SecurityError", "NetworkError",
|
|
189
|
-
"DataCloneError", "OperationError", "EncodingError", "UnknownError", "DOMException", "non_error", "other",
|
|
190
|
-
]),
|
|
163
|
+
error_type: ERROR_TYPE_VALUES,
|
|
191
164
|
error_source: new Set(["app", "extension", "external", "unknown"]),
|
|
192
165
|
});
|
|
193
166
|
const ERROR_FILE_PATTERN = /^[A-Za-z0-9_.-]{1,80}\.(?:m?js|jsx|ts|tsx)$/;
|
|
@@ -227,8 +200,8 @@ let deviceProfileStarted = false;
|
|
|
227
200
|
// Passive signals describe the session; they are not user actions and never
|
|
228
201
|
// move action_count_bucket.
|
|
229
202
|
const PASSIVE_EVENTS = new Set([
|
|
230
|
-
"app:session_started", "app:session_ended", "install:first_launch",
|
|
231
|
-
"app:error", "device:profile",
|
|
203
|
+
"app:session_started", "app:session_ended", "install:first_launch", ...Object.keys(STRICT_EVENT_KEYS),
|
|
204
|
+
"app:error", "device:profile", "app:render_crash", "app:gl_lost",
|
|
232
205
|
]);
|
|
233
206
|
|
|
234
207
|
function storage() {
|
|
@@ -283,16 +256,13 @@ const isSafePropertyValue = (value) => {
|
|
|
283
256
|
export function sanitizeProps(event, props) {
|
|
284
257
|
const allowedKeys = EVENT_PROPERTIES[event] ?? [];
|
|
285
258
|
if (!props || typeof props !== "object" || Array.isArray(props)) return {};
|
|
286
|
-
const sanitized = {};
|
|
259
|
+
const sanitized = {}, strict = strictPropValidator(event);
|
|
287
260
|
for (const key of allowedKeys) {
|
|
288
261
|
if (DENIED_PROPERTY_KEYS.has(key) || !Object.hasOwn(props, key)) continue;
|
|
289
262
|
if (event === "feature:used" && (key !== "name" || !FEATURE_NAMES.has(props[key]))) continue;
|
|
290
263
|
if (event === "install:first_launch" && (key !== "heard_from" || !HEARD_FROM_VALUES.has(props[key]))) continue;
|
|
291
|
-
if (event.startsWith("export:attempt_"))
|
|
292
|
-
|
|
293
|
-
if (typeof props[key] !== "string" || !/^[a-f0-9]{32}$/.test(props[key])) continue;
|
|
294
|
-
} else if (!EXPORT_PROPERTY_VALUES[key]?.has(props[key])) continue;
|
|
295
|
-
}
|
|
264
|
+
if ((event.startsWith("export:attempt_") || event === "export:saved") && !exportPropAllowed(key, props[key])) continue;
|
|
265
|
+
if (strict) { if (strict(key, props[key])) sanitized[key] = props[key]; continue; }
|
|
296
266
|
if (event === "export:keyframe_pack") {
|
|
297
267
|
if (key === "source" && props[key] !== "workflow") continue;
|
|
298
268
|
if (key === "entries" && (!Number.isFinite(props[key]) || props[key] < 0)) continue;
|
|
@@ -301,11 +271,6 @@ export function sanitizeProps(event, props) {
|
|
|
301
271
|
if (key === "edit_kind" && !FIRST_EDIT_KINDS.includes(props[key])) continue;
|
|
302
272
|
if (key === "definition_version" && props[key] !== FIRST_EDIT_VERSION) continue;
|
|
303
273
|
}
|
|
304
|
-
if (event.startsWith("motion:")) {
|
|
305
|
-
if (key === "request_id") {
|
|
306
|
-
if (typeof props[key] !== "string" || !/^[a-f0-9]{32}$/.test(props[key])) continue;
|
|
307
|
-
} else if (!MOTION_PROPERTY_VALUES[key]?.has(props[key])) continue;
|
|
308
|
-
}
|
|
309
274
|
if (event.startsWith("workflow:") || event.startsWith("agent:") || event.startsWith("mcp:")) {
|
|
310
275
|
if (key === "run_id" || key === "turn_id" || key === "request_id") {
|
|
311
276
|
if (typeof props[key] !== "string" || !/^[a-f0-9]{32}$/.test(props[key])) continue;
|
|
@@ -547,63 +512,6 @@ export function exportFailureCode(error, fallbackCode = "unknown") {
|
|
|
547
512
|
return EXPORT_FAILURE_CODES.has(fallbackCode) ? fallbackCode : "unknown";
|
|
548
513
|
}
|
|
549
514
|
|
|
550
|
-
/**
|
|
551
|
-
* One user-initiated export, ending at pipeline completion/download handoff.
|
|
552
|
-
* Optional { now, capture } dependencies keep fixtures deterministic. Telemetry
|
|
553
|
-
* failures (including unavailable randomness) must not change export behavior.
|
|
554
|
-
*/
|
|
555
|
-
export function startExportAttempt(metadata, dependencies = {}) {
|
|
556
|
-
let terminal = false;
|
|
557
|
-
let props = null;
|
|
558
|
-
let startedAt = NaN;
|
|
559
|
-
let now = () => performance.now();
|
|
560
|
-
let capture = track;
|
|
561
|
-
const readClock = () => {
|
|
562
|
-
try { return now(); } catch { return NaN; }
|
|
563
|
-
};
|
|
564
|
-
const emit = (event, payload) => {
|
|
565
|
-
try {
|
|
566
|
-
Promise.resolve(capture(event, sanitizeProps(event, payload))).catch(() => {
|
|
567
|
-
// A rejected transport is as non-fatal as a synchronous failure.
|
|
568
|
-
});
|
|
569
|
-
} catch {
|
|
570
|
-
// Analytics must never affect the export or its error handling.
|
|
571
|
-
}
|
|
572
|
-
};
|
|
573
|
-
try {
|
|
574
|
-
now = dependencies.now ?? now;
|
|
575
|
-
capture = dependencies.capture ?? capture;
|
|
576
|
-
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
|
|
577
|
-
const attempt_id = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
578
|
-
props = { ...sanitizeProps("export:attempt_started", metadata), attempt_id };
|
|
579
|
-
startedAt = readClock();
|
|
580
|
-
emit("export:attempt_started", props);
|
|
581
|
-
} catch {
|
|
582
|
-
// Without a secure random ID, omit the attempt rather than inventing
|
|
583
|
-
// a persistent/content-derived identifier or blocking the export.
|
|
584
|
-
props = null;
|
|
585
|
-
}
|
|
586
|
-
const finish = (result, error, fallbackCode) => {
|
|
587
|
-
if (terminal) return;
|
|
588
|
-
terminal = true;
|
|
589
|
-
if (!props) return;
|
|
590
|
-
try {
|
|
591
|
-
const payload = { ...props, duration_bucket: bucketMs(readClock() - startedAt) };
|
|
592
|
-
if (result === "failed") {
|
|
593
|
-
payload.failure_code = exportFailureCode(error, fallbackCode);
|
|
594
|
-
if (payload.failure_code === "aborted") result = "cancelled";
|
|
595
|
-
}
|
|
596
|
-
emit(`export:attempt_${result}`, payload);
|
|
597
|
-
} catch {
|
|
598
|
-
// Clock/payload failures are isolated from the actual export too.
|
|
599
|
-
}
|
|
600
|
-
};
|
|
601
|
-
return {
|
|
602
|
-
succeed() { finish("succeeded"); },
|
|
603
|
-
fail(error, fallbackCode = "unknown") { finish("failed", error, fallbackCode); },
|
|
604
|
-
};
|
|
605
|
-
}
|
|
606
|
-
|
|
607
515
|
const URL_PROPERTY_KEYS = [
|
|
608
516
|
"$current_url",
|
|
609
517
|
"$initial_current_url",
|
|
@@ -903,6 +811,7 @@ export async function initAnalytics() {
|
|
|
903
811
|
sessionStartedAt = Date.now();
|
|
904
812
|
installSessionEndListeners(resolved, globalProperties);
|
|
905
813
|
installErrorListeners();
|
|
814
|
+
observeLongTasks();
|
|
906
815
|
void recordDeviceProfile();
|
|
907
816
|
if (resolved.distribution === "npm") {
|
|
908
817
|
track("app:session_started");
|
|
@@ -919,7 +828,8 @@ export async function initAnalytics() {
|
|
|
919
828
|
}
|
|
920
829
|
|
|
921
830
|
export function track(event, props = {}) {
|
|
922
|
-
|
|
831
|
+
// The allowlist is the gate: an event it does not name is never sent.
|
|
832
|
+
if (!initialized || !enabled || !posthog || !Object.hasOwn(EVENT_PROPERTIES, event)) return;
|
|
923
833
|
try {
|
|
924
834
|
if (getAnalyticsOptOut()) return;
|
|
925
835
|
const sanitized = sanitizeProps(event, props);
|
|
@@ -983,7 +893,7 @@ function installErrorListeners() {
|
|
|
983
893
|
errorListenersInstalled = true;
|
|
984
894
|
const report = (payload) => {
|
|
985
895
|
try {
|
|
986
|
-
const props = describeError(payload);
|
|
896
|
+
const props = describeError(payload); bumpFriction("errors");
|
|
987
897
|
const signature = [props.error_kind, props.error_type, props.error_source, props.error_file, props.error_line, props.error_col].join("|");
|
|
988
898
|
if (errorSignaturesSeen.has(signature) || errorSignaturesSeen.size >= ERROR_SIGNATURE_LIMIT) return;
|
|
989
899
|
errorSignaturesSeen.add(signature);
|
|
@@ -1113,25 +1023,19 @@ function installSessionEndListeners(resolved, globalProperties) {
|
|
|
1113
1023
|
// This transport bypasses the SDK, so explicitly apply the same
|
|
1114
1024
|
// current opt-out and browser DNT policy before serializing an ID.
|
|
1115
1025
|
if (!enabled || !posthog || getAnalyticsOptOut() || posthog.has_opted_out_capturing()) return;
|
|
1116
|
-
const payload = {
|
|
1117
|
-
api_key: resolved.apiKey,
|
|
1118
|
-
event: "app:session_ended",
|
|
1119
|
-
properties: {
|
|
1120
|
-
...globalProperties,
|
|
1121
|
-
distinct_id: posthog.get_distinct_id(),
|
|
1122
|
-
duration_bucket: bucketSessionDuration(Date.now() - sessionStartedAt),
|
|
1123
|
-
action_count_bucket: bucketCount(sessionActionCount),
|
|
1124
|
-
scenes_touched: Math.min(20, sessionScenesTouched),
|
|
1125
|
-
...sessionEditBuckets(),
|
|
1126
|
-
},
|
|
1127
|
-
};
|
|
1128
|
-
const body = JSON.stringify(payload);
|
|
1129
1026
|
const endpoint = `${resolved.apiHost.replace(/\/$/, "")}/e/`;
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1027
|
+
const identity = { ...globalProperties, distinct_id: posthog.get_distinct_id() };
|
|
1028
|
+
// The same validator as every SDK event, one request per event.
|
|
1029
|
+
const send = (event, props) => {
|
|
1030
|
+
const body = JSON.stringify({ api_key: resolved.apiKey, event, properties: { ...identity, ...sanitizeProps(event, props) } });
|
|
1031
|
+
if (typeof globalThis.navigator?.sendBeacon === "function") globalThis.navigator.sendBeacon(endpoint, new Blob([body], { type: "application/json" }));
|
|
1032
|
+
else void fetch(endpoint, { method: "POST", body, keepalive: true, headers: { "content-type": "application/json" } });
|
|
1033
|
+
};
|
|
1034
|
+
const duration = bucketSessionDuration(Date.now() - sessionStartedAt);
|
|
1035
|
+
// session_id joins the last events of the session.
|
|
1036
|
+
send("app:session_ended", { session_id: posthog.get_session_id?.(), duration_bucket: duration, action_count_bucket: bucketCount(sessionActionCount),
|
|
1037
|
+
scenes_touched: Math.min(20, sessionScenesTouched), ...sessionEditBuckets() });
|
|
1038
|
+
send("app:friction_summary", { active_time: duration, ...frictionSnapshot() });
|
|
1135
1039
|
} catch {
|
|
1136
1040
|
// Unload telemetry is best effort.
|
|
1137
1041
|
}
|
package/src/asset-pane.jsx
CHANGED
|
@@ -8,6 +8,7 @@ import { assetKind, formatAssetBytes } from "./asset-shelf.js";
|
|
|
8
8
|
import { assetRecord } from "./scene-asset-cache.js";
|
|
9
9
|
import ResourceStatus from "./resource-status.jsx";
|
|
10
10
|
import { logStore } from "./shell/log-store.js";
|
|
11
|
+
import { SearchEmpty } from "./search-friction.js";
|
|
11
12
|
import "./asset-pane.css";
|
|
12
13
|
|
|
13
14
|
/** Casting assets offered in the Content browser's Characters folder. A rig's `id` doubles as
|
|
@@ -396,9 +397,7 @@ function catalogueTile(entry, props) {
|
|
|
396
397
|
);
|
|
397
398
|
}
|
|
398
399
|
|
|
399
|
-
|
|
400
|
-
return <p className="content-empty">{children}</p>;
|
|
401
|
-
}
|
|
400
|
+
const EmptyNote = ({ children }) => <p className="content-empty">{children}</p>;
|
|
402
401
|
|
|
403
402
|
export function FolderGrid({ folder, query, ...props }) {
|
|
404
403
|
const { imageAssetIds, meshAssetIds, shots = [], takeVersions = [], poses = [], onShotOpen, onTakeOpen, selectedKey, onSelect } = props;
|
|
@@ -504,7 +503,7 @@ export function FolderGrid({ folder, query, ...props }) {
|
|
|
504
503
|
if (!poses.length) empty = ko("No saved poses yet. Save one from the pose studio.", "저장된 포즈가 아직 없어요. 포즈 스튜디오에서 저장하세요.");
|
|
505
504
|
}
|
|
506
505
|
if (empty) return <EmptyNote>{empty}</EmptyNote>;
|
|
507
|
-
if (query && folder !== "props" && !tiles.length) return <EmptyNote>{ko("Nothing matches this search.", "검색 결과가 없어요.")}</EmptyNote>;
|
|
506
|
+
if (query && folder !== "props" && !tiles.length) return <SearchEmpty surface="assets" query={query}><EmptyNote>{ko("Nothing matches this search.", "검색 결과가 없어요.")}</EmptyNote></SearchEmpty>;
|
|
508
507
|
return <div className="content-grid" role="list">{tiles}</div>;
|
|
509
508
|
}
|
|
510
509
|
|
|
@@ -531,7 +530,7 @@ function LogView({ query }) {
|
|
|
531
530
|
if (list) list.scrollTop = list.scrollHeight;
|
|
532
531
|
}, [visible.length, visible[visible.length - 1]?.text]);
|
|
533
532
|
if (!entries.length) return <EmptyNote>{ko("No events yet this session. Notices, generation jobs and exports appear here.", "이번 세션의 기록이 아직 없어요. 알림, 생성 작업, 내보내기가 여기에 표시됩니다.")}</EmptyNote>;
|
|
534
|
-
if (!visible.length) return <EmptyNote>{ko("Nothing matches this search.", "검색 결과가 없어요.")}</EmptyNote>;
|
|
533
|
+
if (!visible.length) return <SearchEmpty surface="assets" query={query}><EmptyNote>{ko("Nothing matches this search.", "검색 결과가 없어요.")}</EmptyNote></SearchEmpty>;
|
|
535
534
|
return (
|
|
536
535
|
<ol className="content-log" ref={listRef} data-testid="content-log" aria-live="polite">
|
|
537
536
|
{visible.map((entry) => (
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// ui:rage_click and ui:dead_click from one capture-phase pointerdown listener the
|
|
2
|
+
// Studio shell installs (StudioShell.jsx). Real pointer input on a natively disabled
|
|
3
|
+
// button still dispatches pointerdown to a document capture listener (Chrome 155,
|
|
4
|
+
// measured; mousedown and click do not), so no control needs the aria-disabled
|
|
5
|
+
// pattern for its dead clicks to be seen.
|
|
6
|
+
//
|
|
7
|
+
// Only a closed control id leaves (analytics-controls.js), never coordinates, text
|
|
8
|
+
// or element ids. Typing targets and IME composition are ignored outright.
|
|
9
|
+
import { track } from "./analytics.js";
|
|
10
|
+
import { admit, bump } from "./analytics-friction.js";
|
|
11
|
+
import { controlOf } from "./analytics-controls.js";
|
|
12
|
+
|
|
13
|
+
const RAGE_WINDOW_MS = 1000, RAGE_RADIUS_PX = 24, RAGE_MIN = 3, RAGE_COOLDOWN_MS = 10_000, EFFECT_MS = 500;
|
|
14
|
+
const RAGE_PER_CONTROL = 2, DEAD_PER_CONTROL = 2;
|
|
15
|
+
const CONTROL = "[data-fid], button, [role='button'], [role='tab'], [role='menuitem'], a[href]";
|
|
16
|
+
|
|
17
|
+
let send = track, now = () => performance.now();
|
|
18
|
+
let burst = null, burstTimer = null;
|
|
19
|
+
const lastRage = new Map();
|
|
20
|
+
let lastReceiptAt = -Infinity, lastRefusalAt = -Infinity, lastMutationAt = -Infinity;
|
|
21
|
+
|
|
22
|
+
/** The command bus recorded a receipt for a UI-origin command (command-bus-telemetry.js); `ok: false` is a refusal. */
|
|
23
|
+
export function noteBusReceipt(ok) {
|
|
24
|
+
const at = now();
|
|
25
|
+
lastReceiptAt = at;
|
|
26
|
+
if (!ok) lastRefusalAt = at;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const typing = (target, event) => event?.isComposing || Boolean(target?.closest?.("input, textarea, select, [contenteditable=''], [contenteditable='true']"));
|
|
30
|
+
const disabledOf = (control) => Boolean(control?.closest?.("[aria-disabled='true'], :disabled"));
|
|
31
|
+
const bucketClicks = (n) => (n >= 6 ? "gte6" : n >= 4 ? "4-5" : "3");
|
|
32
|
+
const emit = (event, props) => { try { send(event, props); } catch { /* telemetry is advisory */ } };
|
|
33
|
+
|
|
34
|
+
function finishBurst() {
|
|
35
|
+
clearTimeout(burstTimer);
|
|
36
|
+
const ended = burst;
|
|
37
|
+
burst = null;
|
|
38
|
+
if (!ended || ended.count < RAGE_MIN) return;
|
|
39
|
+
bump("rage_clicks");
|
|
40
|
+
const at = now(), last = lastRage.get(ended.control) ?? -Infinity;
|
|
41
|
+
if (at - last < RAGE_COOLDOWN_MS) return;
|
|
42
|
+
lastRage.set(ended.control, at);
|
|
43
|
+
if (admit(`ui:rage_click:${ended.control}`, RAGE_PER_CONTROL)) emit("ui:rage_click", { control: ended.control, clicks: bucketClicks(ended.count), changed: ended.changed() });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function dead(control, reason) {
|
|
47
|
+
bump("dead_clicks");
|
|
48
|
+
if (admit(`ui:dead_click:${control}`, DEAD_PER_CONTROL)) emit("ui:dead_click", { control, reason });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One pointerdown, as the shell's capture listener sees it. `x`, `y` stay local (the rage radius). */
|
|
52
|
+
export function notePointerDown({ target, x, y, event, observe = () => () => {} }) {
|
|
53
|
+
// A right or middle click runs no control's action: only the primary button (mouse, pen, touch: 0) counts.
|
|
54
|
+
if ((event?.button ?? 0) !== 0 || typing(target, event)) return;
|
|
55
|
+
const element = target?.closest?.(CONTROL);
|
|
56
|
+
if (!element) return;
|
|
57
|
+
const fid = element.closest("[data-fid]")?.getAttribute("data-fid");
|
|
58
|
+
const control = controlOf(fid);
|
|
59
|
+
const at = now();
|
|
60
|
+
// A rage burst: the same control, within 1 s of its first click and 24 px of where it began.
|
|
61
|
+
if (burst && burst.control === control && burst.element === element && at - burst.at <= RAGE_WINDOW_MS && Math.hypot(x - burst.x, y - burst.y) <= RAGE_RADIUS_PX) {
|
|
62
|
+
burst.count += 1;
|
|
63
|
+
} else {
|
|
64
|
+
finishBurst();
|
|
65
|
+
const since = at;
|
|
66
|
+
burst = { control, element, at, x, y, count: 1, changed: () => lastReceiptAt >= since || lastMutationAt >= since };
|
|
67
|
+
burstTimer = setTimeout(finishBurst, RAGE_WINDOW_MS);
|
|
68
|
+
}
|
|
69
|
+
if (disabledOf(element)) { dead(control, "aria_disabled"); return; }
|
|
70
|
+
// refused / no_effect: watched only on the closed controls, whose clicks are meant to do something.
|
|
71
|
+
if (control === "other") return;
|
|
72
|
+
const stop = observe(() => { lastMutationAt = now(); });
|
|
73
|
+
setTimeout(() => {
|
|
74
|
+
try { stop(); } catch { /* the observer is advisory */ }
|
|
75
|
+
if (lastRefusalAt >= at) dead(control, "refused");
|
|
76
|
+
else if (lastReceiptAt < at && lastMutationAt < at) dead(control, "no_effect");
|
|
77
|
+
}, EFFECT_MS);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The shell's listener: one capture-phase pointerdown on `root`. Returns its removal. */
|
|
81
|
+
export function installClickFriction(root = globalThis.document) {
|
|
82
|
+
if (!root?.addEventListener) return () => {};
|
|
83
|
+
const observe = (onChange) => {
|
|
84
|
+
if (typeof MutationObserver !== "function") return () => {};
|
|
85
|
+
const observer = new MutationObserver(onChange);
|
|
86
|
+
observer.observe(root.body ?? root, { subtree: true, childList: true, attributes: true, characterData: true });
|
|
87
|
+
return () => observer.disconnect();
|
|
88
|
+
};
|
|
89
|
+
const listener = (event) => { try { notePointerDown({ target: event.target, x: event.clientX, y: event.clientY, event, observe }); } catch { /* never affect a click */ } };
|
|
90
|
+
root.addEventListener("pointerdown", listener, true);
|
|
91
|
+
return () => root.removeEventListener("pointerdown", listener, true);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Tests only: capture what is sent and drive the clock; returns the restore. */
|
|
95
|
+
export function clickFrictionSeam({ capture = track, clock = () => performance.now() } = {}) {
|
|
96
|
+
const previous = [send, now];
|
|
97
|
+
send = capture; now = clock;
|
|
98
|
+
const reset = () => { clearTimeout(burstTimer); burst = null; lastRage.clear(); lastReceiptAt = lastRefusalAt = lastMutationAt = -Infinity; };
|
|
99
|
+
reset();
|
|
100
|
+
return () => { [send, now] = previous; reset(); };
|
|
101
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// The command bus's friction signals: studio:refused (one per refusal, capped per
|
|
2
|
+
// key), the history receipts an undo can name, and edit:undone for the user's own
|
|
3
|
+
// undos (domains/scene-undo.js). Only closed values leave: a refusal code, a
|
|
4
|
+
// command group, an edit kind, an age bucket. Never ids, args or messages.
|
|
5
|
+
import { EDIT_GROUPS, recordSemanticEdit, track } from './analytics.js';
|
|
6
|
+
import { createFirstEditRecorder } from './semantic-edit.js';
|
|
7
|
+
import { admit, bump } from './analytics-friction.js';
|
|
8
|
+
import { bucketAge, refusalCodeOf } from './analytics-strict.js';
|
|
9
|
+
import { noteEditGesture } from './post-turn-edits.js';
|
|
10
|
+
import { noteBusReceipt } from './click-friction.js';
|
|
11
|
+
|
|
12
|
+
let send = track, clock = () => Date.now();
|
|
13
|
+
const REFUSALS_PER_KEY = 3, UNDOS_PER_SESSION = 20, STAMPS_KEPT = 500;
|
|
14
|
+
|
|
15
|
+
// Command id prefix to studio:refused group; anything else is "other".
|
|
16
|
+
const GROUPS = [
|
|
17
|
+
[/^objects?\./, 'object'], [/^shot\./, 'shot'], [/^(characters?|cast)\./, 'cast'], [/^scene\./, 'scene'],
|
|
18
|
+
[/^(?:motion|ik)\./, 'motion'], [/^edit\./, 'edit'], [/^task\./, 'task'], [/^agent\./, 'agent'],
|
|
19
|
+
];
|
|
20
|
+
export const commandGroup = id => GROUPS.find(([pattern]) => pattern.test(String(id)))?.[1] ?? 'other';
|
|
21
|
+
|
|
22
|
+
// The one table from a committed command to its closed edit kind (FIRST_EDIT_KINDS,
|
|
23
|
+
// semantic-edit.js); its group (EDIT_GROUPS, analytics.js) follows from the kind.
|
|
24
|
+
// Recording a camera key is camera_key_record; drawing or clearing a rail (or a
|
|
25
|
+
// setCamera patch naming a rail field) is rail_edit; retiming or removing keys and
|
|
26
|
+
// other camera-block changes are shot_edit, as they were in version 1. Capturing the
|
|
27
|
+
// viewing camera into the shot (after an orbit or fly) is navigation, not an edit.
|
|
28
|
+
// Bone and IK keys are pose; placing or adding a character counts as an object, as it
|
|
29
|
+
// does for object_insert. Everything else is no edit kind.
|
|
30
|
+
const EDIT_KINDS = [
|
|
31
|
+
[/^shot\.captureCamera$/, null],
|
|
32
|
+
[/^shot\.(create|createStill|duplicate|split)$/, 'shot_add'],
|
|
33
|
+
[/^shot\.(setCameraRail|clearCameraRail)$/, 'rail_edit'],
|
|
34
|
+
[/^shot\.addKey$/, 'camera_key_record'],
|
|
35
|
+
[/^shot\./, 'shot_edit'],
|
|
36
|
+
[/^(character\.(setPose|setIkKey|removeIkKey|clearIkKeys)|ik\.|motion\.rangePin\.)/, 'pose_edit'],
|
|
37
|
+
[/^(object\.(add|duplicate|sculpt|createKit)|character\.add)$/, 'object_insert'],
|
|
38
|
+
[/^(objects?\.|character\.(set|remove|update|move)$|characters\.arrange$|cast\.replace$)/, 'object_transform'],
|
|
39
|
+
// The take's fields carry its prompt (motion.take.prompt).
|
|
40
|
+
[/^motion\.set$/, 'prompt_block_edit'],
|
|
41
|
+
];
|
|
42
|
+
// Built on first use: this module loads inside an import cycle (analytics.js -> semantic-edit.js ->
|
|
43
|
+
// command-bus.js -> here), where EDIT_GROUPS is not initialized yet.
|
|
44
|
+
let groups = null;
|
|
45
|
+
const groupOf = kind => (groups ??= new Map(Object.entries(EDIT_GROUPS).flatMap(([group, kinds]) => kinds.map(entry => [entry, group])))).get(kind);
|
|
46
|
+
/** A command's closed edit kind, or null. `hint`: asset.import's closed `placeAs`, and `rail` when a
|
|
47
|
+
* shot.setCamera patch names a rail field (key names only; values are never read). */
|
|
48
|
+
export function firstEditKindOf(action, { placeAs, rail = false } = {}) {
|
|
49
|
+
if (action === 'asset.import') return placeAs === 'cutout' ? 'cutout_insert' : 'object_insert';
|
|
50
|
+
if (action === 'shot.setCamera' && rail) return 'rail_edit';
|
|
51
|
+
return EDIT_KINDS.find(([pattern]) => pattern.test(String(action)))?.[1] ?? null;
|
|
52
|
+
}
|
|
53
|
+
// The camera block's rail fields (semantic-edit.js rail_edit): a setCamera patch naming one edits the rail.
|
|
54
|
+
const RAIL_FIELDS = ['cameraRail', 'craneHeight', 'railFollow', 'dollyTiming'];
|
|
55
|
+
const railPatch = args => Object.keys(args?.patch ?? {}).some(key => RAIL_FIELDS.includes(key));
|
|
56
|
+
// A drag's begin/update patches, remembered by txId until its commit names the edit.
|
|
57
|
+
const railTransactions = new Set();
|
|
58
|
+
function hintOf(id, args, recorded) {
|
|
59
|
+
if (id === 'run.begin' || id === 'run.update') {
|
|
60
|
+
if (recorded.txId && railPatch(args?.args)) railTransactions.add(recorded.txId);
|
|
61
|
+
while (railTransactions.size > 50) railTransactions.delete(railTransactions.values().next().value);
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
return { placeAs: args?.placeAs, rail: id === 'run.commit' ? railTransactions.delete(recorded.txId) : railPatch(args) };
|
|
65
|
+
}
|
|
66
|
+
const AGENT_WORK = new Set(['agent.turn.finish', 'task.finish']);
|
|
67
|
+
/** The edit:undone / post-turn group of a command: pose, camera, object, shot, prompt, agent_turn or other. */
|
|
68
|
+
export function editKind(action, origin = 'ui', hint) {
|
|
69
|
+
// What the agent made (its turn, a task, or an edit it ran on its own) is undone as agent work.
|
|
70
|
+
if (AGENT_WORK.has(action) || origin !== 'ui') return 'agent_turn';
|
|
71
|
+
return groupOf(firstEditKindOf(action, hint)) ?? 'other';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The user's own edits feed the session edit buckets and the surface's first edit
|
|
75
|
+
// (definition_version 2). App starts a new mount boundary with startUserEdits.
|
|
76
|
+
let editSurface = 'craft', firstEdit = createFirstEditRecorder((event, props) => send(event, props));
|
|
77
|
+
export function startUserEdits(surface) {
|
|
78
|
+
editSurface = surface;
|
|
79
|
+
firstEdit = createFirstEditRecorder((event, props) => send(event, props));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const emit = (event, props) => {
|
|
83
|
+
try { send(event, props); } catch { /* telemetry is advisory */ }
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** A refusal receipt: the session counter always moves; the event, at most 3 times per (code, group, origin). */
|
|
87
|
+
export function noteRefusal(value, origin, id) {
|
|
88
|
+
// A cancel the caller asked for is not a refusal. A UI one tells a click it was refused (click-friction.js);
|
|
89
|
+
// an agent's or MCP client's refusal at the same moment is not the user's click.
|
|
90
|
+
if (value.code === 'CANCELLED') return;
|
|
91
|
+
if (origin === 'ui') noteBusReceipt(false);
|
|
92
|
+
const by = origin === 'ui' ? 'ui' : 'agent', code = refusalCodeOf(value.code), group = commandGroup(id);
|
|
93
|
+
bump(by === 'ui' ? 'refusals_ui' : 'refusals_agent');
|
|
94
|
+
if (admit(`studio:refused:${code}:${group}:${by}`, REFUSALS_PER_KEY)) emit('studio:refused', { code, origin: by, phase: value.phase, group });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// receiptId -> { at, kind }: when a history receipt was recorded and what it did.
|
|
98
|
+
const stamps = new Map();
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The bus's history receipts by history entry id (what edit.undo restores), each
|
|
102
|
+
* stamped when it was recorded. Gesture rule: a drag is one transaction whose
|
|
103
|
+
* receipt is recorded at run.commit, and any later receipt naming the same entry
|
|
104
|
+
* replaces the earlier one, so an entry's age runs from its LAST receipt: the
|
|
105
|
+
* time since the user finished that edit, not since they started it.
|
|
106
|
+
*/
|
|
107
|
+
export function createHistoryReceipts(ports) {
|
|
108
|
+
const receipts = new Map();
|
|
109
|
+
return {
|
|
110
|
+
get: historyEntryId => receipts.get(historyEntryId),
|
|
111
|
+
/** `id` the command, `action` the command a transaction ran (run.commit of a drag). */
|
|
112
|
+
remember(recorded, { id, origin, action, args }) {
|
|
113
|
+
const hint = hintOf(id, args, recorded);
|
|
114
|
+
if (origin === 'ui' && recorded.ok !== false) noteBusReceipt(true);
|
|
115
|
+
// Session friction: an undo the user made (agent undos are the agent's own correction).
|
|
116
|
+
if (id === 'edit.undo' && origin === 'ui' && recorded.status === 'undone') bump('undos');
|
|
117
|
+
// The user's edit that changed the scene, once per receipt (a drag commits once): the session edit
|
|
118
|
+
// buckets, the first edit and agent:post_turn_edit. Agent, MCP and undo/redo receipts never count.
|
|
119
|
+
if (origin === 'ui' && recorded.authored === true && !id.startsWith('edit.')) noteUserEdit(firstEditKindOf(action ?? id, hint));
|
|
120
|
+
if (recorded.undo?.canUndoDirect && !id.startsWith('edit.')) {
|
|
121
|
+
receipts.set(recorded.undo.historyEntryId, recorded);
|
|
122
|
+
stamps.set(recorded.receiptId, { at: clock(), kind: editKind(action ?? id, origin, hint) });
|
|
123
|
+
while (stamps.size > STAMPS_KEPT) stamps.delete(stamps.keys().next().value);
|
|
124
|
+
}
|
|
125
|
+
for (const [key, receipt] of receipts) if (ports.isRetained?.(receipt) === false) { receipts.delete(key); stamps.delete(receipt.receiptId); }
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let userEditAt = -Infinity;
|
|
131
|
+
/** When the user's own last edit receipt was recorded (bus clock), or -Infinity: the thrash detectors' "no edit in between". */
|
|
132
|
+
export const lastUserEditAt = () => userEditAt;
|
|
133
|
+
function noteUserEdit(kind) {
|
|
134
|
+
if (!kind) return;
|
|
135
|
+
userEditAt = clock();
|
|
136
|
+
try { recordSemanticEdit(kind); firstEdit(editSurface, kind); } catch { /* telemetry is advisory */ }
|
|
137
|
+
noteEditGesture(groupOf(kind));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const RESULTS = { undone: 'done', noop: 'noop' };
|
|
141
|
+
/** The user's undo (`via` key, menu or button): its receipt, or null for an IK drag preview backed out. */
|
|
142
|
+
export function noteUndone(via, receipt) {
|
|
143
|
+
const result = !receipt ? 'done' : receipt.ok === false ? 'refused' : RESULTS[receipt.status];
|
|
144
|
+
if (!result || !admit('edit:undone', UNDOS_PER_SESSION)) return;
|
|
145
|
+
// A native history entry no receipt names (or nothing undone at all) is "other", with no age.
|
|
146
|
+
const stamp = result === 'done' && receipt ? stamps.get(receipt.undoneReceiptId) : null;
|
|
147
|
+
emit('edit:undone', { kind: receipt ? stamp?.kind ?? 'other' : 'ik_preview', via, result, ...(stamp ? { age: bucketAge(clock() - stamp.at) } : {}) });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Tests only: capture what is sent and drive the clock; returns the restore. */
|
|
151
|
+
export function telemetrySeam({ track: capture = track, now = () => Date.now() } = {}) {
|
|
152
|
+
const previous = [send, clock];
|
|
153
|
+
send = capture; clock = now;
|
|
154
|
+
return () => { [send, clock] = previous; stamps.clear(); };
|
|
155
|
+
}
|