shipping
This commit is contained in:
parent
7f50de14fc
commit
e1d82eca09
|
|
@ -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<TrackType, React.ReactNode>
|
||||
|
||||
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<DecodedAudio>
|
||||
export function collectAudioElements({
|
||||
tracks,
|
||||
mediaAssets,
|
||||
|
|
@ -359,6 +369,17 @@ blog-query.ts
|
|||
export function getAuthors()
|
||||
export function processHtmlContent({ html }: { html: string }): Promise<string>
|
||||
|
||||
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<ExportResult>
|
||||
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<MediaAsset, "id">
|
||||
export function generateThumbnail({
|
||||
videoFile,
|
||||
timeInSeconds,
|
||||
}: {
|
||||
videoFile: File;
|
||||
timeInSeconds: number;
|
||||
}): Promise<string>
|
||||
export function processMediaAssets({
|
||||
files,
|
||||
onProgress,
|
||||
}: {
|
||||
files: FileList | File[];
|
||||
onProgress?: ({ progress }: { progress: number }) => void;
|
||||
}): Promise<ProcessedMediaAsset[]>
|
||||
|
||||
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<FFmpeg> => ...
|
||||
export function getVideoInfo({
|
||||
videoFile,
|
||||
}: {
|
||||
videoFile: File;
|
||||
}): Promise<{
|
||||
duration: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
}>
|
||||
export const extractTimelineAudio = (
|
||||
onProgress?: (progress: number) => void,
|
||||
): Promise<Blob> => ...
|
||||
|
||||
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<WrappedCanvas | null>
|
||||
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<ZeroKnowledgeEncryptionResult>
|
||||
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<RadialOrientation>
|
||||
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<GradientAst> => ...
|
||||
export const GradientParser
|
||||
|
||||
## apps/web/src/lib/media
|
||||
|
||||
mediabunny.ts
|
||||
export const initFFmpeg = (): Promise<FFmpeg> => ...
|
||||
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<Blob> => ...
|
||||
|
||||
processing.ts
|
||||
export interface ProcessedMediaAsset extends Omit<MediaAsset, "id">
|
||||
export function generateThumbnail({
|
||||
videoFile,
|
||||
timeInSeconds,
|
||||
}: {
|
||||
videoFile: File;
|
||||
timeInSeconds: number;
|
||||
}): Promise<string>
|
||||
export function processMediaAssets({
|
||||
files,
|
||||
onProgress,
|
||||
}: {
|
||||
files: FileList | File[];
|
||||
onProgress?: ({ progress }: { progress: number }) => void;
|
||||
}): Promise<ProcessedMediaAsset[]>
|
||||
|
||||
## 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<Omit<TextElement, "type" | "id">>;
|
||||
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<WrappedCanvas | null>
|
||||
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<SceneExporterEvents> {
|
||||
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<T> {
|
||||
|
|
@ -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<Omit<TextElement, "type" | "id">>;
|
||||
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<SceneExporterEvents> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, string> = {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ async function getContributors(): Promise<Contributor[]> {
|
|||
"User-Agent": "OpenCut-Web-App",
|
||||
},
|
||||
next: { revalidate: 600 }, // 10 minutes
|
||||
} as RequestInit,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -51,7 +51,7 @@ async function getContributors(): Promise<Contributor[]> {
|
|||
const contributors = (await response.json()) as Contributor[];
|
||||
|
||||
const filteredContributors = contributors.filter(
|
||||
(contributor: Contributor) => contributor.type === "User",
|
||||
(contributor) => contributor.type === "User",
|
||||
);
|
||||
|
||||
return filteredContributors;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -2,44 +2,34 @@ import { Button } from "@/components/ui/button";
|
|||
import { PropertyGroup } from "../../properties-panel/property-item";
|
||||
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
|
||||
import { LanguageSelect } from "@/components/language-select";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import { extractTimelineAudio } from "@/lib/media/mediabunny";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
|
||||
import { LANGUAGES } from "@/constants/captions-constants";
|
||||
import { Loader2, Shield, Trash2, Upload } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { TextElement } from "@/types/timeline";
|
||||
|
||||
interface TranscriptionSegment {
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
const PRIVACY_DIALOG_KEY = "opencut-transcription-privacy-accepted";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { TranscriptionProgress } from "@/types/transcription";
|
||||
import { transcriptionService } from "@/services/transcription";
|
||||
import { decodeAudioToFloat32 } from "@/lib/audio-utils";
|
||||
import { buildCaptionChunks } from "@/lib/caption-utils";
|
||||
|
||||
export function Captions() {
|
||||
const [selectedCountry, setSelectedCountry] = useState("auto");
|
||||
const [selectedLanguage, setSelectedLanguage] = useState("auto");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [processingStep, setProcessingStep] = useState<string>("");
|
||||
const [processingStep, setProcessingStep] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showPrivacyDialog, setShowPrivacyDialog] = useState(false);
|
||||
const [hasAcceptedPrivacy, setHasAcceptedPrivacy] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(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() {
|
|||
>
|
||||
<PropertyGroup title="Language">
|
||||
<LanguageSelect
|
||||
selectedCountry={selectedCountry}
|
||||
onSelect={setSelectedCountry}
|
||||
selectedCountry={selectedLanguage}
|
||||
onSelect={setSelectedLanguage}
|
||||
containerRef={containerRef}
|
||||
languages={LANGUAGES}
|
||||
/>
|
||||
|
|
@ -182,96 +109,12 @@ export function Captions() {
|
|||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
if (hasAcceptedPrivacy) {
|
||||
handleGenerateTranscript();
|
||||
} else {
|
||||
setShowPrivacyDialog(true);
|
||||
}
|
||||
}}
|
||||
onClick={handleGenerateTranscript}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing && <Loader2 className="mr-1 animate-spin" />}
|
||||
{isProcessing ? processingStep : "Generate transcript"}
|
||||
</Button>
|
||||
|
||||
<Dialog open={showPrivacyDialog} onOpenChange={setShowPrivacyDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Shield className="size-5" />
|
||||
Audio processing notice
|
||||
</DialogTitle>
|
||||
<DialogDescription className="space-y-3">
|
||||
<p>
|
||||
To generate captions, we need to process your timeline audio
|
||||
using speech-to-text technology.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield className="size-4 flex-shrink-0" />
|
||||
<span className="text-sm">
|
||||
Zero-knowledge encryption - we cannot decrypt your files
|
||||
even if we wanted to
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield className="size-4 flex-shrink-0" />
|
||||
<span className="text-sm">
|
||||
Encryption keys generated randomly in your browser, never
|
||||
stored anywhere
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Upload className="size-4 flex-shrink-0" />
|
||||
<span className="text-sm">
|
||||
Audio encrypted before upload - raw audio never leaves
|
||||
your device
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Trash2 className="size-4 flex-shrink-0" />
|
||||
<span className="text-sm">
|
||||
Everything permanently deleted within seconds after
|
||||
transcription
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<strong>True zero-knowledge privacy:</strong> 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.
|
||||
</p>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowPrivacyDialog(false)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
localStorage.setItem(PRIVACY_DIALOG_KEY, "true");
|
||||
setHasAcceptedPrivacy(true);
|
||||
setShowPrivacyDialog(false);
|
||||
handleGenerateTranscript();
|
||||
}}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Continue & generate captions
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</BaseView>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<BaseView
|
||||
value={selectedCategory}
|
||||
onValueChange={(v) => {
|
||||
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 (
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{
|
||||
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" as any]: "96px",
|
||||
...(capSize ? ({ ["--sticker-max"]: "160px" } as any) : {}),
|
||||
}}
|
||||
style={gridStyle}
|
||||
>
|
||||
{icons.map((iconName) => (
|
||||
<StickerItem
|
||||
|
|
@ -201,17 +211,17 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
|||
const collection = collections[c.prefix];
|
||||
return collection
|
||||
? {
|
||||
prefix: c.prefix,
|
||||
name: c.name,
|
||||
total: collection.total,
|
||||
}
|
||||
prefix: c.prefix,
|
||||
name: c.name,
|
||||
total: collection.total,
|
||||
}
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean) as Array<{
|
||||
prefix: string;
|
||||
name: string;
|
||||
total: number;
|
||||
}>;
|
||||
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={() => {
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
>
|
||||
<RadioGroup
|
||||
value={format}
|
||||
onValueChange={(value) =>
|
||||
setFormat(value as ExportFormat)
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
if (isExportFormat(value)) {
|
||||
setFormat(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="mp4" id="mp4" />
|
||||
|
|
@ -186,9 +194,11 @@ function ExportPopover({
|
|||
>
|
||||
<RadioGroup
|
||||
value={quality}
|
||||
onValueChange={(value) =>
|
||||
setQuality(value as ExportQuality)
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
if (isExportQuality(value)) {
|
||||
setQuality(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="low" id="low" />
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<AudioWaveformProps> = ({
|
||||
export function AudioWaveform({
|
||||
audioUrl,
|
||||
audioBuffer,
|
||||
height = 32,
|
||||
className = "",
|
||||
}) => {
|
||||
}: AudioWaveformProps) {
|
||||
const waveformRef = useRef<HTMLDivElement>(null);
|
||||
const wavesurfer = useRef<WaveSurfer | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
|
@ -58,14 +58,10 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
|
|||
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<AudioWaveformProps> = ({
|
|||
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<AudioWaveformProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 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<AudioWaveformProps> = ({
|
|||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default AudioWaveform;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -539,7 +539,7 @@ export function useElementInteraction({
|
|||
const clickOffsetTime = getClickOffsetTime({
|
||||
clientX: event.clientX,
|
||||
elementRect: (
|
||||
event.currentTarget as HTMLElement
|
||||
event.currentTarget
|
||||
).getBoundingClientRect(),
|
||||
zoomLevel,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ export function useTimelineSnapping({
|
|||
|
||||
return {
|
||||
snappedTime: closestSnapPoint
|
||||
? (closestSnapPoint as SnapPoint).time
|
||||
? closestSnapPoint.time
|
||||
: targetTime,
|
||||
snapPoint: closestSnapPoint,
|
||||
snapDistance: closestDistance,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<DecodedAudio> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<Blob> => {
|
||||
// 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<Blob> => {
|
||||
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) { }
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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<TranscriptionResult> {
|
||||
await this.ensureWorker({ modelId, onProgress });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.worker) {
|
||||
reject(new Error("Worker not initialized"));
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMessage = (event: MessageEvent<WorkerResponse>) => {
|
||||
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<void> {
|
||||
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<WorkerResponse>) => {
|
||||
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<void> {
|
||||
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();
|
||||
|
|
@ -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<string, { loaded: number; total: number }>();
|
||||
|
||||
self.onmessage = async (event: MessageEvent<WorkerMessage>) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Tab, { icon: LucideIcon; label: string }>;
|
||||
|
||||
type MediaViewMode = "grid" | "list";
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
85
bun.lock
85
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=="],
|
||||
|
|
|
|||
Loading…
Reference in New Issue