mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2e2668701 | ||
|
|
b949fda7bc | ||
|
|
8e936a7594 | ||
|
|
96f87b050d | ||
|
|
4b9d5f8d23 | ||
|
|
6b81afc20b |
@@ -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)
|
Shared code between apps live in `rust/`, not in `packages/`
|
||||||
- `utils/` - small helper utils (generic, could be copy-pasted into any other app)
|
|
||||||
|
|
||||||
## 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)".
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
|
||||||
|
|
||||||
|
export default defineCloudflareConfig();
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"@huggingface/transformers": "^3.8.1",
|
"@huggingface/transformers": "^3.8.1",
|
||||||
"@opencut/env": "workspace:*",
|
"@opencut/env": "workspace:*",
|
||||||
"@opencut/ui": "workspace:*",
|
"@opencut/ui": "workspace:*",
|
||||||
"@opennextjs/cloudflare": "^1.17.3",
|
"@opennextjs/cloudflare": "^1.18.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export function SoundsView() {
|
|||||||
<div className="px-3 pt-4 pb-0">
|
<div className="px-3 pt-4 pb-0">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="sound-effects">Sound effects</TabsTrigger>
|
<TabsTrigger value="sound-effects">Sound effects</TabsTrigger>
|
||||||
<TabsTrigger value="songs">Songs</TabsTrigger>
|
|
||||||
<TabsTrigger value="saved">Saved</TabsTrigger>
|
<TabsTrigger value="saved">Saved</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,12 +58,6 @@ export function SoundsView() {
|
|||||||
>
|
>
|
||||||
<SavedSoundsView />
|
<SavedSoundsView />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent
|
|
||||||
value="songs"
|
|
||||||
className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0"
|
|
||||||
>
|
|
||||||
<SongsView />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -485,10 +478,6 @@ function SavedSoundsView() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SongsView() {
|
|
||||||
return <div>Songs</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AudioItemProps {
|
interface AudioItemProps {
|
||||||
sound: SoundEffect;
|
sound: SoundEffect;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
|
|||||||
+6
-2
@@ -1,7 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ParamDefinition, NumberParamDefinition } from "@/lib/params";
|
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 { SectionField } from "@/components/section";
|
||||||
import { NumberField } from "@/components/ui/number-field";
|
import { NumberField } from "@/components/ui/number-field";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
@@ -156,7 +160,7 @@ function NumberParamField({
|
|||||||
onPreview(clamped / displayMultiplier);
|
onPreview(clamped / displayMultiplier);
|
||||||
};
|
};
|
||||||
|
|
||||||
const maxFractionDigits = step >= 1 ? 0 : Math.round(-Math.log10(step));
|
const maxFractionDigits = getFractionDigitsForStep({ step });
|
||||||
|
|
||||||
const draft = usePropertyDraft({
|
const draft = usePropertyDraft({
|
||||||
displayValue: formatNumberForDisplay({
|
displayValue: formatNumberForDisplay({
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { NumberField } from "@/components/ui/number-field";
|
import { NumberField } from "@/components/ui/number-field";
|
||||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
|
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
|
||||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
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 type { AudioElement, VideoElement } from "@/lib/timeline";
|
||||||
import { resolveNumberAtTime } from "@/lib/animation";
|
import { resolveNumberAtTime } from "@/lib/animation";
|
||||||
import { useElementPlayhead } from "../hooks/use-element-playhead";
|
import { useElementPlayhead } from "../hooks/use-element-playhead";
|
||||||
@@ -18,6 +24,9 @@ import {
|
|||||||
SectionTitle,
|
SectionTitle,
|
||||||
} from "@/components/section";
|
} from "@/components/section";
|
||||||
|
|
||||||
|
const VOLUME_STEP = 0.1;
|
||||||
|
const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
|
||||||
|
|
||||||
export function AudioTab({
|
export function AudioTab({
|
||||||
element,
|
element,
|
||||||
trackId,
|
trackId,
|
||||||
@@ -42,17 +51,24 @@ export function AudioTab({
|
|||||||
propertyPath: "volume",
|
propertyPath: "volume",
|
||||||
localTime,
|
localTime,
|
||||||
isPlayheadWithinElementRange,
|
isPlayheadWithinElementRange,
|
||||||
displayValue: resolvedVolume.toFixed(1),
|
displayValue: formatNumberForDisplay({
|
||||||
|
value: resolvedVolume,
|
||||||
|
fractionDigits: VOLUME_FRACTION_DIGITS,
|
||||||
|
}),
|
||||||
parse: (input) => {
|
parse: (input) => {
|
||||||
const parsed = parseFloat(input);
|
const parsed = parseFloat(input);
|
||||||
if (Number.isNaN(parsed)) {
|
if (Number.isNaN(parsed)) {
|
||||||
return null;
|
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,
|
valueAtPlayhead: resolvedVolume,
|
||||||
step: 0.1,
|
step: VOLUME_STEP,
|
||||||
buildBaseUpdates: ({ value }) => ({
|
buildBaseUpdates: ({ value }) => ({
|
||||||
volume: value,
|
volume: value,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -38,7 +38,12 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { clamp, formatNumberForDisplay, snapToStep } from "@/utils/math";
|
import {
|
||||||
|
clamp,
|
||||||
|
formatNumberForDisplay,
|
||||||
|
getFractionDigitsForStep,
|
||||||
|
snapToStep,
|
||||||
|
} from "@/utils/math";
|
||||||
import {
|
import {
|
||||||
Section,
|
Section,
|
||||||
SectionContent,
|
SectionContent,
|
||||||
@@ -599,7 +604,7 @@ function MaskNumberField({
|
|||||||
const max = isPercent ? 100 : param.max;
|
const max = isPercent ? 100 : param.max;
|
||||||
const step = isPercent ? 1 : param.step;
|
const step = isPercent ? 1 : param.step;
|
||||||
const displayValue = value * displayMultiplier;
|
const displayValue = value * displayMultiplier;
|
||||||
const maxFractionDigits = step >= 1 ? 0 : Math.round(-Math.log10(step));
|
const maxFractionDigits = getFractionDigitsForStep({ step });
|
||||||
|
|
||||||
const clampDisplay = (nextDisplayValue: number) =>
|
const clampDisplay = (nextDisplayValue: number) =>
|
||||||
max !== undefined
|
max !== undefined
|
||||||
|
|||||||
@@ -22,16 +22,28 @@ import {
|
|||||||
SectionTitle,
|
SectionTitle,
|
||||||
} from "@/components/section";
|
} from "@/components/section";
|
||||||
import { usePropertyDraft } from "../hooks/use-property-draft";
|
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 {
|
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 {
|
function parseSpeedInput({ input }: { input: string }): number | null {
|
||||||
const parsed = parseFloat(input);
|
const parsed = parseFloat(input);
|
||||||
if (Number.isNaN(parsed)) return null;
|
if (Number.isNaN(parsed)) return null;
|
||||||
const rounded = Math.round(parsed * 100) / 100;
|
return clampRetimeRate({
|
||||||
return clampRetimeRate({ rate: rounded });
|
rate: snapToStep({ value: parsed, step: SPEED_STEP }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRetime({
|
function buildRetime({
|
||||||
@@ -56,24 +68,21 @@ export function SpeedTab({
|
|||||||
const rate = clampRetimeRate({
|
const rate = clampRetimeRate({
|
||||||
rate: element.retime?.rate ?? DEFAULT_RETIME_RATE,
|
rate: element.retime?.rate ?? DEFAULT_RETIME_RATE,
|
||||||
});
|
});
|
||||||
const maintainPitchAvailable = canMaintainPitch({ rate });
|
const isPitchPreserveAvailable = canMaintainPitch({ rate });
|
||||||
const maintainPitch = element.retime?.maintainPitch ?? false;
|
const maintainPitch = element.retime?.maintainPitch ?? false;
|
||||||
const pendingRateRef = useRef(rate);
|
const pendingRateRef = useRef(rate);
|
||||||
|
|
||||||
const applyRetime = ({
|
const commitRetime = ({
|
||||||
rate: nextRate,
|
rate: nextRate,
|
||||||
maintainPitch: nextMaintainPitch,
|
maintainPitch: nextMaintainPitch,
|
||||||
pushHistory = true,
|
|
||||||
}: {
|
}: {
|
||||||
rate: number;
|
rate: number;
|
||||||
maintainPitch: boolean;
|
maintainPitch: boolean;
|
||||||
pushHistory?: boolean;
|
|
||||||
}) => {
|
}) => {
|
||||||
editor.timeline.updateElementRetime({
|
editor.timeline.updateElementRetime({
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
retime: buildRetime({ rate: nextRate, maintainPitch: nextMaintainPitch }),
|
retime: buildRetime({ rate: nextRate, maintainPitch: nextMaintainPitch }),
|
||||||
pushHistory,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,10 +91,20 @@ export function SpeedTab({
|
|||||||
parse: (input) => parseSpeedInput({ input }),
|
parse: (input) => parseSpeedInput({ input }),
|
||||||
onPreview: (nextRate) => {
|
onPreview: (nextRate) => {
|
||||||
pendingRateRef.current = 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: () => {
|
onCommit: () => {
|
||||||
applyRetime({ rate: pendingRateRef.current, maintainPitch });
|
commitRetime({ rate: pendingRateRef.current, maintainPitch });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,20 +134,20 @@ export function SpeedTab({
|
|||||||
onScrub={speedDraft.scrubTo}
|
onScrub={speedDraft.scrubTo}
|
||||||
onScrubEnd={speedDraft.commitScrub}
|
onScrubEnd={speedDraft.commitScrub}
|
||||||
onReset={() =>
|
onReset={() =>
|
||||||
applyRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch })
|
commitRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch })
|
||||||
}
|
}
|
||||||
isDefault={rate === DEFAULT_RETIME_RATE}
|
isDefault={rate === DEFAULT_RETIME_RATE}
|
||||||
/>
|
/>
|
||||||
</SectionField>
|
</SectionField>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm">Change pitch</span>
|
<span className="text-sm">Change pitch</span>
|
||||||
<Switch
|
<Switch
|
||||||
checked={!maintainPitch}
|
checked={!maintainPitch}
|
||||||
disabled={!maintainPitchAvailable}
|
disabled={!isPitchPreserveAvailable}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
applyRetime({ rate, maintainPitch: !checked })
|
commitRetime({ rate, maintainPitch: !checked })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SectionFields>
|
</SectionFields>
|
||||||
</SectionContent>
|
</SectionContent>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
import { ColorPicker } from "@/components/ui/color-picker";
|
import { ColorPicker } from "@/components/ui/color-picker";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { uppercase } from "@/utils/string";
|
import { uppercase } from "@/utils/string";
|
||||||
import { clamp } from "@/utils/math";
|
import { clamp, formatNumberForDisplay } from "@/utils/math";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { DEFAULT_COLOR } from "@/constants/project-constants";
|
import { DEFAULT_COLOR } from "@/constants/project-constants";
|
||||||
import {
|
import {
|
||||||
@@ -251,7 +251,10 @@ function SpacingSection({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const lineHeight = usePropertyDraft({
|
const lineHeight = usePropertyDraft({
|
||||||
displayValue: (element.lineHeight ?? DEFAULTS.text.lineHeight).toFixed(1),
|
displayValue: formatNumberForDisplay({
|
||||||
|
value: element.lineHeight ?? DEFAULTS.text.lineHeight,
|
||||||
|
fractionDigits: 1,
|
||||||
|
}),
|
||||||
parse: (input) => {
|
parse: (input) => {
|
||||||
const parsed = parseFloat(input);
|
const parsed = parseFloat(input);
|
||||||
return Number.isNaN(parsed)
|
return Number.isNaN(parsed)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { ColorPicker } from "@/components/ui/color-picker";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { uppercase } from "@/utils/string";
|
import { uppercase } from "@/utils/string";
|
||||||
import { clamp } from "@/utils/math";
|
import { clamp, formatNumberForDisplay } from "@/utils/math";
|
||||||
import {
|
import {
|
||||||
timelineTimeToPixels,
|
timelineTimeToPixels,
|
||||||
timelineTimeToSnappedPixels,
|
timelineTimeToSnappedPixels,
|
||||||
@@ -188,7 +188,7 @@ function TimelineBookmark({
|
|||||||
left: `${bookmarkLeft}px`,
|
left: `${bookmarkLeft}px`,
|
||||||
width: `${bookmarkWidth}px`,
|
width: `${bookmarkWidth}px`,
|
||||||
}}
|
}}
|
||||||
aria-label={`Bookmark at ${time.toFixed(1)}s`}
|
aria-label={`Bookmark at ${formatNumberForDisplay({ value: time, fractionDigits: 1 })}s`}
|
||||||
type="button"
|
type="button"
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
export async function proxy() {
|
export async function middleware() {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
/*
|
/*
|
||||||
* Match all request paths except for the ones starting with:
|
* Match all request paths except for the ones starting with:
|
||||||
* - api (API routes)
|
* - api (API routes)
|
||||||
* - _next/static (static files)
|
* - _next/static (static files)
|
||||||
* - _next/image (image optimization files)
|
* - _next/image (image optimization files)
|
||||||
* - favicon.ico (favicon file)
|
* - favicon.ico (favicon file)
|
||||||
*/
|
*/
|
||||||
"/((?!api|_next/static|_next/image|favicon.ico).*)",
|
"/((?!api|_next/static|_next/image|favicon.ico).*)",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { formatNumberForDisplay } from "@/utils/math";
|
||||||
|
|
||||||
const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const;
|
const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const;
|
||||||
|
|
||||||
export const STORAGE_HEADROOM_RESERVE_BYTES = 50 * 1024 * 1024;
|
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;
|
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> {
|
export async function readStorageQuotaStatus(): Promise<StorageQuotaStatus> {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function clampRound({
|
|||||||
return Math.round(clamp({ value, min, max }));
|
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();
|
const normalizedStep = step.toString().toLowerCase();
|
||||||
if (normalizedStep.includes("e-")) {
|
if (normalizedStep.includes("e-")) {
|
||||||
return Number(normalizedStep.split("e-")[1] ?? 0);
|
return Number(normalizedStep.split("e-")[1] ?? 0);
|
||||||
@@ -59,12 +59,43 @@ export function isNearlyEqual({
|
|||||||
|
|
||||||
export function formatNumberForDisplay({
|
export function formatNumberForDisplay({
|
||||||
value,
|
value,
|
||||||
|
fractionDigits,
|
||||||
|
minFractionDigits = 0,
|
||||||
maxFractionDigits = 6,
|
maxFractionDigits = 6,
|
||||||
}: {
|
}: {
|
||||||
value: number;
|
value: number;
|
||||||
|
fractionDigits?: number;
|
||||||
|
minFractionDigits?: number;
|
||||||
maxFractionDigits?: number;
|
maxFractionDigits?: number;
|
||||||
}): string {
|
}): 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({
|
export function evaluateMathExpression({
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"@huggingface/transformers": "^3.8.1",
|
"@huggingface/transformers": "^3.8.1",
|
||||||
"@opencut/env": "workspace:*",
|
"@opencut/env": "workspace:*",
|
||||||
"@opencut/ui": "workspace:*",
|
"@opencut/ui": "workspace:*",
|
||||||
"@opennextjs/cloudflare": "^1.17.3",
|
"@opennextjs/cloudflare": "^1.18.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@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/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=="],
|
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user