diff --git a/.gitignore b/.gitignore index cbebb7e9..69c8d805 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,7 @@ out/ # AI skills-lock.json .agents -.claude \ No newline at end of file +.claude + +# Projects +projects/ \ No newline at end of file diff --git a/apps/cli/src/cli-client.ts b/apps/cli/src/cli-client.ts index fc125a02..b1b04037 100644 --- a/apps/cli/src/cli-client.ts +++ b/apps/cli/src/cli-client.ts @@ -36,14 +36,22 @@ function requestConnection(handshake: CliHandshake, timeoutMs: number): Promise< sock.setTimeout(timeoutMs, () => settle(() => reject(new Error("Timed out waiting for the app to accept the connection"))), ); - sock.on("connect", () => sock.end(JSON.stringify(handshake))); + sock.on("connect", () => sock.write(JSON.stringify(handshake) + "\n")); sock.on("data", (chunk) => { buf += chunk; + try { + const reply = JSON.parse(buf.trim()) as CliHandshakeReply; + if (reply.ok) settle(resolve); + else settle(() => reject(new Error(reply.error))); + } catch { + // waiting for full json + } }); sock.on("end", () => { + if (settled) return; let reply: CliHandshakeReply; try { - reply = JSON.parse(buf) as CliHandshakeReply; + reply = JSON.parse(buf.trim()) as CliHandshakeReply; } catch (e) { settle(() => reject(e instanceof Error ? e : new Error(String(e)))); return; diff --git a/apps/desktop/src/cli-server.ts b/apps/desktop/src/cli-server.ts index c78cf301..b1174cae 100644 --- a/apps/desktop/src/cli-server.ts +++ b/apps/desktop/src/cli-server.ts @@ -107,24 +107,46 @@ export function startCliServer() { cliServer = createServer({ allowHalfOpen: true }, (sock: Socket) => { enableHeadless(); let buf = ""; + let handled = false; sock.setEncoding("utf8"); sock.setTimeout(60000, () => sock.destroy()); - sock.on("data", (chunk) => { - buf += chunk; - }); - sock.on("end", async () => { - sock.setTimeout(0); + + const tryProcess = async () => { + if (handled) return; let handshake: CliHandshake; try { - handshake = JSON.parse(buf) as CliHandshake; + handshake = JSON.parse(buf.trim()) as CliHandshake; if (typeof handshake.port !== "number" || typeof handshake.token !== "string") { - throw new Error("Malformed handshake"); + return; } } catch { - sock.end(JSON.stringify({ ok: false, error: "Invalid handshake" })); return; } + handled = true; + sock.setTimeout(0); await deliverHandshake(handshake, sock); + }; + + sock.on("data", async (chunk) => { + buf += chunk; + await tryProcess(); + }); + sock.on("end", async () => { + if (!handled) { + sock.setTimeout(0); + let handshake: CliHandshake; + try { + handshake = JSON.parse(buf.trim()) as CliHandshake; + if (typeof handshake.port !== "number" || typeof handshake.token !== "string") { + throw new Error("Malformed handshake"); + } + } catch { + if (!sock.destroyed) sock.end(JSON.stringify({ ok: false, error: "Invalid handshake" })); + return; + } + handled = true; + await deliverHandshake(handshake, sock); + } }); sock.on("error", () => { // Client hung up; nothing to do. diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index bb490a29..c3f68f79 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -35,7 +35,7 @@ function AuthGate(props: { children: JSX.Element }) { {props.children} - + diff --git a/apps/web/src/components/sidebar-right/inspector/animations.tsx b/apps/web/src/components/sidebar-right/inspector/animations.tsx index 59380100..6715fda3 100644 --- a/apps/web/src/components/sidebar-right/inspector/animations.tsx +++ b/apps/web/src/components/sidebar-right/inspector/animations.tsx @@ -45,10 +45,16 @@ import { ANIMATION_GROUPS, DEFAULT_ANIMATION, animationOption } from "./animatio import type { AnimationGroup, AnimationOption } from "./animation-types"; import type { Entity } from "koota"; -/** ``'s defaults; a control left at one of these unsets its prop. */ const DEFAULT_DURATION = 1; const DEFAULT_DELAY = 0; +type PhaseOption = { value: "in" | "out"; label: string }; + +const PHASE_OPTIONS: PhaseOption[] = [ + { value: "in", label: "In" }, + { value: "out", label: "Out" }, +]; + // Stable identity, so a node without animations does not resample every tick. const NO_ANIMATIONS: Entity[] = []; @@ -211,6 +217,7 @@ function AnimationInspector(props: AnimationInspectorProps) { const duration = createMemo(() => framesToSeconds(animation()?.duration ?? 0, fps())); const delay = createMemo(() => framesToSeconds(animation()?.delay ?? 0, fps())); const isOut = createMemo(() => animation()?.phase === AnimationPhase.OUT); + const selectedPhase = createMemo(() => (isOut() ? PHASE_OPTIONS[1]! : PHASE_OPTIONS[0]!)); /** * The groups this node can play, plus whichever one holds the current @@ -287,18 +294,22 @@ function AnimationInspector(props: AnimationInspectorProps) { - - value={isOut()} - onChange={(value) => value !== null && handlePhaseChange(value)} - options={[false, true]} + + value={selectedPhase()} + onChange={(next) => next && handlePhaseChange(next.value === "out")} + options={PHASE_OPTIONS} + optionValue="value" + optionTextValue="label" itemComponent={(itemProps) => ( - {itemProps.item.rawValue ? "Out" : "In"} + {itemProps.item.rawValue.label} )} > - {isOut() ? "Out" : "In"} + class="text-xs"> + {(state) => state.selectedOption()?.label} + diff --git a/apps/web/src/engine/create-engine.ts b/apps/web/src/engine/create-engine.ts index d431ea90..33e0fd03 100644 --- a/apps/web/src/engine/create-engine.ts +++ b/apps/web/src/engine/create-engine.ts @@ -127,7 +127,7 @@ class Engine { keys.held.add(key); if (isMod) keys.held.add('mod'); - if (!event.repeat) { + if (!event.repeat || key.startsWith('arrow')) { keys.pressed.add(key); if (isMod) keys.pressed.add('mod'); } diff --git a/apps/web/src/engine/input/shortcuts.ts b/apps/web/src/engine/input/shortcuts.ts index eb8da1c9..fc555c71 100644 --- a/apps/web/src/engine/input/shortcuts.ts +++ b/apps/web/src/engine/input/shortcuts.ts @@ -141,7 +141,86 @@ export function nudgeSelection(world: World, dx: number, dy: number): void { } } -const nudge = (dx: number, dy: number) => (world: World): void => nudgeSelection(world, dx, dy); + +/** + * Collects all distinct cut/boundary frame positions across the active scene and its timeline items. + */ +function getTimelinePoints(world: World): number[] { + const scene = getActiveEntity(world); + if (scene === null) return []; + + const computed = store(world, Computed); + const points = new Set([0]); + + const sceneEnd = computed.end[scene.id()]; + if (sceneEnd !== undefined && sceneEnd > 0) { + points.add(Math.round(sceneEnd)); + } + + const walk = (parent: Entity): void => { + for (const child of world.query(NODES, ChildOf(parent))) { + const cid = child.id(); + const start = computed.start[cid]; + const end = computed.end[cid]; + if (start !== undefined) points.add(Math.round(start)); + if (end !== undefined) points.add(Math.round(end)); + walk(child); + } + }; + walk(scene); + + return [...points].sort((a, b) => a - b); +} + +/** + * Seeks the playhead to the previous item / cut boundary in the timeline. + */ +export function seekToPreviousItem(world: World): void { + const scene = getActiveEntity(world); + if (scene === null) return; + + const currentFrame = Math.round(store(world, Computed).localTime[scene.id()] ?? 0); + const points = getTimelinePoints(world); + + const prev = points.filter(p => p < currentFrame).pop(); + setPlayhead(world, scene, prev !== undefined ? prev : 0); +} + +/** + * Seeks the playhead to the next item / cut boundary in the timeline. + */ +export function seekToNextItem(world: World): void { + const scene = getActiveEntity(world); + if (scene === null) return; + + const currentFrame = Math.round(store(world, Computed).localTime[scene.id()] ?? 0); + const points = getTimelinePoints(world); + + const next = points.find(p => p > currentFrame); + if (next !== undefined) { + setPlayhead(world, scene, next); + } +} + +const nudgeOrSeekHorizontal = (deltaFrames: number, nudgeDistance: number) => (world: World): void => { + const selection = getSelection(world); + if (selection.length > 0) { + nudgeSelection(world, deltaFrames < 0 ? -nudgeDistance : nudgeDistance, 0); + } else { + seekBy(world, deltaFrames); + } +}; + +const nudgeOrSeekVertical = (direction: 'prev' | 'next', nudgeDistance: number) => (world: World): void => { + const selection = getSelection(world); + if (selection.length > 0) { + nudgeSelection(world, 0, direction === 'prev' ? -nudgeDistance : nudgeDistance); + } else if (direction === 'prev') { + seekToPreviousItem(world); + } else { + seekToNextItem(world); + } +}; /** How long space has to be held to read as a pan and not as a tap. */ const SPACE_HAND_DELAY = 200; @@ -418,14 +497,14 @@ const PRESSED_SHORTCUTS: readonly Shortcut[] = [ { keys: ['\\', '!mod'], action: selectParents }, { keys: ['enter', '!mod'], action: selectChildren }, { keys: ['escape'], action: deselect }, - { keys: ['arrowleft', '!shift'], action: nudge(-NUDGE, 0) }, - { keys: ['arrowright', '!shift'], action: nudge(NUDGE, 0) }, - { keys: ['arrowup', '!shift'], action: nudge(0, -NUDGE) }, - { keys: ['arrowdown', '!shift'], action: nudge(0, NUDGE) }, - { keys: ['arrowleft', 'shift'], action: nudge(-NUDGE_FAST, 0) }, - { keys: ['arrowright', 'shift'], action: nudge(NUDGE_FAST, 0) }, - { keys: ['arrowup', 'shift'], action: nudge(0, -NUDGE_FAST) }, - { keys: ['arrowdown', 'shift'], action: nudge(0, NUDGE_FAST) }, + { keys: ['arrowleft', '!shift'], action: nudgeOrSeekHorizontal(-1, NUDGE) }, + { keys: ['arrowright', '!shift'], action: nudgeOrSeekHorizontal(1, NUDGE) }, + { keys: ['arrowup', '!shift'], action: nudgeOrSeekVertical('prev', NUDGE) }, + { keys: ['arrowdown', '!shift'], action: nudgeOrSeekVertical('next', NUDGE) }, + { keys: ['arrowleft', 'shift'], action: nudgeOrSeekHorizontal(-NUDGE_FAST, NUDGE_FAST) }, + { keys: ['arrowright', 'shift'], action: nudgeOrSeekHorizontal(NUDGE_FAST, NUDGE_FAST) }, + { keys: ['arrowup', 'shift'], action: nudgeOrSeekVertical('prev', NUDGE_FAST) }, + { keys: ['arrowdown', 'shift'], action: nudgeOrSeekVertical('next', NUDGE_FAST) }, { keys: [' '], action: onSpacePressed }, ]; diff --git a/package-lock.json b/package-lock.json index f65f214d..c6c80e37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,9 @@ "packages/*" ], "devDependencies": { - "patch-package": "^8.0.1" + "@types/three": "^0.185.4", + "patch-package": "^8.0.1", + "three": "^0.185.1" } }, "apps/cli": { @@ -651,6 +653,13 @@ "resolved": "apps/web", "link": true }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "devOptional": true, + "license": "Apache-2.0" + }, "node_modules/@electron-forge/cli": { "version": "7.11.2", "resolved": "https://registry.npmjs.org/@electron-forge/cli/-/cli-7.11.2.tgz", @@ -4001,6 +4010,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/appdmg": { "version": "0.5.5", "resolved": "https://registry.npmjs.org/@types/appdmg/-/appdmg-0.5.5.tgz", @@ -4192,7 +4208,29 @@ "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", - "dev": true, + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.185.4", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.4.tgz", + "integrity": "sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "devOptional": true, "license": "MIT" }, "node_modules/@types/wicg-file-system-access": { @@ -6987,6 +7025,13 @@ "pend": "~1.2.0" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -9006,6 +9051,13 @@ "node": ">= 8" } }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "devOptional": true, + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -11317,6 +11369,13 @@ "dev": true, "license": "MIT" }, + "node_modules/three": { + "version": "0.185.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz", + "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/tinyest": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyest/-/tinyest-0.3.2.tgz", diff --git a/package.json b/package.json index 69c193f8..7d2d009c 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "check": "npm run check --workspaces --if-present && tsc -p examples --noEmit" }, "devDependencies": { - "patch-package": "^8.0.1" + "@types/three": "^0.185.4", + "patch-package": "^8.0.1", + "three": "^0.185.1" } } diff --git a/packages/assets/src/browser.ts b/packages/assets/src/browser.ts index e0e61db4..a0853f02 100644 --- a/packages/assets/src/browser.ts +++ b/packages/assets/src/browser.ts @@ -2,6 +2,8 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +/// + // The browser side of getting files into and out of a library: pickers, // drops, and the save dialog. Nothing here talks to the user; a failure is // thrown or returned for the host to report.