From f820d9a8948b97460ebdfdaa6b42b6d510e2b3cf Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sat, 31 Jan 2026 22:19:11 +0100 Subject: refactor services structure --- .cursor/rules/codebase-index.mdc | 1545 ++++++++++---------- .../editor/panels/assets/views/captions.tsx | 2 +- apps/web/src/components/storage-provider.tsx | 2 +- apps/web/src/core/managers/media-manager.ts | 4 +- apps/web/src/core/managers/project-manager.ts | 2 +- apps/web/src/core/managers/scenes-manager.ts | 2 +- apps/web/src/lib/commands/media/add-media-asset.ts | 2 +- .../src/lib/commands/media/remove-media-asset.ts | 4 +- apps/web/src/services/media/video-cache.ts | 314 ---- apps/web/src/services/storage/service.ts | 469 ++++++ apps/web/src/services/storage/storage-service.ts | 475 ------ apps/web/src/services/transcription/index.ts | 186 --- apps/web/src/services/transcription/service.ts | 186 +++ apps/web/src/services/video-cache/service.ts | 316 ++++ apps/web/src/stores/sounds-store.ts | 2 +- 15 files changed, 1756 insertions(+), 1755 deletions(-) delete mode 100644 apps/web/src/services/media/video-cache.ts create mode 100644 apps/web/src/services/storage/service.ts delete mode 100644 apps/web/src/services/storage/storage-service.ts delete mode 100644 apps/web/src/services/transcription/index.ts create mode 100644 apps/web/src/services/transcription/service.ts create mode 100644 apps/web/src/services/video-cache/service.ts diff --git a/.cursor/rules/codebase-index.mdc b/.cursor/rules/codebase-index.mdc index be1509dc..cd6ed84b 100644 --- a/.cursor/rules/codebase-index.mdc +++ b/.cursor/rules/codebase-index.mdc @@ -16,6 +16,16 @@ When implementing new features: 3. Understand the API surface of your codebase ```typescript +## apps/web/src/components/editor/timeline + +audio-waveform.tsx + export function AudioWaveform({ + audioUrl, + audioBuffer, + height = 32, + className = "", + }: AudioWaveformProps) + ## apps/web/src/constants editor-constants.ts @@ -56,28 +66,28 @@ project-constants.ts site-constants.ts export const SITE_URL export const SITE_INFO - export type ExternalTool = { - name: string; - description: string; - url: string; - icon: React.ElementType; + export type ExternalTool = { + name: string; + description: string; + url: string; + icon: React.ElementType; } export const EXTERNAL_TOOLS: ExternalTool[] export const DEFAULT_LOGO_URL export const SOCIAL_LINKS - export type Sponsor = { - name: string; - url: string; - logo: string; - description: string; + export type Sponsor = { + name: string; + url: string; + logo: string; + description: string; } export const SPONSORS: Sponsor[] stickers-constants.ts export const STICKER_CATEGORIES - export const STICKER_CATEGORY_CONFIG: Record< - (typeof STICKER_CATEGORIES)[number], - string | undefined + export const STICKER_CATEGORY_CONFIG: Record< + (typeof STICKER_CATEGORIES)[number], + string | undefined > text-constants.ts @@ -125,19 +135,19 @@ use-editor.ts export function useEditor(): EditorCore use-file-upload.ts - export function useFileUpload({ - accept, - multiple, - onFilesSelected, + export function useFileUpload({ + accept, + multiple, + onFilesSelected, }: UseFileUploadOptions = {}) use-infinite-scroll.ts - export function useInfiniteScroll({ - onLoadMore, - hasMore, - isLoading, - threshold = 200, - enabled = true, + export function useInfiniteScroll({ + onLoadMore, + hasMore, + isLoading, + threshold = 200, + enabled = true, }: UseInfiniteScrollOptions) use-keybindings.ts @@ -162,28 +172,28 @@ use-raf-loop.ts export function useRafLoop(callback: ({ time }: { time: number }) => void) use-reveal-item.ts - export function useRevealItem( - highlightId: string | null, - onClearHighlight: () => void, - highlightDuration = 1000, + export function useRevealItem( + highlightId: string | null, + onClearHighlight: () => void, + highlightDuration = 1000, ) use-sound-search.ts - export function useSoundSearch({ - query, - commercialOnly, - }: { - query: string; - commercialOnly: boolean; + export function useSoundSearch({ + query, + commercialOnly, + }: { + query: string; + commercialOnly: boolean; }) ## apps/web/src/hooks/actions use-action-handler.ts - export function useActionHandler( - action: A, - handler: TActionFunc, - isActive: TActionHandlerOptions, + export function useActionHandler( + action: A, + handler: TActionFunc, + isActive: TActionHandlerOptions, ) use-editor-actions.ts @@ -207,70 +217,70 @@ use-local-storage.ts ## apps/web/src/hooks/timeline use-edge-auto-scroll.ts - export function useEdgeAutoScroll({ - isActive, - getMouseClientX, - rulerScrollRef, - tracksScrollRef, - contentWidth, - edgeThreshold = 100, - maxScrollSpeed = 15, + export function useEdgeAutoScroll({ + isActive, + getMouseClientX, + rulerScrollRef, + tracksScrollRef, + contentWidth, + edgeThreshold = 100, + maxScrollSpeed = 15, }: UseEdgeAutoScrollParams): void use-scroll-sync.ts - export function useScrollSync({ - tracksScrollRef, - rulerScrollRef, - trackLabelsScrollRef, - bookmarksScrollRef, + export function useScrollSync({ + tracksScrollRef, + rulerScrollRef, + trackLabelsScrollRef, + bookmarksScrollRef, }: UseScrollSyncProps) use-selection-box.ts - export function useSelectionBox({ - containerRef, - onSelectionComplete, - isEnabled = true, - tracksScrollRef, - zoomLevel, + export function useSelectionBox({ + containerRef, + onSelectionComplete, + isEnabled = true, + tracksScrollRef, + zoomLevel, }: UseSelectionBoxProps) use-snap-indicator-position.ts - export function useSnapIndicatorPosition({ - snapPoint, - zoomLevel, - tracks, - timelineRef, - trackLabelsRef, - tracksScrollRef, + export function useSnapIndicatorPosition({ + snapPoint, + zoomLevel, + tracks, + timelineRef, + trackLabelsRef, + tracksScrollRef, }: UseSnapIndicatorPositionParams): SnapIndicatorPosition use-timeline-drag-drop.ts - export function useTimelineDragDrop({ - containerRef, - headerRef, - zoomLevel, + export function useTimelineDragDrop({ + containerRef, + headerRef, + zoomLevel, }: UseTimelineDragDropProps) use-timeline-playhead.ts - export function useTimelinePlayhead({ - zoomLevel, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, + export function useTimelinePlayhead({ + zoomLevel, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, }: UseTimelinePlayheadProps) use-timeline-seek.ts - export function useTimelineSeek({ - playheadRef, - trackLabelsRef, - rulerScrollRef, - tracksScrollRef, - zoomLevel, - duration, - isSelecting, - clearSelectedElements, - seek, + export function useTimelineSeek({ + playheadRef, + trackLabelsRef, + rulerScrollRef, + tracksScrollRef, + zoomLevel, + duration, + isSelecting, + clearSelectedElements, + seek, }: UseTimelineSeekProps) use-timeline-snapping.ts @@ -290,10 +300,10 @@ use-timeline-snapping.ts enableElementSnapping?: boolean enablePlayheadSnapping?: boolean } - export function useTimelineSnapping({ - snapThreshold = 10, - enableElementSnapping = true, - enablePlayheadSnapping = true, + export function useTimelineSnapping({ + snapThreshold = 10, + enableElementSnapping = true, + enablePlayheadSnapping = true, }: UseTimelineSnappingOptions = {}) use-timeline-zoom.ts @@ -310,14 +320,14 @@ use-timeline-zoom.ts ## apps/web/src/hooks/timeline/element use-element-interaction.ts - export function useElementInteraction({ - zoomLevel, - timelineRef, - tracksContainerRef, - tracksScrollRef, - headerRef, - snappingEnabled, - onSnapPointChange, + export function useElementInteraction({ + zoomLevel, + timelineRef, + tracksContainerRef, + tracksScrollRef, + headerRef, + snappingEnabled, + onSnapPointChange, }: UseElementInteractionProps) use-element-resize.ts @@ -330,12 +340,12 @@ use-element-resize.ts initialStartTime: number initialDuration: number } - export function useTimelineElementResize({ - element, - track, - zoomLevel, - onSnapPointChange, - onResizeStateChange, + export function useTimelineElementResize({ + element, + track, + zoomLevel, + onSnapPointChange, + onResizeStateChange, }: UseTimelineElementResizeProps) use-element-selection.ts @@ -344,35 +354,35 @@ use-element-selection.ts ## apps/web/src/lib drag-data.ts - export function setDragData({ - dataTransfer, - dragData, - }: { - dataTransfer: DataTransfer; - dragData: TimelineDragData; + export function setDragData({ + dataTransfer, + dragData, + }: { + dataTransfer: DataTransfer; + dragData: TimelineDragData; }): void - export function getDragData({ - dataTransfer, - }: { - dataTransfer: DataTransfer; + export function getDragData({ + dataTransfer, + }: { + dataTransfer: DataTransfer; }): TimelineDragData | null - export function hasDragData({ - dataTransfer, - }: { - dataTransfer: DataTransfer; + export function hasDragData({ + dataTransfer, + }: { + dataTransfer: DataTransfer; }): boolean export function clearDragData(): void export.ts - export function getExportMimeType({ - format, - }: { - format: ExportFormat; + export function getExportMimeType({ + format, + }: { + format: ExportFormat; }): string - export function getExportFileExtension({ - format, - }: { - format: ExportFormat; + export function getExportFileExtension({ + format, + }: { + format: ExportFormat; }): string iconify-api.ts @@ -381,14 +391,14 @@ iconify-api.ts prefix: string name: string total: number - author?: { - name: string; - url?: string; + author?: { + name: string; + url?: string; } - license?: { - title: string; - spdx?: string; - url?: string; + license?: { + title: string; + spdx?: string; + url?: string; } samples?: string[] category?: string @@ -410,41 +420,41 @@ iconify-api.ts hidden?: string[] aliases?: Record } - export function getCollections( - category?: string, + export function getCollections( + category?: string, ): Promise> - export function getCollection( - prefix: string, + export function getCollection( + prefix: string, ): Promise - export function searchIcons( - query: string, - limit: number = 64, - prefixes?: string[], - category?: string, + export function searchIcons( + query: string, + limit: number = 64, + prefixes?: string[], + category?: string, ): Promise - export function buildIconSvgUrl( - host: string, - iconName: string, - params?: { - color?: string; - width?: number; - height?: number; - flip?: "horizontal" | "vertical" | "horizontal,vertical"; - rotate?: number | string; - }, + export function buildIconSvgUrl( + host: string, + iconName: string, + params?: { + color?: string; + width?: number; + height?: number; + flip?: "horizontal" | "vertical" | "horizontal,vertical"; + rotate?: number | string; + }, ): string - export function getIconSvgUrl( - iconName: string, - params?: Parameters[2], + export function getIconSvgUrl( + iconName: string, + params?: Parameters[2], ): string - export function downloadSvgAsText( - iconName: string, - params?: Parameters[1], + export function downloadSvgAsText( + iconName: string, + params?: Parameters[1], ): Promise export function svgToFile(svgText: string, fileName: string): File export const POPULAR_COLLECTIONS - export function getCategoriesFromCollections( - collections: Record, + export function getCategoriesFromCollections( + collections: Record, ): string[] rate-limit.ts @@ -454,46 +464,46 @@ rate-limit.ts scenes.ts export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null export function ensureMainScene({ scenes }: { scenes: TScene[] }): TScene[] - export function buildDefaultScene({ - name, - isMain, - }: { - name: string; - isMain: boolean; + export function buildDefaultScene({ + name, + isMain, + }: { + name: string; + isMain: boolean; }): TScene - export function canDeleteScene({ scene }: { scene: TScene }): { - canDelete: boolean; - reason?: string; - } - export function getFallbackSceneAfterDelete({ - scenes, - deletedSceneId, - currentSceneId, - }: { - scenes: TScene[]; - deletedSceneId: string; - currentSceneId: string | null; + export function canDeleteScene({ scene }: { scene: TScene }): { + canDelete: boolean; + reason?: string; + } + export function getFallbackSceneAfterDelete({ + scenes, + deletedSceneId, + currentSceneId, + }: { + scenes: TScene[]; + deletedSceneId: string; + currentSceneId: string | null; }): TScene | null - export function findCurrentScene({ - scenes, - currentSceneId, - }: { - scenes: TScene[]; - currentSceneId: string; + export function findCurrentScene({ + scenes, + currentSceneId, + }: { + scenes: TScene[]; + currentSceneId: string; }): TScene | null - export function getProjectDurationFromScenes({ - scenes, - }: { - scenes: TScene[]; + export function getProjectDurationFromScenes({ + scenes, + }: { + scenes: TScene[]; }): number - export function updateSceneInArray({ - scenes, - sceneId, - updates, - }: { - scenes: TScene[]; - sceneId: string; - updates: Partial; + export function updateSceneInArray({ + scenes, + sceneId, + updates, + }: { + scenes: TScene[]; + sceneId: string; + updates: Partial; }): TScene[] time.ts @@ -568,13 +578,13 @@ time.ts ## apps/web/src/lib/actions definitions.ts - export type TActionCategory = | "playback" - | "navigation" - | "editing" - | "selection" - | "history" - | "timeline" - | "controls" + export type TActionCategory = | "playback" + | "navigation" + | "editing" + | "selection" + | "history" + | "timeline" + | "con... export interface TActionDefinition { description: string category: TActionCategory @@ -587,40 +597,40 @@ definitions.ts export function getDefaultShortcuts(): Record registry.ts - export function bindAction( - action: A, - handler: TActionFunc, + export function bindAction( + action: A, + handler: TActionFunc, ) - export function unbindAction( - action: A, - handler: TActionFunc, + export function unbindAction( + action: A, + handler: TActionFunc, ) - export const invokeAction = ( - action: A, - args?: TArgOfAction, - trigger?: TInvocationTrigger, + export const invokeAction = ( + action: A, + args?: TArgOfAction, + trigger?: TInvocationTrigger, ) => ... types.ts - export type TActionArgsMap = { - "seek-forward": { seconds: number } | undefined; - "seek-backward": { seconds: number } | undef... + export type TActionArgsMap = { + "seek-forward": { seconds: number } | undefined; + "seek-backward": { seconds: number } | und... export type TActionWithArgs = keyof TActionArgsMap - export type TActionWithOptionalArgs = | TActionWithNoArgs + export type TActionWithOptionalArgs = | TActionWithNoArgs | TKeysWithValueUndefined export type TActionWithNoArgs = Exclude - export type TArgOfAction = A extends TActionWithArgs - ? TActionArgsMap[A] + export type TArgOfAction = A extends TActionWithArgs + ? TActionArgsMap[A] : undefined - export type TActionFunc = A extends TActionWithArgs - ? (arg: TArgOfAction, trigger?: TInvocationTrigger) => void - : (_?:... + export type TActionFunc = A extends TActionWithArgs + ? (arg: TArgOfAction, trigger?: TInvocationTrigger) => void + : (_... export type TInvocationTrigger = "keypress" | "mouseclick" - export type TBoundActionList = { - [A in TAction]?: Array>; + export type TBoundActionList = { + [A in TAction]?: Array>; } - export type TActionHandlerOptions = | MutableRefObject - | boolean + export type TActionHandlerOptions = | MutableRefObject + | boolean | undefined ## apps/web/src/lib/auth @@ -637,10 +647,10 @@ query.ts export function getSinglePost({ slug }: { slug: string }) export function getCategories() export function getAuthors() - export function processHtmlContent({ - html, - }: { - html: string; + export function processHtmlContent({ + html, + }: { + html: string; }): Promise ## apps/web/src/lib/db @@ -657,60 +667,59 @@ schema.ts ## apps/web/src/lib/gradients canvas.ts - export function drawCssBackground({ - ctx, - width, - height, - css, - }: { - ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; - width: number; - height: number; - css: string; + export function drawCssBackground({ + ctx, + width, + height, + css, + }: { + ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; + width: number; + height: number; + css: string; }): void parser.ts export type GradientOrientation = LinearOrientation | Array - export type Color = | { type: "hex"; value: string } - | { type: "literal"; value: string } - | { type: "rgb"; value: A... + export type Color = | { type: "hex"; value: string } + | { type: "literal"; value: string } + | { type: "rgb"; value:... export type ColorStop = Color & { length?: Distance } - export type GradientAst = { - type: GradientType; - orientation: GradientOrientation | undefined; - colorStops: Array => ... export const GradientParser ## apps/web/src/lib/media audio.ts - export type CollectedAudioElement = Omit< - AudioElement, - "type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl" - ... + export type CollectedAudioElement = Omit< + AudioElement, + "type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl... export function createAudioContext(): AudioContext export interface DecodedAudio { samples: Float32Array sampleRate: number } - export function decodeAudioToFloat32({ - audioBlob, - }: { - audioBlob: Blob; + export function decodeAudioToFloat32({ + audioBlob, + }: { + audioBlob: Blob; }): Promise - export function collectAudioElements({ - tracks, - mediaAssets, - audioContext, - }: { - tracks: TimelineTrack[]; - mediaAssets: MediaAsset[]; - audioContext: AudioContext; + export function collectAudioElements({ + tracks, + mediaAssets, + audioContext, + }: { + tracks: TimelineTrack[]; + mediaAssets: MediaAsset[]; + audioContext: AudioContext; }): Promise export interface AudioClipSource { id: string @@ -722,258 +731,258 @@ audio.ts trimEnd: number muted: boolean } - export function collectAudioMixSources({ - tracks, - mediaAssets, - }: { - tracks: TimelineTrack[]; - mediaAssets: MediaAsset[]; + export function collectAudioMixSources({ + tracks, + mediaAssets, + }: { + tracks: TimelineTrack[]; + mediaAssets: MediaAsset[]; }): Promise - export function collectAudioClips({ - tracks, - mediaAssets, - }: { - tracks: TimelineTrack[]; - mediaAssets: MediaAsset[]; + export function collectAudioClips({ + tracks, + mediaAssets, + }: { + tracks: TimelineTrack[]; + mediaAssets: MediaAsset[]; }): Promise - export function createTimelineAudioBuffer({ - tracks, - mediaAssets, - duration, - sampleRate = 44100, - audioContext, - }: { - tracks: TimelineTrack[]; - mediaAssets: MediaAsset[]; - duration: number; - sampleRate?: number; - audioContext?: AudioContext; + export function createTimelineAudioBuffer({ + tracks, + mediaAssets, + duration, + sampleRate = 44100, + audioContext, + }: { + tracks: TimelineTrack[]; + mediaAssets: MediaAsset[]; + duration: number; + sampleRate?: number; + audioContext?: AudioContext; }): Promise media-utils.ts export const SUPPORTS_AUDIO: readonly MediaType[] - export function mediaSupportsAudio({ - media, - }: { - media: MediaAsset | null | undefined; + export function mediaSupportsAudio({ + media, + }: { + media: MediaAsset | null | undefined; }): boolean - export const getMediaTypeFromFile = ({ - file, - }: { - file: File; + export const getMediaTypeFromFile = ({ + file, + }: { + file: File; }): MediaType | null => ... mediabunny.ts - export function getVideoInfo({ - videoFile, - }: { - videoFile: File; - }): Promise<{ - duration: number; - width: number; - height: number; - fps: number; + export function getVideoInfo({ + videoFile, + }: { + videoFile: File; + }): Promise<{ + duration: number; + width: number; + height: number; + fps: number; }> - export const extractTimelineAudio = ({ - tracks, - mediaAssets, - totalDuration, - onProgress, - }: { - tracks: TimelineTrack[]; - mediaAssets: MediaAsset[]; - totalDuration: number; - onProgress?: (progress: number) => void; + export const extractTimelineAudio = ({ + tracks, + mediaAssets, + totalDuration, + onProgress, + }: { + tracks: TimelineTrack[]; + mediaAssets: MediaAsset[]; + totalDuration: number; + onProgress?: (progress: number) => void; }): Promise => ... processing.ts export interface ProcessedMediaAsset extends Omit - export function generateThumbnail({ - videoFile, - timeInSeconds, - }: { - videoFile: File; - timeInSeconds: number; + export function generateThumbnail({ + videoFile, + timeInSeconds, + }: { + videoFile: File; + timeInSeconds: number; }): Promise - export function generateImageThumbnail({ - imageFile, - }: { - imageFile: File; + export function generateImageThumbnail({ + imageFile, + }: { + imageFile: File; }): Promise - export function processMediaAssets({ - files, - onProgress, - }: { - files: FileList | File[]; - onProgress?: ({ progress }: { progress: number }) => void; + export function processMediaAssets({ + files, + onProgress, + }: { + files: FileList | File[]; + onProgress?: ({ progress }: { progress: number }) => void; }): Promise ## apps/web/src/lib/timeline bookmarks.ts - export function findBookmarkIndex({ - bookmarks, - frameTime, - }: { - bookmarks: number[]; - frameTime: number; + export function findBookmarkIndex({ + bookmarks, + frameTime, + }: { + bookmarks: number[]; + frameTime: number; }): number - export function isBookmarkAtTime({ - bookmarks, - frameTime, - }: { - bookmarks: number[]; - frameTime: number; + export function isBookmarkAtTime({ + bookmarks, + frameTime, + }: { + bookmarks: number[]; + frameTime: number; }): boolean - export function toggleBookmarkInArray({ - bookmarks, - frameTime, - }: { - bookmarks: number[]; - frameTime: number; + export function toggleBookmarkInArray({ + bookmarks, + frameTime, + }: { + bookmarks: number[]; + frameTime: number; }): number[] - export function removeBookmarkFromArray({ - bookmarks, - frameTime, - }: { - bookmarks: number[]; - frameTime: number; + export function removeBookmarkFromArray({ + bookmarks, + frameTime, + }: { + bookmarks: number[]; + frameTime: number; }): number[] - export function getFrameTime({ - time, - fps, - }: { - time: number; - fps: number; + export function getFrameTime({ + time, + fps, + }: { + time: number; + fps: number; }): number drop-utils.ts - export function computeDropTarget({ - elementType, - mouseX, - mouseY, - tracks, - playheadTime, - isExternalDrop, - elementDuration, - pixelsPerSecond, - zoomLevel, - verticalDragDirection, - startTimeOverride, - excludeElementId, + export function computeDropTarget({ + elementType, + mouseX, + mouseY, + tracks, + playheadTime, + isExternalDrop, + elementDuration, + pixelsPerSecond, + zoomLevel, + verticalDragDirection, + startTimeOverride, + excludeElementId, }: ComputeDropTargetParams): DropTarget - export function getDropLineY({ - dropTarget, - tracks, - }: { - dropTarget: DropTarget; - tracks: TimelineTrack[]; + export function getDropLineY({ + dropTarget, + tracks, + }: { + dropTarget: DropTarget; + tracks: TimelineTrack[]; }): number element-utils.ts - export function canElementHaveAudio( - element: TimelineElement, + export function canElementHaveAudio( + element: TimelineElement, ) - export function canElementBeHidden( - element: TimelineElement, + export function canElementBeHidden( + element: TimelineElement, ) - export function hasMediaId( - element: TimelineElement, + export function hasMediaId( + element: TimelineElement, ) - export function requiresMediaId({ - element, - }: { - element: CreateTimelineElement; + export function requiresMediaId({ + element, + }: { + element: CreateTimelineElement; }): boolean - export function checkElementOverlaps({ - elements, - }: { - elements: TimelineElement[]; + export function checkElementOverlaps({ + elements, + }: { + elements: TimelineElement[]; }): boolean - export function resolveElementOverlaps({ - elements, - }: { - elements: TimelineElement[]; + export function resolveElementOverlaps({ + elements, + }: { + elements: TimelineElement[]; }): TimelineElement[] - export function wouldElementOverlap({ - elements, - startTime, - endTime, - excludeElementId, - }: { - elements: TimelineElement[]; - startTime: number; - endTime: number; - excludeElementId?: string; + export function wouldElementOverlap({ + elements, + startTime, + endTime, + excludeElementId, + }: { + elements: TimelineElement[]; + startTime: number; + endTime: number; + excludeElementId?: string; }): boolean - export function buildTextElement({ - raw, - startTime, - }: { - raw: Partial>; - startTime: number; + export function buildTextElement({ + raw, + startTime, + }: { + raw: Partial>; + startTime: number; }): CreateTimelineElement - export function buildStickerElement({ - iconName, - startTime, - }: { - iconName: string; - startTime: number; + export function buildStickerElement({ + iconName, + startTime, + }: { + iconName: string; + startTime: number; }): CreateStickerElement - export function buildVideoElement({ - mediaId, - name, - duration, - startTime, - }: { - mediaId: string; - name: string; - duration: number; - startTime: number; + export function buildVideoElement({ + mediaId, + name, + duration, + startTime, + }: { + mediaId: string; + name: string; + duration: number; + startTime: number; }): CreateVideoElement - export function buildImageElement({ - mediaId, - name, - duration, - startTime, - }: { - mediaId: string; - name: string; - duration: number; - startTime: number; + export function buildImageElement({ + mediaId, + name, + duration, + startTime, + }: { + mediaId: string; + name: string; + duration: number; + startTime: number; }): CreateImageElement - export function buildUploadAudioElement({ - mediaId, - name, - duration, - startTime, - buffer, - }: { - mediaId: string; - name: string; - duration: number; - startTime: number; - buffer?: AudioBuffer; + export function buildUploadAudioElement({ + mediaId, + name, + duration, + startTime, + buffer, + }: { + mediaId: string; + name: string; + duration: number; + startTime: number; + buffer?: AudioBuffer; }): CreateUploadAudioElement - export function buildLibraryAudioElement({ - sourceUrl, - name, - duration, - startTime, - buffer, - }: { - sourceUrl: string; - name: string; - duration: number; - startTime: number; - buffer?: AudioBuffer; + export function buildLibraryAudioElement({ + sourceUrl, + name, + duration, + startTime, + buffer, + }: { + sourceUrl: string; + name: string; + duration: number; + startTime: number; + buffer?: AudioBuffer; }): CreateLibraryAudioElement - export function getElementsAtTime({ - tracks, - time, - }: { - tracks: TimelineTrack[]; - time: number; + export function getElementsAtTime({ + tracks, + time, + }: { + tracks: TimelineTrack[]; + time: number; }): { trackId: string; elementId: string }[] index.ts @@ -988,165 +997,144 @@ ruler-utils.ts labelIntervalSeconds: number tickIntervalSeconds: number } - export function getRulerConfig({ - zoomLevel, - fps, - }: { - zoomLevel: number; - fps: number; + export function getRulerConfig({ + zoomLevel, + fps, + }: { + zoomLevel: number; + fps: number; }): RulerConfig - export function shouldShowLabel({ - time, - labelIntervalSeconds, - }: { - time: number; - labelIntervalSeconds: number; + export function shouldShowLabel({ + time, + labelIntervalSeconds, + }: { + time: number; + labelIntervalSeconds: number; }): boolean - export function formatRulerLabel({ - timeInSeconds, - fps, - }: { - timeInSeconds: number; - fps: number; + export function formatRulerLabel({ + timeInSeconds, + fps, + }: { + timeInSeconds: number; + fps: number; }): string track-utils.ts - export function canTracktHaveAudio( - track: TimelineTrack, + export function canTracktHaveAudio( + track: TimelineTrack, ) - export function canTrackBeHidden( - track: TimelineTrack, + export function canTrackBeHidden( + track: TimelineTrack, ) export function getTrackColor({ type }: { type: TrackType }) export function getTrackClasses({ type }: { type: TrackType }) export function getTrackHeight({ type }: { type: TrackType }): number - export function getCumulativeHeightBefore({ - tracks, - trackIndex, - }: { - tracks: Array<{ type: TrackType }>; - trackIndex: number; + export function getCumulativeHeightBefore({ + tracks, + trackIndex, + }: { + tracks: Array<{ type: TrackType }>; + trackIndex: number; }): number - export function getTotalTracksHeight({ - tracks, - }: { - tracks: Array<{ type: TrackType }>; + export function getTotalTracksHeight({ + tracks, + }: { + tracks: Array<{ type: TrackType }>; }): number - export function buildEmptyTrack({ - id, - type, - name, - }: { - id: string; - type: TrackType; - name?: string; + export function buildEmptyTrack({ + id, + type, + name, + }: { + id: string; + type: TrackType; + name?: string; }): TimelineTrack - export function getDefaultInsertIndexForTrack({ - tracks, - trackType, - }: { - tracks: TimelineTrack[]; - trackType: TrackType; + export function getDefaultInsertIndexForTrack({ + tracks, + trackType, + }: { + tracks: TimelineTrack[]; + trackType: TrackType; }): number - export function getHighestInsertIndexForTrack({ - tracks, - trackType, - }: { - tracks: TimelineTrack[]; - trackType: TrackType; + export function getHighestInsertIndexForTrack({ + tracks, + trackType, + }: { + tracks: TimelineTrack[]; + trackType: TrackType; }): number export function isMainTrack(track: TimelineTrack) - export function getMainTrack({ - tracks, - }: { - tracks: TimelineTrack[]; + export function getMainTrack({ + tracks, + }: { + tracks: TimelineTrack[]; }): TimelineTrack | null - export function ensureMainTrack({ - tracks, - }: { - tracks: TimelineTrack[]; + export function ensureMainTrack({ + tracks, + }: { + tracks: TimelineTrack[]; }): TimelineTrack[] - export function canElementGoOnTrack({ - elementType, - trackType, - }: { - elementType: ElementType; - trackType: TrackType; + export function canElementGoOnTrack({ + elementType, + trackType, + }: { + elementType: ElementType; + trackType: TrackType; }): boolean - export function validateElementTrackCompatibility({ - element, - track, - }: { - element: { type: ElementType }; - track: { type: TrackType }; + export function validateElementTrackCompatibility({ + element, + track, + }: { + element: { type: ElementType }; + track: { type: TrackType }; }): { isValid: boolean; errorMessage?: string } zoom-utils.ts - export function getTimelineZoomMin({ - duration, - containerWidth, - }: { - duration: number; - containerWidth: number | null | undefined; + export function getTimelineZoomMin({ + duration, + containerWidth, + }: { + duration: number; + containerWidth: number | null | undefined; }): number - export function getTimelinePaddingPx({ - containerWidth, - zoomLevel, - minZoom, - }: { - containerWidth: number; - zoomLevel: number; - minZoom: number; + export function getTimelinePaddingPx({ + containerWidth, + zoomLevel, + minZoom, + }: { + containerWidth: number; + zoomLevel: number; + minZoom: number; }): number - export function getZoomPercent({ - zoomLevel, - minZoom, - }: { - zoomLevel: number; - minZoom: number; + export function getZoomPercent({ + zoomLevel, + minZoom, + }: { + zoomLevel: number; + minZoom: number; }): number ## apps/web/src/lib/transcription caption.ts - export function buildCaptionChunks({ - segments, - wordsPerChunk = DEFAULT_WORDS_PER_CAPTION, - minDuration = MIN_CAPTION_DURATION_SECONDS, - }: { - segments: TranscriptionSegment[]; - wordsPerChunk?: number; - minDuration?: number; + export function buildCaptionChunks({ + segments, + wordsPerChunk = DEFAULT_WORDS_PER_CAPTION, + minDuration = MIN_CAPTION_DURATION_SECONDS, + }: { + segments: TranscriptionSegment[]; + wordsPerChunk?: number; + minDuration?: number; }): CaptionChunk[] -## apps/web/src/services/media - -video-cache.ts - export class VideoCache { - sinks - initPromises - async getFrameAt({ - mediaId, - file, - time, - }: { - mediaId: string; - file: File; - time: number; - }): Promise - clearVideo({ mediaId }: { mediaId: string }): void - clearAll(): void - getStats() - } - export const videoCache - ## apps/web/src/services/renderer canvas-renderer.ts - export type CanvasRendererParams = { - width: number; - height: number; - fps: number; + export type CanvasRendererParams = { + width: number; + height: number; + fps: number; } export class CanvasRenderer { canvas: OffscreenCanvas | HTMLCanvasElement @@ -1157,33 +1145,33 @@ canvas-renderer.ts constructor({ width, height, fps }: CanvasRendererParams) setSize({ width, height }: { width: number; height: number }) async render({ node, time }: { node: BaseNode; time: number }) - async renderToCanvas({ - node, - time, - targetCanvas, - }: { - node: BaseNode; - time: number; - targetCanvas: HTMLCanvasElement; + async renderToCanvas({ + node, + time, + targetCanvas, + }: { + node: BaseNode; + time: number; + targetCanvas: HTMLCanvasElement; }) } scene-builder.ts - export type BuildSceneParams = { - canvasSize: TCanvasSize; - tracks: TimelineTrack[]; - mediaAssets: MediaAsset[]; - duration: numb... + export type BuildSceneParams = { + canvasSize: TCanvasSize; + tracks: TimelineTrack[]; + mediaAssets: MediaAsset[]; + duration: ... export function buildScene(params: BuildSceneParams) scene-exporter.ts export type ExportFormat = "mp4" | "webm" export type ExportQuality = "low" | "medium" | "high" | "very_high" - export type SceneExporterEvents = { - progress: [progress: number]; - complete: [buffer: ArrayBuffer]; - error: [error: Error]; - cance... + export type SceneExporterEvents = { + progress: [progress: number]; + complete: [buffer: ArrayBuffer]; + error: [error: Error]; + c... export class SceneExporter extends EventEmitter { renderer: CanvasRenderer format: ExportFormat @@ -1191,20 +1179,20 @@ scene-exporter.ts shouldIncludeAudio: boolean audioBuffer: AudioBuffer isCancelled - constructor({ - width, - height, - fps, - format, - quality, - shouldIncludeAudio, - audioBuffer, + constructor({ + width, + height, + fps, + format, + quality, + shouldIncludeAudio, + audioBuffer, }: ExportParams) cancel(): void - async export({ - rootNode, - }: { - rootNode: RootNode; + async export({ + rootNode, + }: { + rootNode: RootNode; }): Promise } @@ -1236,7 +1224,7 @@ opfs-adapter.ts static isSupported(): boolean } -storage-service.ts +service.ts export const storageService types.ts @@ -1261,20 +1249,19 @@ types.ts thumbnailUrl?: string sourceStickerIconName?: string } - export type SerializedScene = Omit & { - createdAt: string; - updatedAt: string; - } - export type SerializedProjectMetadata = Omit< - TProjectMetadata, - "createdAt" | "updatedAt" - > & { - createdAt: string; - updatedAt: string; - } - export type SerializedProject = Omit & { - metadata: SerializedProjectMetadata; - scenes: Serializ... + export type SerializedScene = Omit & { + createdAt: string; + updatedAt: string; + } + export type SerializedProjectMetadata = Omit< + TProjectMetadata, + "createdAt" | "updatedAt" + > & { + createdAt: string; + updatedAt: st... + export type SerializedProject = Omit & { + metadata: SerializedProjectMetadata; + scenes: Serial... export interface StorageConfig { projectsDb: string mediaDb: string @@ -1284,15 +1271,36 @@ types.ts ## apps/web/src/services/transcription -index.ts +service.ts export const transcriptionService worker.ts - export type WorkerMessage = | { type: "init"; modelId: string } - | { type: "transcribe"; audio: Float32Array; language: strin... - export type WorkerResponse = | { type: "init-progress"; progress: number } - | { type: "init-complete" } - | { type: "init-error... + export type WorkerMessage = | { type: "init"; modelId: string } + | { type: "transcribe"; audio: Float32Array; language: stri... + export type WorkerResponse = | { type: "init-progress"; progress: number } + | { type: "init-complete" } + | { type: "init-err... + +## apps/web/src/services/video-cache + +service.ts + export class VideoCache { + sinks + initPromises + async getFrameAt({ + mediaId, + file, + time, + }: { + mediaId: string; + file: File; + time: number; + }): Promise + clearVideo({ mediaId }: { mediaId: string }): void + clearAll(): void + getStats() + } + export const videoCache ## apps/web/src/stores @@ -1354,61 +1362,61 @@ assets.ts } blog.ts - export type Post = { - id: string; - slug: string; - title: string; - content: string; - description: string; - coverImage... - export type Pagination = { - limit: number; - currpage: number; - nextPage: number | null; - prevPage: number | null; - totalIt... - export type MarblePostList = { - posts: Post[]; - pagination: Pagination; - } - export type MarblePost = { - post: Post; - } - export type Tag = { - id: string; - name: string; - slug: string; - } - export type MarbleTag = { - tag: Tag; - } - export type MarbleTagList = { - tags: Tag[]; - pagination: Pagination; - } - export type Category = { - id: string; - name: string; - slug: string; - } - export type MarbleCategory = { - category: Category; - } - export type MarbleCategoryList = { - categories: Category[]; - pagination: Pagination; - } - export type Author = { - id: string; - name: string; - image: string; - } - export type MarbleAuthor = { - author: Author; - } - export type MarbleAuthorList = { - authors: Author[]; - pagination: Pagination; + export type Post = { + id: string; + slug: string; + title: string; + content: string; + description: string; + cove... + export type Pagination = { + limit: number; + currpage: number; + nextPage: number | null; + prevPage: number | null; + to... + export type MarblePostList = { + posts: Post[]; + pagination: Pagination; + } + export type MarblePost = { + post: Post; + } + export type Tag = { + id: string; + name: string; + slug: string; + } + export type MarbleTag = { + tag: Tag; + } + export type MarbleTagList = { + tags: Tag[]; + pagination: Pagination; + } + export type Category = { + id: string; + name: string; + slug: string; + } + export type MarbleCategory = { + category: Category; + } + export type MarbleCategoryList = { + categories: Category[]; + pagination: Pagination; + } + export type Author = { + id: string; + name: string; + image: string; + } + export type MarbleAuthor = { + author: Author; + } + export type MarbleAuthorList = { + authors: Author[]; + pagination: Pagination; } drag.ts @@ -1450,33 +1458,31 @@ export.ts } keybinding.ts - export type ModifierKeys = | "ctrl" - | "alt" - | "shift" - | "ctrl+shift" - | "alt+shift" - | "ctrl+alt" + export type ModifierKeys = | "ctrl" + | "alt" + | "shift" + | "ctrl+shift" + | "alt+shift" + | "ctrl+alt" | "ctrl+alt+shift" - export type Key = | "a" - | "b" - | "c" - | "d" - | "e" - | "f" - | "g" - | "h" - | "i" - | "j" - | "k" - | "l" - | "m" - | "n" - ... + export type Key = | "a" + | "b" + | "c" + | "d" + | "e" + | "f" + | "g" + | "h" + | "i" + | "j" + | "k" + | "l" + |... export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}` export type SingleCharacterShortcutKey = `${Key}` export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey - export type KeybindingConfig = { - [key in ShortcutKey]?: TActionWithOptionalArgs; + export type KeybindingConfig = { + [key in ShortcutKey]?: TActionWithOptionalArgs; } language.ts @@ -1484,14 +1490,13 @@ language.ts export type LanguageCode = Language["code"] project.ts - export type TBackground = | { - type: "color"; - color: string; - } - | { - type: "blur"; - blurIntensity: number; - } + export type TBackground = | { + type: "color"; + color: string; + } + | { + type: "blur"; + blurIntensity: number... export interface TCanvasSize { width: number height: number @@ -1608,9 +1613,9 @@ timeline.ts export type TimelineTrack = VideoTrack | TextTrack | AudioTrack | StickerTrack export interface Transform { scale: number - position: { - x: number; - y: number; + position: { + x: number; + y: number; } rotate: number } @@ -1661,25 +1666,25 @@ timeline.ts opacity: number color?: string } - export type TimelineElement = | AudioElement - | VideoElement - | ImageElement - | TextElement + export type TimelineElement = | AudioElement + | VideoElement + | ImageElement + | TextElement | StickerElement export type ElementType = TimelineElement["type"] export type CreateUploadAudioElement = Omit export type CreateLibraryAudioElement = Omit - export type CreateAudioElement = | CreateUploadAudioElement + export type CreateAudioElement = | CreateUploadAudioElement | CreateLibraryAudioElement export type CreateVideoElement = Omit export type CreateImageElement = Omit export type CreateTextElement = Omit export type CreateStickerElement = Omit - export type CreateTimelineElement = | CreateAudioElement - | CreateVideoElement - | CreateImageElement - | CreateTextElement - | CreateSt... + export type CreateTimelineElement = | CreateAudioElement + | CreateVideoElement + | CreateImageElement + | CreateTextElement + | Crea... export interface ElementDragState { isDragging: boolean elementId: string | null @@ -1729,19 +1734,19 @@ transcription.ts segments: TranscriptionSegment[] language: string } - export type TranscriptionStatus = | "idle" - | "loading-model" - | "transcribing" - | "complete" + export type TranscriptionStatus = | "idle" + | "loading-model" + | "transcribing" + | "complete" | "error" export interface TranscriptionProgress { status: TranscriptionStatus progress: number message?: string } - export type TranscriptionModelId = | "whisper-tiny" - | "whisper-small" - | "whisper-medium" + export type TranscriptionModelId = | "whisper-tiny" + | "whisper-small" + | "whisper-medium" | "whisper-large-v3-turbo" export interface TranscriptionModel { id: TranscriptionModelId @@ -1758,36 +1763,36 @@ transcription.ts ## apps/web/src/utils browser.ts - export function isTypableDOMElement({ - element, - }: { - element: HTMLElement; + export function isTypableDOMElement({ + element, + }: { + element: HTMLElement; }): boolean date.ts export function formatDate({ date }: { date: Date }): string geometry.ts - export function dimensionToAspectRatio({ - width, - height, - }: { - width: number; - height: number; + export function dimensionToAspectRatio({ + width, + height, + }: { + width: number; + height: number; }): string id.ts export function generateUUID(): string math.ts - export function clamp({ - value, - min, - max, - }: { - value: number; - min: number; - max: number; + export function clamp({ + value, + min, + max, + }: { + value: number; + min: number; + max: number; }): number platform.ts diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx index 306bad2a..d93baeff 100644 --- a/apps/web/src/components/editor/panels/assets/views/captions.tsx +++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx @@ -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"; diff --git a/apps/web/src/components/storage-provider.tsx b/apps/web/src/components/storage-provider.tsx index 144790dc..5b233782 100644 --- a/apps/web/src/components/storage-provider.tsx +++ b/apps/web/src/components/storage-provider.tsx @@ -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; diff --git a/apps/web/src/core/managers/media-manager.ts b/apps/web/src/core/managers/media-manager.ts index 7b9db0d5..fd8726d9 100644 --- a/apps/web/src/core/managers/media-manager.ts +++ b/apps/web/src/core/managers/media-manager.ts @@ -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 { diff --git a/apps/web/src/core/managers/project-manager.ts b/apps/web/src/core/managers/project-manager.ts index db9e3fad..fc0b2a36 100644 --- a/apps/web/src/core/managers/project-manager.ts +++ b/apps/web/src/core/managers/project-manager.ts @@ -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"; diff --git a/apps/web/src/core/managers/scenes-manager.ts b/apps/web/src/core/managers/scenes-manager.ts index 447ec820..8c761f9b 100644 --- a/apps/web/src/core/managers/scenes-manager.ts +++ b/apps/web/src/core/managers/scenes-manager.ts @@ -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, diff --git a/apps/web/src/lib/commands/media/add-media-asset.ts b/apps/web/src/lib/commands/media/add-media-asset.ts index d947cb97..ddcca426 100644 --- a/apps/web/src/lib/commands/media/add-media-asset.ts +++ b/apps/web/src/lib/commands/media/add-media-asset.ts @@ -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; diff --git a/apps/web/src/lib/commands/media/remove-media-asset.ts b/apps/web/src/lib/commands/media/remove-media-asset.ts index aa1a5775..ee9ffa2d 100644 --- a/apps/web/src/lib/commands/media/remove-media-asset.ts +++ b/apps/web/src/lib/commands/media/remove-media-asset.ts @@ -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"; diff --git a/apps/web/src/services/media/video-cache.ts b/apps/web/src/services/media/video-cache.ts deleted file mode 100644 index f3ddfcb2..00000000 --- a/apps/web/src/services/media/video-cache.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { - Input, - ALL_FORMATS, - BlobSource, - CanvasSink, - type WrappedCanvas, -} from "mediabunny"; - -interface VideoSinkData { - sink: CanvasSink; - iterator: AsyncGenerator | null; - currentFrame: WrappedCanvas | null; - nextFrame: WrappedCanvas | null; - lastTime: number; - prefetching: boolean; - prefetchPromise: Promise | null; -} -export class VideoCache { - private sinks = new Map(); - private initPromises = new Map>(); - - async getFrameAt({ - mediaId, - file, - time, - }: { - mediaId: string; - file: File; - time: number; - }): Promise { - await this.ensureSink({ mediaId, file }); - - const sinkData = this.sinks.get(mediaId); - if (!sinkData) return null; - - if (sinkData.nextFrame && sinkData.nextFrame.timestamp <= time) { - sinkData.currentFrame = sinkData.nextFrame; - sinkData.nextFrame = null; - this.startPrefetch({ sinkData }); - } - - if ( - sinkData.currentFrame && - this.isFrameValid({ frame: sinkData.currentFrame, time }) - ) { - if (!sinkData.nextFrame && !sinkData.prefetching) { - this.startPrefetch({ sinkData }); - } - return sinkData.currentFrame; - } - - if ( - sinkData.iterator && - sinkData.currentFrame && - time >= sinkData.lastTime && - time < sinkData.lastTime + 2.0 - ) { - const frame = await this.iterateToTime({ sinkData, targetTime: time }); - if (frame) { - if (!sinkData.nextFrame && !sinkData.prefetching) { - this.startPrefetch({ sinkData }); - } - return frame; - } - } - - const frame = await this.seekToTime({ sinkData, time }); - if (frame && !sinkData.nextFrame && !sinkData.prefetching) { - this.startPrefetch({ sinkData }); - } - return frame; - } - - private isFrameValid({ - frame, - time, - }: { - frame: WrappedCanvas; - time: number; - }): boolean { - return time >= frame.timestamp && time < frame.timestamp + frame.duration; - } - private async iterateToTime({ - sinkData, - targetTime, - }: { - sinkData: VideoSinkData; - targetTime: number; - }): Promise { - if (!sinkData.iterator) return null; - - try { - while (true) { - // Wait for any pending prefetch to finish before touching iterator - if (sinkData.prefetching && sinkData.prefetchPromise) { - await sinkData.prefetchPromise; - } - - // Check if the nextFrame (which might have just arrived) is what we need - if ( - sinkData.nextFrame && - sinkData.nextFrame.timestamp <= targetTime + 0.05 // Tolerance - ) { - sinkData.currentFrame = sinkData.nextFrame; - sinkData.nextFrame = null; - } else { - const { value: frame, done } = await sinkData.iterator.next(); - - if (done || !frame) break; - - sinkData.currentFrame = frame; - } - - const frame = sinkData.currentFrame; - if (!frame) break; - - sinkData.lastTime = frame.timestamp; - - if (this.isFrameValid({ frame, time: targetTime })) { - return frame; - } - - if (frame.timestamp > targetTime + 1.0) break; - } - } catch (error) { - console.warn("Iterator failed, will restart:", error); - sinkData.iterator = null; - } - - return null; - } - private async seekToTime({ - sinkData, - time, - }: { - sinkData: VideoSinkData; - time: number; - }): Promise { - try { - if (sinkData.prefetching && sinkData.prefetchPromise) { - await sinkData.prefetchPromise; - } - - if (sinkData.iterator) { - await sinkData.iterator.return(); - sinkData.iterator = null; - } - - sinkData.nextFrame = null; - sinkData.iterator = sinkData.sink.canvases(time); - sinkData.lastTime = time; - - // Fetch current frame - const { value: frame } = await sinkData.iterator.next(); - - if (frame) { - sinkData.currentFrame = frame; - - // Aggressively fetch next frame immediately to fill buffer - // This matches the mediaplayer example which fetches 2 frames on start - try { - const { value: next } = await sinkData.iterator.next(); - if (next) { - sinkData.nextFrame = next; - } - } catch (e) { - console.warn("Failed to pre-fetch next frame on seek:", e); - } - - return frame; - } - } catch (error) { - console.warn("Failed to seek video:", error); - } - - return null; - } - - private startPrefetch({ sinkData }: { sinkData: VideoSinkData }): void { - if (sinkData.prefetching || !sinkData.iterator || sinkData.nextFrame) { - return; - } - - sinkData.prefetching = true; - sinkData.prefetchPromise = this.prefetchNextFrame({ sinkData }); - } - - private async prefetchNextFrame({ - sinkData, - }: { - sinkData: VideoSinkData; - }): Promise { - if (!sinkData.iterator) { - sinkData.prefetching = false; - sinkData.prefetchPromise = null; - return; - } - - try { - const { value: frame, done } = await sinkData.iterator.next(); - - if (done || !frame) { - sinkData.prefetching = false; - sinkData.prefetchPromise = null; - return; - } - - sinkData.nextFrame = frame; - sinkData.prefetching = false; - sinkData.prefetchPromise = null; - } catch (error) { - console.warn("Prefetch failed:", error); - sinkData.prefetching = false; - sinkData.prefetchPromise = null; - sinkData.iterator = null; - } - } - private async ensureSink({ - mediaId, - file, - }: { - mediaId: string; - file: File; - }): Promise { - if (this.sinks.has(mediaId)) return; - - if (this.initPromises.has(mediaId)) { - await this.initPromises.get(mediaId); - return; - } - - const initPromise = this.initializeSink({ mediaId, file }); - this.initPromises.set(mediaId, initPromise); - - try { - await initPromise; - } finally { - this.initPromises.delete(mediaId); - } - } - private async initializeSink({ - mediaId, - file, - }: { - mediaId: string; - file: File; - }): Promise { - try { - const input = new Input({ - source: new BlobSource(file), - formats: ALL_FORMATS, - }); - - const videoTrack = await input.getPrimaryVideoTrack(); - if (!videoTrack) { - throw new Error("No video track found"); - } - - const canDecode = await videoTrack.canDecode(); - if (!canDecode) { - throw new Error("Video codec not supported for decoding"); - } - - const sink = new CanvasSink(videoTrack, { - poolSize: 3, - fit: "contain", - }); - - this.sinks.set(mediaId, { - sink, - iterator: null, - currentFrame: null, - nextFrame: null, - lastTime: -1, - prefetching: false, - prefetchPromise: null, - }); - } catch (error) { - console.error(`Failed to initialize video sink for ${mediaId}:`, error); - throw error; - } - } - - clearVideo({ mediaId }: { mediaId: string }): void { - const sinkData = this.sinks.get(mediaId); - if (sinkData) { - if (sinkData.iterator) { - void sinkData.iterator.return(); - } - - this.sinks.delete(mediaId); - } - - this.initPromises.delete(mediaId); - } - - clearAll(): void { - for (const [mediaId] of this.sinks) { - this.clearVideo({ mediaId }); - } - } - - getStats() { - return { - totalSinks: this.sinks.size, - activeSinks: Array.from(this.sinks.values()).filter((s) => s.iterator) - .length, - cachedFrames: Array.from(this.sinks.values()).filter( - (s) => s.currentFrame, - ).length, - }; - } -} -export const videoCache = new VideoCache(); diff --git a/apps/web/src/services/storage/service.ts b/apps/web/src/services/storage/service.ts new file mode 100644 index 00000000..02f8d290 --- /dev/null +++ b/apps/web/src/services/storage/service.ts @@ -0,0 +1,469 @@ +import type { TProject, TProjectMetadata } from "@/types/project"; +import { getProjectDurationFromScenes } from "@/lib/scenes"; +import type { MediaAsset } from "@/types/assets"; +import { IndexedDBAdapter } from "./indexeddb-adapter"; +import { OPFSAdapter } from "./opfs-adapter"; +import type { + MediaAssetData, + StorageConfig, + SerializedProject, + SerializedScene, +} from "./types"; +import type { SavedSoundsData, SavedSound, SoundEffect } from "@/types/sounds"; +import { + migrations, + runStorageMigrations, +} from "@/services/storage/migrations"; +import type { TimelineTrack, TScene } from "@/types/timeline"; + +class StorageService { + private projectsAdapter: IndexedDBAdapter; + private savedSoundsAdapter: IndexedDBAdapter; + private config: StorageConfig; + private migrationsPromise: Promise | null = null; + + constructor() { + this.config = { + projectsDb: "video-editor-projects", + mediaDb: "video-editor-media", + savedSoundsDb: "video-editor-saved-sounds", + version: 1, + }; + + this.projectsAdapter = new IndexedDBAdapter( + this.config.projectsDb, + "projects", + this.config.version, + ); + + this.savedSoundsAdapter = new IndexedDBAdapter( + this.config.savedSoundsDb, + "saved-sounds", + this.config.version, + ); + } + + private async ensureMigrations(): Promise { + if (this.migrationsPromise) { + await this.migrationsPromise; + return; + } + + this.migrationsPromise = runStorageMigrations({ migrations }).then( + () => undefined, + ); + await this.migrationsPromise; + } + + private getProjectMediaAdapters({ projectId }: { projectId: string }) { + const mediaMetadataAdapter = new IndexedDBAdapter( + `${this.config.mediaDb}-${projectId}`, + "media-metadata", + this.config.version, + ); + + const mediaAssetsAdapter = new OPFSAdapter(`media-files-${projectId}`); + + return { mediaMetadataAdapter, mediaAssetsAdapter }; + } + + private stripAudioBuffers({ + tracks, + }: { + tracks: TimelineTrack[]; + }): TimelineTrack[] { + return tracks.map((track) => { + if (track.type !== "audio") return track; + return { + ...track, + elements: track.elements.map((element) => { + const { buffer: _buffer, ...rest } = element; + return rest; + }), + }; + }); + } + + async saveProject({ project }: { project: TProject }): Promise { + const duration = + project.metadata.duration ?? + getProjectDurationFromScenes({ scenes: project.scenes }); + const serializedScenes: SerializedScene[] = project.scenes.map((scene) => ({ + id: scene.id, + name: scene.name, + isMain: scene.isMain, + tracks: this.stripAudioBuffers({ tracks: scene.tracks }), + bookmarks: scene.bookmarks, + createdAt: scene.createdAt.toISOString(), + updatedAt: scene.updatedAt.toISOString(), + })); + + const serializedProject: SerializedProject = { + metadata: { + id: project.metadata.id, + name: project.metadata.name, + thumbnail: project.metadata.thumbnail, + duration, + createdAt: project.metadata.createdAt.toISOString(), + updatedAt: project.metadata.updatedAt.toISOString(), + }, + scenes: serializedScenes, + currentSceneId: project.currentSceneId, + settings: project.settings, + version: project.version, + timelineViewState: project.timelineViewState, + }; + + await this.projectsAdapter.set(project.metadata.id, serializedProject); + } + + async loadProject({ + id, + }: { + id: string; + }): Promise<{ project: TProject } | null> { + await this.ensureMigrations(); + const serializedProject = await this.projectsAdapter.get(id); + + if (!serializedProject) return null; + + const scenes = + serializedProject.scenes?.map((scene) => ({ + id: scene.id, + name: scene.name, + isMain: scene.isMain, + tracks: (scene.tracks ?? []).map((track) => + track.type === "video" + ? { ...track, isMain: track.isMain ?? false } // legacy: isMain was optional + : track, + ), + bookmarks: scene.bookmarks ?? [], + createdAt: new Date(scene.createdAt), + updatedAt: new Date(scene.updatedAt), + })) ?? []; + + const project: TProject = { + metadata: { + id: serializedProject.metadata.id, + name: serializedProject.metadata.name, + thumbnail: serializedProject.metadata.thumbnail, + duration: + serializedProject.metadata.duration ?? + getProjectDurationFromScenes({ scenes }), + createdAt: new Date(serializedProject.metadata.createdAt), + updatedAt: new Date(serializedProject.metadata.updatedAt), + }, + scenes, + currentSceneId: serializedProject.currentSceneId || "", + settings: serializedProject.settings, + version: serializedProject.version, + timelineViewState: serializedProject.timelineViewState, + }; + + return { project }; + } + + async loadAllProjects(): Promise { + const projectIds = await this.projectsAdapter.list(); + const projects: TProject[] = []; + + for (const id of projectIds) { + const result = await this.loadProject({ id }); + if (result?.project) { + projects.push(result.project); + } + } + + return projects.sort( + (a, b) => b.metadata.updatedAt.getTime() - a.metadata.updatedAt.getTime(), + ); + } + + async loadAllProjectsMetadata(): Promise { + await this.ensureMigrations(); + const serializedProjects = await this.projectsAdapter.getAll(); + + const metadata = serializedProjects.map((serializedProject) => ({ + id: serializedProject.metadata.id, + name: serializedProject.metadata.name, + thumbnail: serializedProject.metadata.thumbnail, + duration: + serializedProject.metadata.duration ?? + getProjectDurationFromScenes({ + scenes: (serializedProject.scenes ?? []) as unknown as TScene[], + }), + createdAt: new Date(serializedProject.metadata.createdAt), + updatedAt: new Date(serializedProject.metadata.updatedAt), + })); + + return metadata.sort( + (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime(), + ); + } + + async deleteProject({ id }: { id: string }): Promise { + await this.projectsAdapter.remove(id); + } + + async saveMediaAsset({ + projectId, + mediaAsset, + }: { + projectId: string; + mediaAsset: MediaAsset; + }): Promise { + const { mediaMetadataAdapter, mediaAssetsAdapter } = + this.getProjectMediaAdapters({ projectId }); + + await mediaAssetsAdapter.set(mediaAsset.id, mediaAsset.file); + + const metadata: MediaAssetData = { + id: mediaAsset.id, + name: mediaAsset.name, + type: mediaAsset.type, + size: mediaAsset.file.size, + lastModified: mediaAsset.file.lastModified, + width: mediaAsset.width, + height: mediaAsset.height, + duration: mediaAsset.duration, + thumbnailUrl: mediaAsset.thumbnailUrl, + ephemeral: mediaAsset.ephemeral, + }; + + await mediaMetadataAdapter.set(mediaAsset.id, metadata); + } + + async loadMediaAsset({ + projectId, + id, + }: { + projectId: string; + id: string; + }): Promise { + const { mediaMetadataAdapter, mediaAssetsAdapter } = + this.getProjectMediaAdapters({ projectId }); + + const [file, metadata] = await Promise.all([ + mediaAssetsAdapter.get(id), + mediaMetadataAdapter.get(id), + ]); + + if (!file || !metadata) return null; + + let url: string; + if (metadata.type === "image" && (!file.type || file.type === "")) { + try { + const text = await file.text(); + if (text.trim().startsWith(" { + const { mediaMetadataAdapter } = this.getProjectMediaAdapters({ + projectId, + }); + + const mediaIds = await mediaMetadataAdapter.list(); + const mediaItems: MediaAsset[] = []; + + for (const id of mediaIds) { + const item = await this.loadMediaAsset({ projectId, id }); + if (item) { + mediaItems.push(item); + } + } + + return mediaItems; + } + + async deleteMediaAsset({ + projectId, + id, + }: { + projectId: string; + id: string; + }): Promise { + const { mediaMetadataAdapter, mediaAssetsAdapter } = + this.getProjectMediaAdapters({ projectId }); + + await Promise.all([ + mediaAssetsAdapter.remove(id), + mediaMetadataAdapter.remove(id), + ]); + } + + async deleteProjectMedia({ + projectId, + }: { + projectId: string; + }): Promise { + const { mediaMetadataAdapter, mediaAssetsAdapter } = + this.getProjectMediaAdapters({ projectId }); + + await Promise.all([ + mediaMetadataAdapter.clear(), + mediaAssetsAdapter.clear(), + ]); + } + + async clearAllData(): Promise { + await this.projectsAdapter.clear(); + // project-specific media and timelines cleaned up when projects are deleted + } + + async getStorageInfo(): Promise<{ + projects: number; + isOPFSSupported: boolean; + isIndexedDBSupported: boolean; + }> { + const projectIds = await this.projectsAdapter.list(); + + return { + projects: projectIds.length, + isOPFSSupported: this.isOPFSSupported(), + isIndexedDBSupported: this.isIndexedDBSupported(), + }; + } + + async getProjectStorageInfo({ projectId }: { projectId: string }): Promise<{ + mediaItems: number; + }> { + const { mediaMetadataAdapter } = this.getProjectMediaAdapters({ + projectId, + }); + + const mediaIds = await mediaMetadataAdapter.list(); + + return { + mediaItems: mediaIds.length, + }; + } + + async loadSavedSounds(): Promise { + try { + const savedSoundsData = await this.savedSoundsAdapter.get("user-sounds"); + return ( + savedSoundsData || { + sounds: [], + lastModified: new Date().toISOString(), + } + ); + } catch (error) { + console.error("Failed to load saved sounds:", error); + return { sounds: [], lastModified: new Date().toISOString() }; + } + } + + async saveSoundEffect({ + soundEffect, + }: { + soundEffect: SoundEffect; + }): Promise { + try { + const currentData = await this.loadSavedSounds(); + + if (currentData.sounds.some((sound) => sound.id === soundEffect.id)) { + return; // Already saved + } + + const savedSound: SavedSound = { + id: soundEffect.id, + name: soundEffect.name, + username: soundEffect.username, + previewUrl: soundEffect.previewUrl, + downloadUrl: soundEffect.downloadUrl, + duration: soundEffect.duration, + tags: soundEffect.tags, + license: soundEffect.license, + savedAt: new Date().toISOString(), + }; + + const updatedData: SavedSoundsData = { + sounds: [...currentData.sounds, savedSound], + lastModified: new Date().toISOString(), + }; + + await this.savedSoundsAdapter.set("user-sounds", updatedData); + } catch (error) { + console.error("Failed to save sound effect:", error); + throw error; + } + } + + async removeSavedSound({ soundId }: { soundId: number }): Promise { + try { + const currentData = await this.loadSavedSounds(); + + const updatedData: SavedSoundsData = { + sounds: currentData.sounds.filter((sound) => sound.id !== soundId), + lastModified: new Date().toISOString(), + }; + + await this.savedSoundsAdapter.set("user-sounds", updatedData); + } catch (error) { + console.error("Failed to remove saved sound:", error); + throw error; + } + } + + async isSoundSaved({ soundId }: { soundId: number }): Promise { + try { + const currentData = await this.loadSavedSounds(); + return currentData.sounds.some((sound) => sound.id === soundId); + } catch (error) { + console.error("Failed to check if sound is saved:", error); + return false; + } + } + + async clearSavedSounds(): Promise { + try { + await this.savedSoundsAdapter.remove("user-sounds"); + } catch (error) { + console.error("Failed to clear saved sounds:", error); + throw error; + } + } + + isOPFSSupported(): boolean { + return OPFSAdapter.isSupported(); + } + + isIndexedDBSupported(): boolean { + return "indexedDB" in window; + } + + isFullySupported(): boolean { + return this.isIndexedDBSupported() && this.isOPFSSupported(); + } +} + +export const storageService = new StorageService(); +export { StorageService }; diff --git a/apps/web/src/services/storage/storage-service.ts b/apps/web/src/services/storage/storage-service.ts deleted file mode 100644 index cb0b2ced..00000000 --- a/apps/web/src/services/storage/storage-service.ts +++ /dev/null @@ -1,475 +0,0 @@ -import type { TProject, TProjectMetadata } from "@/types/project"; -import { getProjectDurationFromScenes } from "@/lib/scenes"; -import type { MediaAsset } from "@/types/assets"; -import { IndexedDBAdapter } from "./indexeddb-adapter"; -import { OPFSAdapter } from "./opfs-adapter"; -import type { - MediaAssetData, - StorageConfig, - SerializedProject, - SerializedScene, -} from "./types"; -import type { SavedSoundsData, SavedSound, SoundEffect } from "@/types/sounds"; -import { - migrations, - runStorageMigrations, -} from "@/services/storage/migrations"; -import type { TimelineTrack, TScene } from "@/types/timeline"; - -class StorageService { - private projectsAdapter: IndexedDBAdapter; - private savedSoundsAdapter: IndexedDBAdapter; - private config: StorageConfig; - private migrationsPromise: Promise | null = null; - - constructor() { - this.config = { - projectsDb: "video-editor-projects", - mediaDb: "video-editor-media", - savedSoundsDb: "video-editor-saved-sounds", - version: 1, - }; - - this.projectsAdapter = new IndexedDBAdapter( - this.config.projectsDb, - "projects", - this.config.version, - ); - - this.savedSoundsAdapter = new IndexedDBAdapter( - this.config.savedSoundsDb, - "saved-sounds", - this.config.version, - ); - } - - private async ensureMigrations(): Promise { - if (this.migrationsPromise) { - await this.migrationsPromise; - return; - } - - this.migrationsPromise = runStorageMigrations({ migrations }).then( - () => undefined, - ); - await this.migrationsPromise; - } - - private getProjectMediaAdapters({ projectId }: { projectId: string }) { - const mediaMetadataAdapter = new IndexedDBAdapter( - `${this.config.mediaDb}-${projectId}`, - "media-metadata", - this.config.version, - ); - - const mediaAssetsAdapter = new OPFSAdapter(`media-files-${projectId}`); - - return { mediaMetadataAdapter, mediaAssetsAdapter }; - } - - private stripAudioBuffers({ - tracks, - }: { - tracks: TimelineTrack[]; - }): TimelineTrack[] { - return tracks.map((track) => { - if (track.type !== "audio") return track; - return { - ...track, - elements: track.elements.map((element) => { - const { buffer: _buffer, ...rest } = element; - return rest; - }), - }; - }); - } - - async saveProject({ project }: { project: TProject }): Promise { - const duration = - project.metadata.duration ?? - getProjectDurationFromScenes({ scenes: project.scenes }); - const serializedScenes: SerializedScene[] = project.scenes.map((scene) => ({ - id: scene.id, - name: scene.name, - isMain: scene.isMain, - tracks: this.stripAudioBuffers({ tracks: scene.tracks }), - bookmarks: scene.bookmarks, - createdAt: scene.createdAt.toISOString(), - updatedAt: scene.updatedAt.toISOString(), - })); - - const serializedProject: SerializedProject = { - metadata: { - id: project.metadata.id, - name: project.metadata.name, - thumbnail: project.metadata.thumbnail, - duration, - createdAt: project.metadata.createdAt.toISOString(), - updatedAt: project.metadata.updatedAt.toISOString(), - }, - scenes: serializedScenes, - currentSceneId: project.currentSceneId, - settings: project.settings, - version: project.version, - timelineViewState: project.timelineViewState, - }; - - await this.projectsAdapter.set(project.metadata.id, serializedProject); - } - - async loadProject({ - id, - }: { - id: string; - }): Promise<{ project: TProject } | null> { - await this.ensureMigrations(); - const serializedProject = await this.projectsAdapter.get(id); - - if (!serializedProject) return null; - - const scenes = - serializedProject.scenes?.map((scene) => ({ - id: scene.id, - name: scene.name, - isMain: scene.isMain, - tracks: (scene.tracks ?? []).map((track) => - track.type === "video" - ? { ...track, isMain: track.isMain ?? false } // legacy: isMain was optional - : track, - ), - bookmarks: scene.bookmarks ?? [], - createdAt: new Date(scene.createdAt), - updatedAt: new Date(scene.updatedAt), - })) ?? []; - - const project: TProject = { - metadata: { - id: serializedProject.metadata.id, - name: serializedProject.metadata.name, - thumbnail: serializedProject.metadata.thumbnail, - duration: - serializedProject.metadata.duration ?? - getProjectDurationFromScenes({ scenes }), - createdAt: new Date(serializedProject.metadata.createdAt), - updatedAt: new Date(serializedProject.metadata.updatedAt), - }, - scenes, - currentSceneId: serializedProject.currentSceneId || "", - settings: serializedProject.settings, - version: serializedProject.version, - timelineViewState: serializedProject.timelineViewState, - }; - - return { project }; - } - - async loadAllProjects(): Promise { - const projectIds = await this.projectsAdapter.list(); - const projects: TProject[] = []; - - for (const id of projectIds) { - const result = await this.loadProject({ id }); - if (result?.project) { - projects.push(result.project); - } - } - - return projects.sort( - (a, b) => b.metadata.updatedAt.getTime() - a.metadata.updatedAt.getTime(), - ); - } - - async loadAllProjectsMetadata(): Promise { - await this.ensureMigrations(); - const serializedProjects = await this.projectsAdapter.getAll(); - - const metadata = serializedProjects.map((serializedProject) => ({ - id: serializedProject.metadata.id, - name: serializedProject.metadata.name, - thumbnail: serializedProject.metadata.thumbnail, - duration: - serializedProject.metadata.duration ?? - getProjectDurationFromScenes({ - scenes: (serializedProject.scenes ?? []) as unknown as TScene[], - }), - createdAt: new Date(serializedProject.metadata.createdAt), - updatedAt: new Date(serializedProject.metadata.updatedAt), - })); - - return metadata.sort( - (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime(), - ); - } - - async deleteProject({ id }: { id: string }): Promise { - await this.projectsAdapter.remove(id); - } - - async saveMediaAsset({ - projectId, - mediaAsset, - }: { - projectId: string; - mediaAsset: MediaAsset; - }): Promise { - const { mediaMetadataAdapter, mediaAssetsAdapter } = - this.getProjectMediaAdapters({ projectId }); - - await mediaAssetsAdapter.set(mediaAsset.id, mediaAsset.file); - - const metadata: MediaAssetData = { - id: mediaAsset.id, - name: mediaAsset.name, - type: mediaAsset.type, - size: mediaAsset.file.size, - lastModified: mediaAsset.file.lastModified, - width: mediaAsset.width, - height: mediaAsset.height, - duration: mediaAsset.duration, - thumbnailUrl: mediaAsset.thumbnailUrl, - ephemeral: mediaAsset.ephemeral, - }; - - await mediaMetadataAdapter.set(mediaAsset.id, metadata); - } - - async loadMediaAsset({ - projectId, - id, - }: { - projectId: string; - id: string; - }): Promise { - const { mediaMetadataAdapter, mediaAssetsAdapter } = - this.getProjectMediaAdapters({ projectId }); - - const [file, metadata] = await Promise.all([ - mediaAssetsAdapter.get(id), - mediaMetadataAdapter.get(id), - ]); - - if (!file || !metadata) return null; - - let url: string; - if (metadata.type === "image" && (!file.type || file.type === "")) { - try { - const text = await file.text(); - if (text.trim().startsWith(" { - const { mediaMetadataAdapter } = this.getProjectMediaAdapters({ - projectId, - }); - - const mediaIds = await mediaMetadataAdapter.list(); - const mediaItems: MediaAsset[] = []; - - for (const id of mediaIds) { - const item = await this.loadMediaAsset({ projectId, id }); - if (item) { - mediaItems.push(item); - } - } - - return mediaItems; - } - - async deleteMediaAsset({ - projectId, - id, - }: { - projectId: string; - id: string; - }): Promise { - const { mediaMetadataAdapter, mediaAssetsAdapter } = - this.getProjectMediaAdapters({ projectId }); - - await Promise.all([ - mediaAssetsAdapter.remove(id), - mediaMetadataAdapter.remove(id), - ]); - } - - async deleteProjectMedia({ - projectId, - }: { - projectId: string; - }): Promise { - const { mediaMetadataAdapter, mediaAssetsAdapter } = - this.getProjectMediaAdapters({ projectId }); - - await Promise.all([ - mediaMetadataAdapter.clear(), - mediaAssetsAdapter.clear(), - ]); - } - - // Utility methods - async clearAllData(): Promise { - // Clear all projects - await this.projectsAdapter.clear(); - - // Note: Project-specific media and timelines will be cleaned up when projects are deleted - } - - async getStorageInfo(): Promise<{ - projects: number; - isOPFSSupported: boolean; - isIndexedDBSupported: boolean; - }> { - const projectIds = await this.projectsAdapter.list(); - - return { - projects: projectIds.length, - isOPFSSupported: this.isOPFSSupported(), - isIndexedDBSupported: this.isIndexedDBSupported(), - }; - } - - async getProjectStorageInfo({ projectId }: { projectId: string }): Promise<{ - mediaItems: number; - }> { - const { mediaMetadataAdapter } = this.getProjectMediaAdapters({ - projectId, - }); - - const mediaIds = await mediaMetadataAdapter.list(); - - return { - mediaItems: mediaIds.length, - }; - } - - async loadSavedSounds(): Promise { - try { - const savedSoundsData = await this.savedSoundsAdapter.get("user-sounds"); - return ( - savedSoundsData || { - sounds: [], - lastModified: new Date().toISOString(), - } - ); - } catch (error) { - console.error("Failed to load saved sounds:", error); - return { sounds: [], lastModified: new Date().toISOString() }; - } - } - - async saveSoundEffect({ - soundEffect, - }: { - soundEffect: SoundEffect; - }): Promise { - try { - const currentData = await this.loadSavedSounds(); - - // Check if sound is already saved - if (currentData.sounds.some((sound) => sound.id === soundEffect.id)) { - return; // Already saved - } - - const savedSound: SavedSound = { - id: soundEffect.id, - name: soundEffect.name, - username: soundEffect.username, - previewUrl: soundEffect.previewUrl, - downloadUrl: soundEffect.downloadUrl, - duration: soundEffect.duration, - tags: soundEffect.tags, - license: soundEffect.license, - savedAt: new Date().toISOString(), - }; - - const updatedData: SavedSoundsData = { - sounds: [...currentData.sounds, savedSound], - lastModified: new Date().toISOString(), - }; - - await this.savedSoundsAdapter.set("user-sounds", updatedData); - } catch (error) { - console.error("Failed to save sound effect:", error); - throw error; - } - } - - async removeSavedSound({ soundId }: { soundId: number }): Promise { - try { - const currentData = await this.loadSavedSounds(); - - const updatedData: SavedSoundsData = { - sounds: currentData.sounds.filter((sound) => sound.id !== soundId), - lastModified: new Date().toISOString(), - }; - - await this.savedSoundsAdapter.set("user-sounds", updatedData); - } catch (error) { - console.error("Failed to remove saved sound:", error); - throw error; - } - } - - async isSoundSaved({ soundId }: { soundId: number }): Promise { - try { - const currentData = await this.loadSavedSounds(); - return currentData.sounds.some((sound) => sound.id === soundId); - } catch (error) { - console.error("Failed to check if sound is saved:", error); - return false; - } - } - - async clearSavedSounds(): Promise { - try { - await this.savedSoundsAdapter.remove("user-sounds"); - } catch (error) { - console.error("Failed to clear saved sounds:", error); - throw error; - } - } - - // Check browser support - isOPFSSupported(): boolean { - return OPFSAdapter.isSupported(); - } - - isIndexedDBSupported(): boolean { - return "indexedDB" in window; - } - - isFullySupported(): boolean { - return this.isIndexedDBSupported() && this.isOPFSSupported(); - } -} - -// Export singleton instance -export const storageService = new StorageService(); -export { StorageService }; diff --git a/apps/web/src/services/transcription/index.ts b/apps/web/src/services/transcription/index.ts deleted file mode 100644 index 37c650da..00000000 --- a/apps/web/src/services/transcription/index.ts +++ /dev/null @@ -1,186 +0,0 @@ -import type { - TranscriptionResult, - TranscriptionProgress, - TranscriptionModelId, -} from "@/types/transcription"; -import { - DEFAULT_TRANSCRIPTION_MODEL, - TRANSCRIPTION_MODELS, -} from "@/constants/transcription-constants"; -import type { WorkerMessage, WorkerResponse } from "./worker"; -import type { LanguageCode } from "@/types/language"; - -type ProgressCallback = (progress: TranscriptionProgress) => void; - -class TranscriptionService { - private worker: Worker | null = null; - private currentModelId: TranscriptionModelId | null = null; - private isInitialized = false; - private isInitializing = false; - - async transcribe({ - audioData, - language, - modelId = DEFAULT_TRANSCRIPTION_MODEL, - onProgress, - }: { - audioData: Float32Array; - language?: LanguageCode; - modelId?: TranscriptionModelId; - onProgress?: ProgressCallback; - }): Promise { - await this.ensureWorker({ modelId, onProgress }); - - return new Promise((resolve, reject) => { - if (!this.worker) { - reject(new Error("Worker not initialized")); - return; - } - - const handleMessage = (event: MessageEvent) => { - const response = event.data; - - switch (response.type) { - case "transcribe-progress": - onProgress?.({ - status: "transcribing", - progress: response.progress, - message: "Transcribing audio...", - }); - break; - - case "transcribe-complete": - this.worker?.removeEventListener("message", handleMessage); - resolve({ - text: response.text, - segments: response.segments, - language, - }); - break; - - case "transcribe-error": - this.worker?.removeEventListener("message", handleMessage); - reject(new Error(response.error)); - break; - - case "cancelled": - this.worker?.removeEventListener("message", handleMessage); - reject(new Error("Transcription cancelled")); - break; - } - }; - - this.worker.addEventListener("message", handleMessage); - - this.worker.postMessage({ - type: "transcribe", - audio: audioData, - language, - } satisfies WorkerMessage); - }); - } - - cancel() { - this.worker?.postMessage({ type: "cancel" } satisfies WorkerMessage); - } - - private async ensureWorker({ - modelId, - onProgress, - }: { - modelId: TranscriptionModelId; - onProgress?: ProgressCallback; - }): Promise { - const needsNewModel = this.currentModelId !== modelId; - - if (this.worker && this.isInitialized && !needsNewModel) { - return; - } - - if (this.isInitializing && !needsNewModel) { - await this.waitForInit(); - return; - } - - this.terminate(); - this.isInitializing = true; - this.isInitialized = false; - - const model = TRANSCRIPTION_MODELS.find((m) => m.id === modelId); - if (!model) { - throw new Error(`Unknown model: ${modelId}`); - } - - this.worker = new Worker(new URL("./worker.ts", import.meta.url), { - type: "module", - }); - - return new Promise((resolve, reject) => { - if (!this.worker) { - reject(new Error("Failed to create worker")); - return; - } - - const handleMessage = (event: MessageEvent) => { - const response = event.data; - - switch (response.type) { - case "init-progress": - onProgress?.({ - status: "loading-model", - progress: response.progress, - message: `Loading ${model.name} model...`, - }); - break; - - case "init-complete": - this.worker?.removeEventListener("message", handleMessage); - this.isInitialized = true; - this.isInitializing = false; - this.currentModelId = modelId; - resolve(); - break; - - case "init-error": - this.worker?.removeEventListener("message", handleMessage); - this.isInitializing = false; - this.terminate(); - reject(new Error(response.error)); - break; - } - }; - - this.worker.addEventListener("message", handleMessage); - - this.worker.postMessage({ - type: "init", - modelId: model.huggingFaceId, - } satisfies WorkerMessage); - }); - } - - private waitForInit(): Promise { - return new Promise((resolve) => { - const checkInit = () => { - if (this.isInitialized) { - resolve(); - } else if (!this.isInitializing) { - resolve(); - } else { - setTimeout(checkInit, 100); - } - }; - checkInit(); - }); - } - - terminate() { - this.worker?.terminate(); - this.worker = null; - this.isInitialized = false; - this.isInitializing = false; - this.currentModelId = null; - } -} - -export const transcriptionService = new TranscriptionService(); diff --git a/apps/web/src/services/transcription/service.ts b/apps/web/src/services/transcription/service.ts new file mode 100644 index 00000000..c34a82ce --- /dev/null +++ b/apps/web/src/services/transcription/service.ts @@ -0,0 +1,186 @@ +import type { + TranscriptionLanguage, + TranscriptionResult, + TranscriptionProgress, + TranscriptionModelId, +} from "@/types/transcription"; +import { + DEFAULT_TRANSCRIPTION_MODEL, + TRANSCRIPTION_MODELS, +} from "@/constants/transcription-constants"; +import type { WorkerMessage, WorkerResponse } from "./worker"; + +type ProgressCallback = (progress: TranscriptionProgress) => void; + +class TranscriptionService { + private worker: Worker | null = null; + private currentModelId: TranscriptionModelId | null = null; + private isInitialized = false; + private isInitializing = false; + + async transcribe({ + audioData, + language = "auto", + modelId = DEFAULT_TRANSCRIPTION_MODEL, + onProgress, + }: { + audioData: Float32Array; + language?: TranscriptionLanguage; + modelId?: TranscriptionModelId; + onProgress?: ProgressCallback; + }): Promise { + await this.ensureWorker({ modelId, onProgress }); + + return new Promise((resolve, reject) => { + if (!this.worker) { + reject(new Error("Worker not initialized")); + return; + } + + const handleMessage = (event: MessageEvent) => { + const response = event.data; + + switch (response.type) { + case "transcribe-progress": + onProgress?.({ + status: "transcribing", + progress: response.progress, + message: "Transcribing audio...", + }); + break; + + case "transcribe-complete": + this.worker?.removeEventListener("message", handleMessage); + resolve({ + text: response.text, + segments: response.segments, + language, + }); + break; + + case "transcribe-error": + this.worker?.removeEventListener("message", handleMessage); + reject(new Error(response.error)); + break; + + case "cancelled": + this.worker?.removeEventListener("message", handleMessage); + reject(new Error("Transcription cancelled")); + break; + } + }; + + this.worker.addEventListener("message", handleMessage); + + this.worker.postMessage({ + type: "transcribe", + audio: audioData, + language, + } satisfies WorkerMessage); + }); + } + + cancel() { + this.worker?.postMessage({ type: "cancel" } satisfies WorkerMessage); + } + + private async ensureWorker({ + modelId, + onProgress, + }: { + modelId: TranscriptionModelId; + onProgress?: ProgressCallback; + }): Promise { + const needsNewModel = this.currentModelId !== modelId; + + if (this.worker && this.isInitialized && !needsNewModel) { + return; + } + + if (this.isInitializing && !needsNewModel) { + await this.waitForInit(); + return; + } + + this.terminate(); + this.isInitializing = true; + this.isInitialized = false; + + const model = TRANSCRIPTION_MODELS.find((m) => m.id === modelId); + if (!model) { + throw new Error(`Unknown model: ${modelId}`); + } + + this.worker = new Worker(new URL("./worker.ts", import.meta.url), { + type: "module", + }); + + return new Promise((resolve, reject) => { + if (!this.worker) { + reject(new Error("Failed to create worker")); + return; + } + + const handleMessage = (event: MessageEvent) => { + const response = event.data; + + switch (response.type) { + case "init-progress": + onProgress?.({ + status: "loading-model", + progress: response.progress, + message: `Loading ${model.name} model...`, + }); + break; + + case "init-complete": + this.worker?.removeEventListener("message", handleMessage); + this.isInitialized = true; + this.isInitializing = false; + this.currentModelId = modelId; + resolve(); + break; + + case "init-error": + this.worker?.removeEventListener("message", handleMessage); + this.isInitializing = false; + this.terminate(); + reject(new Error(response.error)); + break; + } + }; + + this.worker.addEventListener("message", handleMessage); + + this.worker.postMessage({ + type: "init", + modelId: model.huggingFaceId, + } satisfies WorkerMessage); + }); + } + + private waitForInit(): Promise { + return new Promise((resolve) => { + const checkInit = () => { + if (this.isInitialized) { + resolve(); + } else if (!this.isInitializing) { + resolve(); + } else { + setTimeout(checkInit, 100); + } + }; + checkInit(); + }); + } + + terminate() { + this.worker?.terminate(); + this.worker = null; + this.isInitialized = false; + this.isInitializing = false; + this.currentModelId = null; + } +} + +export const transcriptionService = new TranscriptionService(); diff --git a/apps/web/src/services/video-cache/service.ts b/apps/web/src/services/video-cache/service.ts new file mode 100644 index 00000000..36441981 --- /dev/null +++ b/apps/web/src/services/video-cache/service.ts @@ -0,0 +1,316 @@ +import { + Input, + ALL_FORMATS, + BlobSource, + CanvasSink, + type WrappedCanvas, +} from "mediabunny"; + +interface VideoSinkData { + sink: CanvasSink; + iterator: AsyncGenerator | null; + currentFrame: WrappedCanvas | null; + nextFrame: WrappedCanvas | null; + lastTime: number; + prefetching: boolean; + prefetchPromise: Promise | null; +} + +export class VideoCache { + private sinks = new Map(); + private initPromises = new Map>(); + + async getFrameAt({ + mediaId, + file, + time, + }: { + mediaId: string; + file: File; + time: number; + }): Promise { + await this.ensureSink({ mediaId, file }); + + const sinkData = this.sinks.get(mediaId); + if (!sinkData) return null; + + if (sinkData.nextFrame && sinkData.nextFrame.timestamp <= time) { + sinkData.currentFrame = sinkData.nextFrame; + sinkData.nextFrame = null; + this.startPrefetch({ sinkData }); + } + + if ( + sinkData.currentFrame && + this.isFrameValid({ frame: sinkData.currentFrame, time }) + ) { + if (!sinkData.nextFrame && !sinkData.prefetching) { + this.startPrefetch({ sinkData }); + } + return sinkData.currentFrame; + } + + if ( + sinkData.iterator && + sinkData.currentFrame && + time >= sinkData.lastTime && + time < sinkData.lastTime + 2.0 + ) { + const frame = await this.iterateToTime({ sinkData, targetTime: time }); + if (frame) { + if (!sinkData.nextFrame && !sinkData.prefetching) { + this.startPrefetch({ sinkData }); + } + return frame; + } + } + + const frame = await this.seekToTime({ sinkData, time }); + if (frame && !sinkData.nextFrame && !sinkData.prefetching) { + this.startPrefetch({ sinkData }); + } + return frame; + } + + private isFrameValid({ + frame, + time, + }: { + frame: WrappedCanvas; + time: number; + }): boolean { + return time >= frame.timestamp && time < frame.timestamp + frame.duration; + } + private async iterateToTime({ + sinkData, + targetTime, + }: { + sinkData: VideoSinkData; + targetTime: number; + }): Promise { + if (!sinkData.iterator) return null; + + try { + while (true) { + // Wait for any pending prefetch to finish before touching iterator + if (sinkData.prefetching && sinkData.prefetchPromise) { + await sinkData.prefetchPromise; + } + + // Check if the nextFrame (which might have just arrived) is what we need + if ( + sinkData.nextFrame && + sinkData.nextFrame.timestamp <= targetTime + 0.05 // Tolerance + ) { + sinkData.currentFrame = sinkData.nextFrame; + sinkData.nextFrame = null; + } else { + const { value: frame, done } = await sinkData.iterator.next(); + + if (done || !frame) break; + + sinkData.currentFrame = frame; + } + + const frame = sinkData.currentFrame; + if (!frame) break; + + sinkData.lastTime = frame.timestamp; + + if (this.isFrameValid({ frame, time: targetTime })) { + return frame; + } + + if (frame.timestamp > targetTime + 1.0) break; + } + } catch (error) { + console.warn("Iterator failed, will restart:", error); + sinkData.iterator = null; + } + + return null; + } + private async seekToTime({ + sinkData, + time, + }: { + sinkData: VideoSinkData; + time: number; + }): Promise { + try { + if (sinkData.prefetching && sinkData.prefetchPromise) { + await sinkData.prefetchPromise; + } + + if (sinkData.iterator) { + await sinkData.iterator.return(); + sinkData.iterator = null; + } + + sinkData.nextFrame = null; + sinkData.iterator = sinkData.sink.canvases(time); + sinkData.lastTime = time; + + // Fetch current frame + const { value: frame } = await sinkData.iterator.next(); + + if (frame) { + sinkData.currentFrame = frame; + + // Aggressively fetch next frame immediately to fill buffer + // This matches the mediaplayer example which fetches 2 frames on start + try { + const { value: next } = await sinkData.iterator.next(); + if (next) { + sinkData.nextFrame = next; + } + } catch (e) { + console.warn("Failed to pre-fetch next frame on seek:", e); + } + + return frame; + } + } catch (error) { + console.warn("Failed to seek video:", error); + } + + return null; + } + + private startPrefetch({ sinkData }: { sinkData: VideoSinkData }): void { + if (sinkData.prefetching || !sinkData.iterator || sinkData.nextFrame) { + return; + } + + sinkData.prefetching = true; + sinkData.prefetchPromise = this.prefetchNextFrame({ sinkData }); + } + + private async prefetchNextFrame({ + sinkData, + }: { + sinkData: VideoSinkData; + }): Promise { + if (!sinkData.iterator) { + sinkData.prefetching = false; + sinkData.prefetchPromise = null; + return; + } + + try { + const { value: frame, done } = await sinkData.iterator.next(); + + if (done || !frame) { + sinkData.prefetching = false; + sinkData.prefetchPromise = null; + return; + } + + sinkData.nextFrame = frame; + sinkData.prefetching = false; + sinkData.prefetchPromise = null; + } catch (error) { + console.warn("Prefetch failed:", error); + sinkData.prefetching = false; + sinkData.prefetchPromise = null; + sinkData.iterator = null; + } + } + private async ensureSink({ + mediaId, + file, + }: { + mediaId: string; + file: File; + }): Promise { + if (this.sinks.has(mediaId)) return; + + if (this.initPromises.has(mediaId)) { + await this.initPromises.get(mediaId); + return; + } + + const initPromise = this.initializeSink({ mediaId, file }); + this.initPromises.set(mediaId, initPromise); + + try { + await initPromise; + } finally { + this.initPromises.delete(mediaId); + } + } + private async initializeSink({ + mediaId, + file, + }: { + mediaId: string; + file: File; + }): Promise { + try { + const input = new Input({ + source: new BlobSource(file), + formats: ALL_FORMATS, + }); + + const videoTrack = await input.getPrimaryVideoTrack(); + if (!videoTrack) { + throw new Error("No video track found"); + } + + const canDecode = await videoTrack.canDecode(); + if (!canDecode) { + throw new Error("Video codec not supported for decoding"); + } + + const sink = new CanvasSink(videoTrack, { + poolSize: 3, + fit: "contain", + }); + + this.sinks.set(mediaId, { + sink, + iterator: null, + currentFrame: null, + nextFrame: null, + lastTime: -1, + prefetching: false, + prefetchPromise: null, + }); + } catch (error) { + console.error(`Failed to initialize video sink for ${mediaId}:`, error); + throw error; + } + } + + clearVideo({ mediaId }: { mediaId: string }): void { + const sinkData = this.sinks.get(mediaId); + if (sinkData) { + if (sinkData.iterator) { + void sinkData.iterator.return(); + } + + this.sinks.delete(mediaId); + } + + this.initPromises.delete(mediaId); + } + + clearAll(): void { + for (const [mediaId] of this.sinks) { + this.clearVideo({ mediaId }); + } + } + + getStats() { + return { + totalSinks: this.sinks.size, + activeSinks: Array.from(this.sinks.values()).filter((s) => s.iterator) + .length, + cachedFrames: Array.from(this.sinks.values()).filter( + (s) => s.currentFrame, + ).length, + }; + } +} + +export const videoCache = new VideoCache(); diff --git a/apps/web/src/stores/sounds-store.ts b/apps/web/src/stores/sounds-store.ts index bea1a4bc..f393416a 100644 --- a/apps/web/src/stores/sounds-store.ts +++ b/apps/web/src/stores/sounds-store.ts @@ -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"; -- cgit v1.3.1