mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ca32e5e39 | ||
|
|
5120e2efa6 | ||
|
|
1aec9c5cf5 | ||
|
|
e88a059222 | ||
|
|
c3f0d640ef | ||
|
|
22ea6b8925 | ||
|
|
dfccf905bb | ||
|
|
3e4584edde | ||
|
|
4cd15fcf00 | ||
|
|
748856f4c4 | ||
|
|
2759d7ed31 | ||
|
|
03c2b16064 | ||
|
|
e15ccefbe5 | ||
|
|
5e1e509c11 | ||
|
|
bfcf20e108 | ||
|
|
4f64ff50cd | ||
|
|
58f23e8f2a | ||
|
|
33f4a54768 | ||
|
|
1d55b903d7 | ||
|
|
af047ba4a7 | ||
|
|
ef3563745a | ||
|
|
d06b7c3b0f | ||
|
|
b7293dc552 | ||
|
|
82d92934cb | ||
|
|
b2a976ba82 | ||
|
|
02d4f9c314 | ||
|
|
1d31b7a586 | ||
|
|
e00ba706fe | ||
|
|
f820d9a894 | ||
|
|
7878c8bb7b | ||
|
|
1f618601c7 | ||
|
|
59c189ca27 | ||
|
|
7bf0984698 |
@@ -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.
|
||||
+763
-758
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+1
-7
@@ -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 };
|
||||
+3
-3
@@ -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> {
|
||||
+2
@@ -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,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";
|
||||
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user