cozyclay 3.1.4 → 3.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +104 -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-BLS1Zaru.js +5144 -0
- package/dist/assets/app-CEP7jzXB.css +1 -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/mcp/project-camera.mjs +43 -0
- package/package.json +2 -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/tools/verify-package-tarball.mjs +29 -1
- 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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
2
|
import { ko } from "./locale.js";
|
|
3
|
+
import { noteGuideDismissed, noteGuideOpened, noteGuideStep } from "./guide-telemetry.js";
|
|
3
4
|
|
|
4
5
|
const ANIMATION_STEPS = [
|
|
5
6
|
{
|
|
@@ -76,13 +77,16 @@ const STEPS_BY_MODE = { animation: ANIMATION_STEPS, storyboard: STORYBOARD_STEPS
|
|
|
76
77
|
/** A compact, dismissible first-run checklist that follows the v2 shell grammar. */
|
|
77
78
|
export default function FirstSuccessGuide({ open, onDismiss, previsMode = "animation" }) {
|
|
78
79
|
const storyboard = previsMode === "storyboard";
|
|
79
|
-
const
|
|
80
|
+
const mode = storyboard ? "storyboard" : "animation";
|
|
81
|
+
const STEPS = STEPS_BY_MODE[mode];
|
|
80
82
|
const [step, setStep] = useState(0);
|
|
81
83
|
useEffect(() => {
|
|
82
|
-
if (open) setStep(0);
|
|
83
|
-
}, [open]);
|
|
84
|
+
if (open) { setStep(0); noteGuideOpened(mode); }
|
|
85
|
+
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps -- the mode at opening time
|
|
84
86
|
if (!open) return null;
|
|
85
87
|
const complete = step >= STEPS.length;
|
|
88
|
+
// Closing after the last step is not a drop-off: only an unfinished guide reports guide:dismissed.
|
|
89
|
+
const dismiss = () => { if (!complete) noteGuideDismissed(mode, step + 1); onDismiss(); };
|
|
86
90
|
|
|
87
91
|
return (
|
|
88
92
|
<aside className="v2-first-success-guide first-success-guide" role="dialog" aria-labelledby="first-success-guide-title" aria-describedby="first-success-guide-description">
|
|
@@ -91,14 +95,14 @@ export default function FirstSuccessGuide({ open, onDismiss, previsMode = "anima
|
|
|
91
95
|
<strong id="first-success-guide-title">{ko("Your first 60 seconds", "첫 60초")}</strong>
|
|
92
96
|
<span>{complete ? ko("Core loop complete", "핵심 흐름 완료", "核心流程完成") : ko(`Step ${step + 1} of ${STEPS.length}`, `${step + 1} / ${STEPS.length} 단계`, `第 ${step + 1} / ${STEPS.length} 步`)}</span>
|
|
93
97
|
</div>
|
|
94
|
-
<button type="button" className="v2-first-success-guide-close first-success-guide-close" onClick={
|
|
98
|
+
<button type="button" className="v2-first-success-guide-close first-success-guide-close" onClick={dismiss} aria-label={ko("Dismiss guide", "가이드 닫기")}>×</button>
|
|
95
99
|
</div>
|
|
96
100
|
{complete ? (
|
|
97
101
|
<div className="v2-first-success-guide-complete first-success-guide-complete" id="first-success-guide-description" role="status">
|
|
98
102
|
<div className="v2-first-success-guide-check first-success-guide-check">✓</div>
|
|
99
103
|
<strong>{storyboard ? ko("You made your first panel.", "첫 패널을 만들었어요.") : ko("You made your first shot.", "첫 샷을 만들었어요.")}</strong>
|
|
100
104
|
<p>{storyboard ? ko("Describe, review on the Board, then stylize or export. You can close this guide and keep adding panels.", "설명하고, 보드에서 확인한 뒤 스타일을 입히거나 내보내세요. 가이드를 닫고 패널을 계속 추가하세요.") : ko("Select, move, ask, and play are the core CozyClay loop. You can close this guide and keep blocking.", "선택하고, 움직이고, 요청하고, 재생하는 것이 CozyClay의 핵심 흐름입니다. 가이드를 닫고 계속 블로킹하세요.")}</p>
|
|
101
|
-
<button type="button" className="v2-first-success-guide-next first-success-guide-next btn primary" onClick={
|
|
105
|
+
<button type="button" className="v2-first-success-guide-next first-success-guide-next btn primary" onClick={dismiss}>{ko("Continue editing", "편집 계속하기")}</button>
|
|
102
106
|
</div>
|
|
103
107
|
) : (
|
|
104
108
|
<>
|
|
@@ -111,12 +115,12 @@ export default function FirstSuccessGuide({ open, onDismiss, previsMode = "anima
|
|
|
111
115
|
</li>
|
|
112
116
|
))}
|
|
113
117
|
</ol>
|
|
114
|
-
<button type="button" className="v2-first-success-guide-next first-success-guide-next btn primary" onClick={() =>
|
|
118
|
+
<button type="button" className="v2-first-success-guide-next first-success-guide-next btn primary" onClick={() => { noteGuideStep(mode, step + 1, STEPS.length); setStep(step + 1); }}>
|
|
115
119
|
{ko(step === STEPS.length - 1 ? "Mark complete" : "I did this", step === STEPS.length - 1 ? "완료로 표시" : "했어요", step === STEPS.length - 1 ? "标记完成" : "我做了这一步")}
|
|
116
120
|
</button>
|
|
117
121
|
</>
|
|
118
122
|
)}
|
|
119
|
-
<button type="button" className="v2-first-success-guide-skip first-success-guide-skip" onClick={
|
|
123
|
+
<button type="button" className="v2-first-success-guide-skip first-success-guide-skip" onClick={dismiss}>{ko("Skip guide", "가이드 건너뛰기")}</button>
|
|
120
124
|
</aside>
|
|
121
125
|
);
|
|
122
126
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// guide:opened, guide:step, guide:dismissed, guide:completed: the first-60-seconds
|
|
2
|
+
// guide (first-success-guide.jsx) as a funnel. The mode and step numbers are the
|
|
3
|
+
// component's own (its STEPS_BY_MODE keys, 1-based step index); each event and step
|
|
4
|
+
// is sent at most once per session. Only the mode, a step number and a duration
|
|
5
|
+
// bucket leave: never step text or what the user did.
|
|
6
|
+
import { initAnalytics, track } from "./analytics.js";
|
|
7
|
+
import { admit } from "./analytics-friction.js";
|
|
8
|
+
import { bucketLong } from "./analytics-strict.js";
|
|
9
|
+
|
|
10
|
+
const GUIDE_MODES = Object.freeze(["animation", "storyboard"]);
|
|
11
|
+
const GUIDE_STEP_VALUES = Object.freeze(["1", "2", "3", "4"]);
|
|
12
|
+
|
|
13
|
+
// The guide can open before the SDK has loaded; initAnalytics() is idempotent (main.jsx started
|
|
14
|
+
// it), so waiting on it keeps an early guide:opened instead of spending its cap on a no-op.
|
|
15
|
+
let send = (event, props) => { initAnalytics().then(() => track(event, props), () => {}); }, clock = () => Date.now();
|
|
16
|
+
let stepStartedAt = null;
|
|
17
|
+
|
|
18
|
+
const stepValue = (step) => (GUIDE_STEP_VALUES.includes(String(step)) ? String(step) : null);
|
|
19
|
+
const guarded = (run) => { try { run(); } catch { /* Telemetry must never affect the guide. */ } };
|
|
20
|
+
|
|
21
|
+
/** The guide became visible in `mode`; its first step's clock starts. */
|
|
22
|
+
export function noteGuideOpened(mode) {
|
|
23
|
+
guarded(() => {
|
|
24
|
+
if (!GUIDE_MODES.includes(mode)) return;
|
|
25
|
+
stepStartedAt = clock();
|
|
26
|
+
if (admit("guide:opened", 1)) send("guide:opened", { mode });
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The user marked step `step` (1-based) of `total` done; the last one also completes the guide. */
|
|
31
|
+
export function noteGuideStep(mode, step, total) {
|
|
32
|
+
guarded(() => {
|
|
33
|
+
const value = stepValue(step);
|
|
34
|
+
if (!GUIDE_MODES.includes(mode) || !value) return;
|
|
35
|
+
const now = clock();
|
|
36
|
+
const stepMs = stepStartedAt === null ? NaN : now - stepStartedAt;
|
|
37
|
+
stepStartedAt = now;
|
|
38
|
+
if (admit(`guide:step:${value}`, 1)) send("guide:step", { mode, step: value, step_ms: bucketLong(stepMs) });
|
|
39
|
+
if (step === total && admit("guide:completed", 1)) send("guide:completed", { mode });
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The guide was closed before its last step; `reached` is the step it was showing (1-based). */
|
|
44
|
+
export function noteGuideDismissed(mode, reached) {
|
|
45
|
+
guarded(() => {
|
|
46
|
+
const value = stepValue(reached);
|
|
47
|
+
if (!GUIDE_MODES.includes(mode) || !value) return;
|
|
48
|
+
stepStartedAt = null;
|
|
49
|
+
if (admit("guide:dismissed", 1)) send("guide:dismissed", { mode, reached: value });
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Tests only: capture what is sent and drive the clock; returns the restore. */
|
|
54
|
+
export function guideTelemetrySeam(capture, now) {
|
|
55
|
+
const previous = [send, clock];
|
|
56
|
+
send = capture;
|
|
57
|
+
if (now) clock = now;
|
|
58
|
+
stepStartedAt = null;
|
|
59
|
+
return () => { [send, clock] = previous; stepStartedAt = null; };
|
|
60
|
+
}
|
package/src/hierarchy-panel.jsx
CHANGED
|
@@ -7,6 +7,7 @@ import { CatalogueEntries, displayObjectLabel } from "./object-catalog.jsx";
|
|
|
7
7
|
import { Dropdown } from "./ui.jsx";
|
|
8
8
|
import { usePopover } from "./popover.js";
|
|
9
9
|
import { isImeComposing } from "./ime.js";
|
|
10
|
+
import { SearchEmpty } from "./search-friction.js";
|
|
10
11
|
import "./hierarchy-panel.css";
|
|
11
12
|
|
|
12
13
|
const HIERARCHY_LABELS_KO = {
|
|
@@ -881,7 +882,7 @@ export default function HierarchyPanel({
|
|
|
881
882
|
</label>
|
|
882
883
|
<div className="hierarchy-tree" role="tree" ref={treeRef} onKeyDown={onTreeKeyDown} onContextMenu={openCreateMenu}>
|
|
883
884
|
{filteredHierarchyNodes.length > 0 ? renderNodes(filteredHierarchyNodes) : (
|
|
884
|
-
<div className="v2-outliner-empty" role="status">{ko("No matches", "일치하는 항목 없음")}</div>
|
|
885
|
+
<SearchEmpty surface="outliner" query={search}><div className="v2-outliner-empty" role="status">{ko("No matches", "일치하는 항목 없음")}</div></SearchEmpty>
|
|
885
886
|
)}
|
|
886
887
|
</div>
|
|
887
888
|
{sceneHint && (
|
package/src/live-control.js
CHANGED
|
@@ -4,11 +4,7 @@
|
|
|
4
4
|
import { sanitizeProps, track } from "./analytics.js";
|
|
5
5
|
import { EXECUTION_TELEMETRY_EVENTS, EXECUTION_TELEMETRY_PROPERTY_KEYS } from "./execution-telemetry.js";
|
|
6
6
|
|
|
7
|
-
const
|
|
8
|
-
"motion:generate_requested", "motion:preflight_blocked", "motion:preflight_passed",
|
|
9
|
-
"motion:job_started", "motion:job_succeeded", "motion:job_failed", "motion:result_applied",
|
|
10
|
-
]);
|
|
11
|
-
const LIVE_TELEMETRY_EVENTS = new Set([...MOTION_TELEMETRY_EVENTS, ...EXECUTION_TELEMETRY_EVENTS.filter((event) => event.startsWith("mcp:"))]);
|
|
7
|
+
const LIVE_TELEMETRY_EVENTS = new Set(EXECUTION_TELEMETRY_EVENTS.filter((event) => event.startsWith("mcp:")));
|
|
12
8
|
|
|
13
9
|
const LIVE_CONTROL_PORT = import.meta.env?.VITE_COZYCLAY_LIVE_PORT ?? "5184";
|
|
14
10
|
// The npm launcher serves a prebuilt bundle, so the build-time port above is
|
|
@@ -159,7 +155,6 @@ export function createLiveControl({
|
|
|
159
155
|
// A duplicated tab carries a copy of this tab's id; the hub refuses the
|
|
160
156
|
// second socket, so the loser mints and stores a new identity, then retries.
|
|
161
157
|
onDuplicate = () => mintLiveWorkspaceId(),
|
|
162
|
-
captureMotionTelemetry = track,
|
|
163
158
|
captureTelemetry = track,
|
|
164
159
|
workspaceId = "",
|
|
165
160
|
// Optional identity for the hub's live_status listing (scene/project names).
|
|
@@ -191,13 +186,11 @@ export function createLiveControl({
|
|
|
191
186
|
if (!correlationId) return;
|
|
192
187
|
if (EXECUTION_TELEMETRY_EVENTS.includes(payload.event)
|
|
193
188
|
&& EXECUTION_TELEMETRY_PROPERTY_KEYS[payload.event]?.some((key) => !Object.hasOwn(props, key))) return;
|
|
194
|
-
if ((payload.event === "motion:generate_requested" || payload.event.startsWith("motion:preflight_")) && props.surface !== "mcp") return;
|
|
195
189
|
const key = `${correlationId}:${payload.event}`;
|
|
196
190
|
if (capturedStages.has(key)) return;
|
|
197
191
|
capturedStages.add(key);
|
|
198
|
-
const capture = payload.event.startsWith("motion:") ? captureMotionTelemetry : captureTelemetry;
|
|
199
192
|
try {
|
|
200
|
-
Promise.resolve(
|
|
193
|
+
Promise.resolve(captureTelemetry(payload.event, props)).catch(() => {
|
|
201
194
|
// SDK failures must not affect command dispatch or generation.
|
|
202
195
|
});
|
|
203
196
|
} catch {
|
package/src/locale-zh/domains.js
CHANGED
|
@@ -9,6 +9,7 @@ export const ZH_DOMAINS = {
|
|
|
9
9
|
"Edited pose": "已编辑的姿势",
|
|
10
10
|
"Pose saved": "姿势已保存",
|
|
11
11
|
"Nothing to undo": "没有可撤销的操作",
|
|
12
|
+
"IK edit undone": "已撤销 IK 修改",
|
|
12
13
|
"Nothing to redo": "没有可重做的操作",
|
|
13
14
|
"Confirm Replace existing keys before applying this pin.": "应用此固定前请确认替换现有键。",
|
|
14
15
|
"Pose mode — preview the correction, then apply it to the selected motion block": "姿势模式 — 先预览修正,再应用到所选动作片段",
|
package/src/main.jsx
CHANGED
|
@@ -11,11 +11,14 @@ import "./styles.css";
|
|
|
11
11
|
import { registerPwa } from "./pwa.js";
|
|
12
12
|
import { LOCALE } from "./locale.js";
|
|
13
13
|
import { initAnalytics } from "./analytics.js";
|
|
14
|
+
import { StudioReadyMark } from "./studio-telemetry.js";
|
|
15
|
+
import { listenForUnboundShortcuts } from "./shortcut-friction.js";
|
|
14
16
|
import { fetchPlaygroundProject, isPlaygroundEmbed, playgroundSceneUrl, stashPlaygroundProject } from "./playground.js";
|
|
15
17
|
import { preloadBodies } from "./motion3/body.js";
|
|
16
18
|
|
|
17
19
|
registerPwa();
|
|
18
20
|
void initAnalytics();
|
|
21
|
+
listenForUnboundShortcuts();
|
|
19
22
|
document.documentElement.lang = LOCALE === "zh" ? "zh-CN" : LOCALE;
|
|
20
23
|
applyTheme(readTheme());
|
|
21
24
|
|
|
@@ -32,6 +35,8 @@ async function boot() {
|
|
|
32
35
|
<StrictMode>
|
|
33
36
|
<ErrorBoundary>
|
|
34
37
|
<App />
|
|
38
|
+
{/* studio:ready: one frame after this first commit, without delaying it. */}
|
|
39
|
+
<StudioReadyMark />
|
|
35
40
|
</ErrorBoundary>
|
|
36
41
|
</StrictMode>,
|
|
37
42
|
);
|
package/src/motion/timeline.jsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { frameFromClientX, groupKeyRuns, KEY_RUN_MIN, rangePinBandGeometry, shotBlockGeometry } from "./timeline-coordinates.js";
|
|
3
3
|
import { createPlaybackClock } from "./playback-clock.js";
|
|
4
|
+
import { startPlaybackRun } from "../playback-telemetry.js";
|
|
4
5
|
|
|
5
6
|
import { ko } from "../locale.js";
|
|
6
7
|
import { isImeComposing } from "../ime.js";
|
|
@@ -1195,14 +1196,17 @@ export default function Timeline({
|
|
|
1195
1196
|
useEffect(() => {
|
|
1196
1197
|
if (!playing) return;
|
|
1197
1198
|
const elapsedFrames = createPlaybackClock(fps, playbackSpeed, performance.now());
|
|
1199
|
+
// playback:summary: steps > 1 is a late tick (an estimate of dropped frames, not a measured one).
|
|
1200
|
+
const run = startPlaybackRun(fps, playbackSpeed);
|
|
1198
1201
|
const id = window.setInterval(
|
|
1199
1202
|
() => {
|
|
1200
1203
|
const steps = elapsedFrames(performance.now());
|
|
1204
|
+
run.tick(steps);
|
|
1201
1205
|
if (steps) handlers.current.onAdvance?.(steps);
|
|
1202
1206
|
},
|
|
1203
1207
|
1000 / Math.max(1, fps * playbackSpeed),
|
|
1204
1208
|
);
|
|
1205
|
-
return () => window.clearInterval(id);
|
|
1209
|
+
return () => { window.clearInterval(id); run.end(); };
|
|
1206
1210
|
}, [playing, fps, playbackSpeed]);
|
|
1207
1211
|
|
|
1208
1212
|
// Space toggles playback, j/k step the playhead, p toggles waypoint mode —
|
|
@@ -1592,7 +1596,7 @@ export default function Timeline({
|
|
|
1592
1596
|
</button>
|
|
1593
1597
|
<button
|
|
1594
1598
|
type="button"
|
|
1595
|
-
className={"tl-btn play" + (playing ? " on" : "")}
|
|
1599
|
+
className={"tl-btn play" + (playing ? " on" : "")} data-fid="tl_play"
|
|
1596
1600
|
aria-label={playing ? ko("Pause playback", "재생 일시중지") : ko("Play playback", "재생 시작")}
|
|
1597
1601
|
title={ko("Play / pause (Space)", "재생/일시중지 (Space)")}
|
|
1598
1602
|
onClick={() => handlers.current.onPlayToggle?.()}
|
|
@@ -1849,7 +1853,7 @@ export default function Timeline({
|
|
|
1849
1853
|
{name === SHOTS_LANE && (
|
|
1850
1854
|
<button
|
|
1851
1855
|
type="button"
|
|
1852
|
-
className="tl-track-add cut"
|
|
1856
|
+
className="tl-track-add cut" data-fid="shot_add"
|
|
1853
1857
|
disabled={shotCutDisabled}
|
|
1854
1858
|
title={ko("Add a 2 second shot without changing existing shots", "기존 샷을 바꾸지 않고 2초 샷 추가")}
|
|
1855
1859
|
onClick={() => handlers.current.onShotCut?.()}
|
|
@@ -1993,7 +1997,7 @@ export default function Timeline({
|
|
|
1993
1997
|
<span className="tl-shot-actions">
|
|
1994
1998
|
<button type="button" title={ko("Split at the playhead", "재생 헤드에서 분할")} disabled={frame <= shot.startFrame || frame > shot.endFrame} onClick={(e) => { e.stopPropagation(); handlers.current.onShotSplit?.(shot.id); }}>{ko("Split", "분할")}</button>
|
|
1995
1999
|
<button type="button" title={ko("Duplicate shot", "샷 복제")} onClick={(e) => { e.stopPropagation(); handlers.current.onShotDuplicate?.(shot.id); }}>{ko("Duplicate", "복제")}</button>
|
|
1996
|
-
<button type="button" title={ko("Delete shot and leave free-camera time", "샷을 지우고 자유 카메라 구간으로 비우기")} onClick={(e) => { e.stopPropagation(); handlers.current.onShotRemove?.(shot.id); }}>{ko("Delete", "삭제")}</button>
|
|
2000
|
+
<button type="button" data-fid="shot_delete" title={ko("Delete shot and leave free-camera time", "샷을 지우고 자유 카메라 구간으로 비우기")} onClick={(e) => { e.stopPropagation(); handlers.current.onShotRemove?.(shot.id); }}>{ko("Delete", "삭제")}</button>
|
|
1997
2001
|
</span>
|
|
1998
2002
|
<span className="tl-shot-camera-summary">
|
|
1999
2003
|
<span className="tl-camera-block-state">{stateLabel}</span>
|
|
@@ -2055,7 +2059,7 @@ export default function Timeline({
|
|
|
2055
2059
|
one gesture cannot both reorder a shot and edit a curve. */}
|
|
2056
2060
|
<button
|
|
2057
2061
|
type="button"
|
|
2058
|
-
className="tl-shot-key-surface"
|
|
2062
|
+
className="tl-shot-key-surface" data-fid="camera_key_add"
|
|
2059
2063
|
aria-label={shot.camera?.mode === "rail" && shot.camera?.craneHeight
|
|
2060
2064
|
? ko(`Add crane point in ${shot.name}`, `${shot.name}에 크레인 점 추가`, `在 ${shot.name} 中添加摇臂点`)
|
|
2061
2065
|
: ko(`Add camera key in ${shot.name}`, `${shot.name}에 카메라 키 추가`, `在 ${shot.name} 中添加摄影机关键帧`)}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// motion:draft_compiled: one event per motion.compile draft (commands/motion3.js, right
|
|
2
|
+
// after publishDraft), so the compile -> QA -> repair loop is visible per draft. Only
|
|
3
|
+
// counts, buckets and flags leave: never the spec, the take, skill names or QA text.
|
|
4
|
+
// At most 50 per session; past the cap only the friction counter `compiles` moves.
|
|
5
|
+
import { track } from "./analytics.js";
|
|
6
|
+
import { admit, bucketFrictionCount, bucketShortMs, bump } from "./analytics-friction.js";
|
|
7
|
+
import { qaProblems } from "./qa-label.js";
|
|
8
|
+
|
|
9
|
+
const DRAFTS_PER_SESSION = 50;
|
|
10
|
+
let send = track;
|
|
11
|
+
|
|
12
|
+
function bucketQaFrames(n) {
|
|
13
|
+
if (!(n > 0)) return "0";
|
|
14
|
+
if (n <= 10) return "1-10";
|
|
15
|
+
if (n <= 50) return "11-50";
|
|
16
|
+
if (n <= 200) return "51-200";
|
|
17
|
+
return "gte201";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function bucketFrames(n) {
|
|
21
|
+
if (!(n >= 30)) return "lt30";
|
|
22
|
+
if (n <= 120) return "30-120";
|
|
23
|
+
if (n <= 480) return "121-480";
|
|
24
|
+
return "gte481";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The draft publishDraft just stored (`n`: its ordinal for the character this turn,
|
|
29
|
+
* from 1) and the compile result `r`: qa, ms (the whole draft: ms.repair, the repair
|
|
30
|
+
* loop's wall time with every candidate, when it ran, else ms.total, which includes QA;
|
|
31
|
+
* the pipeline has no separate QA time), frameCount, search. `flags` are the
|
|
32
|
+
* handler's own: installed, stuck, repaired, adjusted.
|
|
33
|
+
*/
|
|
34
|
+
export function noteDraftCompiled(draft, r, { installed = false, stuck = false, repaired = false, adjusted = false } = {}) {
|
|
35
|
+
try {
|
|
36
|
+
bump("compiles");
|
|
37
|
+
const problems = qaProblems(r?.qa);
|
|
38
|
+
const props = {
|
|
39
|
+
draft_index: bucketFrictionCount(draft?.n),
|
|
40
|
+
qa_clean: problems === 0,
|
|
41
|
+
qa_problems: bucketFrictionCount(problems),
|
|
42
|
+
// QA records one issue per frame per group (qa-label.js): frame-level issues, not distinct frames.
|
|
43
|
+
qa_frames: bucketQaFrames(r?.qa?.total ?? 0),
|
|
44
|
+
// A repaired draft compiled many candidates: ms.total is only the one it kept (compile-job.js).
|
|
45
|
+
compile: bucketShortMs(r?.ms?.repair ?? r?.ms?.total),
|
|
46
|
+
installed: Boolean(installed), stuck: Boolean(stuck), repaired: Boolean(repaired),
|
|
47
|
+
searched: Boolean(r?.search), adjusted: Boolean(adjusted),
|
|
48
|
+
frames: bucketFrames(r?.frameCount),
|
|
49
|
+
};
|
|
50
|
+
// The payload is built first: a malformed result takes no slot of the cap.
|
|
51
|
+
if (admit("motion:draft_compiled", DRAFTS_PER_SESSION)) send("motion:draft_compiled", props);
|
|
52
|
+
} catch {
|
|
53
|
+
// Telemetry must never affect a compile.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Tests only: capture what is sent; returns the restore. */
|
|
58
|
+
export function draftTelemetrySeam(capture) {
|
|
59
|
+
const previous = send;
|
|
60
|
+
send = capture;
|
|
61
|
+
return () => { send = previous; };
|
|
62
|
+
}
|
|
@@ -42,23 +42,25 @@ function pchipEval(x, y, m, q) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
const FLOW_SMOOTH = /^(spine|chest|head)_(yaw|pitch|roll)$|^(aswing|abend)_[lr]$/;
|
|
45
|
-
const FLOW_LAG = { spine: 0, head: 2, arms: 3 };
|
|
45
|
+
const FLOW_LAG = { spine: 0, head: 2, arms: 3 }, HEAD_LEAD = 5;
|
|
46
46
|
const ARM = /^(hand|armw|elbow|aswing|abend|wflex|wtwist)_[lr]$/; // one side's arm: trails as one (overlap.js sideTrail)
|
|
47
47
|
const OVER_FRAC = 0.15, OVER_MAX = 6; // a smooth segment's overshoot: at most this share of its change, and degrees
|
|
48
48
|
const ikSides = ch => (/^(spine|chest|head)_/.test(ch) ? ["l", "r"] : /^(aswing|abend|wflex|wtwist)_([lr])$/.test(ch) ? [ch.slice(-1)] : []);
|
|
49
49
|
const lagFamily = ch => (/^(spine|chest)_/.test(ch) ? "spine" : /^head_/.test(ch) ? "head" : /^(aswing|abend)_[lr]$/.test(ch) ? "arms" : null);
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
|
-
* spec.flow -> { smooth, lag: { spine, head, arms }, overlap, seed, hands }: undefined is the default, false turns all off.
|
|
53
|
-
* overlap: true (1) | false (0) | a scale of its delays and settle (overlap.js);
|
|
52
|
+
* spec.flow -> { smooth, lag: { spine, head, arms }, overlap, headLead, seed, hands }: undefined is the default, false turns all off.
|
|
53
|
+
* overlap: true (1) | false (0) | a scale of its delays and settle (overlap.js); headLead: the frames the head leads the trunk's
|
|
54
|
+
* turn by, true (HEAD_LEAD) | false (0) | frames (head-lead.js); seed (the spec's label) picks which side trails;
|
|
54
55
|
* hands: the frames each hand works on something (overlap.js handBusy), where a free IK hand's trail stops.
|
|
55
56
|
*/
|
|
56
57
|
export function flowOptions(flow, seed = "", hands = { l: [], r: [] }) {
|
|
57
|
-
if (flow === false) return { smooth: false, lag: { spine: 0, head: 0, arms: 0 }, overlap: 0, seed, hands };
|
|
58
|
+
if (flow === false) return { smooth: false, lag: { spine: 0, head: 0, arms: 0 }, overlap: 0, headLead: 0, seed, hands };
|
|
58
59
|
const f = flow ?? {}, lag = f.lag === false ? 0 : f.lag;
|
|
59
60
|
const each = typeof lag === "number" ? { spine: lag, head: lag, arms: lag } : { ...FLOW_LAG, ...(lag ?? {}) };
|
|
60
61
|
const overlap = f.overlap === false ? 0 : typeof f.overlap === "number" ? f.overlap : 1;
|
|
61
|
-
|
|
62
|
+
const headLead = f.headLead === false ? 0 : typeof f.headLead === "number" ? Math.round(f.headLead) : HEAD_LEAD;
|
|
63
|
+
return { smooth: f.smooth !== false, lag: each, overlap, headLead, seed, hands };
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
/** Catmull-Rom slopes, flat at a hold and at the ends, each segment's overshoot bounded. */
|
|
@@ -31,12 +31,13 @@ import { idleLayer, withIdle, busyHands } from "./idle.js";
|
|
|
31
31
|
import { resolveAnchor, measureProps, armReach } from "./anchors.js";
|
|
32
32
|
import { expandGenerators } from "./walk.js";
|
|
33
33
|
import { steadyHead } from "./head-steady.js";
|
|
34
|
+
import { leadHead } from "./head-lead.js";
|
|
34
35
|
import { fixOptions, applyFixes } from "../fix/index.js";
|
|
35
36
|
import { fitWeight } from "../fix/self.js";
|
|
36
37
|
import { holdHook } from "../object-keys.js";
|
|
37
38
|
import { HAND_CLEAR, keepOut, wristBody } from "../body.js";
|
|
38
39
|
import { objectPoseAt, handOf, restingAt, frameAt, spanOf } from "../object-pose.js";
|
|
39
|
-
import { heldFor } from "./finger-close.js";
|
|
40
|
+
import { heldFor, carryFingers } from "./finger-close.js";
|
|
40
41
|
import { sagOf } from "../load.js";
|
|
41
42
|
|
|
42
43
|
const smooth = x => x * x * (3 - 2 * x);
|
|
@@ -143,6 +144,7 @@ function hooksFor(ctx, c, f, inFit = false, lead = true, fitSide = null) {
|
|
|
143
144
|
sag: inFit ? undefined : (k, s, target) => sagOf(ctx, k, f, s, target, (1 - fitWeight(ctx.spec, c, k)) * Math.min(1, Math.max(0, c[`armw_${k}`] ?? 0))),
|
|
144
145
|
// fingers close on what the hand holds, on the final pose only (finger-close.js)
|
|
145
146
|
heldShapes: inFit || !ctx.grasp ? undefined : (k, s) => heldFor(ctx, c, f, k, s),
|
|
147
|
+
fingerWeights: inFit || !ctx.grasp ? undefined : k => carryFingers(ctx, c, f, k),
|
|
146
148
|
};
|
|
147
149
|
}
|
|
148
150
|
|
|
@@ -209,10 +211,12 @@ export function compileMotion(spec, { sk, scene }, opts = {}) {
|
|
|
209
211
|
const engine = {
|
|
210
212
|
rebake(keys) {
|
|
211
213
|
out.keys = keys;
|
|
212
|
-
|
|
214
|
+
const flow = flowOptions(spec.flow, spec.label ?? "", handBusy(spec));
|
|
215
|
+
out.baked = bakeChannels(keys, frameCount, flow);
|
|
213
216
|
ctx.socketTurns = new Map();
|
|
214
217
|
ctx.graspPlans = new Map();
|
|
215
218
|
steadyHead(out.baked, keys, frameCount);
|
|
219
|
+
leadHead(out.baked, keys, frameCount, flow.headLead);
|
|
216
220
|
// a held pose breathes and shifts its weight (idle.js): offsets solveFrame adds, contacts and out.baked stay as keyed
|
|
217
221
|
ctx.idle = idleLayer(out.baked, { spec, sk, frameCount });
|
|
218
222
|
ctx.busy = busyHands(out.baked, spec, frameCount, out.fps);
|
|
@@ -8,14 +8,15 @@
|
|
|
8
8
|
// then turned once; weighted by how far the hand holds (a fit's weight), so the fingers close over the reach and open
|
|
9
9
|
// on the release. A joint the preset curled into the body straightens when that leaves the finger shallower; a
|
|
10
10
|
// joint whose sub-chain already touches stays, and the next joint out closes on: the finger wraps the body.
|
|
11
|
-
import { add, sub, scale, norm, cross, apply, axisRot } from "./math.js";
|
|
11
|
+
import { add, sub, scale, norm, cross, dot, apply, axisRot } from "./math.js";
|
|
12
12
|
import { rotateSubtree } from "./skeleton.js";
|
|
13
13
|
import { palmAxes, fingerCurl, fistCurl } from "./limbs.js";
|
|
14
|
-
import { capsuleDepth } from "../shapes.js";
|
|
14
|
+
import { capsuleDepth, shapeOf } from "../shapes.js";
|
|
15
|
+
import { GRASP_FINGERS } from "./vocab.js";
|
|
15
16
|
import { partsOf } from "../object-pose.js";
|
|
16
17
|
import { seatsInHand } from "../seat.js";
|
|
17
18
|
|
|
18
|
-
const PHALANX = 0.009, TOUCH = 0.002, STEPS = 8, THUMB_FLEX = 70;
|
|
19
|
+
const PHALANX = 0.009, TOUCH = 0.002, STEPS = 8, THUMB_FLEX = 70, SPREAD_STEP = 5, SPREAD_MAX = 15, SELF_GAP = 0.014;
|
|
19
20
|
const FINGERS = ["Index", "Middle", "Ring", "Pinky"];
|
|
20
21
|
// the hand stays open for most of a reach and closes as it arrives: from OPEN_UNTIL of the fit's weight to CLOSED_AT
|
|
21
22
|
const OPEN_UNTIL = 0.6, CLOSED_AT = 0.9;
|
|
@@ -49,6 +50,34 @@ function clearTurn(points, pivot, axis, limit, entries) {
|
|
|
49
50
|
return hi;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
const saveChain = (sk, s, j) => new Map(sk.subtree[j].map(i => [i, [s.P[i].slice(), s.R[i].map(r => r.slice())]]));
|
|
54
|
+
const restoreChain = (s, saved) => { for (const [i, [p, r]] of saved) { s.P[i] = p.slice(); s.R[i] = r.map(row => row.slice()); } };
|
|
55
|
+
|
|
56
|
+
/** Degrees each of joints 1..3 of finger f is flexed: each phalanx against the one before about the knuckle line. */
|
|
57
|
+
function flexion(sk, s, side, f) {
|
|
58
|
+
const P = i => s.P[sk.index[`${side}Hand${f}${i}`]], { d, l } = palmAxes(sk, s, side), axis = scale(l, side === "Left" ? -1 : 1);
|
|
59
|
+
const flat = v => norm(sub(v, scale(axis, dot(v, axis)))), bone = [1, 2, 3].map(i => flat(norm(sub(P(i + 1), P(i)))));
|
|
60
|
+
return [flat(d), bone[0], bone[1]].map((a, i) => (Math.atan2(dot(cross(a, bone[i]), axis), dot(a, bone[i])) * 180) / Math.PI);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Closest distance between segments p1-q1 and p2-q2. */
|
|
64
|
+
function segmentGap(p1, q1, p2, q2) {
|
|
65
|
+
const d1 = sub(q1, p1), d2 = sub(q2, p2), r = sub(p1, p2), a = dot(d1, d1), e = dot(d2, d2), f = dot(d2, r), c = dot(d1, r), b = dot(d1, d2);
|
|
66
|
+
const den = a * e - b * b, clamp = x => Math.min(1, Math.max(0, x));
|
|
67
|
+
let t1 = den > 1e-12 ? clamp((b * f - c * e) / den) : 0, t2 = e > 1e-12 ? (b * t1 + f) / e : 0;
|
|
68
|
+
if (t2 < 0) { t2 = 0; t1 = clamp(-c / a); } else if (t2 > 1) { t2 = 1; t1 = clamp((b - c) / a); }
|
|
69
|
+
return Math.hypot(...sub(add(p1, scale(d1, t1)), add(p2, scale(d2, t2))));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** True when no phalanx of finger f lies within SELF_GAP of a phalanx of another (non-thumb) finger. */
|
|
73
|
+
function clearOfNeighbours(sk, s, side, f) {
|
|
74
|
+
const P = name => s.P[sk.index[`${side}Hand${name}`]];
|
|
75
|
+
for (const g of FINGERS) if (g !== f) for (let a = 1; a <= 3; a++) for (let b = 1; b <= 3; b++) {
|
|
76
|
+
if (segmentGap(P(`${f}${a}`), P(`${f}${a + 1}`), P(`${g}${b}`), P(`${g}${b + 1}`)) < SELF_GAP) return false;
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
52
81
|
/** Turns joint j by the touching turn about `axis` (or `limit` when it never touches, unless `air` is false), weighted; true when it touched. */
|
|
53
82
|
function closeJoint(sk, s, j, chain, axis, limit, entries, weight, air = true) {
|
|
54
83
|
const turn = touchTurn(chain.map(i => s.P[i]), s.P[j], axis, limit, entries), deg = turn ?? (air ? limit : 0);
|
|
@@ -66,9 +95,7 @@ export function closeOnShape(sk, s, side, { entries: held, stops = [], weight, p
|
|
|
66
95
|
// a finger stops on what it holds or on what that rests against (a table under a ball)
|
|
67
96
|
const entries = [...held, ...stops];
|
|
68
97
|
const sign = side === "Left" ? -1 : 1, J = name => sk.index[`${side}Hand${name}`], touched = [];
|
|
69
|
-
|
|
70
|
-
const joints = [1, 2, 3, 4].map(i => J(`${f}${i}`));
|
|
71
|
-
if (joints.some(j => j === undefined)) continue;
|
|
98
|
+
const close = (f, joints) => {
|
|
72
99
|
let tip = false;
|
|
73
100
|
// a finger the preset curled into the body straightens, joint by joint, when the straight finger is shallower (a
|
|
74
101
|
// finger lying across a box would go deeper); each joint then closes until the finger touches
|
|
@@ -80,6 +107,35 @@ export function closeOnShape(sk, s, side, { entries: held, stops = [], weight, p
|
|
|
80
107
|
if (open > 0) rotateSubtree(sk, s, joints[k], s.P[joints[k]].slice(), axisRot(axis, -open * weight));
|
|
81
108
|
tip = closeJoint(sk, s, joints[k], joints.slice(k), axis, Math.max(0, fistCurl(f, k) - curl + open), entries, weight, false);
|
|
82
109
|
}
|
|
110
|
+
return tip;
|
|
111
|
+
};
|
|
112
|
+
for (const f of FINGERS) {
|
|
113
|
+
const joints = [1, 2, 3, 4].map(i => J(`${f}${i}`));
|
|
114
|
+
if (joints.some(j => j === undefined)) continue;
|
|
115
|
+
const before = saveChain(sk, s, joints[0]);
|
|
116
|
+
let tip = close(f, joints);
|
|
117
|
+
// a finger whose curl passes beside what it holds (a ring or little finger off a ball's side) turns toward it at the
|
|
118
|
+
// knuckle, SPREAD_STEP at a time up to SPREAD_MAX, and closes again; kept when it touches without lying in a neighbour
|
|
119
|
+
if (!tip && f !== "Middle") {
|
|
120
|
+
const after = saveChain(sk, s, joints[0]), { l, n } = palmAxes(sk, s, side);
|
|
121
|
+
const toward = Math.sign(dot(sub(held[0].pos, before.get(joints[0])[0]), l)) * sign;
|
|
122
|
+
for (let deg = SPREAD_STEP; deg <= SPREAD_MAX && !tip; deg += SPREAD_STEP) {
|
|
123
|
+
restoreChain(s, before);
|
|
124
|
+
rotateSubtree(sk, s, joints[0], s.P[joints[0]].slice(), axisRot(n, toward * sign * deg * weight));
|
|
125
|
+
tip = close(f, joints) && clearOfNeighbours(sk, s, side, f);
|
|
126
|
+
}
|
|
127
|
+
if (!tip) restoreChain(s, after);
|
|
128
|
+
}
|
|
129
|
+
// a finger that still cannot reach it curls with the finger next in toward the middle (the ring and little fingers
|
|
130
|
+
// move together), joint by joint as far as that finger's flexion, stopping where it touches
|
|
131
|
+
const inner = { Ring: "Middle", Pinky: "Ring" }[f];
|
|
132
|
+
if (!tip && inner) {
|
|
133
|
+
const axis = scale(palmAxes(sk, s, side).l, sign);
|
|
134
|
+
for (let k = 0; k < 3; k++) {
|
|
135
|
+
const more = flexion(sk, s, side, inner)[k] - flexion(sk, s, side, f)[k];
|
|
136
|
+
if (more > 1) tip = closeJoint(sk, s, joints[k], joints.slice(k), axis, more, entries, weight, true);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
83
139
|
if (tip) touched.push(f);
|
|
84
140
|
}
|
|
85
141
|
const thumb = [1, 2, 3, 4].map(i => J(`Thumb${i}`));
|
|
@@ -119,6 +175,27 @@ export function heldFor(ctx, c, f, k, s) {
|
|
|
119
175
|
return best ?? carriedFor(ctx, c, f, k, s);
|
|
120
176
|
}
|
|
121
177
|
|
|
178
|
+
// A carry outlives the reach that took it: reachGrip lets its grip go after `hold` while the object stays in the hand, and
|
|
179
|
+
// the thumb, wrapped by the grip preset (limbs.js THUMB_WRAP), swung back out 2.3 cm off the ball it held. While hand k
|
|
180
|
+
// holds a seated carry, its grasp's preset is on at full weight, eased in over SETTLE frames from the grab and out over
|
|
181
|
+
// SETTLE before a release (a carry that runs to the end of the take stays held).
|
|
182
|
+
const SETTLE = 4;
|
|
183
|
+
const graspOf = (o, scene, id) => o.grasp ?? ({ sphere: "spherical", cylinder: "power" }[partsOf(scene, id).length === 1 ? shapeOf(scene.objects[partsOf(scene, id)[0]]).kind : "box"] ?? "palm");
|
|
184
|
+
|
|
185
|
+
/** The finger preset weights of hand k at frame f with channels c: the channel's, with a seated carry's grasp preset held on. */
|
|
186
|
+
export function carryFingers(ctx, c, f, k) {
|
|
187
|
+
const { spec, scene } = ctx, own = c[`fingers_${k}`];
|
|
188
|
+
for (const [id, o] of Object.entries(spec.objects ?? {})) {
|
|
189
|
+
if (!seatsInHand(spec, o, scene, id) || spec.props?.[o.prop]?.hand !== k) continue;
|
|
190
|
+
const [grab, release] = ctx.carrySpan(o), on = Math.min(1, (f - grab) / SETTLE, o.release === undefined ? 1 : (release - f) / SETTLE);
|
|
191
|
+
if (!(on > 0)) continue;
|
|
192
|
+
const preset = GRASP_FINGERS[graspOf(o, scene, id)];
|
|
193
|
+
if (preset === "relaxed") return own;
|
|
194
|
+
return { ...own, [preset]: Math.max(own?.[preset] ?? 0, on) };
|
|
195
|
+
}
|
|
196
|
+
return own;
|
|
197
|
+
}
|
|
198
|
+
|
|
122
199
|
/** A carry on the grasp path hand k holds at frame f, at its seat on pose s (seat.js), as { entries, weight, preset }. */
|
|
123
200
|
function carriedFor(ctx, c, f, k, s) {
|
|
124
201
|
const { spec, scene } = ctx;
|
|
@@ -131,7 +208,7 @@ function carriedFor(ctx, c, f, k, s) {
|
|
|
131
208
|
// where it is on this pose (object-pose.js: easing into its seat over the settle, as it is drawn), not the full seat
|
|
132
209
|
const entries = partsOf(scene, id).map(p => ({ ...scene.objects[p], ...ctx.objectPose(p, f, { s }) }));
|
|
133
210
|
const near = Object.entries(ctx.objectsAt(f)).filter(([key, e]) => !partsOf(scene, id).includes(key) && e.half && !e.carried && entries.some(h => Math.hypot(...h.pos.map((v, i) => v - e.pos[i])) < 0.25 + Math.hypot(...e.half))).map(([, e]) => e);
|
|
134
|
-
if (entries.length) return { entries, stops: near, weight, preset: c
|
|
211
|
+
if (entries.length) return { entries, stops: near, weight, preset: carryFingers(ctx, c, f, k) };
|
|
135
212
|
}
|
|
136
213
|
return null;
|
|
137
214
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// When the trunk turns the head turns first: a person's head leads the pelvis into a turn by about 200 ms (about 5
|
|
2
|
+
// frames at 24 fps), walking or standing. The head takes the turn the trunk (the root's and the spine's yaw) makes
|
|
3
|
+
// over the next LEAD frames, no more than MAX degrees (half the neck's turn one way); inside a walk its heading, so the
|
|
4
|
+
// head leads the walk's turns, not each step's sway (head-steady.js steadies that). Inside every walk (start to last
|
|
5
|
+
// step, head-steady.js walkSpans) and around every turn of the trunk faster than TURN deg/frame (from LEAD + FADE
|
|
6
|
+
// frames before it, so the head starts first), faded in and out over FADE frames, and never where the spec's own keys move
|
|
7
|
+
// head_yaw during that span (keys on it, or on either side of it, that differ): a keyed look wins; a head held still
|
|
8
|
+
// (a stance's head_yaw 0) leads.
|
|
9
|
+
import { walkSpans } from "./head-steady.js";
|
|
10
|
+
|
|
11
|
+
const MAX = 30, TURN = 0.3, FADE = 8, STRIDE = 33;
|
|
12
|
+
|
|
13
|
+
/** Inside a walk the trunk's heading, its sway each step taken out: the median over about a stride keeps a turn's timing. */
|
|
14
|
+
function heading(trunk, walks) {
|
|
15
|
+
const out = trunk.slice(), half = Math.floor(STRIDE / 2), n = trunk.length;
|
|
16
|
+
for (const [f0, f1] of walks) for (let f = Math.max(0, f0); f <= Math.min(n - 1, f1); f++) {
|
|
17
|
+
const w = trunk.slice(Math.max(0, f - half), Math.min(n, f + half + 1)).sort((a, b) => a - b);
|
|
18
|
+
out[f] = w.length % 2 ? w[(w.length - 1) / 2] : (w[w.length / 2 - 1] + w[w.length / 2]) / 2;
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Frame runs [f0, f1] (inclusive) where the trunk turns faster than TURN, widened by `before` and `after` frames. */
|
|
24
|
+
function turnSpans(trunk, frameCount, before, after) {
|
|
25
|
+
const spans = [];
|
|
26
|
+
for (let f = 0; f < frameCount - 1; f++) {
|
|
27
|
+
if (Math.abs(trunk[f + 1] - trunk[f]) <= TURN) continue;
|
|
28
|
+
const a = Math.max(0, f - before), b = Math.min(frameCount - 1, f + 1 + after), last = spans.at(-1);
|
|
29
|
+
if (last && a <= last[1] + 1) last[1] = Math.max(last[1], b);
|
|
30
|
+
else spans.push([a, b]);
|
|
31
|
+
}
|
|
32
|
+
return spans;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Overlapping or touching spans joined. */
|
|
36
|
+
function merged(spans) {
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const [a, b] of [...spans].sort((x, y) => x[0] - y[0])) {
|
|
39
|
+
const last = out.at(-1);
|
|
40
|
+
if (last && a <= last[1] + 1) last[1] = Math.max(last[1], b);
|
|
41
|
+
else out.push([a, b]);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The keys outside walks that move head_yaw during [f0, f1]: those in it and the nearest on either side differ. */
|
|
47
|
+
function looked(keys, f0, f1) {
|
|
48
|
+
const looks = keys.filter(k => k.set?.head_yaw !== undefined && !/\bwalk(To)? (start|step \d+|end)$/.test(k.src ?? "")).sort((a, b) => a.f - b.f);
|
|
49
|
+
const near = [looks.filter(k => k.f < f0).at(-1), ...looks.filter(k => k.f >= f0 && k.f <= f1), looks.find(k => k.f > f1)].filter(Boolean);
|
|
50
|
+
return near.some(k => JSON.stringify(k.set.head_yaw) !== JSON.stringify(near[0].set.head_yaw));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Adds the turn the trunk makes over the next `lead` frames to the baked head yaw (in place); lead 0 is off. */
|
|
54
|
+
export function leadHead(baked, keys, frameCount, lead = 5) {
|
|
55
|
+
if (!(lead > 0)) return;
|
|
56
|
+
const zero = new Array(frameCount).fill(0), yaw = baked.yaw ?? zero, spine = baked.spine_yaw ?? zero;
|
|
57
|
+
// a retime keeps its keys on fractional frames (fix/retime.js): the spans are frame indices
|
|
58
|
+
const walks = walkSpans(keys).map(([a, b]) => [Math.ceil(a), Math.floor(b)]), trunk = heading(yaw.map((v, f) => v + spine[f]), walks), at = f => trunk[Math.min(frameCount - 1, f)];
|
|
59
|
+
const spans = merged([...walks, ...turnSpans(trunk, frameCount, lead + FADE, FADE)]);
|
|
60
|
+
let head = null;
|
|
61
|
+
for (const [f0, f1] of spans) {
|
|
62
|
+
if (looked(keys, f0, f1)) continue;
|
|
63
|
+
head ??= (baked.head_yaw ?? zero).slice();
|
|
64
|
+
for (let f = Math.max(0, f0); f <= Math.min(frameCount - 1, f1); f++) {
|
|
65
|
+
const fade = Math.min(1, (f - f0) / FADE, (f1 - f) / FADE), d = Math.max(-MAX, Math.min(MAX, at(f + lead) - trunk[f]));
|
|
66
|
+
head[f] += d * fade * fade * (3 - 2 * fade);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (head) baked.head_yaw = head;
|
|
70
|
+
}
|
|
@@ -9,7 +9,7 @@ const STEADY = { yaw: 0.5, pitch: 0.2, roll: 0.7 }, LIMIT = { yaw: 4, pitch: 6,
|
|
|
9
9
|
const AXES = ["yaw", "pitch", "roll"];
|
|
10
10
|
|
|
11
11
|
/** The walk spans in the keys, start to last step: [[f0, f1], ...]. */
|
|
12
|
-
function walkSpans(keys) {
|
|
12
|
+
export function walkSpans(keys) {
|
|
13
13
|
const spans = new Map();
|
|
14
14
|
for (const k of keys) {
|
|
15
15
|
const m = / (start|step \d+)$/.exec(k.src ?? ""), tag = m && k.src.slice(0, -m[0].length);
|