summaryrefslogtreecommitdiff
path: root/apps/web/src/services
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/services')
-rw-r--r--apps/web/src/services/renderer/compositor/frame-descriptor.ts6
-rw-r--r--apps/web/src/services/renderer/compositor/types.ts4
-rw-r--r--apps/web/src/services/renderer/effect-preview.ts6
-rw-r--r--apps/web/src/services/renderer/gpu-renderer.ts2
-rw-r--r--apps/web/src/services/renderer/nodes/blur-background-node.ts4
-rw-r--r--apps/web/src/services/renderer/nodes/effect-layer-node.ts4
-rw-r--r--apps/web/src/services/renderer/nodes/graphic-node.ts4
-rw-r--r--apps/web/src/services/renderer/nodes/sticker-node.ts2
-rw-r--r--apps/web/src/services/renderer/nodes/text-node.ts10
-rw-r--r--apps/web/src/services/renderer/nodes/visual-node.ts8
-rw-r--r--apps/web/src/services/renderer/resolve.ts16
-rw-r--r--apps/web/src/services/renderer/scene-builder.ts8
-rw-r--r--apps/web/src/services/renderer/scene-exporter.ts6
-rw-r--r--apps/web/src/services/storage/components/storage-persistence-dialog.tsx41
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts6
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts6
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v14-to-v15.ts2
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v17-to-v18.ts4
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v20-to-v21.ts2
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v22-to-v23.ts2
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts2
-rw-r--r--apps/web/src/services/storage/service.ts10
-rw-r--r--apps/web/src/services/storage/types.ts6
-rw-r--r--apps/web/src/services/storage/use-local-storage.ts58
-rw-r--r--apps/web/src/services/storage/use-storage-persistence.ts45
-rw-r--r--apps/web/src/services/transcription/service.ts4
-rw-r--r--apps/web/src/services/transcription/worker.ts4
-rw-r--r--apps/web/src/services/waveform-cache/service.ts4
28 files changed, 210 insertions, 66 deletions
diff --git a/apps/web/src/services/renderer/compositor/frame-descriptor.ts b/apps/web/src/services/renderer/compositor/frame-descriptor.ts
index f93eae27..bae9203f 100644
--- a/apps/web/src/services/renderer/compositor/frame-descriptor.ts
+++ b/apps/web/src/services/renderer/compositor/frame-descriptor.ts
@@ -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;
diff --git a/apps/web/src/services/renderer/compositor/types.ts b/apps/web/src/services/renderer/compositor/types.ts
index 5d9322e8..af5a58c2 100644
--- a/apps/web/src/services/renderer/compositor/types.ts
+++ b/apps/web/src/services/renderer/compositor/types.ts
@@ -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;
diff --git a/apps/web/src/services/renderer/effect-preview.ts b/apps/web/src/services/renderer/effect-preview.ts
index 06e64d0e..c4cacde0 100644
--- a/apps/web/src/services/renderer/effect-preview.ts
+++ b/apps/web/src/services/renderer/effect-preview.ts
@@ -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;
diff --git a/apps/web/src/services/renderer/gpu-renderer.ts b/apps/web/src/services/renderer/gpu-renderer.ts
index c11c3015..c61b6b98 100644
--- a/apps/web/src/services/renderer/gpu-renderer.ts
+++ b/apps/web/src/services/renderer/gpu-renderer.ts
@@ -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;
diff --git a/apps/web/src/services/renderer/nodes/blur-background-node.ts b/apps/web/src/services/renderer/nodes/blur-background-node.ts
index d20e2533..d83c1cf5 100644
--- a/apps/web/src/services/renderer/nodes/blur-background-node.ts
+++ b/apps/web/src/services/renderer/nodes/blur-background-node.ts
@@ -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 = {
diff --git a/apps/web/src/services/renderer/nodes/effect-layer-node.ts b/apps/web/src/services/renderer/nodes/effect-layer-node.ts
index d6e70fcf..d1b51fa3 100644
--- a/apps/web/src/services/renderer/nodes/effect-layer-node.ts
+++ b/apps/web/src/services/renderer/nodes/effect-layer-node.ts
@@ -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 = {
diff --git a/apps/web/src/services/renderer/nodes/graphic-node.ts b/apps/web/src/services/renderer/nodes/graphic-node.ts
index 494d2179..2d022510 100644
--- a/apps/web/src/services/renderer/nodes/graphic-node.ts
+++ b/apps/web/src/services/renderer/nodes/graphic-node.ts
@@ -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,
diff --git a/apps/web/src/services/renderer/nodes/sticker-node.ts b/apps/web/src/services/renderer/nodes/sticker-node.ts
index 3c155cb6..3d22799e 100644
--- a/apps/web/src/services/renderer/nodes/sticker-node.ts
+++ b/apps/web/src/services/renderer/nodes/sticker-node.ts
@@ -1,4 +1,4 @@
-import { resolveStickerId } from "@/lib/stickers";
+import { resolveStickerId } from "@/stickers";
import {
VisualNode,
type ResolvedVisualSourceNodeState,
diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts
index 86384328..d5755315 100644
--- a/apps/web/src/services/renderer/nodes/text-node.ts
+++ b/apps/web/src/services/renderer/nodes/text-node.ts
@@ -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 };
diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts
index 010f146e..995e598d 100644
--- a/apps/web/src/services/renderer/nodes/visual-node.ts
+++ b/apps/web/src/services/renderer/nodes/visual-node.ts
@@ -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;
diff --git a/apps/web/src/services/renderer/resolve.ts b/apps/web/src/services/renderer/resolve.ts
index 3f30f0d5..578c9603 100644
--- a/apps/web/src/services/renderer/resolve.ts
+++ b/apps/web/src/services/renderer/resolve.ts
@@ -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";
diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts
index 47e6139c..e79c62d4 100644
--- a/apps/web/src/services/renderer/scene-builder.ts
+++ b/apps/web/src/services/renderer/scene-builder.ts
@@ -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;
diff --git a/apps/web/src/services/renderer/scene-exporter.ts b/apps/web/src/services/renderer/scene-exporter.ts
index b19005cb..1b3267a7 100644
--- a/apps/web/src/services/renderer/scene-exporter.ts
+++ b/apps/web/src/services/renderer/scene-exporter.ts
@@ -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 = {
diff --git a/apps/web/src/services/storage/components/storage-persistence-dialog.tsx b/apps/web/src/services/storage/components/storage-persistence-dialog.tsx
new file mode 100644
index 00000000..4544ef1d
--- /dev/null
+++ b/apps/web/src/services/storage/components/storage-persistence-dialog.tsx
@@ -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>
+ );
+}
diff --git a/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts b/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts
index 9cea9a2e..6d0c37fb 100644
--- a/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts
+++ b/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts
@@ -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";
diff --git a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts
index a19269cd..c4b64e50 100644
--- a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts
+++ b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts
@@ -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";
diff --git a/apps/web/src/services/storage/migrations/transformers/v14-to-v15.ts b/apps/web/src/services/storage/migrations/transformers/v14-to-v15.ts
index 36e3d9a8..5f3c01e9 100644
--- a/apps/web/src/services/storage/migrations/transformers/v14-to-v15.ts
+++ b/apps/web/src/services/storage/migrations/transformers/v14-to-v15.ts
@@ -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";
diff --git a/apps/web/src/services/storage/migrations/transformers/v17-to-v18.ts b/apps/web/src/services/storage/migrations/transformers/v17-to-v18.ts
index 5dcf6feb..3ef6cde9 100644
--- a/apps/web/src/services/storage/migrations/transformers/v17-to-v18.ts
+++ b/apps/web/src/services/storage/migrations/transformers/v17-to-v18.ts
@@ -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({
diff --git a/apps/web/src/services/storage/migrations/transformers/v20-to-v21.ts b/apps/web/src/services/storage/migrations/transformers/v20-to-v21.ts
index f5194bda..6c30e5c8 100644
--- a/apps/web/src/services/storage/migrations/transformers/v20-to-v21.ts
+++ b/apps/web/src/services/storage/migrations/transformers/v20-to-v21.ts
@@ -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";
diff --git a/apps/web/src/services/storage/migrations/transformers/v22-to-v23.ts b/apps/web/src/services/storage/migrations/transformers/v22-to-v23.ts
index cde1294e..5e3adc33 100644
--- a/apps/web/src/services/storage/migrations/transformers/v22-to-v23.ts
+++ b/apps/web/src/services/storage/migrations/transformers/v22-to-v23.ts
@@ -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 },
diff --git a/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts b/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts
index 186ae1e9..820b0087 100644
--- a/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts
+++ b/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts
@@ -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";
diff --git a/apps/web/src/services/storage/service.ts b/apps/web/src/services/storage/service.ts
index 1aea0297..8ef77846 100644
--- a/apps/web/src/services/storage/service.ts
+++ b/apps/web/src/services/storage/service.ts
@@ -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 [];
diff --git a/apps/web/src/services/storage/types.ts b/apps/web/src/services/storage/types.ts
index 1dcf6545..58c14b49 100644
--- a/apps/web/src/services/storage/types.ts
+++ b/apps/web/src/services/storage/types.ts
@@ -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>;
diff --git a/apps/web/src/services/storage/use-local-storage.ts b/apps/web/src/services/storage/use-local-storage.ts
new file mode 100644
index 00000000..d0188ab3
--- /dev/null
+++ b/apps/web/src/services/storage/use-local-storage.ts
@@ -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];
+}
diff --git a/apps/web/src/services/storage/use-storage-persistence.ts b/apps/web/src/services/storage/use-storage-persistence.ts
new file mode 100644
index 00000000..a026b510
--- /dev/null
+++ b/apps/web/src/services/storage/use-storage-persistence.ts
@@ -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 };
+}
diff --git a/apps/web/src/services/transcription/service.ts b/apps/web/src/services/transcription/service.ts
index 443a4230..9fa17a87 100644
--- a/apps/web/src/services/transcription/service.ts
+++ b/apps/web/src/services/transcription/service.ts
@@ -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;
diff --git a/apps/web/src/services/transcription/worker.ts b/apps/web/src/services/transcription/worker.ts
index 928b580e..0ced6484 100644
--- a/apps/web/src/services/transcription/worker.ts
+++ b/apps/web/src/services/transcription/worker.ts
@@ -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 }
diff --git a/apps/web/src/services/waveform-cache/service.ts b/apps/web/src/services/waveform-cache/service.ts
index 3b7d34f3..f37cf98a 100644
--- a/apps/web/src/services/waveform-cache/service.ts
+++ b/apps/web/src/services/waveform-cache/service.ts
@@ -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;