refactor: restructure files to their domains, new preview overlay system, and dep graph

This commit is contained in:
Maze Winther
2026-04-20 11:31:17 +02:00
parent 729d10592f
commit 3e89d29985
491 changed files with 3565 additions and 2372 deletions
@@ -1,5 +1,5 @@
import { drawCssBackground } from "@/lib/gradients";
import { masksRegistry } from "@/lib/masks";
import { drawCssBackground } from "@/gradients";
import { masksRegistry } from "@/masks";
import type { AnyBaseNode } from "../nodes/base-node";
import type { CanvasRenderer } from "../canvas-renderer";
import { createOffscreenCanvas } from "../canvas-utils";
@@ -19,7 +19,7 @@ import type {
LayerMaskDescriptor,
QuadTransformDescriptor,
} from "./types";
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics";
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
export type TextureUploadDescriptor = {
id: string;
@@ -1,5 +1,5 @@
import type { BlendMode } from "@/lib/rendering";
import type { EffectPass } from "@/lib/effects/types";
import type { BlendMode } from "@/rendering";
import type { EffectPass } from "@/effects/types";
export type FrameDescriptor = {
width: number;
@@ -1,7 +1,7 @@
import { createOffscreenCanvas } from "./canvas-utils";
import { effectsRegistry, resolveEffectPasses } from "@/lib/effects";
import { buildDefaultParamValues } from "@/lib/registry";
import type { ParamValues } from "@/lib/params";
import { effectsRegistry, resolveEffectPasses } from "@/effects";
import { buildDefaultParamValues } from "@/params/registry";
import type { ParamValues } from "@/params";
import { gpuRenderer } from "./gpu-renderer";
const PREVIEW_SIZE = 160;
@@ -3,7 +3,7 @@ import {
applyMaskFeather as applyMaskFeatherWasm,
initializeGpu,
} from "opencut-wasm";
import type { EffectPass, EffectUniformValue } from "@/lib/effects/types";
import type { EffectPass, EffectUniformValue } from "@/effects/types";
let gpuAvailable = false;
let initPromise: Promise<void> | null = null;
@@ -1,5 +1,5 @@
import type { EffectPass } from "@/lib/effects/types";
import type { RetimeConfig } from "@/lib/timeline";
import type { EffectPass } from "@/effects/types";
import type { RetimeConfig } from "@/timeline";
import { BaseNode } from "./base-node";
export type BlurBackgroundNodeParams = {
@@ -1,5 +1,5 @@
import type { EffectPass } from "@/lib/effects/types";
import type { ParamValues } from "@/lib/params";
import type { EffectPass } from "@/effects/types";
import type { ParamValues } from "@/params";
import { BaseNode } from "./base-node";
export type EffectLayerNodeParams = {
@@ -3,8 +3,8 @@ import {
DEFAULT_GRAPHIC_SOURCE_SIZE,
getGraphicDefinition,
registerDefaultGraphics,
} from "@/lib/graphics";
import type { ParamValues } from "@/lib/params";
} from "@/graphics";
import type { ParamValues } from "@/params";
import {
VisualNode,
type ResolvedVisualNodeState,
@@ -1,4 +1,4 @@
import { resolveStickerId } from "@/lib/stickers";
import { resolveStickerId } from "@/stickers";
import {
VisualNode,
type ResolvedVisualSourceNodeState,
@@ -1,11 +1,11 @@
import { BaseNode } from "./base-node";
import type { TextElement } from "@/lib/timeline";
import type { EffectPass } from "@/lib/effects/types";
import type { Transform } from "@/lib/rendering";
import type { TextElement } from "@/timeline";
import type { EffectPass } from "@/effects/types";
import type { Transform } from "@/rendering";
import {
drawMeasuredTextLayout,
} from "@/lib/text/primitives";
import type { MeasuredTextElement } from "@/lib/text/measure-element";
} from "@/text/primitives";
import type { MeasuredTextElement } from "@/text/measure-element";
export type TextNodeParams = TextElement & {
canvasCenter: { x: number; y: number };
@@ -1,8 +1,8 @@
import { BaseNode } from "./base-node";
import type { Effect, EffectPass } from "@/lib/effects/types";
import type { Mask } from "@/lib/masks/types";
import type { BlendMode, Transform } from "@/lib/rendering";
import type { RetimeConfig, VisualElement } from "@/lib/timeline";
import type { Effect, EffectPass } from "@/effects/types";
import type { Mask } from "@/masks/types";
import type { BlendMode, Transform } from "@/rendering";
import type { RetimeConfig, VisualElement } from "@/timeline";
export interface VisualNodeParams {
duration: number;
+8 -8
View File
@@ -5,20 +5,20 @@ import {
resolveGraphicParamsAtTime,
resolveOpacityAtTime,
resolveTransformAtTime,
} from "@/lib/animation";
import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel";
} from "@/animation";
import { resolveEffectParamsAtTime } from "@/animation/effect-param-channel";
import {
buildGaussianBlurPasses,
intensityToSigma,
} from "@/lib/effects/definitions/blur";
import { effectsRegistry, resolveEffectPasses } from "@/lib/effects";
import type { Effect, EffectPass } from "@/lib/effects/types";
import { getSourceTimeAtClipTime } from "@/lib/retime";
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics";
} from "@/effects/definitions/blur";
import { effectsRegistry, resolveEffectPasses } from "@/effects";
import type { Effect, EffectPass } from "@/effects/types";
import { getSourceTimeAtClipTime } from "@/retime";
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
import {
getTextMeasurementContext,
measureTextElement,
} from "@/lib/text/measure-element";
} from "@/text/measure-element";
import { videoCache } from "@/services/video-cache/service";
import type { CanvasRenderer } from "./canvas-renderer";
import type { AnyBaseNode } from "./nodes/base-node";
@@ -1,5 +1,5 @@
import type { SceneTracks, TimelineTrack } from "@/lib/timeline";
import type { MediaAsset } from "@/lib/media/types";
import type { SceneTracks, TimelineTrack } from "@/timeline";
import type { MediaAsset } from "@/media/types";
import { RootNode } from "./nodes/root-node";
import { VideoNode } from "./nodes/video-node";
import { ImageNode } from "./nodes/image-node";
@@ -10,8 +10,8 @@ import { ColorNode } from "./nodes/color-node";
import { BlurBackgroundNode } from "./nodes/blur-background-node";
import { EffectLayerNode } from "./nodes/effect-layer-node";
import type { AnyBaseNode } from "./nodes/base-node";
import type { TBackground, TCanvasSize } from "@/lib/project/types";
import { DEFAULT_BACKGROUND_BLUR_INTENSITY } from "@/lib/background/blur";
import type { TBackground, TCanvasSize } from "@/project/types";
import { DEFAULT_BACKGROUND_BLUR_INTENSITY } from "@/background/blur";
const PREVIEW_MAX_IMAGE_SIZE = 2048;
@@ -14,10 +14,10 @@ import {
} from "mediabunny";
import type { FrameRate } from "opencut-wasm";
import { mediaTimeToSeconds } from "opencut-wasm";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { frameRateToFloat } from "@/lib/fps/utils";
import { TICKS_PER_SECOND } from "@/wasm";
import { frameRateToFloat } from "@/fps/utils";
import type { RootNode } from "./nodes/root-node";
import type { ExportFormat, ExportQuality } from "@/lib/export";
import type { ExportFormat, ExportQuality } from "@/export";
import { CanvasRenderer } from "./canvas-renderer";
type ExportParams = {
@@ -0,0 +1,41 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useStoragePersistence } from "@/services/storage/use-storage-persistence";
export function StoragePersistenceDialog() {
const { showDialog, onConfirm, onDismiss } = useStoragePersistence();
return (
<Dialog open={showDialog} onOpenChange={(open) => !open && onDismiss()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Don't lose your projects</DialogTitle>
</DialogHeader>
<DialogBody>
<p className="text-base text-muted-foreground">
Your browser can automatically delete your projects when storage
runs low.
</p>
<p className="text-base text-muted-foreground">
Allow OpenCut to protect them?
</p>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={onDismiss}>
Not now
</Button>
<Button onClick={onConfirm}>Allow</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test";
import { DEFAULT_BACKGROUND_BLUR_INTENSITY } from "@/lib/background/blur";
import { DEFAULT_BACKGROUND_COLOR } from "@/lib/background/color";
import { DEFAULT_CANVAS_SIZE } from "@/lib/canvas/sizes";
import { DEFAULT_BACKGROUND_BLUR_INTENSITY } from "@/background/blur";
import { DEFAULT_BACKGROUND_COLOR } from "@/background/color";
import { DEFAULT_CANVAS_SIZE } from "@/canvas/sizes";
const DEFAULT_FPS = 30;
import type { MediaAssetData } from "@/services/storage/types";
import { getProjectId, transformProjectV1ToV2 } from "../transformers/v1-to-v2";
@@ -1,6 +1,6 @@
import { DEFAULT_BACKGROUND_BLUR_INTENSITY } from "@/lib/background/blur";
import { DEFAULT_BACKGROUND_COLOR } from "@/lib/background/color";
import { DEFAULT_CANVAS_SIZE } from "@/lib/canvas/sizes";
import { DEFAULT_BACKGROUND_BLUR_INTENSITY } from "@/background/blur";
import { DEFAULT_BACKGROUND_COLOR } from "@/background/color";
import { DEFAULT_CANVAS_SIZE } from "@/canvas/sizes";
const DEFAULT_FPS = 30;
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
import type { MediaAssetData } from "@/services/storage/types";
@@ -1,4 +1,4 @@
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/lib/stickers/intrinsic-size";
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/stickers/intrinsic-size";
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
@@ -1,6 +1,6 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
import { clampDb } from "@/lib/timeline/audio-state";
import { VOLUME_DB_MIN } from "@/timeline/audio-constants";
import { clampDb } from "@/timeline/audio-state";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV17ToV18({
@@ -1,4 +1,4 @@
import { INTENSITY_TO_SIGMA_DIVISOR } from "@/lib/effects/definitions/blur";
import { INTENSITY_TO_SIGMA_DIVISOR } from "@/effects/definitions/blur";
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
@@ -1,7 +1,7 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { TICKS_PER_SECOND } from "@/wasm";
const ARBITRARY_FPS_DENOMINATOR = 1_000_000;
const STANDARD_FRAME_RATES = [
{ value: 24_000 / 1_001, numerator: 24_000, denominator: 1_001 },
@@ -1,4 +1,4 @@
import { parseCustomMaskPath } from "@/lib/masks/custom-path";
import { parseCustomMaskPath } from "@/masks/custom-path";
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
+5 -5
View File
@@ -1,6 +1,6 @@
import type { TProject, TProjectMetadata } from "@/lib/project/types";
import { getProjectDurationFromScenes } from "@/lib/scenes";
import type { MediaAsset } from "@/lib/media/types";
import type { TProject, TProjectMetadata } from "@/project/types";
import { getProjectDurationFromScenes } from "@/timeline/scenes";
import type { MediaAsset } from "@/media/types";
import { IndexedDBAdapter } from "./indexeddb-adapter";
import { OPFSAdapter } from "./opfs-adapter";
import {
@@ -16,12 +16,12 @@ import type {
SerializedProject,
SerializedScene,
} from "./types";
import type { SavedSoundsData, SavedSound, SoundEffect } from "@/lib/sounds/types";
import type { SavedSoundsData, SavedSound, SoundEffect } from "@/sounds/types";
import {
migrations,
runStorageMigrations,
} from "@/services/storage/migrations";
import type { Bookmark, SceneTracks, TScene } from "@/lib/timeline";
import type { Bookmark, SceneTracks, TScene } from "@/timeline";
function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] {
if (!Array.isArray(raw)) return [];
+3 -3
View File
@@ -1,10 +1,10 @@
import type { MediaType } from "@/lib/media/types";
import type { MediaType } from "@/media/types";
import type {
TProject,
TProjectMetadata,
TTimelineViewState,
} from "@/lib/project/types";
import type { TScene } from "@/lib/timeline";
} from "@/project/types";
import type { TScene } from "@/timeline";
export interface StorageAdapter<T> {
get(key: string): Promise<T | null>;
@@ -0,0 +1,58 @@
import { useState, useEffect, useCallback, useRef } from "react";
export function useLocalStorage<T>({
key,
defaultValue,
}: {
key: string;
defaultValue: T;
}): [
T,
({ value }: { value: T | ((previousValue: T) => T) }) => void,
boolean,
] {
const [value, setValue] = useState<T>(defaultValue);
const [isReady, setIsReady] = useState(false);
const valueRef = useRef(defaultValue);
// avoid hydration mismatch by reading after mount
useEffect(() => {
try {
const storedValue = localStorage.getItem(key);
if (storedValue !== null) {
const parsedValue = JSON.parse(storedValue) as T;
valueRef.current = parsedValue;
setValue(parsedValue);
}
} catch {
// localstorage might be unavailable
}
setIsReady(true);
}, [key]);
// sync to localstorage after hydration
useEffect(() => {
if (!isReady) return;
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// localstorage might be full or disabled
}
}, [key, value, isReady]);
const setValueWithCallback = useCallback(
({ value: nextValue }: { value: T | ((previousValue: T) => T) }) => {
const resolvedValue =
typeof nextValue === "function"
? (nextValue as (previousValue: T) => T)(valueRef.current)
: nextValue;
valueRef.current = resolvedValue;
setValue(resolvedValue);
},
[],
);
return [value, setValueWithCallback, isReady];
}
@@ -0,0 +1,45 @@
"use client";
import { useEffect, useState } from "react";
const DISMISSED_KEY = "opencut-storage-persist-dismissed";
function isFirefox(): boolean {
return navigator.userAgent.toLowerCase().includes("firefox");
}
export function useStoragePersistence() {
const [showDialog, setShowDialog] = useState(false);
useEffect(() => {
if (!navigator.storage?.persist) return;
const run = async () => {
const alreadyPersisted = await navigator.storage.persisted();
if (alreadyPersisted) return;
const dismissed = localStorage.getItem(DISMISSED_KEY) === "true";
if (dismissed) return;
if (isFirefox()) {
setShowDialog(true);
} else {
await navigator.storage.persist();
}
};
run();
}, []);
const onConfirm = async () => {
setShowDialog(false);
await navigator.storage.persist();
};
const onDismiss = () => {
setShowDialog(false);
localStorage.setItem(DISMISSED_KEY, "true");
};
return { showDialog, onConfirm, onDismiss };
}
@@ -3,11 +3,11 @@ import type {
TranscriptionResult,
TranscriptionProgress,
TranscriptionModelId,
} from "@/lib/transcription/types";
} from "@/transcription/types";
import {
DEFAULT_TRANSCRIPTION_MODEL,
TRANSCRIPTION_MODELS,
} from "@/lib/transcription/models";
} from "@/transcription/models";
import type { WorkerMessage, WorkerResponse } from "./worker";
type ProgressCallback = (progress: TranscriptionProgress) => void;
@@ -3,11 +3,11 @@ import {
type AutomaticSpeechRecognitionPipeline,
type AutomaticSpeechRecognitionOutput,
} from "@huggingface/transformers";
import type { TranscriptionSegment } from "@/lib/transcription/types";
import type { TranscriptionSegment } from "@/transcription/types";
import {
DEFAULT_CHUNK_LENGTH_SECONDS,
DEFAULT_STRIDE_SECONDS,
} from "@/lib/transcription/audio";
} from "@/transcription/audio";
export type WorkerMessage =
| { type: "init"; modelId: string }
@@ -1,10 +1,10 @@
"use client";
import { createAudioContext } from "@/lib/media/audio";
import { createAudioContext } from "@/media/audio";
import {
buildSourceWaveformSummary,
type SourceWaveformSummary,
} from "@/lib/media/waveform-summary";
} from "@/media/waveform-summary";
interface GetSourceWaveformSummaryArgs {
sourceKey: string;