Compare commits

..
Author SHA1 Message Date
Maze Winther 9ca32e5e39 fix: click empty on track to move playhead 2026-02-02 19:44:27 +01:00
Maze Winther 5120e2efa6 feat: timeline zoom anchoring 2026-02-02 19:42:35 +01:00
Maze Winther 1aec9c5cf5 fix comment 2026-02-02 19:15:25 +01:00
Maze Winther e88a059222 increase zoom of buttons + refactor 2026-02-02 19:14:37 +01:00
Maze Winther c3f0d640ef feat: exponential zoom 2026-02-02 18:11:12 +01:00
Maze Winther 22ea6b8925 change rounding 2026-02-01 15:35:53 +01:00
Maze Winther dfccf905bb more test stuff 2026-02-01 13:31:44 +01:00
Maze Winther 3e4584edde fix 2026-02-01 13:19:51 +01:00
Maze Winther 4cd15fcf00 fix default panel sizes 2026-02-01 12:32:28 +01:00
Maze Winther 748856f4c4 fix onboarding text 2026-02-01 12:32:18 +01:00
Maze Winther 2759d7ed31 add tests for storage migrations 2026-02-01 12:15:37 +01:00
Maze Winther 03c2b16064 next-env.d.ts 2026-02-01 11:31:37 +01:00
Maze Winther e15ccefbe5 deps 2026-02-01 11:26:58 +01:00
Maze Winther 5e1e509c11 testing 4 2026-02-01 11:18:38 +01:00
Maze Winther bfcf20e108 testing 3 2026-02-01 11:16:18 +01:00
Maze Winther 4f64ff50cd testing 2 2026-02-01 11:09:55 +01:00
Maze Winther 58f23e8f2a testing 2026-02-01 11:06:20 +01:00
Maze Winther 33f4a54768 please 2026-02-01 10:59:05 +01:00
Maze Winther 1d55b903d7 fix remaining errors 2026-02-01 10:03:51 +01:00
Maze Winther af047ba4a7 more errors 2026-02-01 09:54:29 +01:00
Maze Winther ef3563745a fix errors 2026-02-01 02:10:24 +01:00
Maze Winther d06b7c3b0f update type definition for containerRef in ColorPickerProps to allow null 2026-02-01 01:44:48 +01:00
Maze Winther b7293dc552 fix type definition for ref in PanelBaseViewProps 2026-02-01 00:32:35 +01:00
Maze Winther 82d92934cb typescript error 2026-02-01 00:22:48 +01:00
Maze Winther b2a976ba82 fix type error (build) 2026-02-01 00:20:34 +01:00
Maze Winther 02d4f9c314 no idea 2026-02-01 00:20:10 +01:00
Maze Winther 1d31b7a586 fix import 2026-01-31 22:54:33 +01:00
Maze Winther e00ba706fe update deps 2026-01-31 22:24:22 +01:00
Maze Winther f820d9a894 refactor services structure 2026-01-31 22:19:11 +01:00
Maze Winther 7878c8bb7b add code review command 2026-01-31 22:18:39 +01:00
Nandni VermaandGitHub 1f618601c7 Clarify statement about CapCut features (#686) 2026-01-31 15:59:59 +01:00
Joep DuinandGitHub 59c189ca27 Github readme logo fix. (#699)
* Add files via upload

* Update logo image source in README.md

* Rename logo.png to apps/web/public/icons/github-logo.png

* Rename github-logo.png to github-logo.png

* Rename github-logo.png to github-logo.png

* Rename apps/web/public/logos/github-logo.png to apps/web/public/logos/opencut/white-black.png

* Rename apps/web/public/logos/opencut/white-black.png to apps/web/public/logos/opencut/1k/logo-white-black.png

* Update logo image source in README

* Update logo image path in README.md
2026-01-31 15:55:01 +01:00
MazeandGitHub 7bf0984698 codebase overhaul (#697) 2026-01-31 00:20:04 +01:00
59 changed files with 2456 additions and 1330 deletions
+109
View File
@@ -0,0 +1,109 @@
# Code Review Checklist
Review every point below carefully to ensure files follow consistent code style and best practices.
---
## Function Signatures & Parameters
- [ ] Every function accepts a single object parameter with destructuring in the signature (for readability and future extensibility)
- Exception: tiny one-liner callbacks (e.g. `array.find(x => ...)`, `map`, `filter`, `sort`) do not need destructuring if it hurts readability
```tsx
// ❌ wrong
function formatTime(seconds: number, fps: number) { ... }
// ✅ correct
function formatTime({ seconds, fps }: { seconds: number; fps: number }) { ... }
```
## TypeScript & Type Safety
- [ ] No `any` references
- [ ] General interfaces are in the `types` folder, not scattered in components
- Example: `TimelineTrack` interface belongs in `src/types/timeline.ts`, not `src/components/timeline/index.tsx`
## JSX & Components
- [ ] JSX is clean — no comments explaining what each part does
- [ ] Complex/reusable JSX is extracted into sub-components (placed below the main component)
- [ ] Components shared across multiple files are in separate files
- [ ] File order: constants specific to file (top) -> utils specific to file -> main component → sub-components (bottom)
- [ ] Components render UI only — domain logic lives in hooks, utilities, or managers
- Simple interaction logic (gestures, modifier keys) can stay if not complex
## Code Organization & File Structure
- [ ] Each file has one single purpose/responsibility
- Example: `timeline/index.tsx` should not define `validateElementTrackCompatibility` — that belongs in a lib file
- Example: `lib/timeline-utils.ts` should not declare `TRACK_COLORS` — that belongs in `constants/`
- [ ] Business logic lives in either `src/lib`, `src/core` or `src/services` folder
## Comments
- [ ] No AI comments — only human comments that explain _why_, not _what_
- Bad: changelog-style comments, explaining readable code, using more words than necessary
- [ ] All comments are lowercase
## Naming Conventions
- [ ] Readability over brevity — use `element` not `el`, `event` not `e`
- [ ] Booleans are named `isSomething`, `hasSomething`, or `shouldSomething` — not `something`
- [ ] No title case in text/UI — use `Hello world` not `Hello World`
## Tailwind & Styling
- [ ] Use `gap-*` instead of `mb-*` or `mt-*` for consistent spacing
- [ ] Use `size-*` instead of `h-* w-*` when width and height are the same
- [ ] When using `size-*` on icons inside `<Button>`, use `!` modifier to override default `size-4`
```tsx
<Button>
<PlusIcon className="!size-6" /> {/* ✅ correct */}
<PlusIcon className="size-6" /> {/* ❌ wrong */}
<PlusIcon className="!size-4" /> {/* ❌ unnecessary, size-4 is default */}
</Button>
```
## State Management (Zustand)
- [ ] React components never use `someStore.getState()` — use the `useSomeStore` hook instead
- [ ] Store/manager methods are not passed as props — sub-components access them directly
```tsx
// ❌ wrong
function Parent() {
const { selectedElements } = useTimelineStore();
return <Child selectedElements={selectedElements} />;
}
// ✅ correct
function Parent() {
return <Child />;
}
function Child() {
const { selectedElements } = useTimelineStore();
}
```
- [ ] Components and hooks should use the `useEditor` hook. Only use `EditorCore.getInstance()` if you are outside of a react component/hook. Eg: in a utility function, event handler.
## Code Quality
- [ ] Code is scannable — use variables and helper functions to make intent clear at a glance
- [ ] Complex logic is extracted into well-named variables or helpers
---
## Function Keywords
| Context | Keyword |
| --------------------------------- | ------------------------- |
| Next.js page components | `export default function` |
| Main react component | `export function` |
| Sub-components | `function` |
| Utility functions | `export function` |
| Functions inside react components | `const` |
---
> Every decision, every edit must be carefully considered. Everything matters.
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,7 +1,7 @@
<table width="100%">
<tr>
<td align="left" width="120">
<img src="apps/web/public/logos/opencut/1k/logo.png" alt="OpenCut Logo" width="100" />
<img src="apps/web/public/logos/opencut/1k/logo-white-black.png" alt="OpenCut Logo" width="100" />
</td>
<td align="right">
<h1>OpenCut</span></h1>
@@ -13,7 +13,7 @@
## Why?
- **Privacy**: Your videos stay on your device
- **Free features**: Every basic feature of CapCut is paywalled now
- **Free features**: Most basic CapCut features are now paywalled
- **Simple**: People want editors that are easy to use - CapCut proved that
## Features
+4 -4
View File
@@ -55,9 +55,9 @@
"pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.2",
"react": "^18.2.0",
"react": "^19.0.0",
"react-day-picker": "^8.10.1",
"react-dom": "^18.2.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.0",
"react-icons": "^5.4.0",
"react-markdown": "^10.1.0",
@@ -85,8 +85,8 @@
"@types/bun": "latest",
"@types/node": "^24.2.1",
"@types/pg": "^8.15.4",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/react": "^19",
"@types/react-dom": "^19",
"cross-env": "^7.0.3",
"drizzle-kit": "^0.31.4",
"dotenv": "^16.5.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

+1 -1
View File
@@ -585,7 +585,7 @@ function ProjectItem({
</div>
{durationLabel && (
<div className="absolute bottom-2 right-2 bg-black/60 text-white text-xs font-semibold px-2 py-1 rounded-lg">
<div className="absolute bottom-2 right-2 bg-black/60 text-white text-xs font-semibold px-2 py-1 rounded-sm">
{durationLabel}
</div>
)}
@@ -55,8 +55,8 @@ export function Onboarding() {
<div className="space-y-5">
<div className="space-y-3">
<Title title={getStepTitle()} />
<Description description="OpenCut started just one month ago. There's still a ton of things to do to make this editor amazing." />
<Description description="A lot of features are still missing, like the export functionality. We're working hard to build them out!" />
<Description description="There's still a ton of things to do to make this editor amazing." />
<Description description="A lot of features are still missing. We're working hard to build them out!" />
<Description description="If you're curious, check out our roadmap [here](https://opencut.app/roadmap)" />
</div>
<NextButton onClick={handleNext}>Next</NextButton>
@@ -85,9 +85,7 @@ export function Onboarding() {
<DialogTitle>
<span className="sr-only">{getStepTitle()}</span>
</DialogTitle>
<DialogBody>
{renderStepContent()}
</DialogBody>
<DialogBody>{renderStepContent()}</DialogBody>
</DialogContent>
</Dialog>
);
@@ -17,7 +17,7 @@ import type {
TranscriptionLanguage,
TranscriptionProgress,
} from "@/types/transcription";
import { transcriptionService } from "@/services/transcription";
import { transcriptionService } from "@/services/transcription/service";
import { decodeAudioToFloat32 } from "@/lib/media/audio";
import { buildCaptionChunks } from "@/lib/transcription/caption";
import { Spinner } from "@/components/ui/spinner";
@@ -15,7 +15,7 @@ interface PanelBaseViewProps {
content: React.ReactNode;
}[];
className?: string;
ref?: React.RefObject<HTMLDivElement>;
ref?: React.Ref<HTMLDivElement>;
}
function ViewContent({
@@ -5,7 +5,7 @@ import { useMemo } from "react";
interface SelectionBoxProps {
startPos: { x: number; y: number } | null;
currentPos: { x: number; y: number } | null;
containerRef: React.RefObject<HTMLElement>;
containerRef: React.RefObject<HTMLElement | null>;
isActive: boolean;
}
@@ -423,7 +423,11 @@ export function Timeline() {
onResizeStateChange={handleResizeStateChange}
onElementMouseDown={handleElementMouseDown}
onElementClick={handleElementClick}
onTrackMouseDown={handleSelectionMouseDown}
onTrackMouseDown={(event) => {
handleSelectionMouseDown(event);
handleTracksMouseDown(event);
}}
onTrackClick={handleTracksClick}
shouldIgnoreClick={shouldIgnoreClick}
/>
</div>
@@ -9,9 +9,9 @@ interface SnapIndicatorProps {
zoomLevel: number;
isVisible: boolean;
tracks: TimelineTrack[];
timelineRef: React.RefObject<HTMLDivElement>;
trackLabelsRef?: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
timelineRef: React.RefObject<HTMLDivElement | null>;
trackLabelsRef?: React.RefObject<HTMLDivElement | null>;
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
}
export function SnapIndicator({
@@ -7,11 +7,11 @@ import { useEditor } from "@/hooks/use-editor";
interface TimelinePlayheadProps {
zoomLevel: number;
rulerRef: React.RefObject<HTMLDivElement>;
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
timelineRef: React.RefObject<HTMLDivElement>;
playheadRef?: React.RefObject<HTMLDivElement>;
rulerRef: React.RefObject<HTMLDivElement | null>;
rulerScrollRef: React.RefObject<HTMLDivElement | null>;
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
timelineRef: React.RefObject<HTMLDivElement | null>;
playheadRef?: React.RefObject<HTMLDivElement | null>;
isSnappingToPlayhead?: boolean;
}
@@ -1,3 +1,4 @@
import type { JSX } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/project-constants";
import { useEditor } from "@/hooks/use-editor";
@@ -7,7 +8,7 @@ import { TimelineTick } from "./timeline-tick";
interface TimelineRulerProps {
zoomLevel: number;
dynamicTimelineWidth: number;
rulerRef: React.RefObject<HTMLDivElement>;
rulerRef: React.Ref<HTMLDivElement>;
handleWheel: (e: React.WheelEvent) => void;
handleTimelineContentClick: (e: React.MouseEvent) => void;
handleRulerTrackingMouseDown: (e: React.MouseEvent) => void;
@@ -16,6 +16,7 @@ import {
import { Slider } from "@/components/ui/slider";
import { formatTimeCode } from "@/lib/time";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
import { EditableTimecode } from "@/components/editable-timecode";
import { ScenesView } from "../scenes-view";
import { type TAction, invokeAction } from "@/lib/actions";
@@ -54,9 +55,9 @@ export function TimelineToolbar({
direction === "in"
? Math.min(
TIMELINE_CONSTANTS.ZOOM_MAX,
zoomLevel + TIMELINE_CONSTANTS.ZOOM_STEP,
zoomLevel * TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR,
)
: Math.max(minZoom, zoomLevel - TIMELINE_CONSTANTS.ZOOM_STEP);
: Math.max(minZoom, zoomLevel / TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR);
setZoomLevel({ zoom: newZoomLevel });
};
@@ -127,17 +128,13 @@ function ToolbarLeftSection() {
<ToolbarButton
icon={<HugeiconsIcon icon={ScissorIcon} />}
tooltip="Split element"
onClick={({ event }) =>
handleAction({ action: "split", event })
}
onClick={({ event }) => handleAction({ action: "split", event })}
/>
<ToolbarButton
icon={<HugeiconsIcon icon={AlignLeftIcon} />}
tooltip="Split left"
onClick={({ event }) =>
handleAction({ action: "split-left", event })
}
onClick={({ event }) => handleAction({ action: "split-left", event })}
/>
<ToolbarButton
@@ -302,15 +299,17 @@ function ToolbarRightSection({
type="button"
onClick={() => onZoom({ direction: "out" })}
>
<HugeiconsIcon icon={SearchAddIcon} />
<HugeiconsIcon icon={SearchMinusIcon} />
</Button>
<Slider
className="w-24"
value={[zoomLevel]}
onValueChange={(values) => onZoomChange(values[0])}
min={minZoom}
max={TIMELINE_CONSTANTS.ZOOM_MAX}
step={TIMELINE_CONSTANTS.ZOOM_STEP}
className="w-28"
value={[zoomToSlider({ zoomLevel, minZoom })]}
onValueChange={(values) =>
onZoomChange(sliderToZoom({ sliderPosition: values[0], minZoom }))
}
min={0}
max={1}
step={0.005}
/>
<Button
variant="text"
@@ -318,7 +317,7 @@ function ToolbarRightSection({
type="button"
onClick={() => onZoom({ direction: "in" })}
>
<HugeiconsIcon icon={SearchMinusIcon} />
<HugeiconsIcon icon={SearchAddIcon} />
</Button>
</div>
</div>
@@ -15,8 +15,8 @@ interface TimelineTrackContentProps {
track: TimelineTrack;
zoomLevel: number;
dragState: ElementDragState;
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
rulerScrollRef: React.RefObject<HTMLDivElement | null>;
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
lastMouseXRef: React.RefObject<number>;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
onResizeStateChange?: (params: { isResizing: boolean }) => void;
@@ -31,6 +31,7 @@ interface TimelineTrackContentProps {
track: TimelineTrack;
}) => void;
onTrackMouseDown?: (event: React.MouseEvent) => void;
onTrackClick?: (event: React.MouseEvent) => void;
shouldIgnoreClick?: () => boolean;
}
@@ -46,6 +47,7 @@ export function TimelineTrackContent({
onElementMouseDown,
onElementClick,
onTrackMouseDown,
onTrackClick,
shouldIgnoreClick,
}: TimelineTrackContentProps) {
const editor = useEditor();
@@ -68,11 +70,13 @@ export function TimelineTrackContent({
return (
<button
className={cn("size-full", hasSelectedElements && "bg-panel-accent/35")}
onClick={() => {
onClick={(event) => {
if (shouldIgnoreClick?.()) return;
clearElementSelection();
onTrackClick?.(event);
}}
onMouseDown={(event) => {
event.preventDefault();
onTrackMouseDown?.(event);
}}
type="button"
+1 -1
View File
@@ -3,7 +3,7 @@
import { createContext, useContext, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useEditor } from "@/hooks/use-editor";
import { storageService } from "@/services/storage/storage-service";
import { storageService } from "@/services/storage/service";
interface StorageContextType {
isInitialized: boolean;
+1 -1
View File
@@ -7,7 +7,7 @@ interface ColorPickerProps {
value?: string;
onChange?: (value: string) => void;
className?: string;
containerRef?: React.RefObject<HTMLDivElement>;
containerRef?: React.RefObject<HTMLDivElement | null>;
}
const hexToHsv = (hex: string) => {
+1 -1
View File
@@ -59,7 +59,7 @@ PhoneInput.displayName = "PhoneInput";
const InputComponent = React.forwardRef<
HTMLInputElement,
React.ComponentProps<"input">
>(({ className, ...props }, ref) => (
>(({ className, size: _size, ...props }, ref) => (
<Input
className={cn("rounded-s-none rounded-e-lg", className)}
{...props}
+1 -1
View File
@@ -263,7 +263,7 @@ function SidebarTrigger({
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
variant="text"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
+2 -2
View File
@@ -9,7 +9,7 @@ export const PANEL_CONFIG = {
tools: 25,
preview: 50,
properties: 25,
mainContent: 70,
timeline: 30,
mainContent: 50,
timeline: 50,
},
};
@@ -35,12 +35,11 @@ export const TRACK_GAP = 4;
export const TIMELINE_CONSTANTS = {
PIXELS_PER_SECOND: 50,
DEFAULT_ELEMENT_DURATION: 5,
PLAYHEAD_LOOKAHEAD_SECONDS: 30, // padding ahead
PADDING_TOP_PX: 0,
ZOOM_LEVELS: [0.1, 0.25, 0.5, 1, 1.5, 2, 3, 4, 6, 8, 10, 15, 20, 30, 50],
ZOOM_MIN: 0.1,
ZOOM_MAX: 100,
ZOOM_STEP: 0.1,
ZOOM_BUTTON_FACTOR: 1.7,
ZOOM_ANCHOR_PLAYHEAD_THRESHOLD: 0.15,
} as const;
export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState = {
+2 -2
View File
@@ -1,8 +1,8 @@
import type { EditorCore } from "@/core";
import type { MediaAsset } from "@/types/assets";
import { storageService } from "@/services/storage/storage-service";
import { storageService } from "@/services/storage/service";
import { generateUUID } from "@/utils/id";
import { videoCache } from "@/services/media/video-cache";
import { videoCache } from "@/services/video-cache/service";
import { hasMediaId } from "@/lib/timeline/element-utils";
export class MediaManager {
@@ -8,7 +8,7 @@ import type {
TTimelineViewState,
} from "@/types/project";
import type { ExportOptions, ExportResult } from "@/types/export";
import { storageService } from "@/services/storage/storage-service";
import { storageService } from "@/services/storage/service";
import { toast } from "sonner";
import { generateUUID } from "@/utils/id";
import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
+1 -1
View File
@@ -1,6 +1,6 @@
import type { EditorCore } from "@/core";
import type { TimelineTrack, TScene } from "@/types/timeline";
import { storageService } from "@/services/storage/storage-service";
import { storageService } from "@/services/storage/service";
import {
getMainScene,
ensureMainScene,
@@ -3,8 +3,8 @@ import { useEffect, useRef } from "react";
interface UseEdgeAutoScrollParams {
isActive: boolean;
getMouseClientX: () => number;
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
rulerScrollRef: React.RefObject<HTMLDivElement | null>;
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
contentWidth: number;
edgeThreshold?: number;
maxScrollSpeed?: number;
@@ -1,10 +1,10 @@
import { useEffect, useRef } from "react";
interface UseScrollSyncProps {
rulerScrollRef?: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
trackLabelsScrollRef?: React.RefObject<HTMLDivElement>;
bookmarksScrollRef?: React.RefObject<HTMLDivElement>;
rulerScrollRef?: React.RefObject<HTMLDivElement | null>;
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
trackLabelsScrollRef?: React.RefObject<HTMLDivElement | null>;
bookmarksScrollRef?: React.RefObject<HTMLDivElement | null>;
}
export function useScrollSync({
@@ -4,12 +4,12 @@ import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline";
import { useEditor } from "../use-editor";
interface UseSelectionBoxProps {
containerRef: React.RefObject<HTMLElement>;
containerRef: React.RefObject<HTMLElement | null>;
onSelectionComplete: (
elements: { trackId: string; elementId: string }[],
) => void;
isEnabled?: boolean;
tracksScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
zoomLevel: number;
}
@@ -6,9 +6,9 @@ interface UseSnapIndicatorPositionParams {
snapPoint: { time: number } | null;
zoomLevel: number;
tracks: TimelineTrack[];
timelineRef: React.RefObject<HTMLDivElement>;
trackLabelsRef?: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
timelineRef: React.RefObject<HTMLDivElement | null>;
trackLabelsRef?: React.RefObject<HTMLDivElement | null>;
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
}
interface SnapIndicatorPosition {
@@ -6,10 +6,10 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
interface UseTimelinePlayheadProps {
zoomLevel: number;
rulerRef: React.RefObject<HTMLDivElement>;
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
playheadRef?: React.RefObject<HTMLDivElement>;
rulerRef: React.RefObject<HTMLDivElement | null>;
rulerScrollRef: React.RefObject<HTMLDivElement | null>;
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
playheadRef?: React.RefObject<HTMLDivElement | null>;
}
export function useTimelinePlayhead({
@@ -5,10 +5,10 @@ import { getSnappedSeekTime } from "@/lib/time";
import { useEditor } from "../use-editor";
interface UseTimelineSeekProps {
playheadRef: RefObject<HTMLDivElement>;
trackLabelsRef: RefObject<HTMLDivElement>;
rulerScrollRef: RefObject<HTMLDivElement>;
tracksScrollRef: RefObject<HTMLDivElement>;
playheadRef: RefObject<HTMLDivElement | null>;
trackLabelsRef: RefObject<HTMLDivElement | null>;
rulerScrollRef: RefObject<HTMLDivElement | null>;
tracksScrollRef: RefObject<HTMLDivElement | null>;
zoomLevel: number;
duration: number;
isSelecting: boolean;
@@ -9,15 +9,16 @@ import {
} from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useEditor } from "@/hooks/use-editor";
import { zoomToSlider } from "@/lib/timeline/zoom-utils";
interface UseTimelineZoomProps {
containerRef: RefObject<HTMLDivElement>;
containerRef: RefObject<HTMLDivElement | null>;
minZoom?: number;
initialZoom?: number;
initialScrollLeft?: number;
initialPlayheadTime?: number;
tracksScrollRef: RefObject<HTMLDivElement>;
rulerScrollRef: RefObject<HTMLDivElement>;
tracksScrollRef: RefObject<HTMLDivElement | null>;
rulerScrollRef: RefObject<HTMLDivElement | null>;
}
interface UseTimelineZoomReturn {
@@ -122,18 +123,47 @@ export function useTimelineZoom({
if (previousZoom === zoomLevel) return;
const scrollElement = tracksScrollRef.current;
if (scrollElement) {
editor.project.setTimelineViewState({
viewState: {
zoomLevel,
scrollLeft: scrollElement.scrollLeft,
playheadTime: editor.playback.getCurrentTime(),
},
});
if (!scrollElement) {
previousZoomRef.current = zoomLevel;
return;
}
const currentScrollLeft = scrollElement.scrollLeft;
const playheadTime = editor.playback.getCurrentTime();
const sliderPercent = zoomToSlider({ zoomLevel, minZoom });
if (sliderPercent >= TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
const playheadPixelsBefore =
playheadTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * previousZoom;
const playheadPixelsAfter =
playheadTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const viewportOffset = playheadPixelsBefore - currentScrollLeft;
const newScrollLeft = playheadPixelsAfter - viewportOffset;
const maxScrollLeft =
scrollElement.scrollWidth - scrollElement.clientWidth;
const clampedScrollLeft = Math.max(
0,
Math.min(maxScrollLeft, newScrollLeft),
);
scrollElement.scrollLeft = clampedScrollLeft;
if (rulerScrollRef.current) {
rulerScrollRef.current.scrollLeft = clampedScrollLeft;
}
}
previousZoomRef.current = zoomLevel;
}, [zoomLevel, editor, tracksScrollRef]);
editor.project.setTimelineViewState({
viewState: {
zoomLevel,
scrollLeft: scrollElement.scrollLeft,
playheadTime,
},
});
}, [zoomLevel, editor, tracksScrollRef, rulerScrollRef, minZoom]);
// biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref
const saveScrollPosition = useCallback(() => {
@@ -2,7 +2,7 @@ import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { MediaAsset } from "@/types/assets";
import { generateUUID } from "@/utils/id";
import { storageService } from "@/services/storage/storage-service";
import { storageService } from "@/services/storage/service";
export class AddMediaAssetCommand extends Command {
private assetId: string;
@@ -1,8 +1,8 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { MediaAsset } from "@/types/assets";
import { storageService } from "@/services/storage/storage-service";
import { videoCache } from "@/services/media/video-cache";
import { storageService } from "@/services/storage/service";
import { videoCache } from "@/services/video-cache/service";
import { hasMediaId } from "@/lib/timeline/element-utils";
import type { TimelineTrack } from "@/types/timeline";
+1 -1
View File
@@ -81,7 +81,7 @@ export function getProjectDurationFromScenes({
scenes: TScene[];
}): number {
const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null;
if (!mainScene) {
if (!mainScene?.tracks || !Array.isArray(mainScene.tracks)) {
return 0;
}
+33
View File
@@ -51,3 +51,36 @@ export function getZoomPercent({
}): number {
return (zoomLevel - minZoom) / (TIMELINE_CONSTANTS.ZOOM_MAX - minZoom);
}
/**
* convert linear slider position (0-1) to exponential zoom level.
* at low slider values, zoom changes are small. at high values, changes are large.
*/
export function sliderToZoom({
sliderPosition,
minZoom,
maxZoom = TIMELINE_CONSTANTS.ZOOM_MAX,
}: {
sliderPosition: number;
minZoom: number;
maxZoom?: number;
}): number {
const clampedPosition = Math.max(0, Math.min(1, sliderPosition));
return minZoom * (maxZoom / minZoom) ** clampedPosition;
}
/**
* convert exponential zoom level to linear slider position (0-1).
*/
export function zoomToSlider({
zoomLevel,
minZoom,
maxZoom = TIMELINE_CONSTANTS.ZOOM_MAX,
}: {
zoomLevel: number;
minZoom: number;
maxZoom?: number;
}): number {
const clampedZoom = Math.max(minZoom, Math.min(maxZoom, zoomLevel));
return Math.log(clampedZoom / minZoom) / Math.log(maxZoom / minZoom);
}
@@ -1,6 +1,6 @@
import type { CanvasRenderer } from "../canvas-renderer";
import { BaseNode } from "./base-node";
import { videoCache } from "@/services/media/video-cache";
import { videoCache } from "@/services/video-cache/service";
const VIDEO_EPSILON = 1 / 1000;
@@ -0,0 +1,9 @@
/**
* Test fixtures for storage migrations.
* These represent real project data shapes from each version.
*/
export * from "./v0";
export * from "./v1";
export * from "./v2";
export * from "./v3";
@@ -0,0 +1,71 @@
export const v0Project = {
id: "project-v0-123",
name: "My V0 Project",
createdAt: "2024-01-15T10:00:00.000Z",
updatedAt: "2024-01-15T12:00:00.000Z",
fps: 30,
canvasSize: { width: 1920, height: 1080 },
backgroundColor: "#000000",
backgroundType: "color",
tracks: [
{
id: "track-1",
type: "video",
name: "Video Track",
elements: [
{
id: "element-1",
type: "video",
mediaId: "media-1",
startTime: 0,
duration: 5,
trimStart: 0,
trimEnd: 0,
},
],
},
],
bookmarks: [1.5, 3.0],
};
export const v0ProjectWithMetadata = {
id: "project-v0-456",
metadata: {
id: "project-v0-456",
name: "V0 With Metadata",
createdAt: "2024-02-01T08:00:00.000Z",
updatedAt: "2024-02-01T09:00:00.000Z",
},
fps: 24,
canvasSize: { width: 1280, height: 720 },
backgroundType: "blur",
blurIntensity: 20,
};
export const v0ProjectEmpty = {
id: "project-empty",
name: "Empty Project",
createdAt: "2024-03-01T00:00:00.000Z",
updatedAt: "2024-03-01T00:00:00.000Z",
};
// Edge cases
export const projectWithNoId = {
name: "No ID Project",
version: 1,
scenes: [],
};
export const projectWithNullValues = {
id: "project-nulls",
version: 1,
name: null,
metadata: null,
scenes: null,
settings: null,
};
export const projectMalformed = {
id: "project-malformed",
// Missing almost everything
};
@@ -0,0 +1,124 @@
export const v1Project = {
id: "project-v1-123",
version: 1,
name: "My V1 Project",
createdAt: "2024-01-15T10:00:00.000Z",
updatedAt: "2024-01-15T12:00:00.000Z",
fps: 30,
canvasSize: { width: 1920, height: 1080 },
backgroundColor: "#1a1a1a",
backgroundType: "color",
currentSceneId: "scene-main",
bookmarks: [2.0, 4.5, 7.0],
scenes: [
{
id: "scene-main",
name: "Main scene",
isMain: true,
tracks: [
{
id: "track-1",
type: "video",
name: "Video Track",
isMain: true,
elements: [
{
id: "element-1",
type: "video",
mediaId: "media-1",
startTime: 0,
duration: 10,
trimStart: 0,
trimEnd: 0,
},
],
},
{
id: "track-2",
type: "audio",
name: "Audio Track",
elements: [
{
id: "element-2",
type: "audio",
sourceType: "upload",
mediaId: "media-2",
startTime: 0,
duration: 10,
trimStart: 0,
trimEnd: 0,
},
],
},
],
bookmarks: [],
createdAt: "2024-01-15T10:00:00.000Z",
updatedAt: "2024-01-15T12:00:00.000Z",
},
],
};
export const v1ProjectWithMultipleScenes = {
id: "project-v1-multi",
version: 1,
metadata: {
id: "project-v1-multi",
name: "Multi-Scene Project",
createdAt: "2024-02-20T14:00:00.000Z",
updatedAt: "2024-02-20T16:00:00.000Z",
},
currentSceneId: "scene-1",
fps: 60,
canvasSize: { width: 3840, height: 2160 },
background: { type: "blur", blurIntensity: 15 },
scenes: [
{
id: "scene-1",
name: "Intro",
isMain: true,
tracks: [
{
id: "track-1",
type: "video",
isMain: true,
elements: [
{
id: "el-1",
type: "video",
mediaId: "m1",
startTime: 0,
duration: 5,
},
],
},
],
bookmarks: [1.0],
createdAt: "2024-02-20T14:00:00.000Z",
updatedAt: "2024-02-20T16:00:00.000Z",
},
{
id: "scene-2",
name: "Content",
isMain: false,
tracks: [
{
id: "track-2",
type: "video",
isMain: true,
elements: [
{
id: "el-2",
type: "video",
mediaId: "m2",
startTime: 0,
duration: 20,
},
],
},
],
bookmarks: [],
createdAt: "2024-02-20T14:30:00.000Z",
updatedAt: "2024-02-20T16:00:00.000Z",
},
],
};
@@ -0,0 +1,147 @@
export const v2Project = {
id: "project-v2-123",
version: 2,
metadata: {
id: "project-v2-123",
name: "My V2 Project",
thumbnail: "data:image/png;base64,abc123",
createdAt: "2024-03-01T10:00:00.000Z",
updatedAt: "2024-03-01T14:00:00.000Z",
},
settings: {
fps: 30,
canvasSize: { width: 1920, height: 1080 },
background: { type: "color", color: "#000000" },
},
currentSceneId: "scene-main",
scenes: [
{
id: "scene-main",
name: "Main scene",
isMain: true,
tracks: [
{
id: "track-1",
type: "video",
name: "Video Track",
isMain: true,
elements: [
{
id: "element-1",
type: "video",
mediaId: "media-1",
startTime: 0,
duration: 15.5,
trimStart: 0,
trimEnd: 0,
},
],
},
{
id: "track-2",
type: "text",
name: "Text Track",
elements: [
{
id: "element-2",
type: "text",
content: "Hello World",
startTime: 2,
duration: 5,
},
],
},
],
bookmarks: [5.0, 10.0],
createdAt: "2024-03-01T10:00:00.000Z",
updatedAt: "2024-03-01T14:00:00.000Z",
},
],
};
export const v2ProjectWithBlurBackground = {
id: "project-v2-blur",
version: 2,
metadata: {
id: "project-v2-blur",
name: "Blur Background Project",
createdAt: "2024-03-15T08:00:00.000Z",
updatedAt: "2024-03-15T10:00:00.000Z",
},
settings: {
fps: 24,
canvasSize: { width: 1080, height: 1920 },
background: { type: "blur", blurIntensity: 25 },
},
currentSceneId: "scene-1",
scenes: [
{
id: "scene-1",
name: "Main scene",
isMain: true,
tracks: [
{
id: "track-1",
type: "video",
isMain: true,
elements: [
{
id: "el-1",
type: "video",
mediaId: "m1",
startTime: 0,
duration: 30,
},
],
},
],
bookmarks: [],
createdAt: "2024-03-15T08:00:00.000Z",
updatedAt: "2024-03-15T10:00:00.000Z",
},
],
};
export const v2ProjectEmptyScenes = {
id: "project-v2-empty",
version: 2,
metadata: {
id: "project-v2-empty",
name: "Empty Scenes Project",
createdAt: "2024-04-01T00:00:00.000Z",
updatedAt: "2024-04-01T00:00:00.000Z",
},
settings: {
fps: 30,
canvasSize: { width: 1920, height: 1080 },
background: { type: "color", color: "#ffffff" },
},
currentSceneId: "",
scenes: [],
};
export const v2ProjectSceneWithoutTracks = {
id: "project-v2-no-tracks",
version: 2,
metadata: {
id: "project-v2-no-tracks",
name: "Scene Without Tracks",
createdAt: "2024-04-01T00:00:00.000Z",
updatedAt: "2024-04-01T00:00:00.000Z",
},
settings: {
fps: 30,
canvasSize: { width: 1920, height: 1080 },
background: { type: "color", color: "#000000" },
},
currentSceneId: "scene-1",
scenes: [
{
id: "scene-1",
name: "Main Scene",
isMain: true,
createdAt: "2024-04-01T00:00:00.000Z",
updatedAt: "2024-04-01T00:00:00.000Z",
},
],
};
@@ -0,0 +1,47 @@
export const v3Project = {
id: "project-v3-123",
version: 3,
metadata: {
id: "project-v3-123",
name: "My V3 Project",
thumbnail: "data:image/png;base64,xyz789",
duration: 25.5,
createdAt: "2024-05-01T10:00:00.000Z",
updatedAt: "2024-05-01T14:00:00.000Z",
},
settings: {
fps: 30,
canvasSize: { width: 1920, height: 1080 },
background: { type: "color", color: "#000000" },
},
currentSceneId: "scene-main",
scenes: [
{
id: "scene-main",
name: "Main scene",
isMain: true,
tracks: [
{
id: "track-1",
type: "video",
name: "Video Track",
isMain: true,
elements: [
{
id: "element-1",
type: "video",
mediaId: "media-1",
startTime: 0,
duration: 25.5,
trimStart: 0,
trimEnd: 0,
},
],
},
],
bookmarks: [],
createdAt: "2024-05-01T10:00:00.000Z",
updatedAt: "2024-05-01T14:00:00.000Z",
},
],
};
@@ -0,0 +1,111 @@
import { describe, expect, test } from "bun:test";
import { getProjectId, transformProjectV0ToV1 } from "../transformers/v0-to-v1";
import {
projectMalformed,
projectWithNoId,
v0Project,
v0ProjectEmpty,
v0ProjectWithMetadata,
v1Project,
} from "./fixtures";
describe("V0 to V1 Migration", () => {
const fixedDate = new Date("2024-06-01T12:00:00.000Z");
describe("transformProjectV0ToV1", () => {
test("adds scenes array to v0 project", () => {
const result = transformProjectV0ToV1({
project: v0Project,
options: { now: fixedDate },
});
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(1);
expect(Array.isArray(result.project.scenes)).toBe(true);
expect((result.project.scenes as unknown[]).length).toBe(1);
expect(result.project.currentSceneId).toBeDefined();
});
test("creates main scene with correct structure", () => {
const result = transformProjectV0ToV1({
project: v0Project,
options: { now: fixedDate },
});
const scenes = result.project.scenes as Array<Record<string, unknown>>;
const mainScene = scenes[0];
expect(mainScene.isMain).toBe(true);
expect(mainScene.name).toBe("Main scene");
expect(typeof mainScene.id).toBe("string");
expect(Array.isArray(mainScene.tracks)).toBe(true);
expect(Array.isArray(mainScene.bookmarks)).toBe(true);
});
test("updates metadata.updatedAt when metadata exists", () => {
const result = transformProjectV0ToV1({
project: v0ProjectWithMetadata,
options: { now: fixedDate },
});
const metadata = result.project.metadata as Record<string, unknown>;
expect(metadata.updatedAt).toBe(fixedDate.toISOString());
});
test("updates root updatedAt when no metadata", () => {
const result = transformProjectV0ToV1({
project: v0ProjectEmpty,
options: { now: fixedDate },
});
expect(result.project.updatedAt).toBe(fixedDate.toISOString());
});
test("skips project that already has scenes", () => {
const result = transformProjectV0ToV1({
project: v1Project,
options: { now: fixedDate },
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("already has scenes");
});
test("preserves original project properties", () => {
const result = transformProjectV0ToV1({
project: v0Project,
options: { now: fixedDate },
});
expect(result.project.id).toBe(v0Project.id);
expect(result.project.name).toBe(v0Project.name);
expect(result.project.fps).toBe(v0Project.fps);
expect(result.project.canvasSize).toEqual(v0Project.canvasSize);
});
});
describe("getProjectId", () => {
test("returns id from root level", () => {
const id = getProjectId({ project: v0Project });
expect(id).toBe("project-v0-123");
});
test("returns id from metadata when root id missing", () => {
const project = {
metadata: { id: "from-metadata" },
};
const id = getProjectId({ project });
expect(id).toBe("from-metadata");
});
test("returns null when no id found", () => {
const id = getProjectId({ project: projectWithNoId });
expect(id).toBe(null);
});
test("returns null for malformed project", () => {
const id = getProjectId({ project: projectMalformed });
expect(id).toBe("project-malformed");
});
});
});
@@ -0,0 +1,165 @@
import { describe, expect, test } from "bun:test";
import {
DEFAULT_BLUR_INTENSITY,
DEFAULT_CANVAS_SIZE,
DEFAULT_COLOR,
DEFAULT_FPS,
} from "@/constants/project-constants";
import { getProjectId, transformProjectV1ToV2 } from "../transformers/v1-to-v2";
import {
projectWithNoId,
projectWithNullValues,
v1Project,
v1ProjectWithMultipleScenes,
v2Project,
} from "./fixtures";
describe("V1 to V2 Migration", () => {
describe("transformProjectV1ToV2", () => {
test("creates metadata object from flat properties", () => {
const result = transformProjectV1ToV2({ project: v1Project });
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(2);
const metadata = result.project.metadata as Record<string, unknown>;
expect(metadata.id).toBe(v1Project.id);
expect(metadata.name).toBe(v1Project.name);
expect(typeof metadata.createdAt).toBe("string");
expect(typeof metadata.updatedAt).toBe("string");
});
test("creates settings object from flat properties", () => {
const result = transformProjectV1ToV2({ project: v1Project });
const settings = result.project.settings as Record<string, unknown>;
expect(settings.fps).toBe(v1Project.fps);
expect(settings.canvasSize).toEqual(v1Project.canvasSize);
});
test("converts color background correctly", () => {
const result = transformProjectV1ToV2({ project: v1Project });
const settings = result.project.settings as Record<string, unknown>;
const background = settings.background as Record<string, unknown>;
expect(background.type).toBe("color");
expect(background.color).toBe(v1Project.backgroundColor);
});
test("converts blur background correctly", () => {
const projectWithBlur = {
...v1Project,
backgroundType: "blur",
blurIntensity: 30,
};
const result = transformProjectV1ToV2({ project: projectWithBlur });
const settings = result.project.settings as Record<string, unknown>;
const background = settings.background as Record<string, unknown>;
expect(background.type).toBe("blur");
expect(background.blurIntensity).toBe(30);
});
test("applies legacy bookmarks to main scene", () => {
const result = transformProjectV1ToV2({ project: v1Project });
const scenes = result.project.scenes as Array<Record<string, unknown>>;
const mainScene = scenes.find((s) => s.isMain === true);
expect(mainScene?.bookmarks).toEqual(v1Project.bookmarks);
});
test("preserves existing scene bookmarks", () => {
const result = transformProjectV1ToV2({
project: v1ProjectWithMultipleScenes,
});
const scenes = result.project.scenes as Array<Record<string, unknown>>;
const introScene = scenes.find((s) => s.name === "Intro");
expect(introScene?.bookmarks).toEqual([1.0]);
});
test("skips project that already has v2 structure", () => {
const result = transformProjectV1ToV2({ project: v2Project });
expect(result.skipped).toBe(true);
expect(result.reason).toBe("already v2");
});
test("skips project with no id", () => {
const result = transformProjectV1ToV2({ project: projectWithNoId });
expect(result.skipped).toBe(true);
expect(result.reason).toBe("no project id");
});
test("handles null values gracefully", () => {
const result = transformProjectV1ToV2({ project: projectWithNullValues });
expect(result.skipped).toBe(false);
const settings = result.project.settings as Record<string, unknown>;
expect(settings.fps).toBe(DEFAULT_FPS);
expect(settings.canvasSize).toEqual(DEFAULT_CANVAS_SIZE);
});
test("uses default values for missing properties", () => {
const minimalProject = {
id: "minimal",
version: 1,
scenes: [],
};
const result = transformProjectV1ToV2({ project: minimalProject });
const settings = result.project.settings as Record<string, unknown>;
expect(settings.fps).toBe(DEFAULT_FPS);
expect(settings.canvasSize).toEqual(DEFAULT_CANVAS_SIZE);
const background = settings.background as Record<string, unknown>;
expect(background.type).toBe("color");
expect(background.color).toBe(DEFAULT_COLOR);
});
test("uses default blur intensity when missing", () => {
const projectWithBlurNoIntensity = {
id: "blur-no-intensity",
version: 1,
backgroundType: "blur",
scenes: [],
};
const result = transformProjectV1ToV2({
project: projectWithBlurNoIntensity,
});
const settings = result.project.settings as Record<string, unknown>;
const background = settings.background as Record<string, unknown>;
expect(background.blurIntensity).toBe(DEFAULT_BLUR_INTENSITY);
});
test("preserves currentSceneId", () => {
const result = transformProjectV1ToV2({ project: v1Project });
expect(result.project.currentSceneId).toBe(v1Project.currentSceneId);
});
test("finds main scene id when currentSceneId missing", () => {
const projectWithoutCurrentScene = {
...v1Project,
currentSceneId: undefined,
};
const result = transformProjectV1ToV2({
project: projectWithoutCurrentScene,
});
expect(result.project.currentSceneId).toBe("scene-main");
});
});
describe("getProjectId", () => {
test("returns id from root level", () => {
const id = getProjectId({ project: v1Project });
expect(id).toBe("project-v1-123");
});
test("returns id from metadata", () => {
const id = getProjectId({ project: v1ProjectWithMultipleScenes });
expect(id).toBe("project-v1-multi");
});
});
});
@@ -0,0 +1,186 @@
import { describe, expect, test } from "bun:test";
import { getProjectId, transformProjectV2ToV3 } from "../transformers/v2-to-v3";
import {
projectWithNoId,
v2Project,
v2ProjectEmptyScenes,
v2ProjectSceneWithoutTracks,
v2ProjectWithBlurBackground,
v3Project,
} from "./fixtures";
describe("V2 to V3 Migration", () => {
describe("transformProjectV2ToV3", () => {
test("adds duration to metadata", () => {
const result = transformProjectV2ToV3({ project: v2Project });
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(3);
const metadata = result.project.metadata as Record<string, unknown>;
expect(typeof metadata.duration).toBe("number");
});
test("calculates duration from scene tracks", () => {
const result = transformProjectV2ToV3({ project: v2Project });
const metadata = result.project.metadata as Record<string, unknown>;
// v2Project has a video element with duration 15.5 and a text element at startTime 2 with duration 5
// Total duration should be max(15.5, 2+5) = 15.5
expect(metadata.duration).toBe(15.5);
});
test("handles project with blur background", () => {
const result = transformProjectV2ToV3({
project: v2ProjectWithBlurBackground,
});
expect(result.skipped).toBe(false);
const metadata = result.project.metadata as Record<string, unknown>;
// v2ProjectWithBlurBackground has a video with duration 30
expect(metadata.duration).toBe(30);
});
test("handles empty scenes with zero duration", () => {
const result = transformProjectV2ToV3({ project: v2ProjectEmptyScenes });
expect(result.skipped).toBe(false);
const metadata = result.project.metadata as Record<string, unknown>;
expect(metadata.duration).toBe(0);
});
test("handles scene without tracks property", () => {
const result = transformProjectV2ToV3({
project: v2ProjectSceneWithoutTracks,
});
expect(result.skipped).toBe(false);
const metadata = result.project.metadata as Record<string, unknown>;
expect(metadata.duration).toBe(0);
});
test("skips project that already has v3 structure", () => {
const result = transformProjectV2ToV3({ project: v3Project });
expect(result.skipped).toBe(true);
expect(result.reason).toBe("already v3");
});
test("skips project that has duration in metadata", () => {
const projectWithDuration = {
...v2Project,
version: 2,
metadata: {
...v2Project.metadata,
duration: 10,
},
};
const result = transformProjectV2ToV3({ project: projectWithDuration });
expect(result.skipped).toBe(true);
expect(result.reason).toBe("already v3");
});
test("skips project with no id", () => {
const result = transformProjectV2ToV3({ project: projectWithNoId });
expect(result.skipped).toBe(true);
expect(result.reason).toBe("no project id");
});
test("preserves existing metadata fields", () => {
const result = transformProjectV2ToV3({ project: v2Project });
const metadata = result.project.metadata as Record<string, unknown>;
expect(metadata.id).toBe(v2Project.metadata.id);
expect(metadata.name).toBe(v2Project.metadata.name);
expect(metadata.thumbnail).toBe(v2Project.metadata.thumbnail);
expect(metadata.createdAt).toBe(v2Project.metadata.createdAt);
expect(metadata.updatedAt).toBe(v2Project.metadata.updatedAt);
});
test("preserves settings object", () => {
const result = transformProjectV2ToV3({ project: v2Project });
expect(result.project.settings).toEqual(v2Project.settings);
});
test("preserves scenes array", () => {
const result = transformProjectV2ToV3({ project: v2Project });
expect(result.project.scenes).toEqual(v2Project.scenes);
});
test("handles project without metadata object", () => {
const projectWithoutMetadata = {
id: "no-metadata",
version: 2,
scenes: [],
};
const result = transformProjectV2ToV3({
project: projectWithoutMetadata,
});
expect(result.skipped).toBe(false);
const metadata = result.project.metadata as Record<string, unknown>;
expect(metadata.duration).toBe(0);
});
test("calculates duration from main scene only", () => {
const multiSceneProject = {
id: "multi-scene",
version: 2,
metadata: { id: "multi-scene", name: "Multi" },
scenes: [
{
id: "scene-1",
isMain: true,
tracks: [
{
type: "video",
elements: [{ startTime: 0, duration: 10 }],
},
],
},
{
id: "scene-2",
isMain: false,
tracks: [
{
type: "video",
elements: [{ startTime: 0, duration: 20 }],
},
],
},
],
};
const result = transformProjectV2ToV3({ project: multiSceneProject });
const metadata = result.project.metadata as Record<string, unknown>;
// Duration is from main scene only, not sum of all scenes
expect(metadata.duration).toBe(10);
});
});
describe("getProjectId", () => {
test("returns id from root level", () => {
const projectWithRootId = { id: "root-id", metadata: {} };
const id = getProjectId({ project: projectWithRootId });
expect(id).toBe("root-id");
});
test("returns id from metadata when root id missing", () => {
const id = getProjectId({ project: v2Project });
expect(id).toBe("project-v2-123");
});
test("prefers root id over metadata id", () => {
const projectWithBothIds = {
id: "root-id",
metadata: { id: "metadata-id" },
};
const id = getProjectId({ project: projectWithBothIds });
expect(id).toBe("root-id");
});
});
});
@@ -0,0 +1,4 @@
export { transformProjectV0ToV1 } from "./v0-to-v1";
export { transformProjectV1ToV2 } from "./v1-to-v2";
export { transformProjectV2ToV3 } from "./v2-to-v3";
export type { MigrationResult, ProjectRecord } from "./types";
@@ -0,0 +1,13 @@
/**
* Type definitions for different project versions used in migrations.
* These types are intentionally loose (using Record) because we're dealing
* with potentially malformed data from older versions.
*/
export type ProjectRecord = Record<string, unknown>;
export interface MigrationResult<T> {
project: T;
skipped: boolean;
reason?: string;
}
@@ -0,0 +1,83 @@
import { buildDefaultScene } from "@/lib/scenes";
import type { SerializedScene } from "@/services/storage/types";
import type { TScene } from "@/types/timeline";
import type { MigrationResult, ProjectRecord } from "./types";
export interface TransformV0ToV1Options {
now?: Date;
}
export function transformProjectV0ToV1({
project,
options = {},
}: {
project: ProjectRecord;
options?: TransformV0ToV1Options;
}): MigrationResult<ProjectRecord> {
const { now = new Date() } = options;
const scenesValue = project.scenes;
if (Array.isArray(scenesValue) && scenesValue.length > 0) {
return { project, skipped: true, reason: "already has scenes" };
}
const mainScene = buildDefaultScene({ isMain: true, name: "Main scene" });
const serializedScene = serializeScene({ scene: mainScene });
const updatedProject: ProjectRecord = {
...project,
scenes: [serializedScene],
currentSceneId: mainScene.id,
version: 1,
};
const updatedAt = now.toISOString();
if (isRecord(project.metadata)) {
updatedProject.metadata = {
...project.metadata,
updatedAt,
};
} else {
updatedProject.updatedAt = updatedAt;
}
return { project: updatedProject, skipped: false };
}
export function getProjectId({
project,
}: {
project: ProjectRecord;
}): string | null {
const idValue = project.id;
if (typeof idValue === "string" && idValue.length > 0) {
return idValue;
}
const metadataValue = project.metadata;
if (!isRecord(metadataValue)) {
return null;
}
const metadataId = metadataValue.id;
if (typeof metadataId === "string" && metadataId.length > 0) {
return metadataId;
}
return null;
}
function serializeScene({ scene }: { scene: TScene }): SerializedScene {
return {
id: scene.id,
name: scene.name,
isMain: scene.isMain,
tracks: scene.tracks,
bookmarks: scene.bookmarks,
createdAt: scene.createdAt.toISOString(),
updatedAt: scene.updatedAt.toISOString(),
};
}
function isRecord(value: unknown): value is ProjectRecord {
return typeof value === "object" && value !== null;
}
@@ -0,0 +1,326 @@
import {
DEFAULT_BLUR_INTENSITY,
DEFAULT_CANVAS_SIZE,
DEFAULT_COLOR,
DEFAULT_FPS,
} from "@/constants/project-constants";
import type { MigrationResult, ProjectRecord } from "./types";
export function transformProjectV1ToV2({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
const projectId = getProjectId({ project });
if (!projectId) {
return { project, skipped: true, reason: "no project id" };
}
if (isV2Project({ project })) {
return { project, skipped: true, reason: "already v2" };
}
const migratedProject = migrateProject({ project, projectId });
return { project: migratedProject, skipped: false };
}
function migrateProject({
project,
projectId,
}: {
project: ProjectRecord;
projectId: string;
}): ProjectRecord {
const createdAt = normalizeDateString({ value: project.createdAt });
const updatedAt = normalizeDateString({ value: project.updatedAt });
const metadataValue = project.metadata;
const metadata = isRecord(metadataValue)
? {
id: getStringValue({ value: metadataValue.id, fallback: projectId }),
name: getStringValue({ value: metadataValue.name, fallback: "" }),
thumbnail: getStringValue({ value: metadataValue.thumbnail }),
createdAt: normalizeDateString({ value: metadataValue.createdAt }),
updatedAt: normalizeDateString({ value: metadataValue.updatedAt }),
}
: {
id: projectId,
name: getStringValue({ value: project.name, fallback: "" }),
thumbnail: getStringValue({ value: project.thumbnail }),
createdAt,
updatedAt,
};
const scenesValue = project.scenes;
const scenes = Array.isArray(scenesValue) ? scenesValue : [];
const legacyBookmarks = Array.isArray(project.bookmarks)
? project.bookmarks
: null;
const normalizedScenes = applyLegacyBookmarks({
scenes,
legacyBookmarks,
});
const settingsValue = project.settings;
const settings = isRecord(settingsValue)
? {
fps: getNumberValue({
value: settingsValue.fps,
fallback: DEFAULT_FPS,
}),
canvasSize: getCanvasSizeValue({
value: settingsValue.canvasSize,
fallback: DEFAULT_CANVAS_SIZE,
}),
background: getBackgroundValue({
value: settingsValue.background,
}),
}
: {
fps: getNumberValue({ value: project.fps, fallback: DEFAULT_FPS }),
canvasSize: getCanvasSizeValue({
value: project.canvasSize,
fallback: DEFAULT_CANVAS_SIZE,
}),
background: getBackgroundValue({
value: project.background,
backgroundType: project.backgroundType,
backgroundColor: project.backgroundColor,
blurIntensity: project.blurIntensity,
}),
};
const currentSceneId = getCurrentSceneId({
value: project.currentSceneId,
scenes: normalizedScenes,
});
return {
...project,
metadata,
scenes: normalizedScenes,
currentSceneId,
settings,
version: 2,
};
}
export function getProjectId({
project,
}: {
project: ProjectRecord;
}): string | null {
const idValue = project.id;
if (typeof idValue === "string" && idValue.length > 0) {
return idValue;
}
const metadataValue = project.metadata;
if (!isRecord(metadataValue)) {
return null;
}
const metadataId = metadataValue.id;
if (typeof metadataId === "string" && metadataId.length > 0) {
return metadataId;
}
return null;
}
function getCurrentSceneId({
value,
scenes,
}: {
value: unknown;
scenes: unknown[];
}): string {
if (typeof value === "string" && value.length > 0) {
return value;
}
const mainSceneId = findMainSceneId({ scenes });
if (mainSceneId) {
return mainSceneId;
}
return "";
}
function findMainSceneId({ scenes }: { scenes: unknown[] }): string | null {
for (const scene of scenes) {
if (!isRecord(scene)) {
continue;
}
if (scene.isMain === true && typeof scene.id === "string") {
return scene.id;
}
}
for (const scene of scenes) {
if (!isRecord(scene)) {
continue;
}
if (typeof scene.id === "string") {
return scene.id;
}
}
return null;
}
function applyLegacyBookmarks({
scenes,
legacyBookmarks,
}: {
scenes: unknown[];
legacyBookmarks: unknown[] | null;
}): unknown[] {
if (!legacyBookmarks || legacyBookmarks.length === 0) {
return scenes;
}
const mainSceneId = findMainSceneId({ scenes });
return scenes.map((scene) => {
if (!isRecord(scene)) {
return scene;
}
if (mainSceneId && scene.id !== mainSceneId) {
return scene;
}
if (Array.isArray(scene.bookmarks) && scene.bookmarks.length > 0) {
return scene;
}
return {
...scene,
bookmarks: legacyBookmarks,
};
});
}
function getBackgroundValue({
value,
backgroundType,
backgroundColor,
blurIntensity,
}: {
value?: unknown;
backgroundType?: unknown;
backgroundColor?: unknown;
blurIntensity?: unknown;
}): {
type: "color" | "blur";
color?: string;
blurIntensity?: number;
} {
if (isRecord(value)) {
const typeValue = value.type;
if (typeValue === "blur") {
return {
type: "blur",
blurIntensity: getNumberValue({
value: value.blurIntensity,
fallback: DEFAULT_BLUR_INTENSITY,
}),
};
}
return {
type: "color",
color: getStringValue({ value: value.color, fallback: DEFAULT_COLOR }),
};
}
if (backgroundType === "blur") {
return {
type: "blur",
blurIntensity: getNumberValue({
value: blurIntensity,
fallback: DEFAULT_BLUR_INTENSITY,
}),
};
}
return {
type: "color",
color: getStringValue({ value: backgroundColor, fallback: DEFAULT_COLOR }),
};
}
function getCanvasSizeValue({
value,
fallback,
}: {
value: unknown;
fallback: { width: number; height: number };
}): { width: number; height: number } {
if (isRecord(value)) {
const width = getNumberValue({
value: value.width,
fallback: fallback.width,
});
const height = getNumberValue({
value: value.height,
fallback: fallback.height,
});
return { width, height };
}
return fallback;
}
function getNumberValue({
value,
fallback,
}: {
value: unknown;
fallback: number;
}): number {
return typeof value === "number" ? value : fallback;
}
function getStringValue({
value,
fallback,
}: {
value: unknown;
fallback?: string;
}): string | undefined {
if (typeof value === "string") {
return value;
}
return fallback;
}
function normalizeDateString({ value }: { value: unknown }): string {
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === "string") {
return value;
}
return new Date().toISOString();
}
function isV2Project({ project }: { project: ProjectRecord }): boolean {
const versionValue = project.version;
if (typeof versionValue === "number" && versionValue >= 2) {
return true;
}
return isRecord(project.metadata) && isRecord(project.settings);
}
function isRecord(value: unknown): value is ProjectRecord {
return typeof value === "object" && value !== null;
}
@@ -0,0 +1,81 @@
import { getProjectDurationFromScenes } from "@/lib/scenes";
import type { TScene } from "@/types/timeline";
import type { MigrationResult, ProjectRecord } from "./types";
export function transformProjectV2ToV3({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
const projectId = getProjectId({ project });
if (!projectId) {
return { project, skipped: true, reason: "no project id" };
}
if (isV3Project({ project })) {
return { project, skipped: true, reason: "already v3" };
}
const scenes = getScenes({ project });
const duration = getProjectDurationFromScenes({ scenes });
const metadataValue = project.metadata;
const metadata = isRecord(metadataValue)
? { ...metadataValue, duration }
: { duration };
const migratedProject = {
...project,
metadata,
version: 3,
};
return { project: migratedProject, skipped: false };
}
export function getProjectId({
project,
}: {
project: ProjectRecord;
}): string | null {
const idValue = project.id;
if (typeof idValue === "string" && idValue.length > 0) {
return idValue;
}
const metadataValue = project.metadata;
if (!isRecord(metadataValue)) {
return null;
}
const metadataId = metadataValue.id;
if (typeof metadataId === "string" && metadataId.length > 0) {
return metadataId;
}
return null;
}
function getScenes({ project }: { project: ProjectRecord }): TScene[] {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) {
return [];
}
return scenesValue.filter(isRecord) as unknown as TScene[];
}
function isV3Project({ project }: { project: ProjectRecord }): boolean {
const versionValue = project.version;
if (typeof versionValue === "number" && versionValue >= 3) {
return true;
}
return (
isRecord(project.metadata) && typeof project.metadata.duration === "number"
);
}
function isRecord(value: unknown): value is ProjectRecord {
return typeof value === "object" && value !== null;
}
@@ -1,10 +1,6 @@
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
import type { SerializedScene } from "@/services/storage/types";
import { buildDefaultScene } from "@/lib/scenes";
import type { TScene } from "@/types/timeline";
import { StorageMigration } from "./base";
type ProjectRecord = Record<string, unknown>;
import { getProjectId, transformProjectV0ToV1 } from "./transformers/v0-to-v1";
export class V0toV1Migration extends StorageMigration {
from = 0;
@@ -19,75 +15,24 @@ export class V0toV1Migration extends StorageMigration {
const projects = await projectsAdapter.getAll();
for (const project of projects) {
if (!isRecord(project)) {
if (typeof project !== "object" || project === null) {
continue;
}
const scenesValue = project.scenes;
if (Array.isArray(scenesValue) && scenesValue.length > 0) {
const result = transformProjectV0ToV1({
project: project as Record<string, unknown>,
});
if (result.skipped) {
continue;
}
const mainScene = buildDefaultScene({ isMain: true, name: "Main scene" });
const serializedScene = serializeScene({ scene: mainScene });
const updatedProject: ProjectRecord = {
...project,
scenes: [serializedScene],
currentSceneId: mainScene.id,
version: 1,
};
const updatedAt = new Date().toISOString();
if (isRecord(project.metadata)) {
updatedProject.metadata = {
...project.metadata,
updatedAt,
};
} else {
updatedProject.updatedAt = updatedAt;
}
const projectId = getProjectId({ project: updatedProject });
const projectId = getProjectId({ project: result.project });
if (!projectId) {
continue;
}
await projectsAdapter.set(projectId, updatedProject);
await projectsAdapter.set(projectId, result.project);
}
}
}
function serializeScene({ scene }: { scene: TScene }): SerializedScene {
return {
id: scene.id,
name: scene.name,
isMain: scene.isMain,
tracks: scene.tracks,
bookmarks: scene.bookmarks,
createdAt: scene.createdAt.toISOString(),
updatedAt: scene.updatedAt.toISOString(),
};
}
function getProjectId({ project }: { project: ProjectRecord }): string | null {
const idValue = project.id;
if (typeof idValue === "string" && idValue.length > 0) {
return idValue;
}
const metadataValue = project.metadata;
if (!isRecord(metadataValue)) {
return null;
}
const metadataId = metadataValue.id;
if (typeof metadataId === "string" && metadataId.length > 0) {
return metadataId;
}
return null;
}
function isRecord(value: unknown): value is ProjectRecord {
return typeof value === "object" && value !== null;
}
@@ -1,13 +1,6 @@
import {
DEFAULT_BLUR_INTENSITY,
DEFAULT_CANVAS_SIZE,
DEFAULT_COLOR,
DEFAULT_FPS,
} from "@/constants/project-constants";
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
import { StorageMigration } from "./base";
type ProjectRecord = Record<string, unknown>;
import { getProjectId, transformProjectV1ToV2 } from "./transformers/v1-to-v2";
export class V1toV2Migration extends StorageMigration {
from = 1;
@@ -22,318 +15,24 @@ export class V1toV2Migration extends StorageMigration {
const projects = await projectsAdapter.getAll();
for (const project of projects) {
if (!isRecord(project)) {
if (typeof project !== "object" || project === null) {
continue;
}
const projectId = getProjectId({ project });
const result = transformProjectV1ToV2({
project: project as Record<string, unknown>,
});
if (result.skipped) {
continue;
}
const projectId = getProjectId({ project: result.project });
if (!projectId) {
continue;
}
if (isV2Project({ project })) {
continue;
}
const migratedProject = migrateProject({ project, projectId });
await projectsAdapter.set(projectId, migratedProject);
await projectsAdapter.set(projectId, result.project);
}
}
}
function migrateProject({
project,
projectId,
}: {
project: ProjectRecord;
projectId: string;
}): ProjectRecord {
const createdAt = normalizeDateString({ value: project.createdAt });
const updatedAt = normalizeDateString({ value: project.updatedAt });
const metadataValue = project.metadata;
const metadata = isRecord(metadataValue)
? {
id: getStringValue({ value: metadataValue.id, fallback: projectId }),
name: getStringValue({ value: metadataValue.name, fallback: "" }),
thumbnail: getStringValue({ value: metadataValue.thumbnail }),
createdAt: normalizeDateString({ value: metadataValue.createdAt }),
updatedAt: normalizeDateString({ value: metadataValue.updatedAt }),
}
: {
id: projectId,
name: getStringValue({ value: project.name, fallback: "" }),
thumbnail: getStringValue({ value: project.thumbnail }),
createdAt,
updatedAt,
};
const scenesValue = project.scenes;
const scenes = Array.isArray(scenesValue) ? scenesValue : [];
const legacyBookmarks = Array.isArray(project.bookmarks)
? project.bookmarks
: null;
const normalizedScenes = applyLegacyBookmarks({
scenes,
legacyBookmarks,
});
const settingsValue = project.settings;
const settings = isRecord(settingsValue)
? {
fps: getNumberValue({
value: settingsValue.fps,
fallback: DEFAULT_FPS,
}),
canvasSize: getCanvasSizeValue({
value: settingsValue.canvasSize,
fallback: DEFAULT_CANVAS_SIZE,
}),
background: getBackgroundValue({
value: settingsValue.background,
}),
}
: {
fps: getNumberValue({ value: project.fps, fallback: DEFAULT_FPS }),
canvasSize: getCanvasSizeValue({
value: project.canvasSize,
fallback: DEFAULT_CANVAS_SIZE,
}),
background: getBackgroundValue({
value: project.background,
backgroundType: project.backgroundType,
backgroundColor: project.backgroundColor,
blurIntensity: project.blurIntensity,
}),
};
const currentSceneId = getCurrentSceneId({
value: project.currentSceneId,
scenes: normalizedScenes,
});
return {
...project,
metadata,
scenes: normalizedScenes,
currentSceneId,
settings,
version: 2,
};
}
function getProjectId({ project }: { project: ProjectRecord }): string | null {
const idValue = project.id;
if (typeof idValue === "string" && idValue.length > 0) {
return idValue;
}
const metadataValue = project.metadata;
if (!isRecord(metadataValue)) {
return null;
}
const metadataId = metadataValue.id;
if (typeof metadataId === "string" && metadataId.length > 0) {
return metadataId;
}
return null;
}
function getCurrentSceneId({
value,
scenes,
}: {
value: unknown;
scenes: unknown[];
}): string {
if (typeof value === "string" && value.length > 0) {
return value;
}
const mainSceneId = findMainSceneId({ scenes });
if (mainSceneId) {
return mainSceneId;
}
return "";
}
function findMainSceneId({ scenes }: { scenes: unknown[] }): string | null {
for (const scene of scenes) {
if (!isRecord(scene)) {
continue;
}
if (scene.isMain === true && typeof scene.id === "string") {
return scene.id;
}
}
for (const scene of scenes) {
if (!isRecord(scene)) {
continue;
}
if (typeof scene.id === "string") {
return scene.id;
}
}
return null;
}
function applyLegacyBookmarks({
scenes,
legacyBookmarks,
}: {
scenes: unknown[];
legacyBookmarks: unknown[] | null;
}): unknown[] {
if (!legacyBookmarks || legacyBookmarks.length === 0) {
return scenes;
}
const mainSceneId = findMainSceneId({ scenes });
return scenes.map((scene) => {
if (!isRecord(scene)) {
return scene;
}
if (mainSceneId && scene.id !== mainSceneId) {
return scene;
}
if (Array.isArray(scene.bookmarks) && scene.bookmarks.length > 0) {
return scene;
}
return {
...scene,
bookmarks: legacyBookmarks,
};
});
}
function getBackgroundValue({
value,
backgroundType,
backgroundColor,
blurIntensity,
}: {
value: unknown;
backgroundType?: unknown;
backgroundColor?: unknown;
blurIntensity?: unknown;
}): {
type: "color" | "blur";
color?: string;
blurIntensity?: number;
} {
if (isRecord(value)) {
const typeValue = value.type;
if (typeValue === "blur") {
return {
type: "blur",
blurIntensity: getNumberValue({
value: value.blurIntensity,
fallback: DEFAULT_BLUR_INTENSITY,
}),
};
}
return {
type: "color",
color: getStringValue({ value: value.color, fallback: DEFAULT_COLOR }),
};
}
if (backgroundType === "blur") {
return {
type: "blur",
blurIntensity: getNumberValue({
value: blurIntensity,
fallback: DEFAULT_BLUR_INTENSITY,
}),
};
}
return {
type: "color",
color: getStringValue({ value: backgroundColor, fallback: DEFAULT_COLOR }),
};
}
function getCanvasSizeValue({
value,
fallback,
}: {
value: unknown;
fallback: { width: number; height: number };
}): { width: number; height: number } {
if (isRecord(value)) {
const width = getNumberValue({
value: value.width,
fallback: fallback.width,
});
const height = getNumberValue({
value: value.height,
fallback: fallback.height,
});
return { width, height };
}
return fallback;
}
function getNumberValue({
value,
fallback,
}: {
value: unknown;
fallback: number;
}): number {
return typeof value === "number" ? value : fallback;
}
function getStringValue({
value,
fallback,
}: {
value: unknown;
fallback?: string;
}): string | undefined {
if (typeof value === "string") {
return value;
}
return fallback;
}
function normalizeDateString({ value }: { value: unknown }): string {
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === "string") {
return value;
}
return new Date().toISOString();
}
function isV2Project({ project }: { project: ProjectRecord }): boolean {
const versionValue = project.version;
if (typeof versionValue === "number" && versionValue >= 2) {
return true;
}
return isRecord(project.metadata) && isRecord(project.settings);
}
function isRecord(value: unknown): value is ProjectRecord {
return typeof value === "object" && value !== null;
}
@@ -1,9 +1,6 @@
import { getProjectDurationFromScenes } from "@/lib/scenes";
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
import type { TScene } from "@/types/timeline";
import { StorageMigration } from "./base";
type ProjectRecord = Record<string, unknown>;
import { getProjectId, transformProjectV2ToV3 } from "./transformers/v2-to-v3";
export class V2toV3Migration extends StorageMigration {
from = 2;
@@ -18,75 +15,24 @@ export class V2toV3Migration extends StorageMigration {
const projects = await projectsAdapter.getAll();
for (const project of projects) {
if (!isRecord(project)) {
if (typeof project !== "object" || project === null) {
continue;
}
const projectId = getProjectId({ project });
const result = transformProjectV2ToV3({
project: project as Record<string, unknown>,
});
if (result.skipped) {
continue;
}
const projectId = getProjectId({ project: result.project });
if (!projectId) {
continue;
}
if (isV3Project({ project })) {
continue;
}
const scenes = getScenes({ project });
const duration = getProjectDurationFromScenes({ scenes });
const metadataValue = project.metadata;
const metadata = isRecord(metadataValue)
? { ...metadataValue, duration }
: { duration };
const migratedProject = {
...project,
metadata,
version: 3,
};
await projectsAdapter.set(projectId, migratedProject);
await projectsAdapter.set(projectId, result.project);
}
}
}
function getProjectId({ project }: { project: ProjectRecord }): string | null {
const idValue = project.id;
if (typeof idValue === "string" && idValue.length > 0) {
return idValue;
}
const metadataValue = project.metadata;
if (!isRecord(metadataValue)) {
return null;
}
const metadataId = metadataValue.id;
if (typeof metadataId === "string" && metadataId.length > 0) {
return metadataId;
}
return null;
}
function getScenes({ project }: { project: ProjectRecord }): TScene[] {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) {
return [];
}
return scenesValue.filter(isRecord) as unknown as TScene[];
}
function isV3Project({ project }: { project: ProjectRecord }): boolean {
const versionValue = project.version;
if (typeof versionValue === "number" && versionValue >= 3) {
return true;
}
return isRecord(project.metadata) && typeof project.metadata.duration === "number";
}
function isRecord(value: unknown): value is ProjectRecord {
return typeof value === "object" && value !== null;
}
@@ -333,12 +333,9 @@ class StorageService {
]);
}
// Utility methods
async clearAllData(): Promise<void> {
// Clear all projects
await this.projectsAdapter.clear();
// Note: Project-specific media and timelines will be cleaned up when projects are deleted
// project-specific media and timelines cleaned up when projects are deleted
}
async getStorageInfo(): Promise<{
@@ -392,7 +389,6 @@ class StorageService {
try {
const currentData = await this.loadSavedSounds();
// Check if sound is already saved
if (currentData.sounds.some((sound) => sound.id === soundEffect.id)) {
return; // Already saved
}
@@ -456,7 +452,6 @@ class StorageService {
}
}
// Check browser support
isOPFSSupported(): boolean {
return OPFSAdapter.isSupported();
}
@@ -470,6 +465,5 @@ class StorageService {
}
}
// Export singleton instance
export const storageService = new StorageService();
export { StorageService };
@@ -1,4 +1,5 @@
import type {
TranscriptionLanguage,
TranscriptionResult,
TranscriptionProgress,
TranscriptionModelId,
@@ -8,7 +9,6 @@ import {
TRANSCRIPTION_MODELS,
} from "@/constants/transcription-constants";
import type { WorkerMessage, WorkerResponse } from "./worker";
import type { LanguageCode } from "@/types/language";
type ProgressCallback = (progress: TranscriptionProgress) => void;
@@ -20,12 +20,12 @@ class TranscriptionService {
async transcribe({
audioData,
language,
language = "auto",
modelId = DEFAULT_TRANSCRIPTION_MODEL,
onProgress,
}: {
audioData: Float32Array;
language?: LanguageCode;
language?: TranscriptionLanguage;
modelId?: TranscriptionModelId;
onProgress?: ProgressCallback;
}): Promise<TranscriptionResult> {
@@ -15,6 +15,7 @@ interface VideoSinkData {
prefetching: boolean;
prefetchPromise: Promise<void> | null;
}
export class VideoCache {
private sinks = new Map<string, VideoSinkData>();
private initPromises = new Map<string, Promise<void>>();
@@ -311,4 +312,5 @@ export class VideoCache {
};
}
}
export const videoCache = new VideoCache();
+1 -1
View File
@@ -1,6 +1,6 @@
import { create } from "zustand";
import type { SoundEffect, SavedSound } from "@/types/sounds";
import { storageService } from "@/services/storage/storage-service";
import { storageService } from "@/services/storage/service";
import { toast } from "sonner";
import { EditorCore } from "@/core";
import { buildLibraryAudioElement } from "@/lib/timeline/element-utils";
+12 -24
View File
@@ -4,6 +4,8 @@
"": {
"name": "opencut",
"dependencies": {
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"better-auth": "^1.4.15",
"next": "^16.1.3",
},
@@ -55,9 +57,9 @@
"pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.2",
"react": "^18.2.0",
"react": "^19.0.0",
"react-day-picker": "^8.10.1",
"react-dom": "^18.2.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.0",
"react-icons": "^5.4.0",
"react-markdown": "^10.1.0",
@@ -85,8 +87,8 @@
"@types/bun": "latest",
"@types/node": "^24.2.1",
"@types/pg": "^8.15.4",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/react": "^19",
"@types/react-dom": "^19",
"cross-env": "^7.0.3",
"dotenv": "^16.5.0",
"drizzle-kit": "^0.31.4",
@@ -497,7 +499,7 @@
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.16", "", { "dependencies": { "lodash.castarray": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.merge": "^4.6.2", "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA=="],
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
"@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="],
@@ -537,11 +539,9 @@
"@types/pg": ["@types/pg@8.15.4", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg=="],
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
"@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="],
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
"@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
@@ -575,7 +575,7 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
"caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="],
@@ -973,7 +973,7 @@
"react-day-picker": ["react-day-picker@8.10.1", "", { "peerDependencies": { "date-fns": "^2.28.0 || ^3.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA=="],
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-hook-form": ["react-hook-form@7.60.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-SBrYOvMbDB7cV8ZfNpaiLcgjH/a1c7aK0lK+aNigpf4xWLO8q+o4tcvVurv3c4EOyzn/3dCsYt4GKD42VvJ/+A=="],
@@ -1027,7 +1027,7 @@
"sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="],
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
@@ -1159,10 +1159,6 @@
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@opencut/web/@types/react": ["@types/react@18.3.23", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w=="],
"@opencut/web/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"@opencut/web/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@radix-ui/react-accordion/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
@@ -1291,8 +1287,6 @@
"@types/pg/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="],
"@types/react-dom/@types/react": ["@types/react@18.3.23", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w=="],
"@upstash/core-analytics/@upstash/redis": ["@upstash/redis@1.35.1", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-sIMuAMU9IYbE2bkgDby8KLoQKRiBMXn0moXxqLvUmQ7VUu2CvulZLtK8O0x3WQZFvvZhU5sRC2/lOVZdGfudkA=="],
"cmdk/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="],
@@ -1333,8 +1327,6 @@
"react-day-picker/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"react-dom/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"recharts/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
"vaul/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="],
@@ -1383,8 +1375,6 @@
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@opencut/web/@types/react/csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"@radix-ui/react-alert-dialog/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="],
"@radix-ui/react-alert-dialog/@radix-ui/react-dialog/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="],
@@ -1395,8 +1385,6 @@
"@types/pg/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"@types/react-dom/@types/react/csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"cmdk/@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
"cmdk/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="],
+4 -1
View File
@@ -13,9 +13,12 @@
"start:tools": "turbo run start --filter=@opencut/tools",
"lint:web": "biome lint apps/web/src --max-diagnostics=1000",
"lint:web:fix": "biome lint apps/web/src --write --max-diagnostics=1000",
"format:web": "biome format apps/web/src/services/renderer --write --max-diagnostics=1000"
"format:web": "biome format apps/web/src/services/renderer --write --max-diagnostics=1000",
"test": "bun test"
},
"dependencies": {
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"better-auth": "^1.4.15",
"next": "^16.1.3"
},