cozyclay 3.0.3 → 3.1.0
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 +67 -0
- package/INSTALL_FOR_AGENTS.md +146 -0
- package/README.md +74 -192
- package/THIRD_PARTY_NOTICES.md +0 -21
- package/bin/agent/agent-routes.mjs +22 -56
- package/bin/agent/agent-runner.mjs +5 -3
- package/bin/agent/main-agent.mjs +70 -0
- package/bin/agent/main-prompt.mjs +17 -0
- package/bin/agent/session-store.mjs +2 -2
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-task-routes.mjs +185 -0
- package/bin/agent/studio-tasks.mjs +156 -0
- package/bin/agent/studio-tools.mjs +3 -2
- package/bin/agent/studio-turn-transaction.mjs +76 -0
- package/bin/cozyclay.mjs +5 -7
- package/dist/app/index.html +7 -6
- package/dist/assets/TaskBoard-DdDXMgZm.js +1 -0
- package/dist/assets/app-B2BpfCl2.js +5088 -0
- package/dist/assets/app-BlwOpest.css +1 -0
- package/dist/assets/compile-worker-D0pKioa0.js +5 -0
- package/dist/assets/landing-y3EqDgSZ.js +1 -0
- package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
- package/dist/assets/motion3-compile-B887n77J.js +1 -0
- package/dist/assets/scene-asset-sync-COGOlF5T.css +1 -0
- package/dist/assets/scene-asset-sync-CQzEdsqu.js +13 -0
- package/dist/assets/task-board-BKaM7d-9.js +14 -0
- package/dist/assets/workflow-B8Dhzexj.js +179 -0
- package/dist/assets/x-bot-tpose-body-BusyR3eg.json +1 -0
- package/dist/assets/y-bot-tpose-body-Dy1VKChZ.json +1 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/greybox-to-video/index.html +2 -3
- package/dist/index.html +7 -57
- package/dist/llms.txt +2 -1
- package/dist/previs-software/index.html +2 -2
- package/dist/privacy/index.html +3 -7
- package/dist/seedance-camera-control/index.html +2 -2
- package/dist/sitemap.xml +5 -5
- package/dist/sw.js +5 -1
- package/dist/thumbs/blank-stage-dark.webp +0 -0
- package/dist/thumbs/blank-stage-light.webp +0 -0
- package/dist/workflow/index.html +5 -4
- package/mcp/mesh-file.mjs +3 -1
- package/mcp/server.mjs +3 -0
- package/package.json +3 -3
- package/src/App.jsx +50 -281
- package/src/analytics.js +3 -148
- package/src/app-stage.jsx +1 -1
- package/src/asset-pane.jsx +5 -10
- package/src/auto-flat.js +10 -0
- package/src/bus-tasks.js +11 -5
- package/src/command-bus.js +1 -1
- package/src/commands/motion3-compile.js +51 -27
- package/src/commands/motion3.js +3 -3
- package/src/commands/project.js +1 -1
- package/src/commands/shot.js +16 -4
- package/src/controls.jsx +0 -11
- package/src/cuts.js +11 -0
- package/src/domains/cast.js +3 -2
- package/src/domains/motion.js +3 -10
- package/src/domains/playback.js +32 -0
- package/src/domains/scenes.js +3 -22
- package/src/domains/shots.js +5 -5
- package/src/dualview.jsx +11 -15
- package/src/hierarchy-panel.css +18 -4
- package/src/hierarchy-panel.jsx +12 -11
- package/src/kits/held.js +3 -2
- package/src/light-blockers.js +2 -2
- package/src/main.jsx +5 -0
- package/src/motion/ground.js +5 -14
- package/src/motion/playback-frame-store.js +79 -0
- package/src/motion/playback-probe.js +16 -0
- package/src/motion/playback-subject.js +7 -0
- package/src/motion/timeline.css +43 -5
- package/src/motion/timeline.jsx +4 -3
- package/src/motion3/body.js +37 -5
- package/src/motion3/carried.js +11 -3
- package/src/motion3/compile-job.js +1 -1
- package/src/motion3/compiler/compile.js +7 -1
- package/src/motion3/compiler/finger-close.js +137 -0
- package/src/motion3/compiler/limbs.js +18 -3
- package/src/motion3/compiler/pose.js +6 -0
- package/src/motion3/compiler/validate-objects.js +17 -2
- package/src/motion3/compiler/validate-parts.js +2 -1
- package/src/motion3/compiler/vocab.js +4 -4
- package/src/motion3/describe.js +2 -2
- package/src/motion3/fix/index.js +5 -3
- package/src/motion3/flight.js +38 -0
- package/src/motion3/free-motion.js +10 -7
- package/src/motion3/load.js +125 -0
- package/src/motion3/mass.js +23 -0
- package/src/motion3/object-keys.js +11 -6
- package/src/motion3/object-pose.js +11 -3
- package/src/motion3/pipeline.js +3 -0
- package/src/motion3/qa/explain.js +3 -1
- package/src/motion3/qa/grasp-checks.js +123 -0
- package/src/motion3/qa/hints-grasp.js +71 -0
- package/src/motion3/qa/index.js +3 -1
- package/src/motion3/qa/object-checks.js +7 -2
- package/src/motion3/qa/patch.js +2 -1
- package/src/motion3/qa/spec-checks.js +10 -1
- package/src/motion3/schema/fields.js +3 -3
- package/src/motion3/schema/topics.js +9 -8
- package/src/motion3/schema/types.js +4 -1
- package/src/motion3/seat.js +84 -0
- package/src/motion3/settle.js +11 -6
- package/src/motion3/shapes.js +70 -0
- package/src/motion3/skills/catch.js +71 -0
- package/src/motion3/skills/index.js +10 -3
- package/src/motion3/skills/info.js +13 -0
- package/src/motion3/skills/throw.js +72 -0
- package/src/object-catalog.jsx +0 -4
- package/src/object-gizmo.jsx +2 -1
- package/src/object-path.js +1 -1
- package/src/panels/ProjectPanel.jsx +42 -21
- package/src/panels/details.css +15 -4
- package/src/popover.js +37 -0
- package/src/posestudio.jsx +15 -10
- package/src/project-browser-art.jsx +19 -0
- package/src/project-browser.css +74 -121
- package/src/project-browser.jsx +36 -60
- package/src/props.jsx +4 -7
- package/src/retired-kinds.js +37 -0
- package/src/scene-objects.js +3 -7
- package/src/scenes.js +4 -5
- package/src/settings-menu.jsx +1 -20
- package/src/shell/BottomDock.jsx +9 -9
- package/src/shell/MenuBar.jsx +1 -9
- package/src/shell/PreferencesDialog.jsx +3 -4
- package/src/shell/ViewportToolbar.jsx +3 -3
- package/src/shell/add-camera-shot.js +11 -0
- package/src/shell/glass-regions.css +3 -2
- package/src/shell/glass.css +0 -5
- package/src/shell/lens-scrub-math.js +51 -0
- package/src/shell/lens-scrub.css +73 -0
- package/src/shell/lens-scrub.jsx +98 -0
- package/src/shell/mode.css +6 -5
- package/src/shell/preferences.css +1 -32
- package/src/shell/shell.css +18 -1
- package/src/shell/studio-shell-context.js +5 -0
- package/src/shot-frame.js +26 -0
- package/src/stage-canvas-rigs.jsx +7 -8
- package/src/stage-characters.jsx +26 -0
- package/src/stage-gizmo.js +43 -0
- package/src/stage-labels-ko.js +0 -6
- package/src/stage-startup-defaults.js +3 -9
- package/src/starter-scenes.js +5 -11
- package/src/studio-agent-commands.js +2 -2
- package/src/studio-agent-protocol.js +2 -7
- package/src/studio-app-binding.js +1 -1
- package/src/studio-elements.js +1 -1
- package/src/styles/themes.css +13 -0
- package/src/styles.css +6 -674
- package/src/text/text-object.jsx +1 -1
- package/src/ui.jsx +8 -16
- package/src/use-embed-bridge.js +3 -6
- package/src/use-render-activity.js +3 -1
- package/src/view-ghosts.jsx +5 -3
- package/src/viewport-visible-rect.js +37 -0
- package/src/workflow/AgentPanel.jsx +75 -42
- package/src/workflow/AgentProgressCard.jsx +1 -1
- package/src/workflow/TaskBoard.jsx +102 -0
- package/src/workflow/TaskInline.jsx +114 -0
- package/src/workflow/agent-client.js +11 -25
- package/src/workflow/agent-panel.css +14 -1
- package/src/workflow/attachment-image.js +10 -3
- package/src/workflow/attachment-video.js +91 -0
- package/src/workflow/task-board.css +98 -0
- package/src/workflow/task-board.js +169 -0
- package/tools/check-eslint.mjs +2 -1
- package/tools/check-knip.mjs +2 -1
- package/tools/dev/pages/greybox-to-video.html +2 -3
- package/tools/dev/pages/previs-software.html +2 -2
- package/tools/dev/pages/privacy.html +3 -7
- package/tools/dev/pages/seedance-camera-control.html +2 -2
- package/tools/dev-full.mjs +11 -2
- package/tools/eslint-baseline.json +6 -6
- package/tools/knip-baseline.json +0 -11
- package/tools/loc-baseline.json +30 -31
- package/tools/metrics-snapshot.mjs +43 -5
- package/tools/render-start-thumbs.mjs +175 -0
- package/tools/run-tests.mjs +7 -14
- package/tools/sitemap.mjs +1 -1
- package/dist/assets/analytics-BibyE5ar.js +0 -6
- package/dist/assets/app-DhRJ-PBz.js +0 -5088
- package/dist/assets/app-f-fkV5uP.css +0 -1
- package/dist/assets/compile-worker-DqiA4AaN.js +0 -5
- package/dist/assets/first-shot-handoff-Qx7SKl38.js +0 -1
- package/dist/assets/landing-DYg87MDY.js +0 -1
- package/dist/assets/scene-asset-sync-DBYQBR0T.js +0 -20
- package/dist/assets/scene-asset-sync-_S8NQl5B.css +0 -1
- package/dist/assets/workflow-BSA22Ofr.js +0 -179
- package/dist/scenes/city-block.cclayproject +0 -1
- package/src/burn-in.js +0 -91
- package/src/camera-tutorial.jsx +0 -520
- package/src/facing-marks.js +0 -88
- package/src/first-shot-handoff.js +0 -44
- package/src/panels/motion.css +0 -197
- package/src/set-pieces.jsx +0 -183
- package/src/tutorial-analytics.js +0 -86
- package/src/tutorial-walk.js +0 -17
- package/src/use-camera-tutorial.js +0 -50
- package/src/use-semantic-state.js +0 -12
- package/tools/metrics/archive-traffic.mjs +0 -138
- package/tools/perf/ik-camera-drag.mjs +0 -138
package/src/domains/motion.js
CHANGED
|
@@ -29,8 +29,8 @@ import {
|
|
|
29
29
|
} from "../motion/ik.js";
|
|
30
30
|
import * as THREE from "three";
|
|
31
31
|
import { StudioProtocolError } from "../studio-agent-protocol.js";
|
|
32
|
-
|
|
33
32
|
import { applyTakeFrame } from "../motion3/playback.js";
|
|
33
|
+
import { advancePlayback, installPlaybackApplier } from "./playback.js";
|
|
34
34
|
import { isKo, ko } from "../locale.js";
|
|
35
35
|
import { isProxyFigure } from "../scenes.js";
|
|
36
36
|
import { refuseRigOnly } from "../character-kind.js";
|
|
@@ -625,15 +625,7 @@ export function useMotion(appContext) {
|
|
|
625
625
|
|
|
626
626
|
|
|
627
627
|
function advanceFrame(steps = 1) {
|
|
628
|
-
|
|
629
|
-
const previewEnd = appContext.shared.cameraPreviewEndRef.current;
|
|
630
|
-
if (previewEnd != null && appContext.shared.tlFrameRef.current + count >= previewEnd) {
|
|
631
|
-
appContext.shared.cameraPreviewEndRef.current = null;
|
|
632
|
-
appContext.shared.setTlFrame(previewEnd);
|
|
633
|
-
appContext.shared.setTlPlaying(false);
|
|
634
|
-
return;
|
|
635
|
-
}
|
|
636
|
-
appContext.shared.setTlFrame((f) => (f + count) % appContext.shared.frameCountRef.current);
|
|
628
|
+
advancePlayback(appContext.shared, steps);
|
|
637
629
|
}
|
|
638
630
|
|
|
639
631
|
function stepFrame(delta) {
|
|
@@ -677,6 +669,7 @@ export function useMotion(appContext) {
|
|
|
677
669
|
appContext.shared.poseMemberAtFrame(appContext.shared.rigs[entry.id], entry.sessionMotion, appContext.shared.ikStatesRef.current.get(entry.id), frame, IK_CORRECTION_BLEND_FRAMES);
|
|
678
670
|
}
|
|
679
671
|
};
|
|
672
|
+
installPlaybackApplier(appContext, motion, poseOtherCastMembers, ikEvaluate, IK_CORRECTION_BLEND_FRAMES);
|
|
680
673
|
|
|
681
674
|
function toggleIkMode() {
|
|
682
675
|
const next = !ikMode;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { advancePlaybackFrame, registerPlaybackFrameApplier } from "../motion/playback-frame-store.js";
|
|
2
|
+
|
|
3
|
+
function createPlaybackApplier(appContext, motion, poseOtherCastMembers, ikEvaluate, blendFrames) {
|
|
4
|
+
return (frame) => {
|
|
5
|
+
appContext.shared.tlFrameRef.current = frame;
|
|
6
|
+
appContext.shared.propFrameRef.current = frame;
|
|
7
|
+
appContext.patchTimeline({ currentFrame: frame });
|
|
8
|
+
if (appContext.shared.activeRig) appContext.shared.poseMemberAtFrame(appContext.shared.activeRig, motion, null, frame);
|
|
9
|
+
poseOtherCastMembers(frame);
|
|
10
|
+
const state = appContext.shared.ikStateRef.current;
|
|
11
|
+
if (!appContext.shared.posing && state?.chains && (appContext.shared.ikMode || state.keys.size > 0)) {
|
|
12
|
+
ikEvaluate(state.chains, state, frame, state.fkJoints, motion ? blendFrames : 0);
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function installPlaybackApplier(...args) {
|
|
18
|
+
return registerPlaybackFrameApplier(createPlaybackApplier(...args));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A camera-shot preview stops at its shot's end; otherwise the playhead wraps.
|
|
22
|
+
export function advancePlayback(shared, steps) {
|
|
23
|
+
const count = Math.max(1, Math.floor(steps));
|
|
24
|
+
const previewEnd = shared.cameraPreviewEndRef.current;
|
|
25
|
+
if (previewEnd != null && shared.tlFrameRef.current + count >= previewEnd) {
|
|
26
|
+
shared.cameraPreviewEndRef.current = null;
|
|
27
|
+
shared.setTlFrame(previewEnd);
|
|
28
|
+
shared.setTlPlaying(false);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
advancePlaybackFrame(shared.frameCountRef.current, count);
|
|
32
|
+
}
|
package/src/domains/scenes.js
CHANGED
|
@@ -208,9 +208,8 @@ export function useScenes(appContext) {
|
|
|
208
208
|
// A first-run author should choose a document (or explicitly start a named
|
|
209
209
|
// local draft). Keep this as a light startup sheet so the studio remains
|
|
210
210
|
// inspectable while the choice is pending; it never traps the topbar.
|
|
211
|
-
// ?
|
|
212
|
-
|
|
213
|
-
const [projectStartupOpen, setProjectStartupOpen] = useState(() => !appContext.shared.playgroundMode && !appContext.shared.cameraTutorialQuery && !playgroundSceneUrl(globalThis.location?.search) && !loadProjectSession()?.name);
|
|
211
|
+
// A ?scene= launch opens its starter instead of the chooser.
|
|
212
|
+
const [projectStartupOpen, setProjectStartupOpen] = useState(() => !appContext.shared.playgroundMode && !playgroundSceneUrl(globalThis.location?.search) && !loadProjectSession()?.name);
|
|
214
213
|
|
|
215
214
|
const [projectManifest, setProjectManifest] = useState({ items: [], totals: { embedded: 0, external: 0, missing: 0, bytes: 0 }, missing: [] });
|
|
216
215
|
|
|
@@ -400,10 +399,6 @@ export function useScenes(appContext) {
|
|
|
400
399
|
if (!["valid", "migrated"].includes(checked.status)) throw new StudioProtocolError("INVALID_ARGUMENT", "Invalid project scene document.");
|
|
401
400
|
project = { ...project, scenesDocument: checked.document };
|
|
402
401
|
appContext.shared.studioDocumentEpochRef.current = crypto.randomUUID();
|
|
403
|
-
appContext.shared.tutorialProjectEpochRef.current += 1;
|
|
404
|
-
appContext.shared.tutorialSeedEpochRef.current = null;
|
|
405
|
-
appContext.shared.setTutorialSeedPending(false);
|
|
406
|
-
appContext.shared.setCameraTutorialHandoff(null);
|
|
407
402
|
appContext.shared.exportShotIdRef.current = null;
|
|
408
403
|
const source = project.scenesDocument;
|
|
409
404
|
// A project FILE carries its own scene document and never passes the
|
|
@@ -425,25 +420,16 @@ export function useScenes(appContext) {
|
|
|
425
420
|
domain.setDirty(false);
|
|
426
421
|
storeProjectSession(project.name, projectPrevisMode);
|
|
427
422
|
setProjectStartupOpen(false);
|
|
428
|
-
// Whatever document this is, it is no longer the scene the tutorial opened
|
|
429
|
-
// for itself; startCameraTutorial re-arms the flag after its own open.
|
|
430
|
-
appContext.shared.tutorialStarterRef.current = false;
|
|
431
423
|
setProjectManifest(resourceManifest({ scenesDocument: doc, workflow: resolvedWorkflow, poseLibrary: mergedCustomPoses, assets: project.assets ?? [], workflowOutputRefs }));
|
|
432
424
|
track("project:opened", { age_bucket: bucketProjectAge(Date.now() - (project.savedAt ?? Date.now())) });
|
|
433
425
|
}
|
|
434
426
|
|
|
435
427
|
/** Open a bundled starter scene as a fresh, saveable project. Used by the
|
|
436
|
-
* first-run dialog and by `npx cozyclay --scene <id>` (`?scene=`)
|
|
437
|
-
* how the landing-page tutorial hands people into the local studio. */
|
|
428
|
+
* first-run dialog and by `npx cozyclay --scene <id>` (`?scene=`). */
|
|
438
429
|
async function openStarterScene(id, source = "starter", context = null, name = null) {
|
|
439
430
|
if (source === "starter" && domain.dirtyStore.read("projectDirty") && !window.confirm(ko("Discard unsaved changes and start a new project?", "저장되지 않은 변경사항을 버리고 새 프로젝트를 시작할까요?"))) return false;
|
|
440
|
-
const before = source === "tutorial" ? collectProjectSnapshot("Tutorial") : null;
|
|
441
|
-
const epoch = appContext.shared.tutorialProjectEpochRef.current;
|
|
442
431
|
const url = playgroundSceneUrl(`?scene=${encodeURIComponent(id)}`);
|
|
443
432
|
const project = url ? await fetchSceneProject(url) : null;
|
|
444
|
-
// A pending tutorial fetch has no authority over work authored/opened
|
|
445
|
-
// while it was loading, including an unnamed project.
|
|
446
|
-
if (source === "tutorial" && (epoch !== appContext.shared.tutorialProjectEpochRef.current || before !== collectProjectSnapshot("Tutorial"))) return false;
|
|
447
433
|
if (!project) {
|
|
448
434
|
appContext.notify(ko("That starter scene is not in this build", "이 빌드에는 그 시작 장면이 없어요"));
|
|
449
435
|
return false;
|
|
@@ -596,11 +582,6 @@ export function useScenes(appContext) {
|
|
|
596
582
|
}
|
|
597
583
|
|
|
598
584
|
function openScene(scene, nextScenes) {
|
|
599
|
-
appContext.shared.tutorialProjectEpochRef.current += 1;
|
|
600
|
-
appContext.shared.tutorialSeedEpochRef.current = null;
|
|
601
|
-
appContext.shared.setTutorialSeedPending(false);
|
|
602
|
-
appContext.shared.setCameraTutorial(false);
|
|
603
|
-
appContext.shared.setCameraTutorialHandoff(null);
|
|
604
585
|
appContext.shared.exportShotIdRef.current = null;
|
|
605
586
|
appContext.shared.studioSceneEpochRef.current = crypto.randomUUID();
|
|
606
587
|
const shotState = restoredShotState(scene);
|
package/src/domains/shots.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { aimAt } from '../controls.jsx';
|
|
3
3
|
import { PRESETS, DEFAULT_DURATION_S, TIMELINE_FPS } from '../app-stage.jsx';
|
|
4
|
+
import { usePlaybackFrameState } from '../motion/playback-frame-store.js';
|
|
4
5
|
import { CAMERA_MOVES, SUBJECT_HEIGHT_M, focalMmToFov, fovToFocalMm } from '../shot.js';
|
|
5
6
|
import { CAMERA_PRESETS, cameraPresetFraming } from '../camera-move.js';
|
|
6
7
|
import { createDocumentStore } from '../document-store.js';
|
|
@@ -236,9 +237,8 @@ export function useShots(appContext) {
|
|
|
236
237
|
const [moveFollow] = useState(true);
|
|
237
238
|
const [railDraw, setRailDraw] = useState(false);
|
|
238
239
|
const [craneSelectedIndex, setCraneSelectedIndex] = useState(null);
|
|
239
|
-
const [tlFrame,
|
|
240
|
-
const
|
|
241
|
-
const activeShotIdx = shotIndexAtFrame(shots, tlFrame), activeShot = shots[activeShotIdx] ?? null;
|
|
240
|
+
const [tlFrame, setTlFrameState] = useState(0), [tlFps, setTlFps] = useState(TIMELINE_FPS);
|
|
241
|
+
const { setTlFrame, commitPlaybackFrame, activeShotIdx, activeShot } = usePlaybackFrameState({ frameCount: tlFrameCount, setReactFrame: setTlFrameState, shots });
|
|
242
242
|
const cameraKeys = activeShot?.cameraKeys ?? [], activeCamera = createCameraBlock(activeShot?.camera);
|
|
243
243
|
const craneActive = !!activeCamera.craneHeight, cameraRail = activeCamera.cameraRail;
|
|
244
244
|
const activeShotDuration = activeShot ? activeShot.endFrame - activeShot.startFrame + 1 : 0;
|
|
@@ -364,7 +364,7 @@ export function useShots(appContext) {
|
|
|
364
364
|
const travel = Math.ceil(curve.length / speed * tlFps) + Math.round(tlFps * 0.5);
|
|
365
365
|
const endFrame = Math.min(tlFrameCount - 1, activeShot.startFrame + Math.max(travel, activeShot.endFrame - activeShot.startFrame));
|
|
366
366
|
setTimelineShotRange(activeShot.id, activeShot.startFrame, endFrame);
|
|
367
|
-
//
|
|
367
|
+
// In the landing playground a drawn rail opens the player
|
|
368
368
|
// on the shot camera, but leave playback to the visitor.
|
|
369
369
|
appContext.shared.enterPreview(); appContext.shared.setTlPlaying(false); setTlFrame(activeShot.startFrame);
|
|
370
370
|
appContext.notify(isKo ? '레일 완성 — 샷 카메라 시점으로 전환했습니다. ▶ 로 재생, Esc 로 복귀' : 'Rail drawn — you are looking through the shot camera. Press ▶ to ride it; Esc goes back to flying.'); return;
|
|
@@ -418,7 +418,7 @@ export function useShots(appContext) {
|
|
|
418
418
|
}
|
|
419
419
|
return { ...domain, shots, setShots, editShots, fovDeg, setFovDeg, cameraMove, customMove, startupShotState,
|
|
420
420
|
movePlaying, setMovePlaying, moveFollow, railDraw, setRailDraw, craneSelectedIndex, setCraneSelectedIndex,
|
|
421
|
-
tlFrame, setTlFrame, tlFrameCount, setTlFrameCount, tlFps, setTlFps, activeShotIdx, activeShot, cameraKeys,
|
|
421
|
+
tlFrame, setTlFrame, commitPlaybackFrame, tlFrameCount, setTlFrameCount, tlFps, setTlFps, activeShotIdx, activeShot, cameraKeys,
|
|
422
422
|
activeCamera, craneActive, cameraRail, activeShotDuration, hasCameraKeys, changeActiveCamera, changeShotTargetModel,
|
|
423
423
|
syncActiveCameraFraming, commitManualCameraFraming, beginCameraFramingGesture, beginTimelineEditGesture,
|
|
424
424
|
setShotCameraRail, clearShotCameraRail, changeCameraRail, addCameraKeyframe, moveCameraKeyframe, removeCameraKeyframe, clearMove,
|
package/src/dualview.jsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef } from "react";
|
|
2
2
|
import { useFrame, useThree } from "@react-three/fiber";
|
|
3
3
|
import * as THREE from "three";
|
|
4
|
+
import { fitAspect } from "./shot-frame.js";
|
|
5
|
+
import { updateVisibleViewOffsets } from "./viewport-visible-rect.js";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Two live viewports out of one scene.
|
|
@@ -104,17 +106,6 @@ const EDGE_FRAGMENT = `
|
|
|
104
106
|
}
|
|
105
107
|
`;
|
|
106
108
|
|
|
107
|
-
/** largest centred sub-rect of `rect` with the given aspect */
|
|
108
|
-
export function fitAspect(rect, aspect) {
|
|
109
|
-
let w = rect.w;
|
|
110
|
-
let h = w / aspect;
|
|
111
|
-
if (h > rect.h) {
|
|
112
|
-
h = rect.h;
|
|
113
|
-
w = h * aspect;
|
|
114
|
-
}
|
|
115
|
-
return { x: rect.x + (rect.w - w) / 2, y: rect.y + (rect.h - h) / 2, w, h };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
109
|
export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCamRef, planCamRef, poserCamRef, editorCamRef, ikMode = false, planIsMain, playMode = false, lookThrough = false, insetCollapsed = false, planZoom = 1, shotAspect = SHOT_ASPECT, editorLook = false }) {
|
|
119
110
|
const invalidate = useThree((state) => state.invalidate);
|
|
120
111
|
const lastPoserPose = useRef({ position: new THREE.Vector3(), quaternion: new THREE.Quaternion(), valid: false });
|
|
@@ -245,6 +236,7 @@ export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCa
|
|
|
245
236
|
return { x: r.left - base.left, y: r.top - base.top, w: r.width, h: r.height };
|
|
246
237
|
};
|
|
247
238
|
const mainRect = rectOf(mainRef.current);
|
|
239
|
+
updateVisibleViewOffsets({ editorCam, poserCam, canvas: gl.domElement.getBoundingClientRect(), aspect: mainRect.w / mainRect.h });
|
|
248
240
|
// A collapsed inset is just its tag pill: skip the whole inset pass
|
|
249
241
|
// instead of drawing a squished viewport into it.
|
|
250
242
|
const insetRect = insetCollapsed ? { x: 0, y: 0, w: 0, h: 0 } : rectOf(insetRef.current);
|
|
@@ -451,8 +443,6 @@ export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCa
|
|
|
451
443
|
// IK mode: the main pane is the poser working view (free navigation,
|
|
452
444
|
// handle layer visible); the inset is the FROZEN shot camera — the
|
|
453
445
|
// separate camera-placement screen the framing lives on.
|
|
454
|
-
poserCam.aspect = mainRect.w / mainRect.h;
|
|
455
|
-
poserCam.updateProjectionMatrix();
|
|
456
446
|
drawEditorPane(poserCam, mainRect, null, !navigatingCamera);
|
|
457
447
|
if (!navigatingCamera || redrawFrozenPanes) drawVisibleInset(shotCam, insetRect, fitAspect(insetRect, shotAspect), !navigatingCamera);
|
|
458
448
|
drawPreviewCard();
|
|
@@ -465,8 +455,6 @@ export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCa
|
|
|
465
455
|
// navigation that never touches the recording. The shot camera only
|
|
466
456
|
// appears in its own preview pane, framed to the export aspect and
|
|
467
457
|
// stripped of editing chrome, exactly like an exported frame.
|
|
468
|
-
editorCam.aspect = mainRect.w / mainRect.h;
|
|
469
|
-
editorCam.updateProjectionMatrix();
|
|
470
458
|
// A right-drag is a camera gesture even with IK off. Keep the main
|
|
471
459
|
// image readable while the camera is moving by deferring the expensive
|
|
472
460
|
// edge/ink pass until the gesture ends; the plan inset stays frozen.
|
|
@@ -479,6 +467,14 @@ export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCa
|
|
|
479
467
|
// can fly the recording lens with the same bindings as the free camera.
|
|
480
468
|
// The QA hook (`window.__cozyclay.setLookThrough`) draws the same path.
|
|
481
469
|
// Look-through is the recording lens: it keeps the authored stage.
|
|
470
|
+
// The pane is the open stage less the rails (shell.css), so the
|
|
471
|
+
// canvas around it is bars too: clear it, or the last full-canvas
|
|
472
|
+
// draw stays there and reads as picture the export does not have.
|
|
473
|
+
if (lookThrough) {
|
|
474
|
+
gl.setScissorTest(false);
|
|
475
|
+
gl.setClearColor(letterbox, 1);
|
|
476
|
+
gl.clear(true, true, false);
|
|
477
|
+
}
|
|
482
478
|
draw(shotCam, shotPane, fitAspect(shotPane, shotAspect), !navigatingCamera);
|
|
483
479
|
if (!navigatingCamera || redrawFrozenPanes) drawVisibleInset(planCam, planPane, null, false);
|
|
484
480
|
drawPreviewCard();
|
package/src/hierarchy-panel.css
CHANGED
|
@@ -326,13 +326,27 @@
|
|
|
326
326
|
outline: var(--outliner-border-width, 1px) solid var(--select, #e8a33d);
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
+
/* The context menu is portaled to <body> inside this layer, which keeps the
|
|
330
|
+
`.v2-outliner` style scope without a box of its own (hierarchy-panel.jsx).
|
|
331
|
+
It and the scene switcher's list share the popover tokens (themes.css). */
|
|
332
|
+
.v2-outliner.v2-outliner-layer {
|
|
333
|
+
display: contents;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
.v2-outliner .hierarchy-context-menu,
|
|
337
|
+
.dropdown-menu.hierarchy-scene-menu {
|
|
338
|
+
z-index: var(--z-popover, 90);
|
|
339
|
+
border: var(--outliner-border-width, 1px) solid var(--popover-border, var(--row-divider, #1d1d20));
|
|
340
|
+
border-radius: var(--popover-radius, 6px);
|
|
341
|
+
background: var(--popover-surface, var(--surface-raised, #1e1e21));
|
|
342
|
+
-webkit-backdrop-filter: var(--popover-blur, none);
|
|
343
|
+
backdrop-filter: var(--popover-blur, none);
|
|
344
|
+
box-shadow: var(--popover-shadow, 0 8px 24px rgba(0, 0, 0, .45));
|
|
345
|
+
}
|
|
346
|
+
|
|
329
347
|
.v2-outliner .hierarchy-context-menu {
|
|
330
348
|
min-width: var(--outliner-menu-width, 220px);
|
|
331
349
|
padding: var(--outliner-menu-padding, 4px);
|
|
332
|
-
border: var(--outliner-border-width, 1px) solid var(--row-divider, #1d1d20);
|
|
333
|
-
border-radius: var(--r-2, 6px);
|
|
334
|
-
background: var(--surface-raised, #1e1e21);
|
|
335
|
-
box-shadow: 0 var(--sp-1, 8px) var(--sp-3, 16px) var(--overlay, rgba(12,12,13,.78));
|
|
336
350
|
}
|
|
337
351
|
|
|
338
352
|
.v2-outliner .hierarchy-context-item {
|
package/src/hierarchy-panel.jsx
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
2
3
|
import { ko, isKo } from "./locale.js";
|
|
3
4
|
import { buildHierarchyNodes } from "./hierarchy-model.js";
|
|
4
5
|
import { sceneObjectIdFromHierarchy } from "./scene-objects.js";
|
|
5
6
|
import { CatalogueEntries, displayObjectLabel } from "./object-catalog.jsx";
|
|
6
7
|
import { Dropdown } from "./ui.jsx";
|
|
8
|
+
import { usePopover } from "./popover.js";
|
|
7
9
|
import { isImeComposing } from "./ime.js";
|
|
8
10
|
import "./hierarchy-panel.css";
|
|
9
11
|
|
|
@@ -97,6 +99,7 @@ function ScenePill({ scenes, activeSceneId, onSceneSelect, onSceneCreate, onRena
|
|
|
97
99
|
value={activeSceneId}
|
|
98
100
|
options={options}
|
|
99
101
|
ariaLabel={ko("Select scene", "장면 선택")}
|
|
102
|
+
menuClassName="hierarchy-scene-menu"
|
|
100
103
|
onChange={(value) => {
|
|
101
104
|
if (value === NEW_SCENE_OPTION) onSceneCreate?.();
|
|
102
105
|
else if (value !== activeSceneId) onSceneSelect?.(value);
|
|
@@ -194,11 +197,9 @@ function RowContextMenu({ menu, onClose, onAction, onAddObject }) {
|
|
|
194
197
|
const [deleteArmed, setDeleteArmed] = useState(false);
|
|
195
198
|
useEffect(() => setDeleteArmed(false), [menu]);
|
|
196
199
|
|
|
200
|
+
usePopover(menu, onClose, [rootRef]);
|
|
197
201
|
useEffect(() => {
|
|
198
202
|
if (!menu) return undefined;
|
|
199
|
-
const onDocDown = (event) => {
|
|
200
|
-
if (rootRef.current && !rootRef.current.contains(event.target)) onClose();
|
|
201
|
-
};
|
|
202
203
|
const onKey = (event) => {
|
|
203
204
|
if (event.key !== "Escape") return;
|
|
204
205
|
// Capture phase + stopImmediatePropagation: closing the menu must
|
|
@@ -206,12 +207,8 @@ function RowContextMenu({ menu, onClose, onAction, onAddObject }) {
|
|
|
206
207
|
event.stopImmediatePropagation();
|
|
207
208
|
onClose();
|
|
208
209
|
};
|
|
209
|
-
document.addEventListener("mousedown", onDocDown);
|
|
210
210
|
window.addEventListener("keydown", onKey, true);
|
|
211
|
-
return () =>
|
|
212
|
-
document.removeEventListener("mousedown", onDocDown);
|
|
213
|
-
window.removeEventListener("keydown", onKey, true);
|
|
214
|
-
};
|
|
211
|
+
return () => window.removeEventListener("keydown", onKey, true);
|
|
215
212
|
}, [menu, onClose]);
|
|
216
213
|
|
|
217
214
|
if (!menu) return null;
|
|
@@ -221,8 +218,11 @@ function RowContextMenu({ menu, onClose, onAction, onAddObject }) {
|
|
|
221
218
|
left: Math.max(4, Math.min(menu.x, window.innerWidth - 244)),
|
|
222
219
|
top: Math.max(4, Math.min(menu.y, window.innerHeight - menu.height)),
|
|
223
220
|
};
|
|
224
|
-
|
|
225
|
-
|
|
221
|
+
// Portaled: the glass column clips and blurs its children, so an in-place
|
|
222
|
+
// menu was cut at the column edge and sat under body-level popovers. The
|
|
223
|
+
// display:contents layer keeps the `.v2-outliner` style scope.
|
|
224
|
+
return createPortal(
|
|
225
|
+
<div className="v2-outliner v2-outliner-layer"><div className="hierarchy-context-menu" role="menu" style={style} ref={rootRef}>
|
|
226
226
|
{menu.kind === "scene" ? (
|
|
227
227
|
<>
|
|
228
228
|
<button type="button" role="menuitem" className="hierarchy-context-item" onClick={() => onAction("rename", menu.id)}>
|
|
@@ -292,7 +292,8 @@ function RowContextMenu({ menu, onClose, onAction, onAddObject }) {
|
|
|
292
292
|
) : (
|
|
293
293
|
<CatalogueEntries onPick={onAddObject} />
|
|
294
294
|
)}
|
|
295
|
-
</div
|
|
295
|
+
</div></div>,
|
|
296
|
+
document.body,
|
|
296
297
|
);
|
|
297
298
|
}
|
|
298
299
|
|
package/src/kits/held.js
CHANGED
|
@@ -38,9 +38,10 @@ export const bat = {
|
|
|
38
38
|
|
|
39
39
|
export const ball = {
|
|
40
40
|
label: "Baseball", prop: "ballGrip",
|
|
41
|
-
description: "7.4 cm baseball held in the right fingers",
|
|
41
|
+
description: "7.4 cm baseball held in the right palm and fingers",
|
|
42
42
|
props: { ballGrip: { hand: "r", origin: RIGHT_FINGERS, points: { centre: [0, 0, -0.035] } } },
|
|
43
|
-
|
|
43
|
+
// no pinned point: it rests against the palm where the hand takes it and the fingers close round it (motion3 seat.js)
|
|
44
|
+
carry: { prop: "ballGrip", grasp: "spherical" },
|
|
44
45
|
parts: [{ role: "body", kind: "sphere", at: [0, -0.037, 0], scale: [0.074, 0.074, 0.074], color: "#f1ede3" }],
|
|
45
46
|
};
|
|
46
47
|
|
package/src/light-blockers.js
CHANGED
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
import { practicalShadows, withShadowCapWarnings } from "./object-look.js";
|
|
20
20
|
import { isEffectivelyHidden } from "./object-hidden.js";
|
|
21
21
|
|
|
22
|
-
/** Renderers whose meshes cast shadows (props.jsx
|
|
22
|
+
/** Renderers whose meshes cast shadows (props.jsx primitives, sculpts, imported meshes).
|
|
23
23
|
* A plane and a cutout are cards: nothing can be inside them. */
|
|
24
|
-
const CASTERS = new Set(["cube", "sphere", "capsule", "cylinder", "cone", "
|
|
24
|
+
const CASTERS = new Set(["cube", "sphere", "capsule", "cylinder", "cone", "sculpt", "mesh"]);
|
|
25
25
|
const DEG = Math.PI / 180;
|
|
26
26
|
const EPS = 1e-6;
|
|
27
27
|
const MAX_WARNINGS = 4;
|
package/src/main.jsx
CHANGED
|
@@ -12,6 +12,7 @@ import { registerPwa } from "./pwa.js";
|
|
|
12
12
|
import { LOCALE } from "./locale.js";
|
|
13
13
|
import { initAnalytics } from "./analytics.js";
|
|
14
14
|
import { fetchPlaygroundProject, isPlaygroundEmbed, playgroundSceneUrl, stashPlaygroundProject } from "./playground.js";
|
|
15
|
+
import { preloadBodies } from "./motion3/body.js";
|
|
15
16
|
|
|
16
17
|
registerPwa();
|
|
17
18
|
void initAnalytics();
|
|
@@ -34,6 +35,10 @@ async function boot() {
|
|
|
34
35
|
</ErrorBoundary>
|
|
35
36
|
</StrictMode>,
|
|
36
37
|
);
|
|
38
|
+
// The motion compiler's body fits are not in the first-load bundle (motion3/body.js): fetch them once
|
|
39
|
+
// the first screen is up, so a saved take compiled on this thread does not have to wait for them.
|
|
40
|
+
const idle = globalThis.requestIdleCallback ?? (run => setTimeout(run, 200));
|
|
41
|
+
idle(() => preloadBodies().catch(error => console.warn("[cozyclay] could not preload the motion body fits", error)));
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
void boot();
|
package/src/motion/ground.js
CHANGED
|
@@ -3,20 +3,11 @@ import { isEffectivelyHidden, objectFootprintBounds } from "../scene-objects.js"
|
|
|
3
3
|
/**
|
|
4
4
|
* Ground height under a point, from the scene's objects (issue #250).
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* surface actually under the foot.
|
|
11
|
-
*
|
|
12
|
-
* DRAFT - the sampler exists and is tested; physics-review.js does not call
|
|
13
|
-
* it yet. Wiring plan:
|
|
14
|
-
* 1. supportIntervals(): compare the sole to groundAt(toe.x, toe.z), not
|
|
15
|
-
* floorY, in the "low enough to be a contact" test.
|
|
16
|
-
* 2. Span anchors carry y = groundAt(anchor.x, anchor.z); footLockTrack
|
|
17
|
-
* anchors and relaxToePath's toeMinHeight take that value per frame.
|
|
18
|
-
* 3. Penetration / float metrics and the root lift measure against the
|
|
19
|
-
* local ground under each support site.
|
|
6
|
+
* Against ONE flat floor (`floorY`), a character standing on a box or a step
|
|
7
|
+
* would read as sunk into it. This module answers "what is the ground here?":
|
|
8
|
+
* studio-agent-motion.js samples it per support site, and physics-review.js
|
|
9
|
+
* takes it as `groundAt` for contact spans, foot anchors and the floor/float
|
|
10
|
+
* metrics, so they measure against the surface actually under the foot.
|
|
20
11
|
*
|
|
21
12
|
* Surface model: each scene object is an axis-aligned box from
|
|
22
13
|
* objectFootprintBounds (yaw-exact XZ footprint, flat top at topY). The
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useEffect, useSyncExternalStore } from "react";
|
|
2
|
+
import { shotIndexAtFrame } from "../cuts.js";
|
|
3
|
+
|
|
4
|
+
// The playhead lives here, outside React: playback ticks write it and only the
|
|
5
|
+
// components that show the frame subscribe, so App does not re-render per frame.
|
|
6
|
+
let currentFrame = 0;
|
|
7
|
+
const listeners = new Set();
|
|
8
|
+
let applyPlaybackFrame = null;
|
|
9
|
+
|
|
10
|
+
export function playbackFrame() {
|
|
11
|
+
return currentFrame;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function setPlaybackFrame(next) {
|
|
15
|
+
const frame = typeof next === "function" ? next(currentFrame) : next;
|
|
16
|
+
if (!Number.isFinite(frame) || frame === currentFrame) return currentFrame;
|
|
17
|
+
currentFrame = frame;
|
|
18
|
+
if (typeof window !== "undefined") window.__cozyclayPlaybackFrame = frame;
|
|
19
|
+
for (const listener of listeners) listener();
|
|
20
|
+
return currentFrame;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function advancePlaybackFrame(frameCount, count) {
|
|
24
|
+
const frame = setPlaybackFrame((current) => (current + count) % frameCount);
|
|
25
|
+
applyPlaybackFrame?.(frame);
|
|
26
|
+
return frame;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function registerPlaybackFrameApplier(applier) {
|
|
30
|
+
applyPlaybackFrame = applier;
|
|
31
|
+
return () => {
|
|
32
|
+
if (applyPlaybackFrame === applier) applyPlaybackFrame = null;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createPlaybackFrameSetter({ getFrameCount, setReactFrame, onFrame }) {
|
|
37
|
+
return (update) => {
|
|
38
|
+
const next = typeof update === "function" ? update(playbackFrame()) : update;
|
|
39
|
+
const frame = Math.max(0, Math.min(getFrameCount() - 1, Math.round(next)));
|
|
40
|
+
setPlaybackFrame(frame);
|
|
41
|
+
setReactFrame(frame);
|
|
42
|
+
onFrame?.(frame);
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function commitPlaybackFrame(setReactFrame, onFrame) {
|
|
47
|
+
const frame = playbackFrame();
|
|
48
|
+
setReactFrame(frame);
|
|
49
|
+
onFrame?.(frame);
|
|
50
|
+
return frame;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function usePlaybackSnapshot(getSnapshot) {
|
|
54
|
+
return useSyncExternalStore(subscribePlaybackFrame, getSnapshot, getSnapshot);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function usePlaybackEffects({ playing = false, commit = () => {}, onStop = null } = {}) {
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!playing) {
|
|
60
|
+
commit();
|
|
61
|
+
onStop?.();
|
|
62
|
+
}
|
|
63
|
+
// Runs on play/stop transitions only; commit and onStop are recreated every render.
|
|
64
|
+
}, [playing]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The shots domain's timeline frame: React state (owned by the caller) for edits, the store for playback.
|
|
68
|
+
* activeShotIdx subscribes to the store, so it re-renders only when playback crosses a cut. */
|
|
69
|
+
export function usePlaybackFrameState({ frameCount, setReactFrame, shots }) {
|
|
70
|
+
const setTlFrame = createPlaybackFrameSetter({ getFrameCount: () => frameCount, setReactFrame });
|
|
71
|
+
const activeShotIdx = usePlaybackSnapshot(() => shotIndexAtFrame(shots, playbackFrame()));
|
|
72
|
+
const activeShot = shots[activeShotIdx] ?? null;
|
|
73
|
+
return { setTlFrame, commitPlaybackFrame: () => commitPlaybackFrame(setReactFrame), activeShotIdx, activeShot };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function subscribePlaybackFrame(listener) {
|
|
77
|
+
listeners.add(listener);
|
|
78
|
+
return () => listeners.delete(listener);
|
|
79
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { sampleAt } from "../sample-at.js";
|
|
2
|
+
|
|
3
|
+
export function playbackProbe(probe, frame, fallback) {
|
|
4
|
+
const sampled = probe.scene ? sampleAt(probe.scene, null, frame).subject : null;
|
|
5
|
+
const carried = sampled && probe.carry ? probe.carry(sampled, frame) : sampled;
|
|
6
|
+
return carried ?? fallback;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function probeVisibility(vector, camera, subject, height) {
|
|
10
|
+
let visible = false;
|
|
11
|
+
for (const y of [0.1, height * 0.94]) {
|
|
12
|
+
vector.set(subject.x, y, subject.z).project(camera);
|
|
13
|
+
if (vector.z < 1 && Math.abs(vector.x) <= 1 && Math.abs(vector.y) <= 1) visible = true;
|
|
14
|
+
}
|
|
15
|
+
return visible;
|
|
16
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { sampleAt } from "../sample-at.js";
|
|
2
|
+
|
|
3
|
+
export function usePlaybackSubjectPosition({ motion, subjectCarry, scene, frame }) {
|
|
4
|
+
if (!motion && !subjectCarry) return null;
|
|
5
|
+
const subject = sampleAt(scene, null, frame).subject;
|
|
6
|
+
return subjectCarry ? subjectCarry(subject, frame) : subject;
|
|
7
|
+
}
|
package/src/motion/timeline.css
CHANGED
|
@@ -778,11 +778,13 @@
|
|
|
778
778
|
box-shadow: none;
|
|
779
779
|
}
|
|
780
780
|
|
|
781
|
+
/* Shots cut against each other: a hairline between neighbours, not a wide gutter, so a run of cuts reads as
|
|
782
|
+
one sequence. Free-camera time between shots still shows as its own gap. */
|
|
781
783
|
.v2-sequencer .tl-shot-block {
|
|
782
784
|
top: var(--tl-clip-inset, 7px);
|
|
783
785
|
bottom: var(--tl-clip-inset, 7px);
|
|
784
|
-
left: calc(var(--tl-f-start) * 100% + var(--tl-
|
|
785
|
-
width: max(var(--tl-min-clip-width, 1px), calc((var(--tl-f-end) - var(--tl-f-start)) * 100% - (var(--tl-
|
|
786
|
+
left: calc(var(--tl-f-start) * 100% + var(--tl-shot-gap, 1px));
|
|
787
|
+
width: max(var(--tl-min-clip-width, 1px), calc((var(--tl-f-end) - var(--tl-f-start)) * 100% - (var(--tl-shot-gap, 1px) * 2)));
|
|
786
788
|
padding: var(--sp-1, 8px) var(--sp-1, 8px) var(--tl-shot-footer-height, 28px);
|
|
787
789
|
border: 0;
|
|
788
790
|
border-radius: var(--r-1, 4px);
|
|
@@ -854,6 +856,10 @@
|
|
|
854
856
|
background: var(--select-tint, rgba(232, 163, 61, .14));
|
|
855
857
|
}
|
|
856
858
|
|
|
859
|
+
/* The trim grips appear with the shot under the pointer, not as a column of marks at every cut. */
|
|
860
|
+
.v2-sequencer .tl-shot-edge { opacity: 0; transition: opacity .12s; }
|
|
861
|
+
.v2-sequencer .tl-shot-block:is(:hover, :focus-within) .tl-shot-edge { opacity: 1; }
|
|
862
|
+
|
|
857
863
|
/* The bar hangs above the block's top-left corner instead of inside it: a
|
|
858
864
|
short cut (40 frames ~ 38px) is narrower than Split + Duplicate + Delete,
|
|
859
865
|
and an in-block bar would sit on the shot's name, turning a click on the
|
|
@@ -926,13 +932,22 @@
|
|
|
926
932
|
pointer-events: auto;
|
|
927
933
|
}
|
|
928
934
|
|
|
935
|
+
/* A camera key sits on its Shot's key strip (the line along the box's foot), as a small diamond: at the
|
|
936
|
+
lane's first-row height it landed on the box's top corner, in the gutter between two Shots. */
|
|
929
937
|
.v2-sequencer .tl-marker.cam {
|
|
930
|
-
top:
|
|
931
|
-
bottom:
|
|
938
|
+
top: auto;
|
|
939
|
+
bottom: calc(var(--tl-clip-inset, 7px) + var(--tl-key-strip-height, 12px) / 2);
|
|
940
|
+
width: 7px;
|
|
941
|
+
height: 7px;
|
|
942
|
+
border-radius: 1.5px;
|
|
932
943
|
background: var(--camera, #a78bfa);
|
|
933
|
-
|
|
944
|
+
box-shadow: 0 0 0 1.5px var(--tl-camera-clip, #3d3354);
|
|
945
|
+
transform: translate(-50%, 50%) rotate(45deg);
|
|
934
946
|
z-index: 7;
|
|
935
947
|
}
|
|
948
|
+
/* A key on a Shot's first or last frame steps just inside the box instead of straddling its edge. */
|
|
949
|
+
.v2-sequencer .tl-marker.cam[data-edge="start"] { margin-left: 7px; }
|
|
950
|
+
.v2-sequencer .tl-marker.cam[data-edge="end"] { margin-left: -7px; }
|
|
936
951
|
|
|
937
952
|
.v2-sequencer .tl-marker.wp {
|
|
938
953
|
background: var(--ok, #4fbf7a);
|
|
@@ -1151,6 +1166,29 @@
|
|
|
1151
1166
|
background: var(--select, #e8a33d);
|
|
1152
1167
|
}
|
|
1153
1168
|
|
|
1169
|
+
/* In a Shot box the height graph is a ~20 px strip: the points are small dots ON the curve, quiet on other
|
|
1170
|
+
shots and full on the selected or hovered one, and their hit area stays a full finger wide. Hovering a curve
|
|
1171
|
+
thickens it and the cursor says which way it moves. The axis labels have no room. */
|
|
1172
|
+
.v2-sequencer .tl-shot-block .tl-crane-knob {
|
|
1173
|
+
width: 7px;
|
|
1174
|
+
height: 7px;
|
|
1175
|
+
border: 1.5px solid var(--tl-camera-rail, #302a40);
|
|
1176
|
+
box-shadow: none;
|
|
1177
|
+
opacity: .5;
|
|
1178
|
+
transition: opacity .12s;
|
|
1179
|
+
}
|
|
1180
|
+
.v2-sequencer .tl-shot-block .sg-shot.height { z-index: 2; }
|
|
1181
|
+
.v2-sequencer .tl-shot-block .tl-crane-knob::before { content: ""; position: absolute; inset: -6px; border-radius: 50%; }
|
|
1182
|
+
.v2-sequencer .tl-shot-block .tl-crane-knob.selected { width: 9px; height: 9px; }
|
|
1183
|
+
.v2-sequencer .tl-shot-block:is(.selected, .active, :hover) .tl-crane-knob { opacity: 1; }
|
|
1184
|
+
.v2-sequencer .tl-shot-block .tl-crane-knob:hover, .v2-sequencer .tl-shot-block .tl-crane-knob:focus-visible { outline: none; transform: translate(-50%, -50%) scale(1.3); }
|
|
1185
|
+
.v2-sequencer .tl-shot-block .tl-crane-line { stroke-width: 1.5px; }
|
|
1186
|
+
.v2-sequencer .tl-shot-block .sg-shot .sg-body svg { cursor: ns-resize; }
|
|
1187
|
+
.v2-sequencer .tl-shot-block .sg-shot:hover :is(.sg-line, .tl-crane-line) { stroke-width: 2.5px; }
|
|
1188
|
+
.v2-sequencer .tl-shot-block .tl-crane-time-pick { opacity: 0; }
|
|
1189
|
+
.v2-sequencer .tl-shot-block .tl-crane-time-pick.selected { opacity: 1; }
|
|
1190
|
+
.v2-sequencer .tl-shot-block .tl-crane-editor :is(.sg-scale-top, .sg-scale-avg, .sg-scale-zero) { display: none; }
|
|
1191
|
+
|
|
1154
1192
|
.v2-sequencer .sg-shot {
|
|
1155
1193
|
left: var(--sp-1, 8px);
|
|
1156
1194
|
right: var(--sp-1, 8px);
|
package/src/motion/timeline.jsx
CHANGED
|
@@ -273,8 +273,9 @@ function CraneHeightEditor({ crane, railRange, durationFrames, selectedIndex, on
|
|
|
273
273
|
key={index}
|
|
274
274
|
type="button"
|
|
275
275
|
className={"tl-crane-knob" + (index === selectedIndex ? " selected" : "")}
|
|
276
|
-
// an end point sits ON the window edge; clamp it inside so the knob stays
|
|
277
|
-
|
|
276
|
+
// an end point sits ON the window edge; clamp it inside so the knob stays whole. The clamp is the
|
|
277
|
+
// knob's own radius: a wider one pinned every knob of a 20 px Shot strip to one row, off its curve.
|
|
278
|
+
style={{ left: `clamp(5px, ${xFor(point.t) * 100}%, calc(100% - 5px))`, top: `clamp(5px, ${yFor(point.height) * 100}%, calc(100% - 5px))` }}
|
|
278
279
|
aria-label={ko(`Crane point ${index + 1}, ${point.height.toFixed(1)} metres`, `크레인 점 ${index + 1}, ${point.height.toFixed(1)}미터`)}
|
|
279
280
|
onPointerDown={(event) => {
|
|
280
281
|
event.preventDefault();
|
|
@@ -2199,7 +2200,7 @@ export default function Timeline({
|
|
|
2199
2200
|
{name === SHOTS_LANE && shots.flatMap((shot, index) => (shot.cameraKeys ?? []).map((key) => (
|
|
2200
2201
|
<span
|
|
2201
2202
|
key={`${shot.id}:${key.frame}`}
|
|
2202
|
-
className="tl-marker cam"
|
|
2203
|
+
className="tl-marker cam" data-edge={key.frame <= shot.startFrame ? "start" : key.frame >= shot.endFrame ? "end" : undefined}
|
|
2203
2204
|
style={{ "--tl-f": framePct(key.frame, displayFrameCount) }}
|
|
2204
2205
|
title={isKo ? `${key.frame}프레임의 카메라 키 — 클릭해 이동, 드래그로 시간 변경, 오른쪽 클릭으로 삭제` : `Camera key at frame ${key.frame} — click to jump, drag to re-time, right-click to remove`}
|
|
2205
2206
|
onPointerDown={(e) => beginCameraKeyDrag(e, key, index)}
|