Compare commits

..
6 Commits
14 changed files with 147 additions and 179 deletions
+9 -113
View File
@@ -1,121 +1,17 @@
# AGENTS.md
# Agents.md
## Overview
## Apps
Privacy-first video editor, with a focus on simplicity and ease of use.
- Web
- Desktop
## Lib vs Utils
## Rust
- `lib/` - domain logic (specific to this app)
- `utils/` - small helper utils (generic, could be copy-pasted into any other app)
Shared code between apps live in `rust/`, not in `packages/`
## Core Editor System
## Web
The editor uses a **singleton EditorCore** that manages all editor state through specialized managers.
### React
### Architecture
- Read components before using them. They may already apply classes, which affects what you need to pass and how to override them.
```
EditorCore (singleton)
├── playback: PlaybackManager
├── timeline: TimelineManager
├── scene: SceneManager
├── project: ProjectManager
├── media: MediaManager
└── renderer: RendererManager
```
### When to Use What
#### In React Components
**Always use the `useEditor()` hook:**
```typescript
import { useEditor } from '@/hooks/use-editor';
function MyComponent() {
const editor = useEditor();
const tracks = editor.timeline.getTracks();
// Call methods
editor.timeline.addTrack({ type: 'media' });
// Display data (auto re-renders on changes)
return <div>{tracks.length} tracks</div>;
}
```
The hook:
- Returns the singleton instance
- Subscribes to all manager changes
- Automatically re-renders when state changes
#### Outside React Components
**Use `EditorCore.getInstance()` directly:**
```typescript
// In utilities, event handlers, or non-React code
import { EditorCore } from "@/core";
const editor = EditorCore.getInstance();
await editor.export({ format: "mp4", quality: "high" });
```
## Actions System
Actions are the trigger layer for user-initiated operations. The single source of truth is `@/lib/actions/definitions.ts`.
**To add a new action:**
1. Add it to `ACTIONS` in `@/lib/actions/definitions.ts`:
```typescript
export const ACTIONS = {
"my-action": {
description: "What the action does",
category: "editing",
defaultShortcuts: ["ctrl+m"],
},
// ...
};
```
2. Add handler in `@/hooks/use-editor-actions.ts`:
```typescript
useActionHandler(
"my-action",
() => {
// implementation
},
undefined,
);
```
**In components, use `invokeAction()` for user-triggered operations:**
```typescript
import { invokeAction } from '@/lib/actions';
// Good - uses action system
const handleSplit = () => invokeAction("split-selected");
// Avoid - bypasses UX layer (toasts, validation feedback)
const handleSplit = () => editor.timeline.splitElements({ ... });
```
Direct `editor.xxx()` calls are for internal use (commands, tests, complex multi-step operations).
## Commands System
Commands handle undo/redo. They live in `@/lib/commands/` organized by domain (timeline, media, scene).
Each command extends `Command` from `@/lib/commands/base-command` and implements:
- `execute()` - saves current state, then does the mutation
- `undo()` - restores the saved state
Actions and commands work together: actions are "what triggered this", commands are "how to do it (and undo it)".
+3
View File
@@ -0,0 +1,3 @@
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
export default defineCloudflareConfig();
+1 -1
View File
@@ -24,7 +24,7 @@
"@huggingface/transformers": "^3.8.1",
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
"@opennextjs/cloudflare": "^1.17.3",
"@opennextjs/cloudflare": "^1.18.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@@ -42,7 +42,6 @@ export function SoundsView() {
<div className="px-3 pt-4 pb-0">
<TabsList>
<TabsTrigger value="sound-effects">Sound effects</TabsTrigger>
<TabsTrigger value="songs">Songs</TabsTrigger>
<TabsTrigger value="saved">Saved</TabsTrigger>
</TabsList>
</div>
@@ -59,12 +58,6 @@ export function SoundsView() {
>
<SavedSoundsView />
</TabsContent>
<TabsContent
value="songs"
className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0"
>
<SongsView />
</TabsContent>
</Tabs>
</div>
);
@@ -485,10 +478,6 @@ function SavedSoundsView() {
);
}
function SongsView() {
return <div>Songs</div>;
}
interface AudioItemProps {
sound: SoundEffect;
isPlaying: boolean;
@@ -1,7 +1,11 @@
"use client";
import type { ParamDefinition, NumberParamDefinition } from "@/lib/params";
import { formatNumberForDisplay, snapToStep } from "@/utils/math";
import {
formatNumberForDisplay,
getFractionDigitsForStep,
snapToStep,
} from "@/utils/math";
import { SectionField } from "@/components/section";
import { NumberField } from "@/components/ui/number-field";
import { Switch } from "@/components/ui/switch";
@@ -156,7 +160,7 @@ function NumberParamField({
onPreview(clamped / displayMultiplier);
};
const maxFractionDigits = step >= 1 ? 0 : Math.round(-Math.log10(step));
const maxFractionDigits = getFractionDigitsForStep({ step });
const draft = usePropertyDraft({
displayValue: formatNumberForDisplay({
@@ -1,7 +1,13 @@
import { NumberField } from "@/components/ui/number-field";
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
import { DEFAULTS } from "@/lib/timeline/defaults";
import { clamp, isNearlyEqual } from "@/utils/math";
import {
clamp,
formatNumberForDisplay,
getFractionDigitsForStep,
isNearlyEqual,
snapToStep,
} from "@/utils/math";
import type { AudioElement, VideoElement } from "@/lib/timeline";
import { resolveNumberAtTime } from "@/lib/animation";
import { useElementPlayhead } from "../hooks/use-element-playhead";
@@ -18,6 +24,9 @@ import {
SectionTitle,
} from "@/components/section";
const VOLUME_STEP = 0.1;
const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
export function AudioTab({
element,
trackId,
@@ -42,17 +51,24 @@ export function AudioTab({
propertyPath: "volume",
localTime,
isPlayheadWithinElementRange,
displayValue: resolvedVolume.toFixed(1),
displayValue: formatNumberForDisplay({
value: resolvedVolume,
fractionDigits: VOLUME_FRACTION_DIGITS,
}),
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) {
return null;
}
return clamp({ value: parsed, min: VOLUME_DB_MIN, max: VOLUME_DB_MAX });
return clamp({
value: snapToStep({ value: parsed, step: VOLUME_STEP }),
min: VOLUME_DB_MIN,
max: VOLUME_DB_MAX,
});
},
valueAtPlayhead: resolvedVolume,
step: 0.1,
step: VOLUME_STEP,
buildBaseUpdates: ({ value }) => ({
volume: value,
}),
@@ -38,7 +38,12 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { clamp, formatNumberForDisplay, snapToStep } from "@/utils/math";
import {
clamp,
formatNumberForDisplay,
getFractionDigitsForStep,
snapToStep,
} from "@/utils/math";
import {
Section,
SectionContent,
@@ -599,7 +604,7 @@ function MaskNumberField({
const max = isPercent ? 100 : param.max;
const step = isPercent ? 1 : param.step;
const displayValue = value * displayMultiplier;
const maxFractionDigits = step >= 1 ? 0 : Math.round(-Math.log10(step));
const maxFractionDigits = getFractionDigitsForStep({ step });
const clampDisplay = (nextDisplayValue: number) =>
max !== undefined
@@ -22,16 +22,28 @@ import {
SectionTitle,
} from "@/components/section";
import { usePropertyDraft } from "../hooks/use-property-draft";
import {
formatNumberForDisplay,
getFractionDigitsForStep,
snapToStep,
} from "@/utils/math";
const SPEED_STEP = 0.01;
const SPEED_FRACTION_DIGITS = getFractionDigitsForStep({ step: SPEED_STEP });
function rateToDisplay({ rate }: { rate: number }): string {
return rate.toFixed(2);
return formatNumberForDisplay({
value: rate,
fractionDigits: SPEED_FRACTION_DIGITS,
});
}
function parseSpeedInput({ input }: { input: string }): number | null {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
const rounded = Math.round(parsed * 100) / 100;
return clampRetimeRate({ rate: rounded });
return clampRetimeRate({
rate: snapToStep({ value: parsed, step: SPEED_STEP }),
});
}
function buildRetime({
@@ -56,24 +68,21 @@ export function SpeedTab({
const rate = clampRetimeRate({
rate: element.retime?.rate ?? DEFAULT_RETIME_RATE,
});
const maintainPitchAvailable = canMaintainPitch({ rate });
const isPitchPreserveAvailable = canMaintainPitch({ rate });
const maintainPitch = element.retime?.maintainPitch ?? false;
const pendingRateRef = useRef(rate);
const applyRetime = ({
const commitRetime = ({
rate: nextRate,
maintainPitch: nextMaintainPitch,
pushHistory = true,
}: {
rate: number;
maintainPitch: boolean;
pushHistory?: boolean;
}) => {
editor.timeline.updateElementRetime({
trackId,
elementId: element.id,
retime: buildRetime({ rate: nextRate, maintainPitch: nextMaintainPitch }),
pushHistory,
});
};
@@ -82,10 +91,20 @@ export function SpeedTab({
parse: (input) => parseSpeedInput({ input }),
onPreview: (nextRate) => {
pendingRateRef.current = nextRate;
applyRetime({ rate: nextRate, maintainPitch, pushHistory: false });
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
retime: buildRetime({ rate: nextRate, maintainPitch }),
},
},
],
});
},
onCommit: () => {
applyRetime({ rate: pendingRateRef.current, maintainPitch });
commitRetime({ rate: pendingRateRef.current, maintainPitch });
},
});
@@ -115,7 +134,7 @@ export function SpeedTab({
onScrub={speedDraft.scrubTo}
onScrubEnd={speedDraft.commitScrub}
onReset={() =>
applyRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch })
commitRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch })
}
isDefault={rate === DEFAULT_RETIME_RATE}
/>
@@ -124,9 +143,9 @@ export function SpeedTab({
<span className="text-sm">Change pitch</span>
<Switch
checked={!maintainPitch}
disabled={!maintainPitchAvailable}
disabled={!isPitchPreserveAvailable}
onCheckedChange={(checked) =>
applyRetime({ rate, maintainPitch: !checked })
commitRetime({ rate, maintainPitch: !checked })
}
/>
</div>
@@ -16,7 +16,7 @@ import {
import { ColorPicker } from "@/components/ui/color-picker";
import { Button } from "@/components/ui/button";
import { uppercase } from "@/utils/string";
import { clamp } from "@/utils/math";
import { clamp, formatNumberForDisplay } from "@/utils/math";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_COLOR } from "@/constants/project-constants";
import {
@@ -251,7 +251,10 @@ function SpacingSection({
});
const lineHeight = usePropertyDraft({
displayValue: (element.lineHeight ?? DEFAULTS.text.lineHeight).toFixed(1),
displayValue: formatNumberForDisplay({
value: element.lineHeight ?? DEFAULTS.text.lineHeight,
fractionDigits: 1,
}),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed)
@@ -27,7 +27,7 @@ import { ColorPicker } from "@/components/ui/color-picker";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { uppercase } from "@/utils/string";
import { clamp } from "@/utils/math";
import { clamp, formatNumberForDisplay } from "@/utils/math";
import {
timelineTimeToPixels,
timelineTimeToSnappedPixels,
@@ -188,7 +188,7 @@ function TimelineBookmark({
left: `${bookmarkLeft}px`,
width: `${bookmarkWidth}px`,
}}
aria-label={`Bookmark at ${time.toFixed(1)}s`}
aria-label={`Bookmark at ${formatNumberForDisplay({ value: time, fractionDigits: 1 })}s`}
type="button"
onMouseDown={handleMouseDown}
onClick={handleClick}
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
export async function proxy() {
export async function middleware() {
return NextResponse.next();
}
+3 -1
View File
@@ -1,3 +1,5 @@
import { formatNumberForDisplay } from "@/utils/math";
const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const;
export const STORAGE_HEADROOM_RESERVE_BYTES = 50 * 1024 * 1024;
@@ -37,7 +39,7 @@ export function formatStorageBytes({ bytes }: { bytes: number }): string {
}
const precision = value >= 10 || unitIndex === 0 ? 0 : 1;
return `${value.toFixed(precision)} ${BYTE_UNITS[unitIndex]}`;
return `${formatNumberForDisplay({ value, fractionDigits: precision })} ${BYTE_UNITS[unitIndex]}`;
}
export async function readStorageQuotaStatus(): Promise<StorageQuotaStatus> {
+33 -2
View File
@@ -22,7 +22,7 @@ export function clampRound({
return Math.round(clamp({ value, min, max }));
}
function getFractionDigitsForStep({ step }: { step: number }): number {
export function getFractionDigitsForStep({ step }: { step: number }): number {
const normalizedStep = step.toString().toLowerCase();
if (normalizedStep.includes("e-")) {
return Number(normalizedStep.split("e-")[1] ?? 0);
@@ -59,12 +59,43 @@ export function isNearlyEqual({
export function formatNumberForDisplay({
value,
fractionDigits,
minFractionDigits = 0,
maxFractionDigits = 6,
}: {
value: number;
fractionDigits?: number;
minFractionDigits?: number;
maxFractionDigits?: number;
}): string {
return Number(value.toFixed(maxFractionDigits)).toString();
const resolvedMaxFractionDigits = Math.max(
0,
fractionDigits ?? maxFractionDigits,
);
const resolvedMinFractionDigits = Math.min(
Math.max(0, fractionDigits ?? minFractionDigits),
resolvedMaxFractionDigits,
);
const fixedValue = value.toFixed(resolvedMaxFractionDigits);
if (resolvedMaxFractionDigits === 0) {
return Number(fixedValue) === 0 ? "0" : fixedValue;
}
const [integerPart, fractionPart = ""] = fixedValue.split(".");
const normalizedIntegerPart = Number(fixedValue) === 0 ? "0" : integerPart;
let trimmedFractionPart = fractionPart;
while (
trimmedFractionPart.length > resolvedMinFractionDigits &&
trimmedFractionPart.endsWith("0")
) {
trimmedFractionPart = trimmedFractionPart.slice(0, -1);
}
return trimmedFractionPart
? `${normalizedIntegerPart}.${trimmedFractionPart}`
: normalizedIntegerPart;
}
export function evaluateMathExpression({
+2 -2
View File
@@ -26,7 +26,7 @@
"@huggingface/transformers": "^3.8.1",
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
"@opennextjs/cloudflare": "^1.17.3",
"@opennextjs/cloudflare": "^1.18.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@@ -517,7 +517,7 @@
"@opennextjs/aws": ["@opennextjs/aws@3.9.16", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": "~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5" }, "bin": { "open-next": "dist/index.js" } }, "sha512-jQQStCysIllNCPqz5W2KSguXpr+ETlOcD8SyNu+h9zwpRVYk4uEPQge+ErG3avI5xsT8vKA7EGLYG59dhj/B6Q=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.17.3", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.9.16", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": "~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5", "wrangler": "^4.65.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-BQT8R/CEqrZS2sYydJ6uqv3U+yZAxbTeDUfgqQIlOnhu3wDPsB/QuuUBBseWOZiOdZ8gLegK6F2SLKia74Nv6g=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.18.0", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.9.16", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": "~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5", "wrangler": "^4.65.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-JM236YHnKzroFAZqst1t28ZGOShvnkVUDtjrp7TJ/W2P3RLo4b6npJ8VEXOn6frs6lsUfR5rvsKYLYb7h1GIJQ=="],
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],