Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,7 @@ out/
# AI
skills-lock.json
.agents
.claude
.claude

# Projects
projects/
12 changes: 10 additions & 2 deletions apps/cli/src/cli-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
38 changes: 30 additions & 8 deletions apps/desktop/src/cli-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ function AuthGate(props: { children: JSX.Element }) {
{props.children}
</Show>
</Show>
<Show when={!auth.isAuthenticated()}>
<Show when={!auth.isAuthenticated() && !auth.headless()}>
<LoginPage />
</Show>
</Show>
Expand Down
25 changes: 18 additions & 7 deletions apps/web/src/components/sidebar-right/inspector/animations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,16 @@ import { ANIMATION_GROUPS, DEFAULT_ANIMATION, animationOption } from "./animatio
import type { AnimationGroup, AnimationOption } from "./animation-types";
import type { Entity } from "koota";

/** `<animation>`'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[] = [];

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -287,18 +294,22 @@ function AnimationInspector(props: AnimationInspectorProps) {
<FloatingInspectorSeparator />
<FloatingInspectorContent class="flex flex-col gap-2 p-4">
<ControlRow label="Phase">
<Select<boolean>
value={isOut()}
onChange={(value) => value !== null && handlePhaseChange(value)}
options={[false, true]}
<Select<PhaseOption>
value={selectedPhase()}
onChange={(next) => next && handlePhaseChange(next.value === "out")}
options={PHASE_OPTIONS}
optionValue="value"
optionTextValue="label"
itemComponent={(itemProps) => (
<SelectItem item={itemProps.item}>
{itemProps.item.rawValue ? "Out" : "In"}
{itemProps.item.rawValue.label}
</SelectItem>
)}
>
<SelectTrigger>
<SelectValue class="text-xs">{isOut() ? "Out" : "In"}</SelectValue>
<SelectValue<PhaseOption> class="text-xs">
{(state) => state.selectedOption()?.label}
</SelectValue>
</SelectTrigger>
<SelectPortal>
<SelectContent />
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/engine/create-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
97 changes: 88 additions & 9 deletions apps/web/src/engine/input/shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>([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;
Expand Down Expand Up @@ -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 },
];

Expand Down
63 changes: 61 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Loading