From e1d82eca094a86250abe663f9f45b88d96c48212 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 21 Jan 2026 00:59:03 +0100 Subject: [PATCH] shipping --- .cursor/rules/codebase-index.mdc | 843 ++++++++++-------- apps/web/package.json | 1 + apps/web/src/app/api/get-upload-url/route.ts | 119 --- apps/web/src/app/api/transcribe/route.ts | 196 ---- apps/web/src/app/contributors/page.tsx | 4 +- .../components/editor/assets-panel/tabbar.tsx | 4 +- .../editor/assets-panel/views/captions.tsx | 231 +---- .../editor/assets-panel/views/settings.tsx | 4 +- .../editor/assets-panel/views/stickers.tsx | 58 +- .../src/components/editor/export-button.tsx | 32 +- .../editor/timeline/audio-waveform.tsx | 35 +- apps/web/src/constants/export-constants.ts | 4 +- .../src/constants/transcription-constants.ts | 36 + apps/web/src/core/managers/project-manager.ts | 8 +- .../element/use-element-interaction.ts | 2 +- .../hooks/timeline/use-timeline-drag-drop.ts | 2 +- .../hooks/timeline/use-timeline-snapping.ts | 2 +- .../src/hooks/use-keyboard-shortcuts-help.ts | 13 +- apps/web/src/lib/audio-utils.ts | 30 + apps/web/src/lib/caption-utils.ts | 49 + apps/web/src/lib/media/mediabunny.ts | 27 +- apps/web/src/lib/transcription-utils.ts | 13 - apps/web/src/services/transcription/index.ts | 186 ++++ apps/web/src/services/transcription/worker.ts | 163 ++++ apps/web/src/stores/assets-panel-store.ts | 29 +- apps/web/src/types/export.ts | 13 +- apps/web/src/types/transcription.ts | 43 + bun.lock | 85 +- 28 files changed, 1223 insertions(+), 1009 deletions(-) delete mode 100644 apps/web/src/app/api/get-upload-url/route.ts delete mode 100644 apps/web/src/app/api/transcribe/route.ts create mode 100644 apps/web/src/constants/transcription-constants.ts create mode 100644 apps/web/src/lib/caption-utils.ts delete mode 100644 apps/web/src/lib/transcription-utils.ts create mode 100644 apps/web/src/services/transcription/index.ts create mode 100644 apps/web/src/services/transcription/worker.ts create mode 100644 apps/web/src/types/transcription.ts diff --git a/.cursor/rules/codebase-index.mdc b/.cursor/rules/codebase-index.mdc index 30660979..77a5fb9b 100644 --- a/.cursor/rules/codebase-index.mdc +++ b/.cursor/rules/codebase-index.mdc @@ -26,7 +26,7 @@ editor-constants.ts export const PANEL_CONFIG export-constants.ts - export const DEFAULT_EXPORT_OPTIONS: ExportOptions + export const DEFAULT_EXPORT_OPTIONS font-constants.ts export interface FontOption { @@ -92,6 +92,14 @@ timeline-constants.tsx export const TIMELINE_CONSTANTS export const TRACK_ICONS: Record +transcription-constants.ts + export const TRANSCRIPTION_MODELS: TranscriptionModel[] + export const DEFAULT_TRANSCRIPTION_MODEL: TranscriptionModelId + export const DEFAULT_CHUNK_LENGTH_SECONDS + export const DEFAULT_STRIDE_SECONDS + export const DEFAULT_WORDS_PER_CAPTION + export const MIN_CAPTION_DURATION_SECONDS + ## apps/web/src/core index.ts @@ -107,16 +115,6 @@ index.ts save: SaveManager static getInstance(): EditorCore static reset(): void - async export({ - format, - quality, - includeAudio = true, - onProgress, - }: ExportOptions): Promise<{ - success: boolean; - buffer?: ArrayBuffer; - error?: string; - }> } ## apps/web/src/hooks @@ -209,12 +207,12 @@ use-scroll-sync.ts }: 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 @@ -283,6 +281,7 @@ use-timeline-zoom.ts export function useTimelineZoom({ containerRef, isInTimeline = false, + minZoom = TIMELINE_CONSTANTS.ZOOM_MIN, }: UseTimelineZoomProps): UseTimelineZoomReturn ## apps/web/src/hooks/timeline/element @@ -311,6 +310,8 @@ use-element-resize.ts element, track, zoomLevel, + onSnapPointChange, + onResizeStateChange, }: UseTimelineElementResizeProps) use-element-selection.ts @@ -323,6 +324,15 @@ audio-utils.ts AudioElement, "type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceU... export function createAudioContext(): AudioContext + export interface DecodedAudio { + samples: Float32Array + sampleRate: number + } + export function decodeAudioToFloat32({ + audioBlob, + }: { + audioBlob: Blob; + }): Promise export function collectAudioElements({ tracks, mediaAssets, @@ -359,6 +369,17 @@ blog-query.ts export function getAuthors() export function processHtmlContent({ html }: { html: string }): Promise +caption-utils.ts + export function buildCaptionChunks({ + segments, + wordsPerChunk = DEFAULT_WORDS_PER_CAPTION, + minDuration = MIN_CAPTION_DURATION_SECONDS, + }: { + segments: TranscriptionSegment[]; + wordsPerChunk?: number; + minDuration?: number; + }): CaptionChunk[] + drag-data.ts export function setDragData({ dataTransfer, @@ -388,15 +409,7 @@ editor-utils.ts height: number; }): string -export-utils.ts - export function exportProject({ - format, - quality, - fps, - includeAudio, - onProgress, - onCancel, - }: ExportOptions): Promise +export.ts export function getExportMimeType({ format, }: { @@ -484,23 +497,6 @@ keyboard-utils.ts export function getPlatformSpecialKey(): string export function getPlatformAlternateKey(): string -media-processing-utils.ts - export interface ProcessedMediaAsset extends Omit - export function generateThumbnail({ - videoFile, - timeInSeconds, - }: { - videoFile: File; - timeInSeconds: number; - }): Promise - export function processMediaAssets({ - files, - onProgress, - }: { - files: FileList | File[]; - onProgress?: ({ progress }: { progress: number }) => void; - }): Promise - media-utils.ts export const SUPPORTS_AUDIO: readonly MediaType[] export function mediaSupportsAudio({ @@ -510,22 +506,6 @@ media-utils.ts }): boolean export const getMediaTypeFromFile = ({ file }: { file: File }): MediaType | null => ... -mediabunny-utils.ts - export const initFFmpeg = (): Promise => ... - export function getVideoInfo({ - videoFile, - }: { - videoFile: File; - }): Promise<{ - duration: number; - width: number; - height: number; - fps: number; - }> - export const extractTimelineAudio = ( - onProgress?: (progress: number) => void, - ): Promise => ... - rate-limit.ts export const baseRateLimit export function checkRateLimit({ request }: { request: Request }) @@ -632,9 +612,6 @@ time-utils.ts fps: number; }): number -transcription-utils.ts - export function isTranscriptionConfigured() - utils.ts export function cn(...inputs: ClassValue[]): string export function formatDate({ date }: { date: Date }): string @@ -657,33 +634,6 @@ utils.ts max: number; }): number -video-cache.ts - export class VideoCache { - sinks - initPromises - async getFrameAt( - mediaId: string, - file: File, - time: number - ): Promise - clearVideo(mediaId: string): void - clearAll(): void - getStats() - } - export const videoCache - -zk-encryption.ts - export interface ZeroKnowledgeEncryptionResult { - encryptedData: ArrayBuffer - key: ArrayBuffer - iv: ArrayBuffer - } - export function encryptWithRandomKey( - data: ArrayBuffer - ): Promise - export function arrayBufferToBase64(buffer: ArrayBuffer): string - export function base64ToArrayBuffer(base64: string): ArrayBuffer - ## apps/web/src/lib/actions definitions.ts @@ -748,21 +698,6 @@ server.ts export const auth export type Auth = typeof auth -## apps/web/src/lib/canvas - -gradients.ts - export function drawCssBackground({ - ctx, - width, - height, - css, - }: { - ctx: CanvasRenderingContext2D; - width: number; - height: number; - css: string; - }): void - ## apps/web/src/lib/db index.ts @@ -774,7 +709,380 @@ schema.ts export const accounts export const verifications -## apps/web/src/lib/storage +## apps/web/src/lib/gradients + +canvas.ts + 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"; ... + export type ColorStop = Color & { length?: Distance } + export type GradientAst = { + type: GradientType; + orientation: GradientOrientation | undefined; + colorStops: Ar... + export const parseGradient = ({ code }: { code: string }): Array => ... + export const GradientParser + +## apps/web/src/lib/media + +mediabunny.ts + export const initFFmpeg = (): Promise => ... + 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; + }): Promise => ... + +processing.ts + export interface ProcessedMediaAsset extends Omit + export function generateThumbnail({ + videoFile, + timeInSeconds, + }: { + videoFile: File; + timeInSeconds: number; + }): Promise + 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; + }): number + export function isBookmarkAtTime({ + bookmarks, + frameTime, + }: { + bookmarks: number[]; + frameTime: number; + }): boolean + export function toggleBookmarkInArray({ + bookmarks, + frameTime, + }: { + bookmarks: number[]; + frameTime: number; + }): number[] + export function removeBookmarkFromArray({ + bookmarks, + frameTime, + }: { + bookmarks: number[]; + frameTime: number; + }): 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, + }: ComputeDropTargetParams): DropTarget + export function getDropLineY({ + dropTarget, + tracks, + }: { + dropTarget: DropTarget; + tracks: TimelineTrack[]; + }): number + +element-utils.ts + export function canElementHaveAudio( + element: TimelineElement, + ) + export function canElementBeHidden( + element: TimelineElement, + ) + export function hasMediaId( + element: TimelineElement, + ) + export function requiresMediaId({ + element, + }: { + element: CreateTimelineElement; + }): boolean + export function checkElementOverlaps({ + elements, + }: { + elements: TimelineElement[]; + }): boolean + export function resolveElementOverlaps({ + elements, + }: { + elements: TimelineElement[]; + }): TimelineElement[] + 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; + }): CreateTimelineElement + export function buildStickerElement({ + iconName, + startTime, + }: { + iconName: string; + startTime: number; + }): CreateStickerElement + 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; + }): CreateLibraryAudioElement + +index.ts + export function calculateTotalDuration({ + tracks, + }: { + tracks: TimelineTrack[]; + }): number + +track-utils.ts + export function canTracktHaveAudio( + 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; + }): number + export function getTotalTracksHeight({ + tracks, + }: { + tracks: Array<{ type: TrackType }>; + }): number + export function buildEmptyTrack({ + id, + type, + name, + }: { + id: string; + type: TrackType; + name?: string; + }): TimelineTrack + export function getDefaultInsertIndexForTrack({ + tracks, + trackType, + }: { + tracks: TimelineTrack[]; + trackType: TrackType; + }): number + export function getHighestInsertIndexForTrack({ + tracks, + trackType, + }: { + tracks: TimelineTrack[]; + trackType: TrackType; + }): number + export function isMainTrack(track: TimelineTrack) + export function getMainTrack({ + tracks, + }: { + tracks: TimelineTrack[]; + }): TimelineTrack | null + export function ensureMainTrack({ + tracks, + }: { + tracks: TimelineTrack[]; + }): TimelineTrack[] + export function canElementGoOnTrack({ + elementType, + trackType, + }: { + elementType: ElementType; + trackType: TrackType; + }): boolean + 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; + }): number + +## 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 class CanvasRenderer { + canvas: OffscreenCanvas | HTMLCanvasElement + context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D + width: number + height: number + fps: number + 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; + }) + } + +scene-builder.ts + 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]; ... + export class SceneExporter extends EventEmitter { + renderer: CanvasRenderer + format: ExportFormat + quality: ExportQuality + includeAudio: boolean + audioBuffer: AudioBuffer + cancelled + constructor(params: ExportParams) + cancel() + async export(rootNode: RootNode) + } + +## apps/web/src/services/storage indexeddb-adapter.ts export class IndexedDBAdapter implements StorageAdapter { @@ -847,282 +1155,24 @@ types.ts version: number } -## apps/web/src/lib/timeline - -bookmark-utils.ts - export function findBookmarkIndex({ - bookmarks, - frameTime, - }: { - bookmarks: number[]; - frameTime: number; - }): number - export function isBookmarkAtTime({ - bookmarks, - frameTime, - }: { - bookmarks: number[]; - frameTime: number; - }): boolean - export function toggleBookmarkInArray({ - bookmarks, - frameTime, - }: { - bookmarks: number[]; - frameTime: number; - }): number[] - export function removeBookmarkFromArray({ - bookmarks, - frameTime, - }: { - bookmarks: number[]; - frameTime: number; - }): 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, - }: ComputeDropTargetParams): DropTarget - export function getDropLineY({ - dropTarget, - tracks, - }: { - dropTarget: DropTarget; - tracks: TimelineTrack[]; - }): number - -element-utils.ts - export function canElementHaveAudio( - element: TimelineElement, - ) - export function canElementBeHidden( - element: TimelineElement, - ) - export function hasMediaId( - element: TimelineElement, - ) - export function requiresMediaId({ - element, - }: { - element: CreateTimelineElement; - }): boolean - export function checkElementOverlaps({ - elements, - }: { - elements: TimelineElement[]; - }): boolean - export function resolveElementOverlaps({ - elements, - }: { - elements: TimelineElement[]; - }): TimelineElement[] - 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; - }): CreateTimelineElement - export function buildStickerElement({ - iconName, - startTime, - }: { - iconName: string; - startTime: number; - }): CreateStickerElement - 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; - }): CreateLibraryAudioElement +## apps/web/src/services/transcription index.ts - export function calculateTotalDuration({ - tracks, - }: { - tracks: TimelineTrack[]; - }): number + export const transcriptionService -track-utils.ts - export function canTracktHaveAudio( - 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; - }): number - export function getTotalTracksHeight({ - tracks, - }: { - tracks: Array<{ type: TrackType }>; - }): number - export function buildEmptyTrack({ - id, - type, - name, - }: { - id: string; - type: TrackType; - name?: string; - }): TimelineTrack - export function getDefaultInsertIndexForTrack({ - tracks, - trackType, - }: { - tracks: TimelineTrack[]; - trackType: TrackType; - }): number - export function isMainTrack(track: TimelineTrack) - export function getMainTrack({ - tracks, - }: { - tracks: TimelineTrack[]; - }): TimelineTrack | null - export function ensureMainTrack({ - tracks, - }: { - tracks: TimelineTrack[]; - }): TimelineTrack[] - export function canElementGoOnTrack({ - elementType, - trackType, - }: { - elementType: ElementType; - trackType: TrackType; - }): boolean - export function validateElementTrackCompatibility({ - element, - track, - }: { - element: { type: ElementType }; - track: { type: TrackType }; - }): { isValid: boolean; errorMessage?: string } - -## apps/web/src/services/renderer - -canvas-renderer.ts - export type CanvasRendererParams = { - width: number; - height: number; - fps: number; - } - export class CanvasRenderer { - canvas: OffscreenCanvas | HTMLCanvasElement - context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D - width: number - height: number - fps: number - 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; - }) - } - -scene-builder.ts - 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]; ... - export class SceneExporter extends EventEmitter { - renderer: CanvasRenderer - format: ExportFormat - quality: ExportQuality - includeAudio: boolean - audioBuffer: AudioBuffer - cancelled - constructor(params: ExportParams) - cancel() - async export(rootNode: RootNode) - } +worker.ts + export type WorkerMessage = | { type: "init"; modelId: string } + | { type: "transcribe"; audio: Float32Array; language: st... + export type WorkerResponse = | { type: "init-progress"; progress: number } + | { type: "init-complete" } + | { type: "init... ## apps/web/src/stores assets-panel-store.ts - export type Tab = | "media" - | "sounds" - | "text" - | "stickers" - | "effects" - | "transitions" - | "capti... - export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } + export const TAB_KEYS + export type Tab = (typeof TAB_KEYS)[number] + export const tabs export const useAssetsPanelStore editor-store.ts @@ -1252,8 +1302,10 @@ editor.ts export type TPlatformLayout = "tiktok" export.ts - export type ExportFormat = "mp4" | "webm" - export type ExportQuality = "low" | "medium" | "high" | "very_high" + export const EXPORT_QUALITY_VALUES + export const EXPORT_FORMAT_VALUES + export type ExportFormat = (typeof EXPORT_FORMAT_VALUES)[number] + export type ExportQuality = (typeof EXPORT_QUALITY_VALUES)[number] export interface ExportOptions { format: ExportFormat quality: ExportQuality @@ -1516,10 +1568,49 @@ timeline.ts excludeElementId?: string } export interface ClipboardItem { + trackId: string trackType: TrackType element: CreateTimelineElement } +transcription.ts + export interface TranscriptionSegment { + text: string + start: number + end: number + } + export interface TranscriptionResult { + text: string + segments: TranscriptionSegment[] + language: string + } + export type TranscriptionStatus = | "idle" + | "loading-model" + | "ready" + | "transcribing" + | "complete" + | "er... + export interface TranscriptionProgress { + status: TranscriptionStatus + progress: number + message?: string + } + export type TranscriptionModelId = | "whisper-tiny" + | "whisper-small" + | "whisper-medium" + | "whisper-large-v3-turbo" + export interface TranscriptionModel { + id: TranscriptionModelId + name: string + huggingFaceId: string + description: string + } + export interface CaptionChunk { + text: string + startTime: number + duration: number + } + ## packages/ui/src/icons index.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 7b0fbe4a..4cef6ecd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "@ffmpeg/util": "^0.12.2", "@hello-pangea/dnd": "^18.0.1", "@hookform/resolvers": "^3.9.1", + "@huggingface/transformers": "^3.8.1", "@opencut/env": "workspace:*", "@opencut/ui": "workspace:*", "@radix-ui/react-dialog": "^1.1.15", diff --git a/apps/web/src/app/api/get-upload-url/route.ts b/apps/web/src/app/api/get-upload-url/route.ts deleted file mode 100644 index 242048aa..00000000 --- a/apps/web/src/app/api/get-upload-url/route.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -import { AwsClient } from "aws4fetch"; -import { nanoid } from "nanoid"; -import { webEnv } from "@opencut/env/web"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { isTranscriptionConfigured } from "@/lib/transcription-utils"; - -const uploadRequestSchema = z.object({ - fileExtension: z.enum(["wav", "mp3", "m4a", "flac"], { - errorMap: () => ({ - message: "File extension must be wav, mp3, m4a, or flac", - }), - }), -}); - -const apiResponseSchema = z.object({ - uploadUrl: z.string().url(), - fileName: z.string().min(1), -}); - -export async function POST(request: NextRequest) { - try { - const { limited } = await checkRateLimit({ request }); - if (limited) { - return NextResponse.json({ error: "Too many requests" }, { status: 429 }); - } - - const transcriptionCheck = isTranscriptionConfigured(); - if (!transcriptionCheck.configured) { - console.error( - "Missing environment variables:", - JSON.stringify(transcriptionCheck.missingVars) - ); - - return NextResponse.json( - { - error: "Transcription not configured", - message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`, - }, - { status: 503 } - ); - } - - const rawBody = await request.json().catch(() => null); - if (!rawBody) { - return NextResponse.json( - { error: "Invalid JSON in request body" }, - { status: 400 } - ); - } - - const validationResult = uploadRequestSchema.safeParse(rawBody); - if (!validationResult.success) { - return NextResponse.json( - { - error: "Invalid request parameters", - details: validationResult.error.flatten().fieldErrors, - }, - { status: 400 } - ); - } - - const { fileExtension } = validationResult.data; - - const client = new AwsClient({ - accessKeyId: webEnv.R2_ACCESS_KEY_ID, - secretAccessKey: webEnv.R2_SECRET_ACCESS_KEY, - }); - - const timestamp = Date.now(); - const fileName = `audio/${timestamp}-${nanoid()}.${fileExtension}`; - - const url = new URL( - `https://${webEnv.R2_BUCKET_NAME}.${webEnv.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com/${fileName}` - ); - - url.searchParams.set("X-Amz-Expires", "3600"); // 1 hour expiry - - const signed = await client.sign(new Request(url, { method: "PUT" }), { - aws: { signQuery: true }, - }); - - if (!signed.url) { - throw new Error("Failed to generate presigned URL"); - } - - const responseData = { - uploadUrl: signed.url, - fileName, - }; - - const responseValidation = apiResponseSchema.safeParse(responseData); - if (!responseValidation.success) { - console.error( - "Invalid API response structure:", - responseValidation.error - ); - return NextResponse.json( - { error: "Internal response formatting error" }, - { status: 500 } - ); - } - - return NextResponse.json(responseValidation.data); - } catch (error) { - console.error("Error generating upload URL:", error); - return NextResponse.json( - { - error: "Failed to generate upload URL", - message: - error instanceof Error - ? error.message - : "An unexpected error occurred", - }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/apps/web/src/app/api/transcribe/route.ts b/apps/web/src/app/api/transcribe/route.ts deleted file mode 100644 index a236a38d..00000000 --- a/apps/web/src/app/api/transcribe/route.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -import { webEnv } from "@opencut/env/web"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { isTranscriptionConfigured } from "@/lib/transcription-utils"; - -const transcribeRequestSchema = z.object({ - filename: z.string().min(1, "Filename is required"), - language: z.string().optional().default("auto"), - decryptionKey: z.string().min(1, "Decryption key is required").optional(), - iv: z.string().min(1, "IV is required").optional(), -}); - -const modalResponseSchema = z.object({ - text: z.string(), - segments: z.array( - z.object({ - id: z.number(), - seek: z.number(), - start: z.number(), - end: z.number(), - text: z.string(), - tokens: z.array(z.number()), - temperature: z.number(), - avg_logprob: z.number(), - compression_ratio: z.number(), - no_speech_prob: z.number(), - }) - ), - language: z.string(), -}); - -const apiResponseSchema = z.object({ - text: z.string(), - segments: z.array( - z.object({ - id: z.number(), - seek: z.number(), - start: z.number(), - end: z.number(), - text: z.string(), - tokens: z.array(z.number()), - temperature: z.number(), - avg_logprob: z.number(), - compression_ratio: z.number(), - no_speech_prob: z.number(), - }) - ), - language: z.string(), -}); - -function buildModalRequestBody({ filename, language, decryptionKey, iv }: { - filename: string; - language: string; - decryptionKey?: string; - iv?: string; -}) { - const requestBody: Record = { - filename, - language, - }; - - if (decryptionKey && iv) { - requestBody.decryptionKey = decryptionKey; - requestBody.iv = iv; - } - - return requestBody; -} - -function parseModalError({ errorText }: { errorText: string }) { - let errorMessage = "Transcription service unavailable"; - try { - const errorData = JSON.parse(errorText); - errorMessage = errorData.error || errorMessage; - } catch {} - return errorMessage; -} - -export async function POST(request: NextRequest) { - try { - const { limited } = await checkRateLimit({ request }); - if (limited) { - return NextResponse.json({ error: "Too many requests" }, { status: 429 }); - } - - const transcriptionCheck = isTranscriptionConfigured(); - if (!transcriptionCheck.configured) { - console.error( - "Missing environment variables:", - JSON.stringify(transcriptionCheck.missingVars) - ); - - return NextResponse.json( - { - error: "Transcription not configured", - message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`, - }, - { status: 503 } - ); - } - - const rawBody = await request.json().catch(() => null); - if (!rawBody) { - return NextResponse.json( - { error: "Invalid JSON in request body" }, - { status: 400 } - ); - } - - const validationResult = transcribeRequestSchema.safeParse(rawBody); - if (!validationResult.success) { - return NextResponse.json( - { - error: "Invalid request parameters", - details: validationResult.error.flatten().fieldErrors, - }, - { status: 400 } - ); - } - - const { filename, language, decryptionKey, iv } = validationResult.data; - - const modalRequestBody = buildModalRequestBody({ - filename, - language, - decryptionKey, - iv - }); - - const response = await fetch(webEnv.MODAL_TRANSCRIPTION_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(modalRequestBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error("Modal API error:", response.status, errorText); - - const errorMessage = parseModalError({ errorText }); - - return NextResponse.json( - { - error: errorMessage, - message: "Failed to process transcription request", - }, - { status: response.status >= 500 ? 502 : response.status } - ); - } - - const rawResult = await response.json(); - - const modalValidation = modalResponseSchema.safeParse(rawResult); - if (!modalValidation.success) { - console.error("Invalid Modal API response:", modalValidation.error); - return NextResponse.json( - { error: "Invalid response from transcription service" }, - { status: 502 } - ); - } - - const result = modalValidation.data; - - const responseData = { - text: result.text, - segments: result.segments, - language: result.language, - }; - - const responseValidation = apiResponseSchema.safeParse(responseData); - if (!responseValidation.success) { - console.error( - "Invalid API response structure:", - responseValidation.error - ); - return NextResponse.json( - { error: "Internal response formatting error" }, - { status: 500 } - ); - } - - return NextResponse.json(responseValidation.data); - } catch (error) { - console.error("Transcription API error:", error); - return NextResponse.json( - { - error: "Internal server error", - message: "An unexpected error occurred during transcription", - }, - { status: 500 } - ); - } -} diff --git a/apps/web/src/app/contributors/page.tsx b/apps/web/src/app/contributors/page.tsx index daca4651..716002fa 100644 --- a/apps/web/src/app/contributors/page.tsx +++ b/apps/web/src/app/contributors/page.tsx @@ -40,7 +40,7 @@ async function getContributors(): Promise { "User-Agent": "OpenCut-Web-App", }, next: { revalidate: 600 }, // 10 minutes - } as RequestInit, + }, ); if (!response.ok) { @@ -51,7 +51,7 @@ async function getContributors(): Promise { const contributors = (await response.json()) as Contributor[]; const filteredContributors = contributors.filter( - (contributor: Contributor) => contributor.type === "User", + (contributor) => contributor.type === "User", ); return filteredContributors; diff --git a/apps/web/src/components/editor/assets-panel/tabbar.tsx b/apps/web/src/components/editor/assets-panel/tabbar.tsx index 215001e8..537f325c 100644 --- a/apps/web/src/components/editor/assets-panel/tabbar.tsx +++ b/apps/web/src/components/editor/assets-panel/tabbar.tsx @@ -2,7 +2,7 @@ import { cn } from "@/lib/utils"; import { - Tab, + TAB_KEYS, tabs, useAssetsPanelStore, } from "../../../stores/assets-panel-store"; @@ -50,7 +50,7 @@ export function TabBar() { ref={scrollRef} className="scrollbar-hidden relative flex h-full w-full flex-col items-center justify-start gap-5 overflow-y-auto px-4 py-4" > - {(Object.keys(tabs) as Tab[]).map((tabKey) => { + {TAB_KEYS.map((tabKey) => { const tab = tabs[tabKey]; return (
(""); + const [processingStep, setProcessingStep] = useState(""); const [error, setError] = useState(null); - const [showPrivacyDialog, setShowPrivacyDialog] = useState(false); - const [hasAcceptedPrivacy, setHasAcceptedPrivacy] = useState(false); const containerRef = useRef(null); const editor = useEditor(); - useEffect(() => { - const hasAccepted = localStorage.getItem(PRIVACY_DIALOG_KEY) === "true"; - setHasAcceptedPrivacy(hasAccepted); - }, []); + const handleProgress = (progress: TranscriptionProgress) => { + if (progress.status === "loading-model") { + setProcessingStep( + `Loading model ${Math.round(progress.progress)}%`, + ); + } else if (progress.status === "transcribing") { + setProcessingStep("Transcribing..."); + } + }; const handleGenerateTranscript = async () => { try { @@ -47,107 +37,44 @@ export function Captions() { setError(null); setProcessingStep("Extracting audio..."); - const audioBlob = await extractTimelineAudio(); - - setProcessingStep("Encrypting audio..."); - const audioBuffer = await audioBlob.arrayBuffer(); - const encryptionResult = await encryptWithRandomKey(audioBuffer); - const encryptedBlob = new Blob([encryptionResult.encryptedData]); - - setProcessingStep("Uploading..."); - const uploadResponse = await fetch("/api/get-upload-url", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ fileExtension: "wav" }), + const audioBlob = await extractTimelineAudio({ + tracks: editor.timeline.getTracks(), + mediaAssets: editor.media.getAssets(), + totalDuration: editor.timeline.getTotalDuration(), }); - if (!uploadResponse.ok) { - const error = await uploadResponse.json(); - throw new Error(error.message || "Failed to get upload URL"); - } + setProcessingStep("Preparing audio..."); + const { samples } = await decodeAudioToFloat32({ audioBlob }); - const { uploadUrl, fileName } = await uploadResponse.json(); - - await fetch(uploadUrl, { - method: "PUT", - body: encryptedBlob, + const result = await transcriptionService.transcribe({ + audioData: samples, + language: selectedLanguage === "auto" ? "auto" : selectedLanguage.toLowerCase(), + onProgress: handleProgress, }); - setProcessingStep("Transcribing..."); - - const transcriptionResponse = await fetch("/api/transcribe", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - filename: fileName, - language: - selectedCountry === "auto" ? "auto" : selectedCountry.toLowerCase(), - decryptionKey: arrayBufferToBase64(encryptionResult.key), - iv: arrayBufferToBase64(encryptionResult.iv), - }), - }); - - if (!transcriptionResponse.ok) { - const error = await transcriptionResponse.json(); - throw new Error(error.message || "Transcription failed"); - } - - const { text, segments } = await transcriptionResponse.json(); - - const shortCaptions: Array<{ - text: string; - startTime: number; - duration: number; - }> = []; - - let globalEndTime = 0; - - segments.forEach((segment: TranscriptionSegment) => { - const words = segment.text.trim().split(/\s+/); - const segmentDuration = segment.end - segment.start; - const wordsPerSecond = words.length / segmentDuration; - - const chunks: string[] = []; - for (let i = 0; i < words.length; i += 3) { - chunks.push(words.slice(i, i + 3).join(" ")); - } - - let chunkStartTime = segment.start; - chunks.forEach((chunk) => { - const chunkWords = chunk.split(/\s+/).length; - const chunkDuration = Math.max(0.8, chunkWords / wordsPerSecond); - const adjustedStartTime = Math.max(chunkStartTime, globalEndTime); - - shortCaptions.push({ - text: chunk, - startTime: adjustedStartTime, - duration: chunkDuration, - }); - - globalEndTime = adjustedStartTime + chunkDuration; - chunkStartTime += chunkDuration; - }); - }); + setProcessingStep("Generating captions..."); + const captionChunks = buildCaptionChunks({ segments: result.segments }); const captionTrackId = editor.timeline.addTrack({ type: "text", index: 0, }); - shortCaptions.forEach((caption, index) => { + for (let i = 0; i < captionChunks.length; i++) { + const caption = captionChunks[i]; editor.timeline.insertElement({ placement: { mode: "explicit", trackId: captionTrackId }, element: { ...DEFAULT_TEXT_ELEMENT, - name: `Caption ${index + 1}`, + name: `Caption ${i + 1}`, content: caption.text, duration: caption.duration, startTime: caption.startTime, fontSize: 65, fontWeight: "bold", - } as TextElement, + }, }); - }); + } } catch (error) { console.error("Transcription failed:", error); setError( @@ -166,8 +93,8 @@ export function Captions() { > @@ -182,96 +109,12 @@ export function Captions() { - - - - - - - Audio processing notice - - -

- To generate captions, we need to process your timeline audio - using speech-to-text technology. -

- -
-
- - - Zero-knowledge encryption - we cannot decrypt your files - even if we wanted to - -
- -
- - - Encryption keys generated randomly in your browser, never - stored anywhere - -
- -
- - - Audio encrypted before upload - raw audio never leaves - your device - -
- -
- - - Everything permanently deleted within seconds after - transcription - -
-
- -

- True zero-knowledge privacy: Encryption keys - are generated randomly in your browser and never stored - anywhere. It's cryptographically impossible for us, our cloud - providers, or anyone else to decrypt your audio files. -

-
-
- - - - -
-
); diff --git a/apps/web/src/components/editor/assets-panel/views/settings.tsx b/apps/web/src/components/editor/assets-panel/views/settings.tsx index 72a6e1ca..4392729e 100644 --- a/apps/web/src/components/editor/assets-panel/views/settings.tsx +++ b/apps/web/src/components/editor/assets-panel/views/settings.tsx @@ -26,11 +26,9 @@ import Image from "next/image"; import { cn } from "@/lib/utils"; import { colors } from "@/data/colors/solid"; import { patternCraftGradients } from "@/data/colors/pattern-craft"; -import { PipetteIcon, PlusIcon } from "lucide-react"; +import { PipetteIcon } from "lucide-react"; import { useMemo, memo, useCallback } from "react"; import { syntaxUIGradients } from "@/data/colors/syntax-ui"; -import { Button } from "@/components/ui/button"; -import { Separator } from "@/components/ui/separator"; import { useEditor } from "@/hooks/use-editor"; import type { TProject } from "@/types/project"; diff --git a/apps/web/src/components/editor/assets-panel/views/stickers.tsx b/apps/web/src/components/editor/assets-panel/views/stickers.tsx index d3447999..fcc9e997 100644 --- a/apps/web/src/components/editor/assets-panel/views/stickers.tsx +++ b/apps/web/src/components/editor/assets-panel/views/stickers.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState, useMemo } from "react"; +import type { CSSProperties } from "react"; import { useStickersStore } from "@/stores/stickers-store"; import { Loader2, @@ -38,6 +39,10 @@ import type { StickerCategory } from "@/types/stickers"; import { STICKER_CATEGORIES } from "@/constants/stickers-constants"; import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; +function isStickerCategory(value: string): value is StickerCategory { + return STICKER_CATEGORIES.includes(value as StickerCategory); +} + export function StickersView() { const { selectedCategory, setSelectedCategory } = useStickersStore(); @@ -45,8 +50,8 @@ export function StickersView() { { - if (STICKER_CATEGORIES.includes(v as StickerCategory)) { - setSelectedCategory({ category: v as StickerCategory }); + if (isStickerCategory(v)) { + setSelectedCategory({ category: v }); } }} tabs={[ @@ -91,16 +96,21 @@ function StickerGrid({ addingSticker: string | null; capSize?: boolean; }) { + const gridStyle: CSSProperties & { + "--sticker-min": string; + "--sticker-max"?: string; + } = { + gridTemplateColumns: capSize + ? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))" + : "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))", + "--sticker-min": "96px", + ...(capSize ? { "--sticker-max": "160px" } : {}), + }; + return (
{icons.map((iconName) => ( ; + prefix: string; + name: string; + total: number; + }>; }, [collections, category]); const { scrollAreaRef, handleScroll } = useInfiniteScroll({ @@ -524,10 +534,10 @@ function StickerItem({ hostIndex === 0 ? getIconSvgUrl(iconName, { width: 64, height: 64 }) : buildIconSvgUrl( - ICONIFY_HOSTS[Math.min(hostIndex, ICONIFY_HOSTS.length - 1)], - iconName, - { width: 64, height: 64 }, - ) + ICONIFY_HOSTS[Math.min(hostIndex, ICONIFY_HOSTS.length - 1)], + iconName, + { width: 64, height: 64 }, + ) } alt={displayName} width={64} @@ -536,9 +546,9 @@ function StickerItem({ style={ capSize ? { - maxWidth: "var(--sticker-max, 160px)", - maxHeight: "var(--sticker-max, 160px)", - } + maxWidth: "var(--sticker-max, 160px)", + maxHeight: "var(--sticker-max, 160px)", + } : undefined } onError={() => { diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index f67cdae4..f72c2d84 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -11,7 +11,13 @@ import { Checkbox } from "../ui/checkbox"; import { cn } from "@/lib/utils"; import { getExportMimeType, getExportFileExtension } from "@/lib/export"; import { Check, Copy, Download, RotateCcw, X } from "lucide-react"; -import { ExportFormat, ExportQuality, ExportResult } from "@/types/export"; +import { + EXPORT_FORMAT_VALUES, + EXPORT_QUALITY_VALUES, + ExportFormat, + ExportQuality, + ExportResult, +} from "@/types/export"; import { PropertyGroup } from "./properties-panel/property-item"; import { useEditor } from "@/hooks/use-editor"; import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants"; @@ -160,9 +166,11 @@ function ExportPopover({ > - setFormat(value as ExportFormat) - } + onValueChange={(value) => { + if (isExportFormat(value)) { + setFormat(value); + } + }} >
@@ -186,9 +194,11 @@ function ExportPopover({ > - setQuality(value as ExportQuality) - } + onValueChange={(value) => { + if (isExportQuality(value)) { + setQuality(value); + } + }} >
@@ -267,6 +277,14 @@ function ExportPopover({ ); } +function isExportFormat(value: string): value is ExportFormat { + return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value); +} + +function isExportQuality(value: string): value is ExportQuality { + return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value); +} + function ExportError({ error, onRetry, diff --git a/apps/web/src/components/editor/timeline/audio-waveform.tsx b/apps/web/src/components/editor/timeline/audio-waveform.tsx index 265119ae..59a315b9 100644 --- a/apps/web/src/components/editor/timeline/audio-waveform.tsx +++ b/apps/web/src/components/editor/timeline/audio-waveform.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import WaveSurfer from "wavesurfer.js"; interface AudioWaveformProps { @@ -39,12 +39,12 @@ function extractPeaks({ return peaks; } -const AudioWaveform: React.FC = ({ +export function AudioWaveform({ audioUrl, audioBuffer, height = 32, className = "", -}) => { +}: AudioWaveformProps) { const waveformRef = useRef(null); const wavesurfer = useRef(null); const [isLoading, setIsLoading] = useState(true); @@ -58,14 +58,10 @@ const AudioWaveform: React.FC = ({ if (!waveformRef.current || (!audioUrl && !audioBuffer)) return; try { - // Clear any existing instance safely if (ws) { - // Instead of immediately destroying, just set to null - // We'll destroy it outside this function wavesurfer.current = null; } - // Create a fresh instance const newWaveSurfer = WaveSurfer.create({ container: waveformRef.current, waveColor: "rgba(255, 255, 255, 0.6)", @@ -78,20 +74,16 @@ const AudioWaveform: React.FC = ({ interact: false, }); - // Assign to ref only if component is still mounted if (mounted) { wavesurfer.current = newWaveSurfer; } else { - // Component unmounted during initialization, clean up try { newWaveSurfer.destroy(); - } catch (e) { - // Ignore destroy errors + } catch { } return; } - // Event listeners newWaveSurfer.on("ready", () => { if (mounted) { setIsLoading(false); @@ -122,48 +114,35 @@ const AudioWaveform: React.FC = ({ } }; - // First safely destroy previous instance if it exists if (ws) { - // Use this pattern to safely destroy the previous instance const wsToDestroy = ws; - // Detach from ref immediately wavesurfer.current = null; - // Wait a tick to destroy so any pending operations can complete requestAnimationFrame(() => { try { wsToDestroy.destroy(); - } catch (e) { - // Ignore errors during destroy + } catch { } - // Only initialize new instance after destroying the old one if (mounted) { initWaveSurfer(); } }); } else { - // No previous instance to clean up, initialize directly initWaveSurfer(); } return () => { - // Mark component as unmounted mounted = false; - // Store reference to current wavesurfer instance const wsToDestroy = wavesurfer.current; - // Immediately clear the ref to prevent accessing it after unmount wavesurfer.current = null; - // If we have an instance to clean up, do it safely if (wsToDestroy) { - // Delay destruction to avoid race conditions requestAnimationFrame(() => { try { wsToDestroy.destroy(); - } catch (e) { - // Ignore destroy errors - they're expected + } catch { } }); } @@ -195,6 +174,6 @@ const AudioWaveform: React.FC = ({ />
); -}; +} export default AudioWaveform; diff --git a/apps/web/src/constants/export-constants.ts b/apps/web/src/constants/export-constants.ts index c9201dbe..a4e83707 100644 --- a/apps/web/src/constants/export-constants.ts +++ b/apps/web/src/constants/export-constants.ts @@ -1,7 +1,7 @@ import { ExportOptions } from "@/types/export"; -export const DEFAULT_EXPORT_OPTIONS: ExportOptions = { +export const DEFAULT_EXPORT_OPTIONS = { format: "mp4", quality: "high", includeAudio: true, -}; +} satisfies ExportOptions; diff --git a/apps/web/src/constants/transcription-constants.ts b/apps/web/src/constants/transcription-constants.ts new file mode 100644 index 00000000..9fc5f90f --- /dev/null +++ b/apps/web/src/constants/transcription-constants.ts @@ -0,0 +1,36 @@ +import type { TranscriptionModel, TranscriptionModelId } from "@/types/transcription"; + +export const TRANSCRIPTION_MODELS: TranscriptionModel[] = [ + { + id: "whisper-tiny", + name: "Tiny", + huggingFaceId: "onnx-community/whisper-tiny", + description: "Fastest, lower accuracy", + }, + { + id: "whisper-small", + name: "Small", + huggingFaceId: "onnx-community/whisper-small", + description: "Good balance of speed and accuracy", + }, + { + id: "whisper-medium", + name: "Medium", + huggingFaceId: "onnx-community/whisper-medium", + description: "Higher accuracy, slower", + }, + { + id: "whisper-large-v3-turbo", + name: "Large v3 Turbo", + huggingFaceId: "onnx-community/whisper-large-v3-turbo", + description: "Best accuracy, requires WebGPU for good performance", + }, +]; + +export const DEFAULT_TRANSCRIPTION_MODEL: TranscriptionModelId = "whisper-small"; + +export const DEFAULT_CHUNK_LENGTH_SECONDS = 30; +export const DEFAULT_STRIDE_SECONDS = 5; + +export const DEFAULT_WORDS_PER_CAPTION = 3; +export const MIN_CAPTION_DURATION_SECONDS = 0.8; diff --git a/apps/web/src/core/managers/project-manager.ts b/apps/web/src/core/managers/project-manager.ts index 9b19bd71..d1ec703e 100644 --- a/apps/web/src/core/managers/project-manager.ts +++ b/apps/web/src/core/managers/project-manager.ts @@ -384,12 +384,8 @@ export class ProjectManager { const tracks = this.editor.timeline.getTracks(); const mediaAssets = this.editor.media.getAssets(); - const allElements: TimelineElement[] = tracks.flatMap( - (track) => track.elements as TimelineElement[], - ); - const sortedElements = allElements.sort( - (a, b) => a.startTime - b.startTime, - ); + const allElements = tracks.flatMap((track): TimelineElement[] => track.elements); + const sortedElements = allElements.sort((a, b) => a.startTime - b.startTime); const firstElement = sortedElements[0]; if ( diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts index 7a00981b..55b79ab8 100644 --- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts +++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts @@ -539,7 +539,7 @@ export function useElementInteraction({ const clickOffsetTime = getClickOffsetTime({ clientX: event.clientX, elementRect: ( - event.currentTarget as HTMLElement + event.currentTarget ).getBoundingClientRect(), zoomLevel, }); diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts index 281ba30e..f3d54c3e 100644 --- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts +++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts @@ -50,7 +50,7 @@ export function useTimelineDragDrop({ if (dragData.type === "text") return "text"; if (dragData.type === "sticker") return "sticker"; if (dragData.type === "media") { - return dragData.mediaType as ElementType; + return dragData.mediaType; } return null; }, diff --git a/apps/web/src/hooks/timeline/use-timeline-snapping.ts b/apps/web/src/hooks/timeline/use-timeline-snapping.ts index 103c9051..576e3755 100644 --- a/apps/web/src/hooks/timeline/use-timeline-snapping.ts +++ b/apps/web/src/hooks/timeline/use-timeline-snapping.ts @@ -102,7 +102,7 @@ export function useTimelineSnapping({ return { snappedTime: closestSnapPoint - ? (closestSnapPoint as SnapPoint).time + ? closestSnapPoint.time : targetTime, snapPoint: closestSnapPoint, snapDistance: closestDistance, diff --git a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts index e68e279c..2317ec04 100644 --- a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts +++ b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts @@ -52,18 +52,15 @@ export function useKeyboardShortcutsHelp() { } for (const [actionId, keys] of Object.entries(actionToKeys)) { - if (!Object.prototype.hasOwnProperty.call(ACTIONS, actionId)) { - continue; - } + if (!isAction(actionId)) continue; - const action = actionId as TAction; - const actionDef = ACTIONS[action]; + const actionDef = ACTIONS[actionId]; result.push({ id: actionId, keys, description: actionDef.description, category: actionDef.category, - action, + action: actionId, }); } @@ -79,3 +76,7 @@ export function useKeyboardShortcutsHelp() { shortcuts, }; } + +function isAction(id: string): id is TAction { + return id in ACTIONS; +} diff --git a/apps/web/src/lib/audio-utils.ts b/apps/web/src/lib/audio-utils.ts index 5eef92a4..6923c626 100644 --- a/apps/web/src/lib/audio-utils.ts +++ b/apps/web/src/lib/audio-utils.ts @@ -23,6 +23,36 @@ export function createAudioContext(): AudioContext { return new AudioContextConstructor(); } +export interface DecodedAudio { + samples: Float32Array; + sampleRate: number; +} + +export async function decodeAudioToFloat32({ + audioBlob, +}: { + audioBlob: Blob; +}): Promise { + const audioContext = createAudioContext(); + const arrayBuffer = await audioBlob.arrayBuffer(); + const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); + + // mix down to mono + const numChannels = audioBuffer.numberOfChannels; + const length = audioBuffer.length; + const samples = new Float32Array(length); + + for (let i = 0; i < length; i++) { + let sum = 0; + for (let channel = 0; channel < numChannels; channel++) { + sum += audioBuffer.getChannelData(channel)[i]; + } + samples[i] = sum / numChannels; + } + + return { samples, sampleRate: audioBuffer.sampleRate }; +} + export async function collectAudioElements({ tracks, mediaAssets, diff --git a/apps/web/src/lib/caption-utils.ts b/apps/web/src/lib/caption-utils.ts new file mode 100644 index 00000000..1c00d175 --- /dev/null +++ b/apps/web/src/lib/caption-utils.ts @@ -0,0 +1,49 @@ +import type { TranscriptionSegment, CaptionChunk } from "@/types/transcription"; +import { + DEFAULT_WORDS_PER_CAPTION, + MIN_CAPTION_DURATION_SECONDS, +} from "@/constants/transcription-constants"; + +export function buildCaptionChunks({ + segments, + wordsPerChunk = DEFAULT_WORDS_PER_CAPTION, + minDuration = MIN_CAPTION_DURATION_SECONDS, +}: { + segments: TranscriptionSegment[]; + wordsPerChunk?: number; + minDuration?: number; +}): CaptionChunk[] { + const captions: CaptionChunk[] = []; + let globalEndTime = 0; + + for (const segment of segments) { + const words = segment.text.trim().split(/\s+/); + if (words.length === 0 || (words.length === 1 && words[0] === "")) continue; + + const segmentDuration = segment.end - segment.start; + const wordsPerSecond = words.length / segmentDuration; + + const chunks: string[] = []; + for (let i = 0; i < words.length; i += wordsPerChunk) { + chunks.push(words.slice(i, i + wordsPerChunk).join(" ")); + } + + let chunkStartTime = segment.start; + for (const chunk of chunks) { + const chunkWords = chunk.split(/\s+/).length; + const chunkDuration = Math.max(minDuration, chunkWords / wordsPerSecond); + const adjustedStartTime = Math.max(chunkStartTime, globalEndTime); + + captions.push({ + text: chunk, + startTime: adjustedStartTime, + duration: chunkDuration, + }); + + globalEndTime = adjustedStartTime + chunkDuration; + chunkStartTime += chunkDuration; + } + } + + return captions; +} diff --git a/apps/web/src/lib/media/mediabunny.ts b/apps/web/src/lib/media/mediabunny.ts index 6a2f2bd1..9f763af4 100644 --- a/apps/web/src/lib/media/mediabunny.ts +++ b/apps/web/src/lib/media/mediabunny.ts @@ -1,7 +1,8 @@ import { FFmpeg } from "@ffmpeg/ffmpeg"; import { Input, ALL_FORMATS, BlobSource } from "mediabunny"; import { collectAudioMixSources } from "@/lib/audio-utils"; -import { useEditor } from "@/hooks/use-editor"; +import type { TimelineTrack } from "@/types/timeline"; +import type { MediaAsset } from "@/types/assets"; let ffmpeg: FFmpeg | null = null; @@ -50,12 +51,18 @@ export async function getVideoInfo({ // audio mixing for timeline - keeping ffmpeg for now due to complexity // TODO: Replace with Mediabunny audio processing when implementing canvas preview -export const extractTimelineAudio = async ( - onProgress?: (progress: number) => void, -): Promise => { - // Create fresh FFmpeg instance for this operation +export const extractTimelineAudio = async ({ + tracks, + mediaAssets, + totalDuration, + onProgress, +}: { + tracks: TimelineTrack[]; + mediaAssets: MediaAsset[]; + totalDuration: number; + onProgress?: (progress: number) => void; +}): Promise => { const ffmpeg = new FFmpeg(); - const editor = useEditor(); try { await ffmpeg.load(); @@ -64,10 +71,6 @@ export const extractTimelineAudio = async ( throw new Error("Unable to initialize audio processing. Please try again."); } - const tracks = editor.timeline.getTracks(); - const mediaAssets = editor.media.getAssets(); - const totalDuration = editor.timeline.getTotalDuration(); - if (totalDuration === 0) { const emptyAudioData = new ArrayBuffer(44); return new Blob([emptyAudioData], { type: "audio/wav" }); @@ -182,11 +185,11 @@ export const extractTimelineAudio = async ( for (const inputFile of inputFiles) { try { await ffmpeg.deleteFile(inputFile); - } catch (cleanupError) {} + } catch (cleanupError) { } } try { await ffmpeg.deleteFile("timeline_audio.wav"); - } catch (cleanupError) {} + } catch (cleanupError) { } } }; diff --git a/apps/web/src/lib/transcription-utils.ts b/apps/web/src/lib/transcription-utils.ts deleted file mode 100644 index 4986cfe5..00000000 --- a/apps/web/src/lib/transcription-utils.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { webEnv } from "@opencut/env/web"; - -export function isTranscriptionConfigured() { - const missingVars = []; - - if (!webEnv.CLOUDFLARE_ACCOUNT_ID) missingVars.push("CLOUDFLARE_ACCOUNT_ID"); - if (!webEnv.R2_ACCESS_KEY_ID) missingVars.push("R2_ACCESS_KEY_ID"); - if (!webEnv.R2_SECRET_ACCESS_KEY) missingVars.push("R2_SECRET_ACCESS_KEY"); - if (!webEnv.R2_BUCKET_NAME) missingVars.push("R2_BUCKET_NAME"); - if (!webEnv.MODAL_TRANSCRIPTION_URL) missingVars.push("MODAL_TRANSCRIPTION_URL"); - - return { configured: missingVars.length === 0, missingVars }; -} diff --git a/apps/web/src/services/transcription/index.ts b/apps/web/src/services/transcription/index.ts new file mode 100644 index 00000000..bfd0d0bf --- /dev/null +++ b/apps/web/src/services/transcription/index.ts @@ -0,0 +1,186 @@ +import type { + 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?: string; + 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/worker.ts b/apps/web/src/services/transcription/worker.ts new file mode 100644 index 00000000..d2611158 --- /dev/null +++ b/apps/web/src/services/transcription/worker.ts @@ -0,0 +1,163 @@ +import { + pipeline, + type AutomaticSpeechRecognitionPipeline, + type AutomaticSpeechRecognitionOutput, +} from "@huggingface/transformers"; +import type { TranscriptionSegment } from "@/types/transcription"; +import { DEFAULT_CHUNK_LENGTH_SECONDS, DEFAULT_STRIDE_SECONDS } from "@/constants/transcription-constants"; + +export type WorkerMessage = + | { type: "init"; modelId: string } + | { type: "transcribe"; audio: Float32Array; language: string } + | { type: "cancel" }; + +export type WorkerResponse = + | { type: "init-progress"; progress: number } + | { type: "init-complete" } + | { type: "init-error"; error: string } + | { type: "transcribe-progress"; progress: number } + | { type: "transcribe-complete"; text: string; segments: TranscriptionSegment[] } + | { type: "transcribe-error"; error: string } + | { type: "cancelled" }; + +let transcriber: AutomaticSpeechRecognitionPipeline | null = null; +let cancelled = false; +let lastReportedProgress = -1; +const fileBytes = new Map(); + +self.onmessage = async (event: MessageEvent) => { + const message = event.data; + + switch (message.type) { + case "init": + await handleInit({ modelId: message.modelId }); + break; + case "transcribe": + await handleTranscribe({ audio: message.audio, language: message.language }); + break; + case "cancel": + cancelled = true; + self.postMessage({ type: "cancelled" } satisfies WorkerResponse); + break; + } +}; + +async function handleInit({ modelId }: { modelId: string }) { + lastReportedProgress = -1; + fileBytes.clear(); + + try { + transcriber = (await pipeline("automatic-speech-recognition", modelId, { + dtype: "q4", + device: "auto", + progress_callback: (progressInfo: { + status?: string; + file?: string; + loaded?: number; + total?: number; + }) => { + const file = progressInfo.file; + if (!file) return; + + const loaded = progressInfo.loaded ?? 0; + const total = progressInfo.total ?? 0; + + if (progressInfo.status === "progress" && total > 0) { + fileBytes.set(file, { loaded, total }); + } else if (progressInfo.status === "done") { + const existing = fileBytes.get(file); + if (existing) { + fileBytes.set(file, { loaded: existing.total, total: existing.total }); + } + } + + // sum all bytes + let totalLoaded = 0; + let totalSize = 0; + for (const { loaded, total } of fileBytes.values()) { + totalLoaded += loaded; + totalSize += total; + } + + if (totalSize === 0) return; + + const overallProgress = (totalLoaded / totalSize) * 100; + const roundedProgress = Math.floor(overallProgress); + + if (roundedProgress !== lastReportedProgress) { + lastReportedProgress = roundedProgress; + self.postMessage({ + type: "init-progress", + progress: roundedProgress, + } satisfies WorkerResponse); + } + }, + })) as unknown as AutomaticSpeechRecognitionPipeline; + + self.postMessage({ type: "init-complete" } satisfies WorkerResponse); + } catch (error) { + self.postMessage({ + type: "init-error", + error: error instanceof Error ? error.message : "Failed to load model", + } satisfies WorkerResponse); + } +} + +async function handleTranscribe({ + audio, + language, +}: { + audio: Float32Array; + language: string; +}) { + if (!transcriber) { + self.postMessage({ + type: "transcribe-error", + error: "Model not initialized", + } satisfies WorkerResponse); + return; + } + + cancelled = false; + + try { + const rawResult = await transcriber(audio, { + chunk_length_s: DEFAULT_CHUNK_LENGTH_SECONDS, + stride_length_s: DEFAULT_STRIDE_SECONDS, + language: language === "auto" ? undefined : language, + return_timestamps: true, + }); + + if (cancelled) return; + + const result: AutomaticSpeechRecognitionOutput = Array.isArray(rawResult) + ? rawResult[0] + : rawResult; + + const segments: TranscriptionSegment[] = []; + + if (result.chunks) { + for (const chunk of result.chunks) { + if (chunk.timestamp && chunk.timestamp.length >= 2) { + segments.push({ + text: chunk.text, + start: chunk.timestamp[0] ?? 0, + end: chunk.timestamp[1] ?? chunk.timestamp[0] ?? 0, + }); + } + } + } + + self.postMessage({ + type: "transcribe-complete", + text: result.text, + segments, + } satisfies WorkerResponse); + } catch (error) { + if (cancelled) return; + self.postMessage({ + type: "transcribe-error", + error: error instanceof Error ? error.message : "Transcription failed", + } satisfies WorkerResponse); + } +} diff --git a/apps/web/src/stores/assets-panel-store.ts b/apps/web/src/stores/assets-panel-store.ts index 154214b2..1c451488 100644 --- a/apps/web/src/stores/assets-panel-store.ts +++ b/apps/web/src/stores/assets-panel-store.ts @@ -13,19 +13,22 @@ import { } from "lucide-react"; import { create } from "zustand"; -export type Tab = - | "media" - | "sounds" - | "text" - | "stickers" - | "effects" - | "transitions" - | "captions" - | "filters" - | "adjustment" - | "settings"; +export const TAB_KEYS = [ + "media", + "sounds", + "text", + "stickers", + "effects", + "transitions", + "captions", + "filters", + "adjustment", + "settings", +] as const; -export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = { +export type Tab = (typeof TAB_KEYS)[number]; + +export const tabs = { media: { icon: VideoIcon, label: "Media", @@ -66,7 +69,7 @@ export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = { icon: SettingsIcon, label: "Settings", }, -}; +} satisfies Record; type MediaViewMode = "grid" | "list"; diff --git a/apps/web/src/types/export.ts b/apps/web/src/types/export.ts index 9f62ca52..ab277f3e 100644 --- a/apps/web/src/types/export.ts +++ b/apps/web/src/types/export.ts @@ -1,5 +1,14 @@ -export type ExportFormat = "mp4" | "webm"; -export type ExportQuality = "low" | "medium" | "high" | "very_high"; +export const EXPORT_QUALITY_VALUES = [ + "low", + "medium", + "high", + "very_high", +] as const; + +export const EXPORT_FORMAT_VALUES = ["mp4", "webm"] as const; + +export type ExportFormat = (typeof EXPORT_FORMAT_VALUES)[number]; +export type ExportQuality = (typeof EXPORT_QUALITY_VALUES)[number]; export interface ExportOptions { format: ExportFormat; diff --git a/apps/web/src/types/transcription.ts b/apps/web/src/types/transcription.ts new file mode 100644 index 00000000..fe5d246b --- /dev/null +++ b/apps/web/src/types/transcription.ts @@ -0,0 +1,43 @@ +export interface TranscriptionSegment { + text: string; + start: number; + end: number; +} + +export interface TranscriptionResult { + text: string; + segments: TranscriptionSegment[]; + language: string; +} + +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" + | "whisper-large-v3-turbo"; + +export interface TranscriptionModel { + id: TranscriptionModelId; + name: string; + huggingFaceId: string; + description: string; +} + +export interface CaptionChunk { + text: string; + startTime: number; + duration: number; +} diff --git a/bun.lock b/bun.lock index e228a5f7..382292e2 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,7 @@ }, "devDependencies": { "@biomejs/biome": "2.1.2", - "turbo": "^2.5.4", + "turbo": "^2.7.5", "typescript": "5.8.3", }, }, @@ -23,6 +23,7 @@ "@ffmpeg/util": "^0.12.2", "@hello-pangea/dnd": "^18.0.1", "@hookform/resolvers": "^3.9.1", + "@huggingface/transformers": "^3.8.1", "@opencut/env": "workspace:*", "@opencut/ui": "workspace:*", "@radix-ui/react-dialog": "^1.1.15", @@ -237,6 +238,10 @@ "@hookform/resolvers": ["@hookform/resolvers@3.10.0", "", { "peerDependencies": { "react-hook-form": "^7.0.0" } }, "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag=="], + "@huggingface/jinja": ["@huggingface/jinja@0.5.3", "", {}, "sha512-asqfZ4GQS0hD876Uw4qiUb7Tr/V5Q+JZuo2L+BtdrD4U40QU58nIRq3ZSgAzJgT874VLjhGVacaYfrdpXtEvtA=="], + + "@huggingface/transformers": ["@huggingface/transformers@3.8.1", "", { "dependencies": { "@huggingface/jinja": "^0.5.3", "onnxruntime-node": "1.21.0", "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", "sharp": "^0.34.1" } }, "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA=="], + "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], @@ -325,6 +330,26 @@ "@opencut/web": ["@opencut/web@workspace:apps/web"], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -553,6 +578,8 @@ "better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="], + "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], + "botid": ["botid@1.4.2", "", { "peerDependencies": { "next": "*", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["next"] }, "sha512-yiRWEdxXa5QhxzJW4lTk0lRZkbqPsVWdGrhnHLLihZf0xBEtsTUGtxLqK++IY80FX/Ye/rNMnGqBp2pl4yYU8w=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], @@ -629,12 +656,18 @@ "decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="], + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], @@ -657,10 +690,18 @@ "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], + "esbuild": ["esbuild@0.25.8", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.8", "@esbuild/android-arm": "0.25.8", "@esbuild/android-arm64": "0.25.8", "@esbuild/android-x64": "0.25.8", "@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-x64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-x64": "0.25.8", "@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-x64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-x64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-x64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.8", "@esbuild/sunos-x64": "0.25.8", "@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-x64": "0.25.8" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q=="], "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], @@ -671,6 +712,8 @@ "feed": ["feed@5.1.0", "", { "dependencies": { "xml-js": "^1.6.11" } }, "sha512-qGNhgYygnefSkAHHrNHqC7p3R8J0/xQDS/cYUud8er/qD9EFGWyCdUDfULHTJQN1d3H3WprzVwMc9MfB4J50Wg=="], + "flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="], + "framer-motion": ["framer-motion@12.23.6", "", { "dependencies": { "motion-dom": "^12.23.6", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-dsJ389QImVE3lQvM8Mnk99/j8tiZDM/7706PCqvkQ8sSCnpmWxsgX+g0lj7r5OBVL0U36pIecCTBoIWcM2RuKw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -681,8 +724,18 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], + "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], @@ -735,6 +788,8 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], + "kysely": ["kysely@0.28.9", "", {}, "sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA=="], "libphonenumber-js": ["libphonenumber-js@1.12.10", "", {}, "sha512-E91vHJD61jekHHR/RF/E83T/CMoaLXT7cwYA75T4gim4FZjnM6hbJjVIGg7chqlSqRsSvQ3izGmOjHy1SQzcGQ=="], @@ -769,6 +824,8 @@ "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], @@ -777,6 +834,8 @@ "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], + "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], @@ -861,6 +920,14 @@ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "onnxruntime-common": ["onnxruntime-common@1.21.0", "", {}, "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ=="], + + "onnxruntime-node": ["onnxruntime-node@1.21.0", "", { "dependencies": { "global-agent": "^3.0.0", "onnxruntime-common": "1.21.0", "tar": "^7.0.1" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw=="], + + "onnxruntime-web": ["onnxruntime-web@1.22.0-dev.20250409-89f8206ba4", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], @@ -885,6 +952,8 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], @@ -907,6 +976,8 @@ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + "radix-ui": ["radix-ui@1.4.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.11", "@radix-ui/react-alert-dialog": "1.1.14", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.2", "@radix-ui/react-collapsible": "1.1.11", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.15", "@radix-ui/react-dialog": "1.1.14", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-dropdown-menu": "2.1.15", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.7", "@radix-ui/react-hover-card": "1.1.14", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.15", "@radix-ui/react-menubar": "1.1.15", "@radix-ui/react-navigation-menu": "1.2.13", "@radix-ui/react-one-time-password-field": "0.1.7", "@radix-ui/react-password-toggle-field": "0.1.2", "@radix-ui/react-popover": "1.1.14", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.7", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-scroll-area": "1.2.9", "@radix-ui/react-select": "2.2.5", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.5", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.5", "@radix-ui/react-tabs": "1.1.12", "@radix-ui/react-toast": "1.2.14", "@radix-ui/react-toggle": "1.1.9", "@radix-ui/react-toggle-group": "1.1.10", "@radix-ui/react-toolbar": "1.1.10", "@radix-ui/react-tooltip": "1.2.7", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-fT/3YFPJzf2WUpqDoQi005GS8EpCi+53VhcLaHUj5fwkPYiZAjk1mSxFvbMA8Uq71L03n+WysuYC+mlKkXxt/Q=="], "raf-schd": ["raf-schd@4.0.3", "", {}, "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="], @@ -965,6 +1036,8 @@ "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], + "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], "sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="], @@ -973,6 +1046,10 @@ "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], + + "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], + "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], @@ -993,6 +1070,8 @@ "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], "style-to-js": ["style-to-js@1.1.17", "", { "dependencies": { "style-to-object": "1.0.9" } }, "sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA=="], @@ -1035,6 +1114,8 @@ "turbo-windows-arm64": ["turbo-windows-arm64@2.7.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-G377Gxn6P42RnCzfMyDvsqQV7j69kVHKlhz9J4RhtJOB5+DyY4yYh/w0oTIxZQ4JRMmhjwLu3w9zncMoQ6nNDw=="], + "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], @@ -1223,6 +1304,8 @@ "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + "onnxruntime-web/onnxruntime-common": ["onnxruntime-common@1.22.0-dev.20250409-89f8206ba4", "", {}, "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],