diff --git a/.gitignore b/.gitignore index 1290fe7e3..0c5e613dc 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,10 @@ coverage .temp *.mp4 + +# DevTools performance trace exports +Trace-*.json + # TanStack Router auto-generated files # Note: Some teams commit this, uncomment if you prefer to commit # src/routeTree.gen.ts diff --git a/CLAUDE.md b/CLAUDE.md index 981a13fc2..e2058d72d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,7 @@ src/ - **State**: Zustand stores + Zundo for undo/redo - **Timeline store split**: `useTimelineStore` (from `timeline-store.ts`) is a **facade** over domain stores (`items-store`, `transitions-store`, `keyframes-store`, `markers-store`, `timeline-settings-store`, `timeline-command-store`). Components use the facade with selectors; action code accesses domain stores via `.getState()` directly - **Timeline mutations**: Action modules in `features/timeline/stores/actions/*.ts` use `execute()` wrapper from `shared.ts` for undo/redo integration. Never mutate timeline stores directly — use these actions +- **TimelineItem composition**: The per-clip component in `features/timeline/components/timeline-item/index.tsx` delegates to dedicated hooks: `useCaptionDialogState`, `useFadeEditors`, `useFadeMath`, `useEditPreviewShifts`, `useTimelineItemBounds`, `useSmartTrimHover`, `useContextMenuState`, plus the existing `useTimelineItemActions` / `useTimelineItemDropHandlers` / `useDragVisualState`. The host file orchestrates these hooks and renders the JSX; sub-components live alongside (`EdgeHalos`, `TransitionDropGhost`, `TranscribeDialogController`, etc.). When adding new clip state, prefer a new hook over inlining - **Timeline item types**: `TimelineItem` is a discriminated union on `type`: `video | audio | text | image | shape | adjustment | composition` — GIFs use `image` type, no separate gif type. Types in `src/types/timeline.ts` - **Item positioning**: Remotion convention — `from` (start frame in project FPS) + `durationInFrames` - **Compositions**: Pre-compositions (sub-comps) have dedicated stores (`compositions-store.ts`, `composition-navigation-store.ts`). 1-level nesting only. Actions in `composition-actions.ts` @@ -136,6 +137,9 @@ src/ - **Progressive downscaling** — when scaling high-res canvases to small sizes (e.g. 1920→320 thumbnails), halve dimensions repeatedly instead of one large jump. Single-step downscaling causes moire/aliasing with high-frequency GPU effects (halftone, pixelate, etc.) - `StableVideoSequence`'s `areGroupPropsEqual` in `stable-video-sequence.tsx` whitelists item properties for React.memo comparison. When adding new visual properties to `TimelineItem`, add them to this comparison — missing properties cause stale renders during playback - **GPU pipeline caching** — `EffectsPipeline.requestCachedDevice()` caches the WebGPU adapter + device globally. Subsequent `EffectsPipeline.create()` calls reuse the device (~50-100ms saved). The device-loss handler checks identity before clearing to avoid discarding a freshly acquired device. The preview component eagerly warms the GPU pipeline on mount (parallel with media resolution) -- **`__DEBUG__` API** — `window.__DEBUG__` (DEV-only, tree-shaken in prod) provides console debugging: `stores()`, `getTransitions()`, `getTransitionWindows()`, `getPlaybackState()`, `getTracks()`, `getMediaLibrary()`, `jitter()` (frame timing), `previewPerf()`, `transitionTrace()`, `prewarmCache()`, `filmstripMetrics()`, plus playback control (`seekTo`, `play`, `pause`). All use lazy `await import()` to avoid pulling in stores eagerly +- **`__DEBUG__` API** — `window.__DEBUG__` (DEV-only, tree-shaken in prod) provides console debugging: `stores()`, `getTransitions()`, `getTransitionWindows()`, `getPlaybackState()`, `getTracks()`, `getMediaLibrary()`, `jitter()` (frame timing), `previewPerf()`, `transitionTrace()`, `prewarmCache()`, `filmstripMetrics()`, `perfSummary(prefix?)` / `perfClear()` (User Timing aggregation, default prefix `tl.`), plus playback control (`seekTo`, `play`, `pause`). All use lazy `await import()` to avoid pulling in stores eagerly +- **Timeline perf-marks** — `withPerfMeasure(name, fn)` in `src/shared/logging/perf-marks.ts` wraps hot paths so they appear as named entries on the User Timing track in Chrome DevTools Performance. Currently instruments `tl.action.*` (every timeline mutation, via `actions/shared.ts::execute`), `tl.repairTransitions`, and the RAF loops `tl.raf.{viewportSync,previewHover,zoomApply,scrollThumb,momentum,playheadScrub}`. `withPerfMeasure` is opt-in — gated on `window.__TL_PERF__ = true` (off by default, zero overhead) so the User Timing buffer doesn't grow unbounded in normal use; set the flag before profiling (`npm run perf`), then read marks via the Performance tab or `__DEBUG__.perfSummary()`. `perfMarkRender(name)` adds per-render `tl.render.*` marks to the high-fanout components (ClipContent, TimelineItem, TimelineTrack, TimelineContent, TimelineMarkers, TimelinePlayhead, TransitionItem) — gated on `window.__TL_RENDER_MARKS__ = true` (off by default, zero overhead) for diagnosing which components re-render during a gesture +- **Clip content tracks SETTLED zoom** — `ClipContent` (`timeline-item/clip-content.tsx`) drives filmstrip/waveform width from `contentPixelsPerSecond` (settled, updates ~100ms after a zoom gesture ends), NOT the live per-frame `pixelsPerSecond`. The clip shell resizes smoothly during the gesture via the `--timeline-px-per-frame` CSS variable (no React); the filmstrip tile grid would otherwise rebuild on every wheel/momentum frame (~73% of zoom cost). During the gesture the content is briefly at pre-zoom scale, hidden by the repeating cover-frame background (zoom-in) or `overflow:hidden` clipping (zoom-out), snapping sharp on settle. The `preferImmediateRendering` prop opts back into live pps for active edit previews (trim/slide) where settle lag would distract +- **Clip content defers mount during zoom** — `ClipContent` also reads `isZoomInteracting` **once at mount** via `getState()` (not a reactive subscription) into `deferVisual` state. A clip that first mounts mid-gesture (e.g. entering the viewport while zooming out) renders only its colored shell — no filmstrip/waveform — until the zoom settles, then a one-shot `useZoomStore.subscribe` flips it on. This was ~90% of zoom-OUT cost: zooming out brings many clips into view at once and mounting each one's tile grid + canvas draws stalled the gesture (226ms/frame → ~42ms/frame). Reading at mount (not subscribing) is critical — already-mounted clips must NOT re-render when `isZoomInteracting` flips, or they'd flash empty - **Transition prearm covers all types** — the `forceFastScrubOverlay` subscription uses `getPlayingAnyTransitionPrewarmStartFrame` (not complex-only) so all transitions get their session pinned and DOM video elements playing before entry. Also checks `getTransitionWindowForFrame` for playback starting inside an active transition - **Feature boundary rules** — cross-feature imports must go through `deps/` adapter modules. The pre-push hook enforces this via `check:boundaries`. (A `check:legacy-lib-imports` tripwire also catches any reintroduction of `@/lib/*` imports — the `src/lib/` layer was removed and merged into `infrastructure/`.) diff --git a/src/app/debug/project-debug.ts b/src/app/debug/project-debug.ts index 19d6c8af7..0ae60bc6b 100644 --- a/src/app/debug/project-debug.ts +++ b/src/app/debug/project-debug.ts @@ -115,6 +115,19 @@ interface ProjectDebugAPI { // eslint-disable-next-line @typescript-eslint/no-explicit-any overlaps: () => Promise + // User Timing summary: aggregates `performance.measure` entries (default + // `tl.*` prefix from timeline perf-marks) into a per-name stat row. + perfSummary: (prefix?: string) => Array<{ + name: string + count: number + minMs: number + maxMs: number + avgMs: number + p95Ms: number + totalMs: number + }> + perfClear: () => void + // Render pipeline diagnostics — delegates to existing ad-hoc window globals // eslint-disable-next-line @typescript-eslint/no-explicit-any previewPerf: () => any @@ -481,6 +494,58 @@ function createDebugAPI(): ProjectDebugAPI { } }, + perfSummary: (prefix = 'tl.') => { + if ( + typeof performance === 'undefined' || + typeof performance.getEntriesByType !== 'function' + ) { + return [] + } + const entries = performance.getEntriesByType('measure') as PerformanceMeasure[] + const byName = new Map() + for (const entry of entries) { + if (!entry.name.startsWith(prefix)) continue + let durations = byName.get(entry.name) + if (!durations) { + durations = [] + byName.set(entry.name, durations) + } + durations.push(entry.duration) + } + const rows: Array<{ + name: string + count: number + minMs: number + maxMs: number + avgMs: number + p95Ms: number + totalMs: number + }> = [] + for (const [name, durations] of byName) { + const sorted = [...durations].sort((a, b) => a - b) + const count = sorted.length + const total = durations.reduce((sum, value) => sum + value, 0) + const p95Index = Math.min(count - 1, Math.floor(count * 0.95)) + rows.push({ + name, + count, + minMs: Number((sorted[0] ?? 0).toFixed(3)), + maxMs: Number((sorted[count - 1] ?? 0).toFixed(3)), + avgMs: Number((total / count).toFixed(3)), + p95Ms: Number((sorted[p95Index] ?? 0).toFixed(3)), + totalMs: Number(total.toFixed(2)), + }) + } + rows.sort((a, b) => b.totalMs - a.totalMs) + return rows + }, + + perfClear: () => { + if (typeof performance !== 'undefined' && typeof performance.clearMeasures === 'function') { + performance.clearMeasures() + } + }, + // Render pipeline diagnostics — thin delegates to existing window globals // so we never need to add/remove ad-hoc globals in components again. previewPerf: () => { diff --git a/src/features/editor/components/media-sidebar.tsx b/src/features/editor/components/media-sidebar.tsx index c46e5f27c..ad3e0dd8d 100644 --- a/src/features/editor/components/media-sidebar.tsx +++ b/src/features/editor/components/media-sidebar.tsx @@ -28,6 +28,7 @@ import { useTimelineStore } from '@/features/editor/deps/timeline-store' import { usePlaybackStore } from '@/shared/state/playback' import { useSelectionStore } from '@/shared/state/selection' import { useProjectStore } from '@/features/editor/deps/projects' +import { DEFAULT_PROJECT_HEIGHT, DEFAULT_PROJECT_WIDTH } from '@/shared/projects/defaults' import { clearMediaDragData, MediaLibrary, @@ -390,8 +391,8 @@ export const MediaSidebar = memo(function MediaSidebar() { proposedPosition // Fallback to proposed if no space found // Get canvas dimensions for initial transform - const canvasWidth = currentProject?.metadata.width ?? 1920 - const canvasHeight = currentProject?.metadata.height ?? 1080 + const canvasWidth = currentProject?.metadata.width ?? DEFAULT_PROJECT_WIDTH + const canvasHeight = currentProject?.metadata.height ?? DEFAULT_PROJECT_HEIGHT const textStylePreset = presetId ? TEXT_STYLE_PRESETS.find((preset) => preset.id === presetId) @@ -444,8 +445,8 @@ export const MediaSidebar = memo(function MediaSidebar() { findNearestAvailableSpace(proposedPosition, durationInFrames, targetTrack.id, items) ?? proposedPosition - const canvasWidth = currentProject?.metadata.width ?? 1920 - const canvasHeight = currentProject?.metadata.height ?? 1080 + const canvasWidth = currentProject?.metadata.width ?? DEFAULT_PROJECT_WIDTH + const canvasHeight = currentProject?.metadata.height ?? DEFAULT_PROJECT_HEIGHT const shapeItem: ShapeItem = createDefaultShapeItem({ trackId: targetTrack.id, diff --git a/src/features/editor/components/preview-area.tsx b/src/features/editor/components/preview-area.tsx index 9d6aba6bc..8d2fcd5f0 100644 --- a/src/features/editor/components/preview-area.tsx +++ b/src/features/editor/components/preview-area.tsx @@ -11,7 +11,6 @@ import { InlineCompositionPreview, ColorScopesMonitor, } from '@/features/editor/deps/preview' -import { useTimelineStore } from '@/features/editor/deps/timeline-store' import { useProjectStore } from '@/features/editor/deps/projects' import { useSettingsStore } from '@/features/editor/deps/settings' import { useMaskEditorStore, useItemsStore } from '@/features/editor/deps/preview' @@ -178,20 +177,9 @@ export const PreviewArea = memo(function PreviewArea({ project }: PreviewAreaPro const fps = projectFps ?? project.fps const backgroundColor = projectBgColor ?? '#000000' - // Derive timeline end frame directly from store state to avoid recreating selector functions. - const timelineEndFrame = useTimelineStore((s) => { - if (s.items.length === 0) return null - let maxFrame = 0 - for (const item of s.items) { - const itemEnd = item.from + item.durationInFrames - if (itemEnd > maxFrame) { - maxFrame = itemEnd - } - } - return maxFrame - }) - - const totalFrames = timelineEndFrame ?? fps * DEFAULT_EMPTY_TIMELINE_SECONDS + // Use the precomputed index from items-store; returns 0 when there are no items. + const maxItemEndFrame = useItemsStore((s) => s.maxItemEndFrame) + const totalFrames = maxItemEndFrame > 0 ? maxItemEndFrame : fps * DEFAULT_EMPTY_TIMELINE_SECONDS const isPathEditModeActive = isMaskEditingActive && !isPenModeActive const canFinishPenPath = isShapePenModeActive && penVertexCount >= 3 const selectedVertexCount = selectedVertexIndices.length @@ -534,7 +522,9 @@ export const PreviewArea = memo(function PreviewArea({ project }: PreviewAreaPro {isPenModeActive ? (
@@ -578,7 +568,9 @@ export const PreviewArea = memo(function PreviewArea({ project }: PreviewAreaPro ) : isPathEditModeActive ? (
diff --git a/src/features/editor/components/properties-sidebar/canvas-panel/index.tsx b/src/features/editor/components/properties-sidebar/canvas-panel/index.tsx index fdc9eced3..f49a5190b 100644 --- a/src/features/editor/components/properties-sidebar/canvas-panel/index.tsx +++ b/src/features/editor/components/properties-sidebar/canvas-panel/index.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button' import { Separator } from '@/components/ui/separator' import { ArrowLeftRight, RotateCcw, LayoutDashboard, Clock } from 'lucide-react' import { useProjectStore } from '@/features/editor/deps/projects' +import { DEFAULT_PROJECT_HEIGHT, DEFAULT_PROJECT_WIDTH } from '@/shared/projects/defaults' import { useTimelineStore } from '@/features/editor/deps/timeline-store' import { useGizmoStore } from '@/features/editor/deps/preview' import { HexColorPicker } from 'react-colorful' @@ -113,8 +114,8 @@ export const CanvasPanel = memo(function CanvasPanel() { ) // All handlers must be defined before any early returns (Rules of Hooks) - const width = currentProject?.metadata.width ?? 1920 - const height = currentProject?.metadata.height ?? 1080 + const width = currentProject?.metadata.width ?? DEFAULT_PROJECT_WIDTH + const height = currentProject?.metadata.height ?? DEFAULT_PROJECT_HEIGHT const storedBackgroundColor = currentProject?.metadata.backgroundColor ?? '#000000' const applyProjectMetadataChange = useCallback( diff --git a/src/features/editor/components/properties-sidebar/clip-panel/caption-style-controls.tsx b/src/features/editor/components/properties-sidebar/clip-panel/caption-style-controls.tsx index 618a22a30..2c0fafd09 100644 --- a/src/features/editor/components/properties-sidebar/clip-panel/caption-style-controls.tsx +++ b/src/features/editor/components/properties-sidebar/clip-panel/caption-style-controls.tsx @@ -12,7 +12,7 @@ import { type CaptionStylePreset, detectActiveCaptionPreset, resolveCaptionStylePatch, -} from './caption-style-presets' +} from '@/shared/typography/caption-style-presets' type CaptionStylableItem = SubtitleSegmentItem | TextItem diff --git a/src/features/editor/components/properties-sidebar/clip-panel/index.tsx b/src/features/editor/components/properties-sidebar/clip-panel/index.tsx index 398bb572f..5776543cb 100644 --- a/src/features/editor/components/properties-sidebar/clip-panel/index.tsx +++ b/src/features/editor/components/properties-sidebar/clip-panel/index.tsx @@ -8,6 +8,11 @@ import { useEditorStore } from '@/shared/state/editor' import { useSelectionStore } from '@/shared/state/selection' import { useItemsStore, useTimelineStore } from '@/features/editor/deps/timeline-store' import { useProjectStore } from '@/features/editor/deps/projects' +import { + DEFAULT_PROJECT_FPS, + DEFAULT_PROJECT_HEIGHT, + DEFAULT_PROJECT_WIDTH, +} from '@/shared/projects/defaults' import type { ClipInspectorTab } from '@/shared/state/editor' import type { SelectionState, SelectionActions } from '@/shared/state/selection' import type { TimelineState, TimelineActions } from '@/features/editor/deps/timeline-store' @@ -77,9 +82,13 @@ export const ClipPanel = memo(function ClipPanel() { const updateItemsTransform = useTimelineStore( (s: TimelineState & TimelineActions) => s.updateItemsTransform, ) - const projectWidth = useProjectStore((s) => s.currentProject?.metadata.width ?? 1920) - const projectHeight = useProjectStore((s) => s.currentProject?.metadata.height ?? 1080) - const projectFps = useProjectStore((s) => s.currentProject?.metadata.fps ?? 30) + const projectWidth = useProjectStore( + (s) => s.currentProject?.metadata.width ?? DEFAULT_PROJECT_WIDTH, + ) + const projectHeight = useProjectStore( + (s) => s.currentProject?.metadata.height ?? DEFAULT_PROJECT_HEIGHT, + ) + const projectFps = useProjectStore((s) => s.currentProject?.metadata.fps ?? DEFAULT_PROJECT_FPS) const selectedItems = useItemsStore( useShallow( useCallback( diff --git a/src/features/editor/components/properties-sidebar/clip-panel/layout-section.tsx b/src/features/editor/components/properties-sidebar/clip-panel/layout-section.tsx index 1cefb026e..92751e524 100644 --- a/src/features/editor/components/properties-sidebar/clip-panel/layout-section.tsx +++ b/src/features/editor/components/properties-sidebar/clip-panel/layout-section.tsx @@ -563,7 +563,7 @@ export const LayoutSection = memo(function LayoutSection({ ) // Get media items for fallback source dimensions lookup - const mediaItems = useMediaLibraryStore((s) => s.mediaItems) + const mediaById = useMediaLibraryStore((s) => s.mediaById) // Reset scale to source dimensions (1:1 scale) // For shapes: reset to 1:1 aspect ratio (square based on smaller dimension) @@ -599,7 +599,7 @@ export const LayoutSection = memo(function LayoutSection({ // Fallback: look up dimensions from media library if item has mediaId if (!source && item.mediaId) { - const media = mediaItems.find((m) => m.id === item.mediaId) + const media = mediaById[item.mediaId] if (media && media.width && media.height) { source = { width: media.width, height: media.height } } @@ -622,7 +622,7 @@ export const LayoutSection = memo(function LayoutSection({ onTransformChange([item.id], updates) }) queueMicrotask(clearTransformUiState) - }, [items, onTransformChange, mediaItems, canvas, clearTransformUiState]) + }, [items, onTransformChange, mediaById, canvas, clearTransformUiState]) // Reset position to center (x=0, y=0) const handleResetPosition = useCallback(() => { diff --git a/src/features/editor/components/properties-sidebar/clip-panel/subtitle-section.tsx b/src/features/editor/components/properties-sidebar/clip-panel/subtitle-section.tsx index 3d61d31f9..0ddae5e7e 100644 --- a/src/features/editor/components/properties-sidebar/clip-panel/subtitle-section.tsx +++ b/src/features/editor/components/properties-sidebar/clip-panel/subtitle-section.tsx @@ -6,6 +6,7 @@ import { i18n } from '@/i18n' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { DEFAULT_PROJECT_HEIGHT, DEFAULT_PROJECT_WIDTH } from '@/shared/projects/defaults' import { useTimelineStore } from '@/features/editor/deps/timeline-store' import { usePlaybackStore } from '@/shared/state/playback' import { @@ -57,8 +58,8 @@ export const SubtitleSection = memo(function SubtitleSection({ if (segments.length === 0) return null - const canvasWidth = canvas?.width ?? 1920 - const canvasHeight = canvas?.height ?? 1080 + const canvasWidth = canvas?.width ?? DEFAULT_PROJECT_WIDTH + const canvasHeight = canvas?.height ?? DEFAULT_PROJECT_HEIGHT if (segments.length > 1) { const totalCues = segments.reduce((sum, segment) => sum + segment.cues.length, 0) diff --git a/src/features/editor/components/properties-sidebar/clip-panel/video-section.tsx b/src/features/editor/components/properties-sidebar/clip-panel/video-section.tsx index 74459c048..427d0f73c 100644 --- a/src/features/editor/components/properties-sidebar/clip-panel/video-section.tsx +++ b/src/features/editor/components/properties-sidebar/clip-panel/video-section.tsx @@ -13,6 +13,7 @@ import { useTimelineCommandStore, useTimelineStore, } from '@/features/editor/deps/timeline-store' +import { DEFAULT_PROJECT_HEIGHT, DEFAULT_PROJECT_WIDTH } from '@/shared/projects/defaults' import { useGizmoStore, useThrottledFrame } from '@/features/editor/deps/preview' import type { TimelineState, TimelineActions } from '@/features/editor/deps/timeline-store' import { @@ -61,8 +62,8 @@ const CROP_EDGE_PROPERTY: Record function getCropDimensions(item: VideoItem): CropDimensions { return { - width: Math.max(1, item.sourceWidth ?? item.transform?.width ?? 1920), - height: Math.max(1, item.sourceHeight ?? item.transform?.height ?? 1080), + width: Math.max(1, item.sourceWidth ?? item.transform?.width ?? DEFAULT_PROJECT_WIDTH), + height: Math.max(1, item.sourceHeight ?? item.transform?.height ?? DEFAULT_PROJECT_HEIGHT), } } diff --git a/src/features/editor/components/settings-dialog.tsx b/src/features/editor/components/settings-dialog.tsx index eaafacfe5..7f62a62fb 100644 --- a/src/features/editor/components/settings-dialog.tsx +++ b/src/features/editor/components/settings-dialog.tsx @@ -56,6 +56,7 @@ import { importWaveformCache, } from '@/features/editor/deps/timeline-cache' import { clearPreviewAudioCache } from '@/features/editor/deps/composition-runtime' +import { CAPTION_STYLE_PRESETS } from '@/shared/typography/caption-style-presets' import { createLogger } from '@/shared/logging/logger' import { cn } from '@/shared/ui/cn' @@ -360,6 +361,7 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { const maxUndoHistory = useSettingsStore((s) => s.maxUndoHistory) const captioningIntervalUnit = useSettingsStore((s) => s.captioningIntervalUnit) const captioningIntervalValue = useSettingsStore((s) => s.captioningIntervalValue) + const defaultCaptionStylePresetId = useSettingsStore((s) => s.defaultCaptionStylePresetId) const setSetting = useSettingsStore((s) => s.setSetting) const resetToDefaults = useSettingsStore((s) => s.resetToDefaults) @@ -684,6 +686,33 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { })}

+ +
+
+ +

+ {t('settings.ai.defaultCaptionStyleDescription')} +

+
+
+ {CAPTION_STYLE_PRESETS.map((preset) => ( + + ))} +
+
)} diff --git a/src/features/export/components/export-dialog.tsx b/src/features/export/components/export-dialog.tsx index 9e76b7ddc..41020f781 100644 --- a/src/features/export/components/export-dialog.tsx +++ b/src/features/export/components/export-dialog.tsx @@ -35,6 +35,7 @@ import { import type { ExportSettings, ExportMode } from '@/types/export' import { useClientRender } from '../hooks/use-client-render' import { useProjectStore } from '@/features/export/deps/projects' +import { DEFAULT_PROJECT_HEIGHT, DEFAULT_PROJECT_WIDTH } from '@/shared/projects/defaults' import { useTimelineStore } from '@/features/export/deps/timeline' import { formatTimecode, framesToSeconds } from '@/shared/utils/time-utils' import { @@ -95,6 +96,70 @@ function formatFileSize(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(2)} MB` } +/** + * Scale a dimension and round to the nearest even number (encoders require + * even dimensions). Shared by the resolution dropdown and the quick presets so + * preset detection compares against identical values. + */ +function scaleDimension(value: number, scale: number): number { + const scaled = Math.round(value * scale) + return scaled % 2 === 0 ? scaled : scaled + 1 +} + +function scaledResolution(projectWidth: number, projectHeight: number, scale: number) { + return { + width: scaleDimension(projectWidth, scale), + height: scaleDimension(projectHeight, scale), + } +} + +type ExportPreset = { + id: 'max' | 'recommended' | 'balanced' | 'small' + labelKey: string + container: ClientVideoContainer + codec: ExportSettings['codec'] + quality: ExportSettings['quality'] + scale: number +} + +// One-click targets that bundle container/codec/quality/resolution. All keep the +// project's aspect ratio (scale only) so output is never distorted; they vary the +// quality/size tradeoff, which is the part users shouldn't need codec knowledge for. +const EXPORT_PRESETS: ExportPreset[] = [ + { + id: 'max', + labelKey: 'export.settings.presetMax', + container: 'mp4', + codec: 'h264', + quality: 'ultra', + scale: 1, + }, + { + id: 'recommended', + labelKey: 'export.settings.presetRecommended', + container: 'mp4', + codec: 'h264', + quality: 'high', + scale: 1, + }, + { + id: 'balanced', + labelKey: 'export.settings.presetBalanced', + container: 'mp4', + codec: 'h264', + quality: 'medium', + scale: 0.666, + }, + { + id: 'small', + labelKey: 'export.settings.presetSmall', + container: 'mp4', + codec: 'h264', + quality: 'low', + scale: 0.5, + }, +] + /** * Generate resolution options based on project dimensions. */ @@ -106,10 +171,7 @@ function getResolutionOptions( const scales = [1, 0.666, 0.5] return scales.map((scale) => { - const w = Math.round(projectWidth * scale) - const h = Math.round(projectHeight * scale) - const width = w % 2 === 0 ? w : w + 1 - const height = h % 2 === 0 ? h : h + 1 + const { width, height } = scaledResolution(projectWidth, projectHeight, scale) const label = scale === 1 @@ -126,8 +188,12 @@ function getDefaultCodecForFormat(format: 'mp4' | 'webm'): ExportSettings['codec export function ExportDialog({ open, onClose }: ExportDialogProps) { const { t } = useTranslation() - const projectWidth = useProjectStore((s) => s.currentProject?.metadata.width ?? 1920) - const projectHeight = useProjectStore((s) => s.currentProject?.metadata.height ?? 1080) + const projectWidth = useProjectStore( + (s) => s.currentProject?.metadata.width ?? DEFAULT_PROJECT_WIDTH, + ) + const projectHeight = useProjectStore( + (s) => s.currentProject?.metadata.height ?? DEFAULT_PROJECT_HEIGHT, + ) // Timeline state for in/out points and duration calculation const fps = useTimelineStore((s) => s.fps) const items = useTimelineStore((s) => s.items) @@ -180,6 +246,39 @@ export function ExportDialog({ open, onClose }: ExportDialogProps) { [projectWidth, projectHeight, t], ) + // Which preset (if any) the current settings exactly match. null = "Custom". + const activePresetId = useMemo(() => { + const match = EXPORT_PRESETS.find((preset) => { + const res = scaledResolution(projectWidth, projectHeight, preset.scale) + return ( + videoContainer === preset.container && + settings.codec === preset.codec && + settings.quality === preset.quality && + settings.resolution.width === res.width && + settings.resolution.height === res.height + ) + }) + return match?.id ?? null + }, [ + videoContainer, + settings.codec, + settings.quality, + settings.resolution.width, + settings.resolution.height, + projectWidth, + projectHeight, + ]) + + const applyPreset = (preset: ExportPreset) => { + setVideoContainer(preset.container) + setSettings((prev) => ({ + ...prev, + codec: preset.codec, + quality: preset.quality, + resolution: scaledResolution(projectWidth, projectHeight, preset.scale), + })) + } + // Sync resolution when project dimensions change useEffect(() => { setSettings((prev) => ({ @@ -257,7 +356,10 @@ export function ExportDialog({ open, onClose }: ExportDialogProps) { mode: exportMode, videoContainer: exportMode === 'video' ? videoContainer : undefined, audioContainer: exportMode === 'audio' ? audioContainer : undefined, - embedSubtitles: exportMode === 'video' && hasTranscriptSubtitles ? embedSubtitles : false, + embedSubtitles: + exportMode === 'video' && hasTranscriptSubtitles && containerSupportsEmbeddedSubtitles + ? embedSubtitles + : false, renderWholeProject, } await startExport(extendedSettings) @@ -363,12 +465,6 @@ export function ExportDialog({ open, onClose }: ExportDialogProps) { const hasCapabilityData = supportedVideoCodecs !== null && !videoSupportError const hasSupportedVideoPath = videoContainerOptions.some((option) => option.supported) - const hasSubtitleExportConflict = - exportMode === 'video' && - embedSubtitles && - hasTranscriptSubtitles && - !containerSupportsEmbeddedSubtitles - useEffect(() => { if (exportMode !== 'video' || !hasCapabilityData) return @@ -566,6 +662,37 @@ export function ExportDialog({ open, onClose }: ExportDialogProps) { {/* Video Export Settings */} {exportMode === 'video' && ( <> +
+
+ + {activePresetId === null && ( + + {t('export.settings.presetCustom')} + + )} +
+
+ {EXPORT_PRESETS.map((preset) => { + const isActive = activePresetId === preset.id + return ( + + ) + })} +
+
+
{!isCheckingVideoSupport && videoSupportError && ( @@ -691,20 +818,21 @@ export function ExportDialog({ open, onClose }: ExportDialogProps) {

{t('export.settings.embedSubtitlesDescription')}

+ {hasTranscriptSubtitles && !containerSupportsEmbeddedSubtitles && ( +

+ {t('export.settings.embedSubtitlesUnsupported', { + container: videoContainer.toUpperCase(), + })} +

+ )} {embedSubtitles && hasTranscriptSubtitles && - !containerSupportsEmbeddedSubtitles && ( -

- {t('export.settings.embedSubtitlesUnsupported', { - container: videoContainer.toUpperCase(), - })} + containerSupportsEmbeddedSubtitles && + videoContainer === 'mp4' && ( +

+ {t('export.settings.embedSubtitlesMp4Note')}

)} - {embedSubtitles && hasTranscriptSubtitles && videoContainer === 'mp4' && ( -

- {t('export.settings.embedSubtitlesMp4Note')} -

- )} {!hasTranscriptSubtitles && (

{t('export.settings.noTranscriptSegments')} @@ -713,8 +841,8 @@ export function ExportDialog({ open, onClose }: ExportDialogProps) {

@@ -785,8 +913,7 @@ export function ExportDialog({ open, onClose }: ExportDialogProps) { + ) : ( + {label} + )} {meta &&
{meta}
}
+ {details && expanded &&
{details}
} {trailing} diff --git a/src/features/media-library/components/media-card.tsx b/src/features/media-library/components/media-card.tsx index 5c2665ce9..bc1f5b1db 100644 --- a/src/features/media-library/components/media-card.tsx +++ b/src/features/media-library/components/media-card.tsx @@ -419,7 +419,7 @@ export const MediaCard = memo(function MediaCard({ const selectedIds = store.selectedMediaIds if (selectedIds.length > 1 && selectedIds.includes(media.id)) { return selectedIds - .map((id) => store.mediaItems.find((m) => m.id === id)) + .map((id) => store.mediaById[id]) .filter((m): m is MediaMetadata => m !== undefined) } return [media] @@ -830,7 +830,7 @@ export const MediaCard = memo(function MediaCard({ e.dataTransfer.effectAllowed = 'copy' const mediaStore = useMediaLibraryStore.getState() const selectedMediaIds = mediaStore.selectedMediaIds - const mediaItems = mediaStore.mediaItems + const mediaById = mediaStore.mediaById // If this item is selected and there are multiple selected items, drag all of them const isPartOfSelection = selectedMediaIds.includes(media.id) @@ -839,7 +839,7 @@ export const MediaCard = memo(function MediaCard({ if (isPartOfSelection && hasMultipleSelected) { // Build array of all selected media items in their current order const selectedItems = selectedMediaIds - .map((id) => mediaItems.find((m) => m.id === id)) + .map((id) => mediaById[id]) .filter((m): m is MediaMetadata => m !== undefined) .map((m) => ({ mediaId: m.id, diff --git a/src/features/media-library/components/media-library.tsx b/src/features/media-library/components/media-library.tsx index 83816379e..3af375615 100644 --- a/src/features/media-library/components/media-library.tsx +++ b/src/features/media-library/components/media-library.tsx @@ -690,6 +690,37 @@ export const MediaLibrary = memo(function MediaLibrary({ onMediaSelect }: MediaL return null }, [transcriptStatus, transcriptProgress, transcribingCount]) + // Per-item breakdowns shown when the aggregate progress bar is expanded. + const proxyItemRows = useMemo(() => { + const rows: Array<{ id: string; name: string; percent: number }> = [] + for (const [id, status] of proxyStatus.entries()) { + if (status === 'generating') { + rows.push({ + id, + name: mediaById[id]?.fileName ?? id, + percent: Math.round((proxyProgress.get(id) ?? 0) * 100), + }) + } + } + return rows + }, [proxyStatus, proxyProgress, mediaById]) + + const transcriptionItemRows = useMemo(() => { + const rows: Array<{ id: string; name: string; percent: number; stage: string | null }> = [] + for (const [id, status] of transcriptStatus.entries()) { + if (status === 'queued' || status === 'transcribing') { + const progress = transcriptProgress.get(id) + rows.push({ + id, + name: mediaById[id]?.fileName ?? id, + percent: progress ? Math.round(getTranscriptionOverallProgress(progress) * 100) : 0, + stage: progress ? getTranscriptionStageLabel(progress.stage) : null, + }) + } + } + return rows + }, [transcriptStatus, transcriptProgress, mediaById]) + const handleGenerateSelectedProxies = async () => { const selectedItems = selectedMediaIds .map((id) => mediaById[id]) @@ -1498,7 +1529,7 @@ export const MediaLibrary = memo(function MediaLibrary({ onMediaSelect }: MediaL onClick={() => mediaAnalysisService.requestCancel()} className="text-muted-foreground hover:text-foreground transition-colors" > - {t('common.cancel')} + {analysisProgress.total > 1 ? t('media.library.cancelAll') : t('common.cancel')} ) : ( {t('media.library.cancelling')} @@ -1517,6 +1548,23 @@ export const MediaLibrary = memo(function MediaLibrary({ onMediaSelect }: MediaL label={t('media.library.generatingTranscripts', { count: transcribingCount })} progressAriaLabel={t('media.library.transcriptGenerationProgress')} progressPercent={transcribingAvgProgress * 100} + detailsToggleAriaLabel={t('media.library.perItemProgress')} + details={ + transcriptionItemRows.length > 1 + ? transcriptionItemRows.map((row) => ( +
+ {row.name} + + {row.stage && {row.stage}} + {row.percent}% + +
+ )) + : undefined + } meta={ <> {singleTranscriptionStageLabel && ( @@ -1543,6 +1591,20 @@ export const MediaLibrary = memo(function MediaLibrary({ onMediaSelect }: MediaL label={t('media.library.generatingProxies', { count: generatingCount })} progressAriaLabel={t('media.library.proxyGenerationProgress')} progressPercent={generatingAvgProgress * 100} + detailsToggleAriaLabel={t('media.library.perItemProgress')} + details={ + proxyItemRows.length > 1 + ? proxyItemRows.map((row) => ( +
+ {row.name} + {row.percent}% +
+ )) + : undefined + } meta={ <> {Math.round(generatingAvgProgress * 100)}% diff --git a/src/features/media-library/services/media-analysis-service.ts b/src/features/media-library/services/media-analysis-service.ts index e9ad681c9..023b5e92f 100644 --- a/src/features/media-library/services/media-analysis-service.ts +++ b/src/features/media-library/services/media-analysis-service.ts @@ -72,8 +72,7 @@ class MediaAnalysisService { */ async analyzeMedia(mediaOrId: string | MediaMetadata): Promise { const store = useMediaLibraryStore.getState() - const media = - typeof mediaOrId === 'string' ? store.mediaItems.find((m) => m.id === mediaOrId) : mediaOrId + const media = typeof mediaOrId === 'string' ? store.mediaById[mediaOrId] : mediaOrId if (!media) return false const ownsRun = !this.batchInFlight && !store.analysisProgress diff --git a/src/features/media-library/services/media-captioning-service.ts b/src/features/media-library/services/media-captioning-service.ts index 6601e51fa..6e744dfb6 100644 --- a/src/features/media-library/services/media-captioning-service.ts +++ b/src/features/media-library/services/media-captioning-service.ts @@ -10,6 +10,7 @@ import { useSelectionStore } from '@/shared/state/selection' import { createLogger } from '@/shared/logging/logger' +import { DEFAULT_PROJECT_HEIGHT, DEFAULT_PROJECT_WIDTH } from '@/shared/projects/defaults' import type { MediaCaption } from '@/infrastructure/analysis' import type { AudioItem, TextItem, TimelineItem, TimelineTrack, VideoItem } from '@/types/timeline' import { @@ -20,10 +21,12 @@ import { findCompatibleCaptionTrackForRanges, isCaptionTrackCandidate, getCaptionTextItemTemplate, + getCaptionStyleTemplateFromPreset, getCaptionRangeForClip, } from '../utils/caption-items' import { useProjectStore } from '@/features/media-library/deps/projects' import { useTimelineStore } from '@/features/media-library/deps/timeline-stores' +import { useSettingsStore } from '@/features/media-library/deps/settings-contract' const logger = createLogger('MediaCaptioningService') @@ -93,8 +96,13 @@ class MediaCaptioningService { return { insertedItemCount: 0, removedItemCount: 0, noTargetClips: true } } - const canvasWidth = project?.metadata.width ?? 1920 - const canvasHeight = project?.metadata.height ?? 1080 + const canvasWidth = project?.metadata.width ?? DEFAULT_PROJECT_WIDTH + const canvasHeight = project?.metadata.height ?? DEFAULT_PROJECT_HEIGHT + const defaultCaptionTemplate = getCaptionStyleTemplateFromPreset( + useSettingsStore.getState().defaultCaptionStylePresetId, + canvasWidth, + canvasHeight, + ) const newTracks: TimelineTrack[] = [...timeline.tracks] const generatedCaptionIdsToRemove = options.replaceExisting ? new Set( @@ -164,7 +172,7 @@ class MediaCaptioningService { sourceType: 'ai-captions', styleTemplate: existingGeneratedCaptions[0] ? getCaptionTextItemTemplate(existingGeneratedCaptions[0]) - : undefined, + : defaultCaptionTemplate, }) logger.info('buildCaptionTextItems produced items', { clipId: clip.id, diff --git a/src/features/media-library/services/media-transcription-service.ts b/src/features/media-library/services/media-transcription-service.ts index 06808890e..ecd3af66a 100644 --- a/src/features/media-library/services/media-transcription-service.ts +++ b/src/features/media-library/services/media-transcription-service.ts @@ -7,6 +7,7 @@ import { import { usePlaybackStore } from '@/shared/state/playback' import { useSelectionStore } from '@/shared/state/selection' import { createLogger } from '@/shared/logging/logger' +import { DEFAULT_PROJECT_HEIGHT, DEFAULT_PROJECT_WIDTH } from '@/shared/projects/defaults' import type { MediaTranscript, MediaTranscriptModel, MediaTranscriptSegment } from '@/types/storage' import type { AudioItem, @@ -23,6 +24,7 @@ import { import { mediaLibraryService } from './media-library-service' import { buildSubtitleSegmentForClip, + getCaptionStyleTemplateFromPreset, buildCaptionTrackAbove, findReplaceableCaptionItemsForClip, findCompatibleCaptionTrackForRanges, @@ -494,8 +496,13 @@ class MediaTranscriptionService { throw new Error('Select a clip for this media, or place one on the timeline first') } - const canvasWidth = project?.metadata.width ?? 1920 - const canvasHeight = project?.metadata.height ?? 1080 + const canvasWidth = project?.metadata.width ?? DEFAULT_PROJECT_WIDTH + const canvasHeight = project?.metadata.height ?? DEFAULT_PROJECT_HEIGHT + const defaultCaptionTemplate = getCaptionStyleTemplateFromPreset( + useSettingsStore.getState().defaultCaptionStylePresetId, + canvasWidth, + canvasHeight, + ) const newTracks: TimelineTrack[] = [...timeline.tracks] const generatedCaptionIdsToRemove = options.replaceExisting ? new Set( @@ -560,7 +567,7 @@ class MediaTranscriptionService { }, styleTemplate: existingGeneratedCaptions[0] ? getCaptionTextItemTemplate(existingGeneratedCaptions[0]) - : undefined, + : defaultCaptionTemplate, }) if (!clipCaptionItem) { diff --git a/src/features/media-library/services/subtitle-sidecar-service.ts b/src/features/media-library/services/subtitle-sidecar-service.ts index 2cf8b2f23..1c7ca3f4c 100644 --- a/src/features/media-library/services/subtitle-sidecar-service.ts +++ b/src/features/media-library/services/subtitle-sidecar-service.ts @@ -6,6 +6,7 @@ import { type EmbeddedSubtitleTrack, } from '@/shared/utils/matroska-subtitles' import { getEmbeddedSubtitleSidecar, saveEmbeddedSubtitleSidecar } from '@/infrastructure/storage' +import { DEFAULT_PROJECT_HEIGHT, DEFAULT_PROJECT_WIDTH } from '@/shared/projects/defaults' import type { MediaMetadata } from '@/types/storage' import type { TimelineItem, TimelineTrack } from '@/types/timeline' import { @@ -107,8 +108,8 @@ class SubtitleSidecarService { ): number { const timeline = useTimelineStore.getState() const project = useProjectStore.getState().currentProject - const canvasWidth = project?.metadata.width ?? 1920 - const canvasHeight = project?.metadata.height ?? 1080 + const canvasWidth = project?.metadata.width ?? DEFAULT_PROJECT_WIDTH + const canvasHeight = project?.metadata.height ?? DEFAULT_PROJECT_HEIGHT const clips = findCaptionTargetClipsForMedia(timeline.items, media.id) if (clips.length === 0) return 0 diff --git a/src/features/media-library/utils/caption-items.ts b/src/features/media-library/utils/caption-items.ts index e70d1584c..da03658fd 100644 --- a/src/features/media-library/utils/caption-items.ts +++ b/src/features/media-library/utils/caption-items.ts @@ -17,6 +17,10 @@ import type { VideoItem, } from '@/types/timeline' import { timelineToSourceFrames } from '../deps/timeline-contract' +import { + CAPTION_STYLE_PRESETS, + resolveCaptionStylePatch, +} from '@/shared/typography/caption-style-presets' /** * Fallback segment duration when AI captions can't infer an `end` time from @@ -508,6 +512,21 @@ export function findReplaceableCaptionItemsForClip( return items.filter((item): item is TextItem => isLegacyGeneratedCaptionItemForClip(item, clip)) } +/** + * Resolve a named caption style preset into a template applied to freshly + * generated captions. Returns undefined when the preset id is unknown so the + * builders fall back to their hardcoded default look. + */ +export function getCaptionStyleTemplateFromPreset( + presetId: string, + canvasWidth: number, + canvasHeight: number, +): CaptionTextItemTemplate | undefined { + const preset = CAPTION_STYLE_PRESETS.find((p) => p.id === presetId) + if (!preset) return undefined + return resolveCaptionStylePatch(preset, canvasWidth, canvasHeight) +} + export function getCaptionTextItemTemplate( item: TextItem | SubtitleSegmentItem, ): CaptionTextItemTemplate { diff --git a/src/features/media-library/utils/media-resolver.ts b/src/features/media-library/utils/media-resolver.ts index cccd3b97e..fff88ed65 100644 --- a/src/features/media-library/utils/media-resolver.ts +++ b/src/features/media-library/utils/media-resolver.ts @@ -180,7 +180,7 @@ export async function resolveMediaUrls( const signal = options?.signal // Deep clone tracks to avoid mutating original - const resolvedTracks: TimelineTrack[] = JSON.parse(JSON.stringify(tracks)) + const resolvedTracks: TimelineTrack[] = structuredClone(tracks) // Resolve all media URLs in parallel const resolutionPromises: Promise[] = [] diff --git a/src/features/media-library/workers/media-processor.worker.ts b/src/features/media-library/workers/media-processor.worker.ts index f6d785fa2..065c42af9 100644 --- a/src/features/media-library/workers/media-processor.worker.ts +++ b/src/features/media-library/workers/media-processor.worker.ts @@ -10,6 +10,7 @@ */ import { createLogger, createOperationId } from '@/shared/logging/logger' +import { DEFAULT_PROJECT_HEIGHT, DEFAULT_PROJECT_WIDTH } from '@/shared/projects/defaults' const logger = createLogger('MediaProcessorWorker') const KEYFRAME_EXTRACTION_TIMEOUT_MS = 8_000 @@ -444,8 +445,8 @@ async function extractVideoMetadata(file: File): Promise { return { type: 'video', duration: duration || 0, - width: videoTrack.displayWidth || 1920, - height: videoTrack.displayHeight || 1080, + width: videoTrack.displayWidth || DEFAULT_PROJECT_WIDTH, + height: videoTrack.displayHeight || DEFAULT_PROJECT_HEIGHT, fps, codec: videoTrack.codec || 'unknown', bitrate: 0, diff --git a/src/features/preview/components/inline-composition-preview.tsx b/src/features/preview/components/inline-composition-preview.tsx index fa0cb42dc..ed270fb19 100644 --- a/src/features/preview/components/inline-composition-preview.tsx +++ b/src/features/preview/components/inline-composition-preview.tsx @@ -55,7 +55,6 @@ const InlineCompositionPreviewContent = memo(function InlineCompositionPreviewCo containerSize, }: InlineCompositionPreviewProps) { const composition = useCompositionsStore((s) => s.compositionById[compositionId]) - const compositionById = useCompositionsStore((s) => s.compositionById) const zoom = usePlaybackStore((s) => s.zoom) const useProxy = usePlaybackStore((s) => s.useProxy) const [resolvedTracks, setResolvedTracks] = useState(null) @@ -66,7 +65,7 @@ const InlineCompositionPreviewContent = memo(function InlineCompositionPreviewCo ) useEffect(() => { - if (!composition || !compositionInput) { + if (!compositionInput) { setResolvedTracks(null) return } @@ -75,7 +74,11 @@ const InlineCompositionPreviewContent = memo(function InlineCompositionPreviewCo setResolvedTracks(null) const loadResolvedTracks = async () => { - const mediaIds = collectSubCompositionMediaIds(compositionId, compositionById) + // Read compositionById lazily so unrelated composition edits don't retrigger this effect. + const mediaIds = collectSubCompositionMediaIds( + compositionId, + useCompositionsStore.getState().compositionById, + ) await Promise.all(mediaIds.map((mediaId) => resolveMediaUrl(mediaId))) const nextResolvedTracks = await resolveMediaUrls(compositionInput.tracks, { useProxy }) @@ -93,7 +96,7 @@ const InlineCompositionPreviewContent = memo(function InlineCompositionPreviewCo return () => { cancelled = true } - }, [composition, compositionById, compositionId, compositionInput, useProxy]) + }, [compositionId, compositionInput, useProxy]) const compositionWidth = composition?.width || 640 const compositionHeight = composition?.height || 360 diff --git a/src/features/preview/components/source-monitor.test.tsx b/src/features/preview/components/source-monitor.test.tsx index 56ed274cf..e6da58900 100644 --- a/src/features/preview/components/source-monitor.test.tsx +++ b/src/features/preview/components/source-monitor.test.tsx @@ -27,20 +27,22 @@ const sourcePlayerStoreState = vi.hoisted(() => ({ setPendingSeekFrame: vi.fn(), })) -const mediaStoreState = vi.hoisted(() => ({ - mediaItems: [ - { - id: 'media-1', - fileName: 'clip.mp4', - mimeType: 'video/mp4', - duration: 5, - width: 1920, - height: 1080, - fps: 30, - audioCodec: 'aac', - }, - ], -})) +const mediaStoreState = vi.hoisted(() => { + const media1 = { + id: 'media-1', + fileName: 'clip.mp4', + mimeType: 'video/mp4', + duration: 5, + width: 1920, + height: 1080, + fps: 30, + audioCodec: 'aac', + } + return { + mediaItems: [media1], + mediaById: { 'media-1': media1 } as Record, + } +}) const itemsStoreState = vi.hoisted(() => ({ tracks: [], diff --git a/src/features/preview/components/source-monitor.tsx b/src/features/preview/components/source-monitor.tsx index 10c619244..0d91b22a6 100644 --- a/src/features/preview/components/source-monitor.tsx +++ b/src/features/preview/components/source-monitor.tsx @@ -192,7 +192,7 @@ const SourceMonitorContent = memo(function SourceMonitorContent({ seekFrame = null, }: SourceMonitorProps) { const [blobUrl, setBlobUrl] = useState('') - const media = useMediaLibraryStore((s) => s.mediaItems.find((m) => m.id === mediaId)) + const media = useMediaLibraryStore((s) => s.mediaById[mediaId]) // Sync current media ID into source player store for I/O points useEffect(() => { diff --git a/src/features/preview/hooks/use-canvas-media-drop.ts b/src/features/preview/hooks/use-canvas-media-drop.ts index bce8f7134..9c70124bb 100644 --- a/src/features/preview/hooks/use-canvas-media-drop.ts +++ b/src/features/preview/hooks/use-canvas-media-drop.ts @@ -325,9 +325,7 @@ export function useCanvasMediaDrop({ coordParams, projectSize }: UseCanvasMediaD return } - const media = - useMediaLibraryStore.getState().mediaById[mediaId] ?? - useMediaLibraryStore.getState().mediaItems.find((item) => item.id === mediaId) + const media = useMediaLibraryStore.getState().mediaById[mediaId] if (!media) { toast.error(i18n.t('preview.canvasDrop.mediaNoLongerAvailable')) return diff --git a/src/features/preview/hooks/use-preview-view-model.ts b/src/features/preview/hooks/use-preview-view-model.ts index 9e31bf4d8..e14efb07d 100644 --- a/src/features/preview/hooks/use-preview-view-model.ts +++ b/src/features/preview/hooks/use-preview-view-model.ts @@ -77,13 +77,14 @@ export function usePreviewViewModel({ const useProxy = usePlaybackStore((s) => s.useProxy) const busAudioEq = usePlaybackStore((s) => s.busAudioEq) const blobUrlVersion = useBlobUrlVersion() - const proxyReadyCount = useMediaLibraryStore((s) => { + const proxyStatus = useMediaLibraryStore((s) => s.proxyStatus) + const proxyReadyCount = useMemo(() => { let count = 0 - for (const status of s.proxyStatus.values()) { + for (const status of proxyStatus.values()) { if (status === 'ready') count++ } return count - }) + }, [proxyStatus]) const activeGizmoItemType = useMemo( () => diff --git a/src/features/projects/components/project-card.tsx b/src/features/projects/components/project-card.tsx index cd2e6536e..9bfff6278 100644 --- a/src/features/projects/components/project-card.tsx +++ b/src/features/projects/components/project-card.tsx @@ -38,6 +38,11 @@ import { useRestoreProject, } from '../hooks/use-project-actions' import { useProjectThumbnail } from '../hooks/use-project-thumbnail' +import { + DEFAULT_PROJECT_FPS, + DEFAULT_PROJECT_HEIGHT, + DEFAULT_PROJECT_WIDTH, +} from '@/shared/projects/defaults' interface ProjectCardProps { project: Project @@ -145,9 +150,9 @@ export function ProjectCard({ } // Safe metadata access with defaults - const width = project?.metadata?.width || 1920 - const height = project?.metadata?.height || 1080 - const fps = project?.metadata?.fps || 30 + const width = project?.metadata?.width || DEFAULT_PROJECT_WIDTH + const height = project?.metadata?.height || DEFAULT_PROJECT_HEIGHT + const fps = project?.metadata?.fps || DEFAULT_PROJECT_FPS const resolution = `${width}×${height}` const aspectRatio = width / height diff --git a/src/features/projects/utils/validation.ts b/src/features/projects/utils/validation.ts index be68faee9..ba89ccd96 100644 --- a/src/features/projects/utils/validation.ts +++ b/src/features/projects/utils/validation.ts @@ -1,5 +1,10 @@ import { z } from 'zod' import { i18n } from '@/i18n' +import { + DEFAULT_PROJECT_FPS, + DEFAULT_PROJECT_HEIGHT, + DEFAULT_PROJECT_WIDTH, +} from '@/shared/projects/defaults' import { DEFAULT_PROJECT_FPS_OPTIONS, isAllowedProjectFps } from './project-fps' /** @@ -162,9 +167,9 @@ export const FPS_PRESETS = [...DEFAULT_PROJECT_FPS_OPTIONS] export const DEFAULT_PROJECT_VALUES: ProjectFormData = { name: '', description: '', - width: 1920, - height: 1080, - fps: 30, + width: DEFAULT_PROJECT_WIDTH, + height: DEFAULT_PROJECT_HEIGHT, + fps: DEFAULT_PROJECT_FPS, } /** diff --git a/src/features/settings/stores/settings-store.ts b/src/features/settings/stores/settings-store.ts index 01504ba01..a89f5ce90 100644 --- a/src/features/settings/stores/settings-store.ts +++ b/src/features/settings/stores/settings-store.ts @@ -16,6 +16,7 @@ import { type HotkeyKey, type HotkeyOverrideMap, } from '@/config/hotkeys' +import { CAPTION_STYLE_PRESETS } from '@/shared/typography/caption-style-presets' /** * App-wide settings stored in localStorage @@ -50,6 +51,10 @@ interface AppSettings { // substring + fuzzy-prefix matching on caption text. captionSearchMode: CaptionSearchMode + // Caption style preset id applied automatically to captions generated from + // transcripts / AI captioning (when not inheriting an existing caption's style). + defaultCaptionStylePresetId: string + // Keyboard shortcuts hotkeyOverrides: HotkeyOverrideMap } @@ -60,6 +65,14 @@ function normalizeCaptionSearchMode(value: unknown): CaptionSearchMode { return value === 'semantic' ? 'semantic' : 'keyword' } +export const DEFAULT_CAPTION_STYLE_PRESET_ID = CAPTION_STYLE_PRESETS[0]?.id ?? 'netflix' + +function normalizeCaptionStylePresetId(value: unknown): string { + return typeof value === 'string' && CAPTION_STYLE_PRESETS.some((preset) => preset.id === value) + ? value + : DEFAULT_CAPTION_STYLE_PRESET_ID +} + export type CaptioningIntervalUnit = 'seconds' | 'frames' export const CAPTIONING_INTERVAL_BOUNDS = { @@ -143,6 +156,9 @@ const DEFAULT_SETTINGS: AppSettings = { // Scene Browser defaults captionSearchMode: 'keyword', + // Caption styling default + defaultCaptionStylePresetId: DEFAULT_CAPTION_STYLE_PRESET_ID, + // Keyboard shortcuts hotkeyOverrides: {}, } @@ -186,6 +202,9 @@ export const useSettingsStore = create()( if (key === 'editorDensity') { return { editorDensity: normalizeEditorDensityPreset(value) } } + if (key === 'defaultCaptionStylePresetId') { + return { defaultCaptionStylePresetId: normalizeCaptionStylePresetId(value) } + } return { [key]: value } }), @@ -267,6 +286,9 @@ export const useSettingsStore = create()( captioningIntervalUnit, ), captionSearchMode: normalizeCaptionSearchMode(typedState.captionSearchMode), + defaultCaptionStylePresetId: normalizeCaptionStylePresetId( + typedState.defaultCaptionStylePresetId, + ), } }, }, diff --git a/src/features/timeline/components/bento-layout-dialog.tsx b/src/features/timeline/components/bento-layout-dialog.tsx index 62cabf7b7..888fb65e8 100644 --- a/src/features/timeline/components/bento-layout-dialog.tsx +++ b/src/features/timeline/components/bento-layout-dialog.tsx @@ -15,6 +15,7 @@ import { X } from 'lucide-react' import { useBentoLayoutDialogStore } from './bento-layout-dialog-store' import { useBentoPresetsStore } from '../stores/bento-presets-store' import { useProjectStore } from '@/features/timeline/deps/projects' +import { DEFAULT_PROJECT_HEIGHT, DEFAULT_PROJECT_WIDTH } from '@/shared/projects/defaults' import { useItemsStore } from '../stores/items-store' import { useTransitionsStore } from '../stores/transitions-store' import { applyBentoLayout } from '../stores/actions/transform-actions' @@ -634,8 +635,12 @@ export function BentoLayoutDialog() { const addPreset = useBentoPresetsStore((s) => s.addPreset) const removePreset = useBentoPresetsStore((s) => s.removePreset) - const canvasWidth = useProjectStore((s) => s.currentProject?.metadata.width ?? 1920) - const canvasHeight = useProjectStore((s) => s.currentProject?.metadata.height ?? 1080) + const canvasWidth = useProjectStore( + (s) => s.currentProject?.metadata.width ?? DEFAULT_PROJECT_WIDTH, + ) + const canvasHeight = useProjectStore( + (s) => s.currentProject?.metadata.height ?? DEFAULT_PROJECT_HEIGHT, + ) // Build transition chains when dialog opens const transitions = useTransitionsStore((s) => s.transitions) diff --git a/src/features/timeline/components/keyframe-graph-panel.tsx b/src/features/timeline/components/keyframe-graph-panel.tsx index bb7ab816d..f90fe0243 100644 --- a/src/features/timeline/components/keyframe-graph-panel.tsx +++ b/src/features/timeline/components/keyframe-graph-panel.tsx @@ -35,6 +35,11 @@ import { } from '@/features/timeline/deps/keyframes' import { resolveTransform, getSourceDimensions } from '@/features/timeline/deps/composition-runtime' import { useProjectStore } from '@/features/timeline/deps/projects' +import { + DEFAULT_PROJECT_FPS, + DEFAULT_PROJECT_HEIGHT, + DEFAULT_PROJECT_WIDTH, +} from '@/shared/projects/defaults' import { useSelectionStore } from '@/shared/state/selection' import { useItemsStore } from '../stores/items-store' import { useKeyframesStore } from '../stores/keyframes-store' @@ -719,9 +724,9 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ const canvas = useMemo( () => ({ - width: currentProject?.metadata.width ?? 1920, - height: currentProject?.metadata.height ?? 1080, - fps: currentProject?.metadata.fps ?? 30, + width: currentProject?.metadata.width ?? DEFAULT_PROJECT_WIDTH, + height: currentProject?.metadata.height ?? DEFAULT_PROJECT_HEIGHT, + fps: currentProject?.metadata.fps ?? DEFAULT_PROJECT_FPS, }), [currentProject], ) diff --git a/src/features/timeline/components/timeline-content.tsx b/src/features/timeline/components/timeline-content.tsx index 28ce46a70..df94bf960 100644 --- a/src/features/timeline/components/timeline-content.tsx +++ b/src/features/timeline/components/timeline-content.tsx @@ -15,6 +15,7 @@ import { useSelectionStore } from '@/shared/state/selection' // Hooks import { useMarqueeSelection } from '@/shared/marquee/use-marquee-selection' import { useWaveformPrefetch } from '../hooks/use-waveform-prefetch' +import { withPerfMeasure, perfMarkRender } from '@/shared/logging/perf-marks' // Constants import { @@ -220,7 +221,7 @@ function TrackSectionScrollbarOverlay({ if (scrollFrameId !== 0) return scrollFrameId = window.requestAnimationFrame(() => { scrollFrameId = 0 - updateThumbPosition() + withPerfMeasure('tl.raf.scrollThumb', updateThumbPosition) }) } @@ -682,6 +683,8 @@ export const TimelineContent = memo(function TimelineContent({ }: TimelineContentProps) { void duration + perfMarkRender('TimelineContent') + // Prefetch waveforms for clips approaching the viewport useWaveformPrefetch() @@ -827,15 +830,24 @@ export const TimelineContent = memo(function TimelineContent({ const queuedZoomScrollLeftRef = useRef(null) const zoomApplyRafRef = useRef(null) + // Cached viewport box dimensions. clientWidth/clientHeight are invariant under + // scroll and horizontal zoom (only the *content* width changes), so reading + // them every scroll/zoom frame forces a needless layout flush. A ResizeObserver + // refreshes this cache; we fall back to a live read until it has measured once. + const viewportDimsRef = useRef<{ width: number; height: number } | null>(null) + const syncViewportFromContainer = useCallback(() => { const container = containerRef.current if (!container) return - const tracksViewportHeight = tracksContainerRef.current?.clientHeight ?? container.clientHeight + const dims = viewportDimsRef.current + const viewportWidth = dims?.width ?? container.clientWidth + const viewportHeight = + dims?.height ?? tracksContainerRef.current?.clientHeight ?? container.clientHeight useTimelineViewportStore.getState().setViewport({ scrollLeft: container.scrollLeft, scrollTop: container.scrollTop, - viewportWidth: container.clientWidth, - viewportHeight: tracksViewportHeight, + viewportWidth, + viewportHeight, }) }, []) @@ -843,7 +855,7 @@ export const TimelineContent = memo(function TimelineContent({ if (viewportSyncRafRef.current !== null) return viewportSyncRafRef.current = requestAnimationFrame(() => { viewportSyncRafRef.current = null - syncViewportFromContainer() + withPerfMeasure('tl.raf.viewportSync', syncViewportFromContainer) }) }, [syncViewportFromContainer]) @@ -870,10 +882,16 @@ export const TimelineContent = memo(function TimelineContent({ // Measure container width - run after render and on resize useEffect(() => { const updateWidth = () => { - if (containerRef.current) { - setContainerWidth(containerRef.current.clientWidth) - syncViewportFromContainer() + const container = containerRef.current + if (!container) return + // Refresh the cached box dims (read here, on actual resizes only, instead + // of every scroll/zoom frame). + viewportDimsRef.current = { + width: container.clientWidth, + height: tracksContainerRef.current?.clientHeight ?? container.clientHeight, } + setContainerWidth(container.clientWidth) + syncViewportFromContainer() } // Measure immediately @@ -882,8 +900,12 @@ export const TimelineContent = memo(function TimelineContent({ // Re-measure during idle in case DOM wasn't fully laid out on mount const idleId = requestIdleCallback(updateWidth) - // Measure on resize + // Measure on resize. A ResizeObserver catches panel/track-height changes that + // the window 'resize' event misses, and keeps the cached dims fresh. window.addEventListener('resize', updateWidth) + const ro = new ResizeObserver(updateWidth) + if (containerRef.current) ro.observe(containerRef.current) + if (tracksContainerRef.current) ro.observe(tracksContainerRef.current) return () => { cancelIdleCallback(idleId) @@ -892,6 +914,7 @@ export const TimelineContent = memo(function TimelineContent({ viewportSyncRafRef.current = null } window.removeEventListener('resize', updateWidth) + ro.disconnect() } }, [syncViewportFromContainer]) @@ -1249,7 +1272,7 @@ export const TimelineContent = memo(function TimelineContent({ } previewRafRef.current = requestAnimationFrame(() => { previewRafRef.current = null - setPreviewFrameRef.current(frame, itemId) + withPerfMeasure('tl.raf.previewHover', () => setPreviewFrameRef.current(frame, itemId)) }) }, [buildRazorSnapTargets], @@ -1327,18 +1350,20 @@ export const TimelineContent = memo(function TimelineContent({ zoomApplyRafRef.current = requestAnimationFrame(() => { zoomApplyRafRef.current = null - const queuedZoomLevel = queuedZoomLevelRef.current - const queuedScrollLeft = queuedZoomScrollLeftRef.current - queuedZoomLevelRef.current = null - queuedZoomScrollLeftRef.current = null - - if (queuedZoomLevel === null || queuedScrollLeft === null) { - return - } + withPerfMeasure('tl.raf.zoomApply', () => { + const queuedZoomLevel = queuedZoomLevelRef.current + const queuedScrollLeft = queuedZoomScrollLeftRef.current + queuedZoomLevelRef.current = null + queuedZoomScrollLeftRef.current = null + + if (queuedZoomLevel === null || queuedScrollLeft === null) { + return + } - pendingScrollRef.current = queuedScrollLeft - scrollLeftRef.current = queuedScrollLeft - setZoomImmediate(queuedZoomLevel) + pendingScrollRef.current = queuedScrollLeft + scrollLeftRef.current = queuedScrollLeft + setZoomImmediate(queuedZoomLevel) + }) }) }, [setZoomImmediate], @@ -1523,14 +1548,19 @@ export const TimelineContent = memo(function TimelineContent({ } const momentumLoop = () => { - if (!containerRef.current) return + const container = containerRef.current + if (!container) return + + withPerfMeasure('tl.raf.momentum', () => momentumLoopBody(container)) + } + const momentumLoopBody = (container: HTMLDivElement) => { let hasScrollMomentum = false let hasZoomMomentum = false // Apply velocity to scroll position if (Math.abs(velocityXRef.current) > SCROLL_MIN_VELOCITY) { - containerRef.current.scrollLeft += velocityXRef.current + container.scrollLeft += velocityXRef.current velocityXRef.current *= SCROLL_FRICTION hasScrollMomentum = true } else { diff --git a/src/features/timeline/components/timeline-item/clip-content.test.tsx b/src/features/timeline/components/timeline-item/clip-content.test.tsx index 503589167..d78b60e46 100644 --- a/src/features/timeline/components/timeline-item/clip-content.test.tsx +++ b/src/features/timeline/components/timeline-item/clip-content.test.tsx @@ -106,12 +106,15 @@ describe('ClipContent', () => { }) it('uses settled zoom for filmstrip content by default', () => { + // Not interacting: content renders (mid-gesture deferral is covered by its + // own test below). pixelsPerSecond (180) and contentPixelsPerSecond (100) + // are set apart purely to verify which one the filmstrip reads. useZoomStore.setState({ level: 1.8, pixelsPerSecond: 180, contentLevel: 1, contentPixelsPerSecond: 100, - isZoomInteracting: true, + isZoomInteracting: false, }) useSettingsStore.setState({ showFilmstrips: true, @@ -131,7 +134,10 @@ describe('ClipContent', () => { render() - expect(screen.getByTestId('clip-filmstrip')).toHaveAttribute('data-pps', '180') + // Default (no preferImmediateRendering): filmstrip tracks the SETTLED zoom + // (contentPixelsPerSecond = 100), not the live in-gesture pps (180). This is + // what keeps the filmstrip tile grid from re-rendering on every zoom frame. + expect(screen.getByTestId('clip-filmstrip')).toHaveAttribute('data-pps', '100') }) it('can opt clip internals into live zoom for immediate edit previews', () => { @@ -140,7 +146,7 @@ describe('ClipContent', () => { pixelsPerSecond: 180, contentLevel: 1, contentPixelsPerSecond: 100, - isZoomInteracting: true, + isZoomInteracting: false, }) useSettingsStore.setState({ showFilmstrips: false, @@ -170,4 +176,35 @@ describe('ClipContent', () => { expect(screen.getByTestId('clip-waveform')).toHaveAttribute('data-pps', '180') }) + + it('defers filmstrip content for clips that mount during an active zoom gesture', () => { + // A clip first appearing mid-zoom (e.g. entering the viewport while zooming + // out) must NOT mount its filmstrip tile grid yet — that mount burst is the + // bulk of zoom-out cost. It shows just the clip shell until the zoom settles. + useZoomStore.setState({ + level: 1, + pixelsPerSecond: 100, + contentLevel: 1, + contentPixelsPerSecond: 100, + isZoomInteracting: true, + }) + useSettingsStore.setState({ showFilmstrips: true, showWaveforms: false }) + + const item: TimelineItem = { + id: 'video-defer', + type: 'video', + trackId: 'track-1', + from: 0, + durationInFrames: 60, + label: 'Video clip', + mediaId: 'media-1', + src: 'blob:test', + } as TimelineItem + + render() + + // Mounted mid-gesture → filmstrip deferred (label still renders). + expect(screen.queryByTestId('clip-filmstrip')).toBeNull() + expect(screen.getByText('Video clip')).toBeInTheDocument() + }) }) diff --git a/src/features/timeline/components/timeline-item/clip-content.tsx b/src/features/timeline/components/timeline-item/clip-content.tsx index 571b32917..78a2bb1df 100644 --- a/src/features/timeline/components/timeline-item/clip-content.tsx +++ b/src/features/timeline/components/timeline-item/clip-content.tsx @@ -1,5 +1,6 @@ -import { memo, useCallback, useMemo } from 'react' +import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { Link2 } from 'lucide-react' +import { perfMarkRender } from '@/shared/logging/perf-marks' import type { TimelineItem } from '@/types/timeline' import { ClipFilmstrip } from '../clip-filmstrip' import { ImageFilmstrip } from '../clip-filmstrip/image-filmstrip' @@ -159,13 +160,49 @@ export const ClipContent = memo(function ClipContent({ audioWaveformScale = 1, linkedSyncOffsetFrames = null, }: ClipContentProps) { - // Subscribe to live pixelsPerSecond so filmstrip/waveform content stays in sync - // with the CSS-variable-driven clip shell during zoom — avoids a visible catchup - // jump at settle. Per-item render cost is kept low by the filmstrip skip (<5px) - // and compact clip shell optimizations in the parent. - const pixelsPerSecond = useZoomStore((s) => s.pixelsPerSecond) + perfMarkRender('ClipContent') + // Drive filmstrip/waveform width from the SETTLED zoom (contentPixelsPerSecond) + // by default, not the live per-frame pps. The clip shell itself resizes + // smoothly during a zoom gesture via the --timeline-px-per-frame CSS variable + // (no React), while contentPixelsPerSecond only updates ~100ms after the gesture + // settles. This stops ClipContent (and the expensive filmstrip tile grid / + // waveform render) from re-rendering on every wheel/momentum frame — previously + // ~73% of zoom cost. During the gesture the filmstrip is briefly at the pre-zoom + // scale, covered by the repeating cover-frame background (zoom-in) or clipped by + // overflow:hidden (zoom-out); it snaps sharp on settle. + // + // preferImmediateRendering (active edit previews — trim/slide) opts back into + // the live pps so the content tracks the shell frame-for-frame while the user + // is actively dragging an edge, where the settle lag would be distracting. + const pixelsPerSecond = useZoomStore((s) => + preferImmediateRendering ? s.pixelsPerSecond : s.contentPixelsPerSecond, + ) const showWaveforms = useSettingsStore((s) => s.showWaveforms) const showFilmstrips = useSettingsStore((s) => s.showFilmstrips) + + // Defer the heavy filmstrip/waveform mount for clips that first appear DURING + // an active zoom gesture. Zooming out brings many clips into the viewport at + // once, and mounting each one's tile grid + canvas draws is ~90% of zoom-out + // cost. A clip that mounts mid-gesture shows just its colored shell until the + // zoom settles, then reveals the thumbnails. This is read once at mount via + // getState() (NOT a reactive subscription) so already-mounted clips never + // re-render — only clips born mid-gesture defer, and only they subscribe (to + // flip themselves on once interaction ends). + const [deferVisual, setDeferVisual] = useState(() => useZoomStore.getState().isZoomInteracting) + useEffect(() => { + if (!deferVisual) return + // The gesture may have settled between the mount-time getState() read and + // this effect attaching. The subscription only fires on *future* changes, so + // without this re-check the clip would stay shell-only until the next zoom. + if (!useZoomStore.getState().isZoomInteracting) { + setDeferVisual(false) + return + } + return useZoomStore.subscribe((state) => { + if (!state.isZoomInteracting) setDeferVisual(false) + }) + }, [deferVisual]) + const clipLeftPx = useMemo( () => (fps > 0 ? (clipLeftFrames / fps) * pixelsPerSecond : 0), [clipLeftFrames, fps, pixelsPerSecond], @@ -344,7 +381,7 @@ export const ClipContent = memo(function ClipContent({ [renderTitleText], ) - const showVisualContent = clipWidth >= FILMSTRIP_MIN_WIDTH_PX + const showVisualContent = clipWidth >= FILMSTRIP_MIN_WIDTH_PX && !deferVisual // Video clip 2-row layout: label | filmstrip if (item.type === 'video' && item.mediaId) { diff --git a/src/features/timeline/components/timeline-item/edge-halos.tsx b/src/features/timeline/components/timeline-item/edge-halos.tsx new file mode 100644 index 000000000..3bf0e154c --- /dev/null +++ b/src/features/timeline/components/timeline-item/edge-halos.tsx @@ -0,0 +1,76 @@ +import { memo } from 'react' +import { CONSTRAINED_COLORS, FREE_COLORS, type ActiveEdgeState } from './trim-constants' + +function getFramePositionStyle(frame: number): string { + return `calc(${frame} * var(--timeline-px-per-frame, 0px))` +} + +interface EdgeHalosProps { + activeEdges: ActiveEdgeState | null + visualLeftFrame: number + visualWidthFrames: number +} + +export const EdgeHalos = memo(function EdgeHalos({ + activeEdges, + visualLeftFrame, + visualWidthFrames, +}: EdgeHalosProps) { + if (!activeEdges) return null + + return ( +
+ {activeEdges.start && + (() => { + const constrained = + activeEdges.constrainedEdge === 'start' || activeEdges.constrainedEdge === 'both' + const colors = constrained ? CONSTRAINED_COLORS : FREE_COLORS + return ( + <> +
+
+ + ) + })()} + {activeEdges.end && + (() => { + const constrained = + activeEdges.constrainedEdge === 'end' || activeEdges.constrainedEdge === 'both' + const colors = constrained ? CONSTRAINED_COLORS : FREE_COLORS + return ( + <> +
+
+ + ) + })()} +
+ ) +}) diff --git a/src/features/timeline/components/timeline-item/index.tsx b/src/features/timeline/components/timeline-item/index.tsx index 7eb11ec85..a58a6cd9e 100644 --- a/src/features/timeline/components/timeline-item/index.tsx +++ b/src/features/timeline/components/timeline-item/index.tsx @@ -2,58 +2,23 @@ import { useRef, useEffect, useLayoutEffect, useMemo, memo, useCallback, useStat import { createPortal } from 'react-dom' import type { TimelineItem as TimelineItemType } from '@/types/timeline' import { useShallow } from 'zustand/react/shallow' -import { - setMixerLiveGains, - getMixerLiveGain, - clearMixerLiveGain, -} from '@/shared/state/mixer-live-gain' import { useTimelineStore } from '../../stores/timeline-store' import { useItemsStore } from '../../stores/items-store' +import { selectReplaceableCaptionClipIds } from '../../stores/items-store-indexes' import { useKeyframesStore } from '../../stores/keyframes-store' import { useTransitionsStore } from '../../stores/transitions-store' import { useEffectDropPreviewStore } from '../../stores/effect-drop-preview-store' -import { useLinkedEditPreviewStore } from '../../stores/linked-edit-preview-store' -import { useRollingEditPreviewStore } from '../../stores/rolling-edit-preview-store' -import { useRippleEditPreviewStore } from '../../stores/ripple-edit-preview-store' -import { useTrackPushPreviewStore } from '../../stores/track-push-preview-store' -import { useSlipEditPreviewStore } from '../../stores/slip-edit-preview-store' -import { useSlideEditPreviewStore } from '../../stores/slide-edit-preview-store' +import { useEditPreviewShifts } from './use-edit-preview-shifts' import { useSelectionStore } from '@/shared/state/selection' import { useEditorStore } from '@/shared/state/editor' import { useSourcePlayerStore } from '@/shared/state/source-player' import { usePlaybackStore } from '@/shared/state/playback' +import { perfMarkRender } from '@/shared/logging/perf-marks' import { useTransitionDragStore } from '@/shared/state/transition-drag' import { TRANSITION_CONFIGS } from '@/types/transition' import { useMediaLibraryStore } from '@/features/timeline/deps/media-library-store' -import { mediaTranscriptionService } from '@/features/timeline/deps/media-transcription-service' -import { - mediaLibraryService as mediaLibraryServiceForSubtitles, - useEmbeddedSubtitlePickerStore, -} from '@/features/timeline/deps/media-library-service' - -function isEmbeddedSubtitleContainer(fileName: string, mimeType: string): boolean { - const name = fileName.toLowerCase() - return ( - mimeType === 'video/x-matroska' || - mimeType === 'video/matroska' || - mimeType === 'video/webm' || - name.endsWith('.mkv') || - name.endsWith('.webm') - ) -} -import { - TranscribeDialog, - type TranscribeDialogValues, -} from '@/features/timeline/deps/transcribe-dialog' -import { - getTranscriptionOverallPercent, - getTranscriptionStageLabel, -} from '@/shared/utils/transcription-progress' -import { - isTranscriptionOutOfMemoryError, - TRANSCRIPTION_OOM_HINT, -} from '@/shared/utils/transcription-cancellation' -import type { PreviewItemUpdate } from '../../utils/item-edit-preview' +import { useCaptionDialogState } from './use-caption-dialog-state' +import { TranscribeDialogController } from './transcribe-dialog-controller' import { useTimelineDrag, dragOffsetRef, @@ -75,7 +40,9 @@ import { ClipIndicators } from './clip-indicators' import { shouldSuppressLinkedSyncBadge } from './linked-sync-badge' import { shouldSuppressTimelineItemClickAfterDrag } from './post-drag-click-guard' import { TrimHandles } from './trim-handles' -import { CONSTRAINED_COLORS, FREE_COLORS, type ActiveEdgeState } from './trim-constants' +import { type ActiveEdgeState } from './trim-constants' +import { EdgeHalos } from './edge-halos' +import { TransitionDropGhost } from './transition-drop-ghost' import { TrackPushHandle } from './track-push-handle' import { StretchHandles } from './stretch-handles' import { AudioFadeHandles } from './audio-fade-handles' @@ -119,32 +86,20 @@ import { smartTrimIntentToHandle, smartTrimIntentToMode, type SmartBodyIntent, - type SmartTrimIntent, } from '../../utils/smart-trim-zones' +import { useSmartTrimHover } from './use-smart-trim-hover' +import { useContextMenuState } from './use-context-menu-state' import { useMarkersStore } from '../../stores/markers-store' import { useCompositionNavigationStore } from '../../stores/composition-navigation-store' import { useTimelineItemOverlayStore } from '../../stores/timeline-item-overlay-store' import { useRollHoverStore } from '../../stores/roll-hover-store' import { useZoomStore } from '../../stores/zoom-store' import { frameToPixelsNow, pixelsToFrameNow } from '../../utils/zoom-conversions' -import { timelineToSourceFrames } from '../../utils/source-calculations' -import { computeSlideContinuitySourceDelta } from '../../utils/slide-utils' +import { useTimelineItemBounds } from './use-timeline-item-bounds' import { getTransitionBridgeBounds } from '../../utils/transition-preview-geometry' -import { - getAudioFadeRatio, - getAudioFadeSecondsFromOffset, - type AudioFadeHandle, -} from '../../utils/audio-fade' -import { - getAudioFadeCurveControlPoint, - getAudioFadeCurveFromOffset, - getAudioFadeCurvePath, -} from '../../utils/audio-fade-curve' -import { - getAudioVolumeDbFromDragDelta, - getAudioVisualizationScale, - getAudioVolumeLineY, -} from '../../utils/audio-volume' +import { getAudioVisualizationScale, getAudioVolumeLineY } from '../../utils/audio-volume' +import { useFadeEditors } from './use-fade-editors' +import { useFadeMath } from './use-fade-math' import { EDITOR_LAYOUT_CSS_VALUES } from '@/config/editor-layout' import { formatSignedFrameDelta, formatTimecodeCompact } from '@/shared/utils/time-utils' import { @@ -165,9 +120,6 @@ const ACTIVE_CURSOR_CLASSES = [ 'timeline-cursor-track-push', ] as const -// Width in pixels for trim edge hover detection -const EDGE_HOVER_ZONE = SMART_TRIM_EDGE_ZONE_PX - // Track-push trigger zone: scale with zoom so it stays hittable when zoomed out const TRACK_PUSH_MIN_PX = 6 const TRACK_PUSH_MAX_PX = 14 @@ -189,13 +141,8 @@ function getTrackPushZoneStyle(gapFrames: number): string { const adaptiveWidth = `clamp(${TRACK_PUSH_MIN_PX}px, calc(${TRACK_PUSH_MAX_PX}px - (var(--timeline-pixels-per-second, 0px) / ${zoomSlopeDivisor})), ${TRACK_PUSH_MAX_PX}px)` return `min(${gapWidth}, ${adaptiveWidth})` } -const VIDEO_FADE_EPSILON = 0.0001 -const AUDIO_FADE_EPSILON = 0.0001 -const AUDIO_VOLUME_EPSILON = 0.05 const AUDIO_ENVELOPE_VIEWBOX_HEIGHT = 100 const FADE_VIEWBOX_WIDTH = 1000 -const AUDIO_VOLUME_DRAG_ACTIVATION_DELAY_MS = 120 -const AUDIO_VOLUME_DRAG_ACTIVATION_DISTANCE_PX = 4 function TrimInfoOverlay({ anchorRef, @@ -282,6 +229,7 @@ export const TimelineItem = memo( trackLocked = false, trackHidden = false, }: TimelineItemProps) { + perfMarkRender('TimelineItem') // Granular selector: only re-render when THIS item's selection state changes const isSelected = useSelectionStore( useCallback((s) => s.selectedItemIdSet.has(item.id), [item.id]), @@ -302,46 +250,10 @@ export const TimelineItem = memo( [item.mediaId, item.id], ), ) - const transcriptStatus = useMediaLibraryStore( - useCallback( - (s) => (item.mediaId ? (s.transcriptStatus.get(item.mediaId) ?? 'idle') : 'idle'), - [item.mediaId], - ), - ) - const transcriptProgress = useMediaLibraryStore( - useCallback( - (s) => (item.mediaId ? (s.transcriptProgress.get(item.mediaId) ?? null) : null), - [item.mediaId], - ), - ) - const mediaFileName = useMediaLibraryStore( - useCallback( - (s) => - item.mediaId ? (s.mediaItems.find((m) => m.id === item.mediaId)?.fileName ?? '') : '', - [item.mediaId], - ), - ) - const [captionDialogOpen, setCaptionDialogOpen] = useState(false) - const [captionDialogError, setCaptionDialogError] = useState(null) - const mediaHasTranscript = transcriptStatus === 'ready' - const captionStartedRef = useRef(false) - const captionStopRequestedRef = useRef(false) - - const captionIsActive = transcriptStatus === 'queued' || transcriptStatus === 'transcribing' - useEffect(() => { - if (captionStartedRef.current && !captionIsActive) { - captionStartedRef.current = false - const keepOpen = captionStopRequestedRef.current || captionDialogError !== null - captionStopRequestedRef.current = false - setCaptionDialogOpen((wasOpen) => { - return wasOpen && keepOpen - }) - } - }, [captionIsActive, captionDialogError]) - // O(1) index lookup that preserves both explicit captionSource links and - // legacy generated-caption detection. + // Lazy, items-keyed memo: legacy generated-caption detection rebuilds only + // when the items array identity changes (not on every store mutation). const hasGeneratedCaptions = useItemsStore( - useCallback((s) => s.replaceableCaptionClipIds.has(item.id), [item.id]), + useCallback((s) => selectReplaceableCaptionClipIds(s).has(item.id), [item.id]), ) // O(1) via index, including legacy linked audio/video pairs. const isLinked = useItemsStore(useCallback((s) => !!s.linkedItemsByItemId[item.id], [item.id])) @@ -363,20 +275,11 @@ export const TimelineItem = memo( [itemKeyframes], ) const hasKeyframes = keyframedProperties.length > 0 - const linkedVideoCaptionOwner = useMemo(() => { - if (item.type !== 'audio' || !item.mediaId) { - return null - } - - return ( - linkedItemsForCaptionOwnership.find( - (linkedItem) => - linkedItem.id !== item.id && - linkedItem.type === 'video' && - linkedItem.mediaId === item.mediaId, - ) ?? null - ) - }, [item.id, item.mediaId, item.type, linkedItemsForCaptionOwnership]) + const caption = useCaptionDialogState({ + item, + isBroken, + linkedItemsForCaptionOwnership, + }) const reverseMenuShowsUnreverse = useMemo(() => { if (item.type !== 'video' && item.type !== 'audio') { return false @@ -392,101 +295,6 @@ export const TimelineItem = memo( reversibleItems.every((candidate) => candidate.isReversed === true) ) }, [item, linkedItemsForCaptionOwnership]) - const canManageCaptions = - !!item.mediaId && - !isBroken && - (item.type === 'video' || (item.type === 'audio' && linkedVideoCaptionOwner === null)) - - const mediaForItem = useMediaLibraryStore( - useCallback( - (s) => (item.mediaId ? (s.mediaItems.find((m) => m.id === item.mediaId) ?? null) : null), - [item.mediaId], - ), - ) - const canExtractEmbeddedSubtitles = !!( - mediaForItem && - !isBroken && - isEmbeddedSubtitleContainer(mediaForItem.fileName, mediaForItem.mimeType) - ) - const handleExtractEmbeddedSubtitles = useCallback(async () => { - if (!mediaForItem) return - const mediaStore = useMediaLibraryStore.getState() - try { - const handle = mediaForItem.fileHandle - if (mediaForItem.storageType === 'handle' && handle) { - const granted = - (await handle.requestPermission({ mode: 'read' }).catch(() => 'denied' as const)) === - 'granted' - if (!granted) { - mediaStore.showNotification?.({ - type: 'error', - message: `FreeCut needs permission to read "${mediaForItem.fileName}" before extracting subtitles.`, - }) - return - } - const blob = await handle.getFile() - useEmbeddedSubtitlePickerStore.getState().open(mediaForItem, blob) - return - } - // Non-handle storage: fall back to the workspace blob lookup. - const blob = await mediaLibraryServiceForSubtitles.getMediaFile(mediaForItem.id) - if (!blob) { - mediaStore.showNotification?.({ - type: 'error', - message: `FreeCut could not load "${mediaForItem.fileName}".`, - }) - return - } - useEmbeddedSubtitlePickerStore.getState().open(mediaForItem, blob) - } catch (error) { - mediaStore.showNotification?.({ - type: 'error', - message: - error instanceof Error - ? error.message - : `Failed to open "${mediaForItem.fileName}" for subtitle extraction.`, - }) - } - }, [mediaForItem]) - - // Per-cue caption consolidation — only meaningful when this clip has at - // least one generated caption text item linked to it. - const hasConsolidatablePerCueCaptions = useTimelineStore( - useCallback( - (s) => - s.items.some( - (other) => - other.type === 'text' && - (other.captionSource?.type === 'embedded-subtitles' || - other.captionSource?.type === 'subtitle-import') && - other.captionSource.clipId === item.id, - ), - [item.id], - ), - ) - const handleConsolidateCaptionsToSegment = useCallback(async () => { - const mediaStore = useMediaLibraryStore.getState() - try { - const { subtitleSidecarService } = - await import('@/features/timeline/deps/subtitle-sidecar-service') - const result = subtitleSidecarService.consolidatePerCueCaptionsToSegments({ - clipId: item.id, - }) - mediaStore.showNotification?.({ - type: 'success', - message: - result.segmentsCreated > 0 - ? `Consolidated ${result.cuesConsolidated} caption${result.cuesConsolidated === 1 ? '' : 's'} into ${result.segmentsCreated} segment${result.segmentsCreated === 1 ? '' : 's'}.` - : 'No per-cue captions found for this clip.', - }) - } catch (error) { - mediaStore.showNotification?.({ - type: 'error', - message: - error instanceof Error ? error.message : 'Failed to consolidate captions to segment.', - }) - } - }, [item.id]) // Use refs for actions to avoid selector re-renders - read from store in callbacks const activeTool = useSelectionStore((s) => s.activeTool) @@ -496,48 +304,36 @@ export const TimelineItem = memo( const activeToolRef = useRef(activeTool) activeToolRef.current = activeTool - // Track which edge is being hovered for showing trim/rate-stretch handles - const [hoveredEdge, setHoveredEdge] = useState<'start' | 'end' | null>(null) - const [smartTrimIntent, setSmartTrimIntent] = useState(null) - const [smartBodyIntent, setSmartBodyIntent] = useState(null) - - // Clear stale hover state when the active tool changes (mouse may be stationary) - useEffect(() => { - setHoveredEdge(null) - setSmartTrimIntent(null) - setSmartBodyIntent(null) - useRollHoverStore.getState().clearRollHover(item.id) - }, [activeTool, item.id]) - // When an adjacent item enters roll mode, this item's edge should glow too const rollHoverEdge = useRollHoverStore( useCallback((s) => (s.neighborItemId === item.id ? s.neighborEdge : null), [item.id]), ) - const isSingleEffectDropTarget = useEffectDropPreviewStore( - useCallback( - (state) => state.targetItemIds.length === 1 && state.targetItemIds[0] === item.id, - [item.id], - ), - ) - const isMultiEffectDropTarget = useEffectDropPreviewStore( - useCallback( - (state) => state.targetItemIds.length > 1 && state.targetItemIds.includes(item.id), - [item.id], - ), - ) - const multiEffectDropTargetCount = useEffectDropPreviewStore( - useCallback( - (state) => - state.hoveredItemId === item.id && state.targetItemIds.length > 1 - ? state.targetItemIds.length - : 0, - [item.id], + // Single shallow read replaces three subscriptions on the same store. + const effectDropPreview = useEffectDropPreviewStore( + useShallow( + useCallback( + (state) => { + const targets = state.targetItemIds + const isTarget = targets.includes(item.id) + const isSingle = targets.length === 1 && targets[0] === item.id + const isMulti = isTarget && targets.length > 1 + return { + isSingle, + isMulti, + hoveredMultiCount: + state.hoveredItemId === item.id && targets.length > 1 ? targets.length : 0, + } + }, + [item.id], + ), ), ) + const isSingleEffectDropTarget = effectDropPreview.isSingle + const isMultiEffectDropTarget = effectDropPreview.isMulti + const multiEffectDropTargetCount = effectDropPreview.hoveredMultiCount const isEffectDropTarget = isSingleEffectDropTarget || isMultiEffectDropTarget - // Track which edge was closer when context menu was triggered - const [closerEdge, setCloserEdge] = useState<'left' | 'right' | null>(null) + const { closerEdge, handleContextMenu } = useContextMenuState(item) // Track blocked drag attempt tooltip (shown on mousedown in rate-stretch mode) const [pointerHint, setPointerHint] = useState<{ @@ -699,33 +495,20 @@ export const TimelineItem = memo( ghostRef, }) - const linkedEditPreviewUpdate = useLinkedEditPreviewStore( - useCallback((s) => s.updatesById[item.id] ?? null, [item.id]), - ) - const isHiddenByLinkedEditPreview = linkedEditPreviewUpdate?.hidden === true - const moveDragPreviewFromDelta = useMemo(() => { - if (!linkedEditPreviewUpdate || !(isDragging || isPartOfDrag) || gestureMode !== 'none') { - return 0 - } - - return (linkedEditPreviewUpdate.from ?? item.from) - item.from - }, [gestureMode, isDragging, isPartOfDrag, item.from, linkedEditPreviewUpdate]) - const previewBaseItem = useMemo( - () => - linkedEditPreviewUpdate && moveDragPreviewFromDelta === 0 - ? ({ ...item, ...linkedEditPreviewUpdate } as TimelineItemType) - : item, - [item, linkedEditPreviewUpdate, moveDragPreviewFromDelta], - ) - - // Get visual feedback for rate stretch - const stretchFeedback = isStretching ? getVisualFeedback() : null - - // Check if this clip supports rate stretch (video/audio/composition/GIF) - const isRateStretchItem = isRateStretchableItem(previewBaseItem) - - // Current speed for badge display - const currentSpeed = previewBaseItem.speed || 1 + const { + hoveredEdge, + smartTrimIntent, + smartBodyIntent, + smartTrimIntentRef, + handleMouseMove, + handleMouseLeave, + } = useSmartTrimHover({ + item, + trackLocked, + activeTool, + activeToolRef, + isAnyDragActiveRef, + }) // Get FPS for frame-to-time conversion const fps = useTimelineStore((s) => s.fps) @@ -744,25 +527,45 @@ export const TimelineItem = memo( ), ), ) - const linkedSyncPreviewUpdatesById = useLinkedEditPreviewStore( - useShallow( - useCallback( - (s) => { - const updatesById: Record = {} - for (const linkedItem of linkedItemsForSync) { - const linkedPreviewUpdate = s.updatesById[linkedItem.id] - if (linkedPreviewUpdate) { - updatesById[linkedItem.id] = linkedPreviewUpdate - } - } + const editPreviewShifts = useEditPreviewShifts({ + item, + linkedItemsForSync, + isDragging, + isPartOfDrag, + gestureMode, + }) + const { + linkedEditPreviewUpdate, + isHiddenByLinkedEditPreview, + moveDragPreviewFromDelta, + previewBaseItem, + linkedSyncPreviewUpdatesById, + rollingEditDelta, + rollingEditHandle, + rollingEditConstrained, + rippleEditOffset, + rippleEdgeDelta, + trackPushOffset, + slipEditDelta, + isLinkedSlipCompanion, + slideEditOffset, + slideNeighborDelta, + slideNeighborSide, + isLinkedSlideCompanion, + slideRange, + slideLeftNeighborForSlidItem, + slideRightNeighborForSlidItem, + } = editPreviewShifts - return updatesById - }, - [linkedItemsForSync], - ), - ), - ) + // Get visual feedback for rate stretch + const stretchFeedback = isStretching ? getVisualFeedback() : null + + // Check if this clip supports rate stretch (video/audio/composition/GIF) + const isRateStretchItem = isRateStretchableItem(previewBaseItem) + + // Current speed for badge display + const currentSpeed = previewBaseItem.speed || 1 const draggedTransition = useTransitionDragStore((s) => s.draggedTransition) const transitionDragPreview = useTransitionDragStore( @@ -784,181 +587,6 @@ export const TimelineItem = memo( ), ) - // Rolling edit preview: this item is the neighbor being inversely adjusted - const rollingEditDelta = useRollingEditPreviewStore( - useCallback( - (s) => { - if (s.neighborItemId !== item.id) return 0 - return s.neighborDelta - }, - [item.id], - ), - ) - const rollingEditHandle = useRollingEditPreviewStore( - useCallback( - (s) => { - if (s.neighborItemId !== item.id) return null - return s.handle - }, - [item.id], - ), - ) - const rollingEditConstrained = useRollingEditPreviewStore( - useCallback((s) => s.neighborItemId === item.id && s.constrained, [item.id]), - ) - - // Ripple edit preview: downstream items shift by delta during ripple trim - const rippleEditOffset = useRippleEditPreviewStore( - useCallback( - (s) => { - if (!s.trimmedItemId) return 0 - if (s.downstreamItemIds.has(item.id)) return s.delta - return 0 - }, - [item.id], - ), - ) - - // Ripple edit preview: trimmed item reads the downstream shift (delta) from - // the same store so the new right edge can be computed from frames - the same - // rounding path downstream items use - preventing Math.round(A)+Math.round(B) - // != Math.round(A+B) gaps. - const rippleEdgeDelta = useRippleEditPreviewStore( - useCallback( - (s) => { - if (s.trimmedItemId !== item.id) return 0 - return s.delta - }, - [item.id], - ), - ) - - // Track push preview: all shifted items (anchor + downstream) move by delta - const trackPushOffset = useTrackPushPreviewStore( - useCallback( - (s) => { - if (!s.anchorItemId) return 0 - if (s.shiftedItemIds.has(item.id)) return s.delta - return 0 - }, - [item.id], - ), - ) - - // Slip edit preview: source window shift for the active slipped clip. - // Used to update filmstrip/waveform source alignment during drag. - const slipEditDelta = useSlipEditPreviewStore( - useCallback( - (s) => { - if (s.itemId !== item.id) return 0 - return s.slipDelta - }, - [item.id], - ), - ) - - // Linked slip companion: true when another clip is being slipped and this - // item receives a linked sourceStart/sourceEnd preview update. - const isLinkedSlipCompanion = - useSlipEditPreviewStore( - useCallback((s) => s.itemId !== null && s.itemId !== item.id, [item.id]), - ) && - linkedEditPreviewUpdate !== null && - linkedEditPreviewUpdate.sourceStart !== undefined - - // Linked slide companion: true ONLY when this item is the direct linked - // companion of the slid clip (not a counterpart of a neighbor). - // Verified by checking that this item is linked to the slid clip. - const isLinkedSlideCompanion = useSlideEditPreviewStore( - useCallback( - (s) => { - if (!s.itemId || s.itemId === item.id) return false - if (s.leftNeighborId === item.id || s.rightNeighborId === item.id) return false - // Must actually be linked to the slid clip - const items = useItemsStore.getState().items - const linkedIds = getLinkedItemIds(items, s.itemId) - return linkedIds.includes(item.id) - }, - [item.id], - ), - ) - - // Slide edit preview: real-time visual offsets during slide drag. - // - Slid clip: position shifts by slideDelta - // - Left neighbor: end extends/shrinks by slideDelta (width change only) - // - Right neighbor: start extends/shrinks by slideDelta (position + width change) - const slideEditOffset = useSlideEditPreviewStore( - useCallback( - (s) => { - if (!s.itemId) return 0 - if (s.itemId === item.id) return s.slideDelta - return 0 - }, - [item.id], - ), - ) - - const slideNeighborDelta = useSlideEditPreviewStore( - useCallback( - (s) => { - if (!s.itemId) return 0 - // Left neighbor: end edge moves by slideDelta - if (s.leftNeighborId === item.id) return s.slideDelta - // Right neighbor: start edge moves by slideDelta - if (s.rightNeighborId === item.id) return s.slideDelta - return 0 - }, - [item.id], - ), - ) - - const slideNeighborSide = useSlideEditPreviewStore( - useCallback( - (s): 'left' | 'right' | null => { - if (!s.itemId) return null - if (s.leftNeighborId === item.id) return 'left' - if (s.rightNeighborId === item.id) return 'right' - return null - }, - [item.id], - ), - ) - - // Slide range from the preview store - the tightest constraint across all tracks. - // Used by both primary and companion overlays so limit boxes match. - const slideRange = useSlideEditPreviewStore( - useShallow( - useCallback((s) => (s.itemId ? { minDelta: s.minDelta, maxDelta: s.maxDelta } : null), []), - ), - ) - - // For the actively slid item, read neighbor IDs from preview store so we can - // mirror commit-time source continuity logic in filmstrip/waveform preview. - const slideLeftNeighborIdForSlidItem = useSlideEditPreviewStore( - useCallback((s) => (s.itemId === item.id ? s.leftNeighborId : null), [item.id]), - ) - const slideRightNeighborIdForSlidItem = useSlideEditPreviewStore( - useCallback((s) => (s.itemId === item.id ? s.rightNeighborId : null), [item.id]), - ) - const slideLeftNeighborForSlidItem = useItemsStore( - useCallback( - (s) => { - if (!slideLeftNeighborIdForSlidItem) return null - return s.itemById[slideLeftNeighborIdForSlidItem] ?? null - }, - [slideLeftNeighborIdForSlidItem], - ), - ) - const slideRightNeighborForSlidItem = useItemsStore( - useCallback( - (s) => { - if (!slideRightNeighborIdForSlidItem) return null - return s.itemById[slideRightNeighborIdForSlidItem] ?? null - }, - [slideRightNeighborIdForSlidItem], - ), - ) - const transitionDropGhost = useMemo(() => { if (!transitionDragPreview || !transitionDragPreviewRightClip) return null @@ -988,263 +616,39 @@ export const TimelineItem = memo( transitionDragPreviewRightClip, ]) - // Calculate position and width (convert frames to seconds, then to pixels) - // Clip edges stay at their true cut positions; transition bridges render as an overlay. - // Fold overlap + ripple + slide into the frame value BEFORE rounding so both clip edges - // derive from a single Math.round - avoids 1px gaps from independent rounding - // (Math.round(A) + Math.round(B) != Math.round(A + B)). - // - // Slide edit: the slid clip shifts by slideEditOffset. Neighbors adjust edges: - // - Left neighbor (slideNeighborSide==='left'): end edge extends/shrinks by slideNeighborDelta - // - Right neighbor (slideNeighborSide==='right'): start edge shifts by slideNeighborDelta - const slideFromOffset = - slideEditOffset + (slideNeighborSide === 'right' ? slideNeighborDelta : 0) - const slideDurationOffset = - (slideNeighborSide === 'left' ? slideNeighborDelta : 0) + - (slideNeighborSide === 'right' ? -slideNeighborDelta : 0) - - const leftFrame = previewBaseItem.from + slideFromOffset + rippleEditOffset + trackPushOffset - const rightFrame = - previewBaseItem.from + - previewBaseItem.durationInFrames + - slideDurationOffset + - slideFromOffset + - rippleEditOffset + - trackPushOffset - const left = Math.round(frameToPixelsNow(leftFrame)) - const right = Math.round(frameToPixelsNow(rightFrame)) - const width = right - left - - // Source FPS for converting source frames -> timeline frames (sourceStart etc. are in source-native FPS) - const effectiveSourceFps = previewBaseItem.sourceFps ?? fps - - // Preview item for clip internals (filmstrip/waveform) during edit drags. - const contentPreviewItem = useMemo(() => { - let nextItem = previewBaseItem - let previewStartTrimDelta = 0 - let previewEndTrimDelta = 0 - let previewDurationDelta = 0 - - // Active local trim (normal / rolling / ripple on trimmed item). - if (isTrimming && trimHandle) { - if (trimHandle === 'start') { - previewStartTrimDelta += trimDelta - previewDurationDelta += -trimDelta - } else { - previewEndTrimDelta += trimDelta - previewDurationDelta += trimDelta - } - } - - // Rolling neighbor preview (this item is the inverse-adjusted neighbor). - if (rollingEditDelta !== 0) { - if (rollingEditHandle === 'end') { - // Neighbor start handle equivalent. - previewStartTrimDelta += rollingEditDelta - previewDurationDelta += -rollingEditDelta - } else if (rollingEditHandle === 'start') { - // Neighbor end handle equivalent. - previewEndTrimDelta += rollingEditDelta - previewDurationDelta += rollingEditDelta - } - } - - // Slide neighbor preview (left adjusts end, right adjusts start). - if (slideNeighborSide && slideNeighborDelta !== 0) { - if (slideNeighborSide === 'right') { - previewStartTrimDelta += slideNeighborDelta - previewDurationDelta += -slideNeighborDelta - } else { - previewEndTrimDelta += slideNeighborDelta - previewDurationDelta += slideNeighborDelta - } - } + const { + left, + width, + visualLeftFrame, + visualWidthFrames, + visualLeft, + visualWidth, + isCompactWidth, + slideFromOffset, + contentPreviewItem, + preferImmediateContentRendering, + } = useTimelineItemBounds({ + previewBaseItem, + fps, + isTrimming, + trimHandle, + trimDelta, + isStretching, + stretchFeedback, + isSlipSlideActive, + slipEditDelta, + slideEditOffset, + slideNeighborSide, + slideNeighborDelta, + slideLeftNeighborForSlidItem, + slideRightNeighborForSlidItem, + rollingEditDelta, + rollingEditHandle, + rippleEditOffset, + rippleEdgeDelta, + trackPushOffset, + }) - // Slide continuity preview for split-contiguous chains: - // match slideItem commit logic so playback continuity stays correct in-drag. - if ((nextItem.type === 'video' || nextItem.type === 'audio') && slideEditOffset !== 0) { - const sourceDelta = computeSlideContinuitySourceDelta( - nextItem, - slideLeftNeighborForSlidItem, - slideRightNeighborForSlidItem, - slideEditOffset, - fps, - ) - if (sourceDelta !== 0 && nextItem.sourceEnd !== undefined) { - nextItem = { - ...nextItem, - sourceStart: (nextItem.sourceStart ?? 0) + sourceDelta, - sourceEnd: nextItem.sourceEnd + sourceDelta, - } - } - } - - if ( - (previewBaseItem.type === 'video' || previewBaseItem.type === 'audio') && - slipEditDelta !== 0 - ) { - const nextSourceStart = Math.max(0, (nextItem.sourceStart ?? 0) + slipEditDelta) - const nextSourceEnd = - nextItem.sourceEnd !== undefined - ? Math.max(nextSourceStart + 1, nextItem.sourceEnd + slipEditDelta) - : undefined - - nextItem = { - ...nextItem, - sourceStart: nextSourceStart, - sourceEnd: nextSourceEnd, - } - } - - // Composition wrappers clip their inner segments by sourceEnd/sourceStart, - // so treat them like video/audio for source-frame trims. - const isCompositionWrapper = - nextItem.type === 'composition' || (nextItem.type === 'audio' && !!nextItem.compositionId) - - // Start-trim equivalents shift sourceStart in source-frame units. - const supportsStartTrimSourceShift = - previewBaseItem.type === 'video' || previewBaseItem.type === 'audio' || isCompositionWrapper - if (supportsStartTrimSourceShift && previewStartTrimDelta !== 0) { - const sourceFramesDelta = timelineToSourceFrames( - previewStartTrimDelta, - nextItem.speed ?? 1, - fps, - effectiveSourceFps, - ) - nextItem = { - ...nextItem, - sourceStart: Math.max(0, (nextItem.sourceStart ?? 0) + sourceFramesDelta), - } - } - - if (previewDurationDelta !== 0) { - nextItem = { - ...nextItem, - durationInFrames: Math.max(1, nextItem.durationInFrames + previewDurationDelta), - } - } - - // Composition wrappers clip their inner segments by sourceEnd, so live - // end-trim needs sourceEnd bumped alongside durationInFrames — otherwise - // the filmstrip stops at the stale committed value while the clip grows. - if (isCompositionWrapper && previewEndTrimDelta !== 0 && nextItem.sourceEnd !== undefined) { - const endSourceFramesDelta = timelineToSourceFrames( - previewEndTrimDelta, - nextItem.speed ?? 1, - fps, - effectiveSourceFps, - ) - nextItem = { - ...nextItem, - sourceEnd: Math.max( - (nextItem.sourceStart ?? 0) + 1, - nextItem.sourceEnd + endSourceFramesDelta, - ), - } - } - - return nextItem - }, [ - previewBaseItem, - isTrimming, - trimHandle, - trimDelta, - rollingEditDelta, - rollingEditHandle, - slipEditDelta, - slideEditOffset, - slideNeighborSide, - slideNeighborDelta, - slideLeftNeighborForSlidItem, - slideRightNeighborForSlidItem, - fps, - effectiveSourceFps, - ]) - // During edit previews, prioritize visual sync over deferred rendering so - // filmstrip growth keeps up with the edit gesture. - const preferImmediateContentRendering = - isTrimming || - isSlipSlideActive || - rollingEditDelta !== 0 || - rippleEditOffset !== 0 || - rippleEdgeDelta !== 0 || - slideEditOffset !== 0 || - slideNeighborDelta !== 0 - - // Calculate visual positions during trim/stretch - const { visualLeftFrame, visualWidthFrames } = useMemo(() => { - let trimVisualLeftFrame = leftFrame - let trimVisualRightFrame = rightFrame - - // Ripple edit: compute the new right edge from frames - the SAME rounding - // path that downstream items use for their `left` - so both edges go through - // a single Math.round(timeToPixels(totalFrames / fps)) and can never diverge - // by even 1 px. `rippleEdgeDelta` equals the downstream `rippleEditOffset`. - if (rippleEdgeDelta !== 0) { - trimVisualRightFrame = - previewBaseItem.from + previewBaseItem.durationInFrames + rippleEdgeDelta - } else if (isTrimming && trimHandle) { - if (trimHandle === 'start') { - trimVisualLeftFrame = previewBaseItem.from + trimDelta - } else { - trimVisualRightFrame = previewBaseItem.from + previewBaseItem.durationInFrames + trimDelta - } - } - - // Rolling edit neighbor visual feedback - // Compute the shared boundary from absolute frame position (same path as anchor) - // to avoid sub-pixel divergence between the two clips. - if (rollingEditDelta !== 0) { - if (rollingEditHandle === 'end') { - // Trimmed item's end handle was dragged -- this neighbor's start adjusts - trimVisualLeftFrame = previewBaseItem.from + rollingEditDelta - } else if (rollingEditHandle === 'start') { - // Trimmed item's start handle was dragged -- this neighbor's end adjusts - trimVisualRightFrame = - previewBaseItem.from + previewBaseItem.durationInFrames + rollingEditDelta - } - } - - let stretchVisualLeftFrame = trimVisualLeftFrame - let stretchVisualRightFrame = trimVisualRightFrame - - if (isStretching && stretchFeedback) { - stretchVisualLeftFrame = stretchFeedback.from - stretchVisualRightFrame = stretchFeedback.from + stretchFeedback.duration - } - - const isActive = rippleEdgeDelta !== 0 || isTrimming || rollingEditDelta !== 0 - const nextVisualLeftFrame = isStretching - ? stretchVisualLeftFrame - : isActive - ? trimVisualLeftFrame - : leftFrame - const nextVisualRightFrame = isStretching - ? stretchVisualRightFrame - : isActive - ? trimVisualRightFrame - : rightFrame - - return { - visualLeftFrame: nextVisualLeftFrame, - visualWidthFrames: Math.max(1, nextVisualRightFrame - nextVisualLeftFrame), - } - }, [ - isTrimming, - trimHandle, - isStretching, - stretchFeedback, - previewBaseItem.from, - previewBaseItem.durationInFrames, - trimDelta, - rollingEditDelta, - rollingEditHandle, - rippleEdgeDelta, - leftFrame, - rightFrame, - ]) - const visualLeft = Math.round(frameToPixelsNow(visualLeftFrame)) - const visualWidth = Math.round(frameToPixelsNow(visualWidthFrames)) const transitionDropHitWidth = Math.min( TRANSITION_DROP_HIT_MAX_WIDTH_PX, Math.max( @@ -1253,9 +657,6 @@ export const TimelineItem = memo( ), ) const transitionDropHalfHitWidth = transitionDropHitWidth / 2 - // Early width check — used to short-circuit expensive computations below. - // The full useCompactClipShell (which also checks interaction/badge state) is computed later for JSX gating. - const isCompactWidth = visualWidth > 0 && visualWidth <= COMPACT_CLIP_MAX_WIDTH_PX const toolOperationOverlay = useMemo(() => { if (visualWidth <= 0) return null @@ -1571,7 +972,7 @@ export const TimelineItem = memo( selectItems(targetIds) } }, - [dragWasActiveRef, trackLocked, item.from, item.id], + [dragWasActiveRef, trackLocked, item.from, item.id, smartTrimIntentRef], ) // Double-click: open media in source monitor with clip's source range as I/O @@ -1617,144 +1018,6 @@ export const TimelineItem = memo( [trackLocked, item], ) - // Handle mouse move for edge hover detection - const hoveredEdgeRef = useRef(hoveredEdge) - hoveredEdgeRef.current = hoveredEdge - const smartTrimIntentRef = useRef(smartTrimIntent) - smartTrimIntentRef.current = smartTrimIntent - const smartBodyIntentRef = useRef(smartBodyIntent) - smartBodyIntentRef.current = smartBodyIntent - - const syncHoveredEdge = useCallback((nextHoveredEdge: 'start' | 'end' | null) => { - hoveredEdgeRef.current = nextHoveredEdge - setHoveredEdge(nextHoveredEdge) - }, []) - - const syncSmartTrimIntent = useCallback((nextIntent: SmartTrimIntent) => { - smartTrimIntentRef.current = nextIntent - setSmartTrimIntent(nextIntent) - }, []) - - const syncSmartBodyIntent = useCallback((nextIntent: SmartBodyIntent) => { - smartBodyIntentRef.current = nextIntent - setSmartBodyIntent(nextIntent) - }, []) - - const handleMouseMove = useCallback( - (e: React.MouseEvent) => { - if (trackLocked || activeToolRef.current === 'razor' || isAnyDragActiveRef.current) { - if (hoveredEdgeRef.current !== null) syncHoveredEdge(null) - if (smartTrimIntentRef.current !== null) syncSmartTrimIntent(null) - if (smartBodyIntentRef.current !== null) syncSmartBodyIntent(null) - return - } - - const rect = e.currentTarget.getBoundingClientRect() - const x = e.clientX - rect.left - const y = e.clientY - rect.top - const itemWidth = rect.width - - if (activeToolRef.current === 'trim-edit' || activeToolRef.current === 'select') { - const items = useTimelineStore.getState().items - const transitions = useTransitionsStore.getState().transitions - const hasLeftNeighbor = !!findHandleNeighborWithTransitions( - item, - 'start', - items, - transitions, - ) - const hasRightNeighbor = !!findHandleNeighborWithTransitions( - item, - 'end', - items, - transitions, - ) - const hasStartBridge = hasTransitionBridgeAtHandle(transitions, item.id, 'start') - const hasEndBridge = hasTransitionBridgeAtHandle(transitions, item.id, 'end') - const nextIntent = resolveSmartTrimIntent({ - x, - width: itemWidth, - hasLeftNeighbor, - hasRightNeighbor, - hasStartBridge, - hasEndBridge, - preferRippleOuterEdges: activeToolRef.current === 'trim-edit', - currentIntent: smartTrimIntentRef.current, - edgeZonePx: SMART_TRIM_EDGE_ZONE_PX, - rollZonePx: SMART_TRIM_ROLL_ZONE_PX, - retentionPx: SMART_TRIM_RETENTION_PX, - }) - const nextHoveredEdge = smartTrimIntentToHandle(nextIntent) - - if (smartTrimIntentRef.current !== nextIntent) { - const prevIntent = smartTrimIntentRef.current - syncSmartTrimIntent(nextIntent) - // Publish roll-hover neighbor so the adjacent item also shows its edge - if (nextIntent === 'roll-start') { - const neighbor = findHandleNeighborWithTransitions(item, 'start', items, transitions) - if (neighbor) useRollHoverStore.getState().setRollHover(item.id, neighbor.id, 'end') - } else if (nextIntent === 'roll-end') { - const neighbor = findHandleNeighborWithTransitions(item, 'end', items, transitions) - if (neighbor) useRollHoverStore.getState().setRollHover(item.id, neighbor.id, 'start') - } else if (prevIntent === 'roll-start' || prevIntent === 'roll-end') { - // Was rolling, no longer - clear - useRollHoverStore.getState().clearRollHover(item.id) - } - } - if (hoveredEdgeRef.current !== nextHoveredEdge) { - syncHoveredEdge(nextHoveredEdge) - } - - if (activeToolRef.current === 'select') { - if (smartBodyIntentRef.current !== null) syncSmartBodyIntent(null) - return - } - - if (nextIntent) { - if (smartBodyIntentRef.current !== null) syncSmartBodyIntent(null) - return - } - - const nextBodyIntent = resolveSmartBodyIntent({ - y, - height: rect.height, - labelRowHeight: getTimelineClipLabelRowHeightPx(e.currentTarget), - isMediaItem: - item.type === 'video' || item.type === 'audio' || item.type === 'composition', - currentIntent: smartBodyIntentRef.current, - }) - if (smartBodyIntentRef.current !== nextBodyIntent) { - syncSmartBodyIntent(nextBodyIntent) - } - return - } - - if (smartTrimIntentRef.current !== null) syncSmartTrimIntent(null) - if (smartBodyIntentRef.current !== null) syncSmartBodyIntent(null) - - if (activeToolRef.current === 'rate-stretch') { - if (hoveredEdgeRef.current !== null) syncHoveredEdge(null) - return - } - - if (x <= EDGE_HOVER_ZONE) { - if (hoveredEdgeRef.current !== 'start') syncHoveredEdge('start') - } else if (x >= itemWidth - EDGE_HOVER_ZONE) { - if (hoveredEdgeRef.current !== 'end') syncHoveredEdge('end') - } else { - if (hoveredEdgeRef.current !== null) syncHoveredEdge(null) - } - }, - [ - isAnyDragActiveRef, - item, - syncHoveredEdge, - syncSmartBodyIntent, - syncSmartTrimIntent, - trackLocked, - ], - ) - // Cursor class based on state const cursorClass = trackLocked ? 'cursor-not-allowed opacity-60' @@ -1765,1139 +1028,260 @@ export const TimelineItem = memo( ? 'cursor-trim-center' : (activeTool === 'trim-edit' || activeTool === 'select') && smartTrimIntent === 'roll-end' - ? 'cursor-trim-center' - : (activeTool === 'trim-edit' || activeTool === 'select') && - smartTrimIntent === 'ripple-start' - ? 'cursor-ripple-left' - : (activeTool === 'trim-edit' || activeTool === 'select') && - smartTrimIntent === 'ripple-end' - ? 'cursor-ripple-right' - : (activeTool === 'trim-edit' || activeTool === 'select') && - smartTrimIntent === 'trim-start' - ? 'cursor-trim-left' - : (activeTool === 'trim-edit' || activeTool === 'select') && - smartTrimIntent === 'trim-end' - ? 'cursor-trim-right' - : activeTool === 'trim-edit' && smartBodyIntent === 'slide-body' - ? 'cursor-slide-smart' - : activeTool === 'trim-edit' && smartBodyIntent === 'slip-body' - ? 'cursor-slip-smart' - : activeTool === 'trim-edit' && smartBodyIntent !== null - ? 'cursor-ew-resize' - : hoveredEdge !== null && activeTool === 'trim-edit' - ? 'cursor-ew-resize' - : activeTool === 'rate-stretch' - ? 'cursor-gauge' - : activeTool === 'slip' || activeTool === 'slide' - ? item.type === 'video' || - item.type === 'audio' || - item.type === 'composition' - ? 'cursor-ew-resize' - : 'cursor-not-allowed' - : isBeingDragged - ? 'cursor-grabbing' - : 'cursor-default' - - // Reactive neighbor detection: recompute join indicators when adjacent items - // change (covers deletion, moves to another track, and position shifts). - // Uses itemsByTrackId for O(trackItems) instead of O(allItems) lookup. - const neighborKey = useItemsStore( - useCallback( - (s) => { - const trackItems = s.itemsByTrackId[item.trackId] - if (!trackItems) return '|' - let leftId = '' - let rightId = '' - for (const other of trackItems) { - if (other.id === item.id) continue - if (other.from + other.durationInFrames === item.from) leftId = other.id - else if (other.from === item.from + item.durationInFrames) rightId = other.id - } - return leftId + '|' + rightId - }, - [item.id, item.trackId, item.from, item.durationInFrames], - ), - ) - - const getNeighbors = useCallback(() => { - const trackItems = useItemsStore.getState().itemsByTrackId[item.trackId] ?? [] - - const left = - trackItems.find( - (other) => other.id !== item.id && other.from + other.durationInFrames === item.from, - ) ?? null - - const right = - trackItems.find( - (other) => other.id !== item.id && other.from === item.from + item.durationInFrames, - ) ?? null - - return { - leftNeighbor: left, - rightNeighbor: right, - hasJoinableLeft: left ? canJoinItems(left, item) : false, - hasJoinableRight: right ? canJoinItems(item, right) : false, - } - }, [item]) - - // Recomputes when item props change OR when adjacent neighbor set changes - const { leftNeighbor, rightNeighbor, hasJoinableLeft, hasJoinableRight } = useMemo(() => { - void neighborKey - return getNeighbors() - }, [getNeighbors, neighborKey]) - - // Gap detection: clip has empty space before it (no strictly adjacent left neighbor) - const hasGapBefore = item.from > 0 && !leftNeighbor - - // Gap width in frames - lets the track-push affordance follow zoom through CSS - // variables without forcing the entire item shell to re-render on every wheel tick. - const gapBeforeFrames = useMemo(() => { - if (!hasGapBefore) return 0 - const trackItems = useItemsStore.getState().itemsByTrackId[item.trackId] ?? [] - let prevEnd = 0 - for (const ti of trackItems) { - if (ti.id === item.id) continue - const end = ti.from + ti.durationInFrames - if (end <= item.from && end > prevEnd) prevEnd = end - } - return Math.max(0, item.from - prevEnd) - }, [hasGapBefore, item.trackId, item.id, item.from]) - - const { - getCanJoinSelected, - getCanLinkSelected, - getCanUnlinkSelected, - hasSpeakableText, - isSceneDetectionActive, - isCompositionItem, - handleJoinSelected, - handleJoinLeft, - handleJoinRight, - handleDelete, - handleRippleDelete, - handleLinkSelected, - handleUnlinkSelected, - handleReverseSelected, - handleClearAllKeyframes, - handleClearPropertyKeyframes, - handleBentoLayout, - handleFreezeFrame, - handleGenerateAudioFromText, - handleCaptionsFromDialog, - handleApplyCaptionsFromTranscript, - handleCreatePreComp, - handleEnterComposition, - handleDissolveComposition, - handleDetectScenes, - handleRemoveSilence, - handleRemoveFillers, - isRemovingSilence, - isRemovingFillers, - } = useTimelineItemActions({ - item, - isBroken, - leftNeighbor, - rightNeighbor, - segmentOverlays, - }) - - const { - handleTransitionCutDragOver, - handleTransitionCutDragLeave, - handleTransitionCutDrop, - handleEffectDragEnter, - handleEffectDragOver, - handleEffectDragLeave, - handleEffectDrop, - } = useTimelineItemDropHandlers({ - item, - trackLocked, - addEffects, - }) - - // Composition operations - const isVisualFadeItem = supportsVisualFadeControls(item) - const [videoFadeEdit, setVideoFadeEdit] = useState<{ - handle: AudioFadeHandle - previewFadeIn: number - previewFadeOut: number - originalFadeIn: number - originalFadeOut: number - isCommitting: boolean - } | null>(null) - const videoFadeEditRef = useRef(videoFadeEdit) - videoFadeEditRef.current = videoFadeEdit - const videoFadeCleanupRef = useRef<(() => void) | null>(null) - const [audioFadeEdit, setAudioFadeEdit] = useState<{ - handle: AudioFadeHandle - previewFadeIn: number - previewFadeOut: number - originalFadeIn: number - originalFadeOut: number - isCommitting: boolean - } | null>(null) - const audioFadeEditRef = useRef(audioFadeEdit) - audioFadeEditRef.current = audioFadeEdit - const audioFadeCleanupRef = useRef<(() => void) | null>(null) - const [audioFadeCurveEdit, setAudioFadeCurveEdit] = useState<{ - handle: AudioFadeHandle - previewFadeInCurve: number - previewFadeOutCurve: number - previewFadeInCurveX: number - previewFadeOutCurveX: number - originalFadeInCurve: number - originalFadeOutCurve: number - originalFadeInCurveX: number - originalFadeOutCurveX: number - isCommitting: boolean - } | null>(null) - const audioFadeCurveEditRef = useRef(audioFadeCurveEdit) - audioFadeCurveEditRef.current = audioFadeCurveEdit - const audioFadeCurveCleanupRef = useRef<(() => void) | null>(null) - const [audioVolumeEdit, setAudioVolumeEdit] = useState<{ - originalVolume: number - isCommitting: boolean - } | null>(null) - const audioVolumeCleanupRef = useRef<(() => void) | null>(null) - const audioVolumePreviewRef = useRef(item.type === 'audio' ? (item.volume ?? 0) : 0) - const audioVolumeEditLabelRef = useRef(null) - useEffect( - () => () => { - videoFadeCleanupRef.current?.() - audioFadeCleanupRef.current?.() - audioFadeCurveCleanupRef.current?.() - audioVolumeCleanupRef.current?.() - }, - [], - ) - const displayedVideoFadeIn = isVisualFadeItem - ? (videoFadeEdit?.previewFadeIn ?? item.fadeIn ?? 0) - : 0 - const displayedVideoFadeOut = isVisualFadeItem - ? (videoFadeEdit?.previewFadeOut ?? item.fadeOut ?? 0) - : 0 - const displayedAudioFadeIn = - item.type === 'audio' ? (audioFadeEdit?.previewFadeIn ?? item.audioFadeIn ?? 0) : 0 - const displayedAudioFadeOut = - item.type === 'audio' ? (audioFadeEdit?.previewFadeOut ?? item.audioFadeOut ?? 0) : 0 - const displayedAudioFadeInCurve = - item.type === 'audio' - ? (audioFadeCurveEdit?.previewFadeInCurve ?? item.audioFadeInCurve ?? 0) - : 0 - const displayedAudioFadeOutCurve = - item.type === 'audio' - ? (audioFadeCurveEdit?.previewFadeOutCurve ?? item.audioFadeOutCurve ?? 0) - : 0 - const displayedAudioFadeInCurveX = - item.type === 'audio' - ? (audioFadeCurveEdit?.previewFadeInCurveX ?? item.audioFadeInCurveX ?? 0.52) - : 0.52 - const displayedAudioFadeOutCurveX = - item.type === 'audio' - ? (audioFadeCurveEdit?.previewFadeOutCurveX ?? item.audioFadeOutCurveX ?? 0.52) - : 0.52 - const displayedAudioVolumeDb = item.type === 'audio' ? (item.volume ?? 0) : 0 - // Hoisted before fade memos so the compact guard can account for active interactions. - // A narrow clip that is selected/edited should still compute its fade ratios. - const hasActiveClipInteraction = - isSelected || - isBeingDragged || - isPartOfDrag || - isTrimming || - isStretching || - isSlipSlideActive || - isTrackPushActive || - isEffectDropTarget || - videoFadeEdit !== null || - audioFadeEdit !== null || - audioFadeCurveEdit !== null || - audioVolumeEdit !== null || - transitionDropGhost !== null || - draggedTransition !== null || - pointerHint !== null || - hoveredEdge !== null || - smartTrimIntent !== null || - smartBodyIntent !== null || - rollHoverEdge !== null || - activeEdges !== null - const skipFadeComputation = isCompactWidth && !hasActiveClipInteraction - const clipFadeDurationFrames = Math.max(1, Math.round(visualWidthFrames)) - const videoFadeInRatio = useMemo( - () => - skipFadeComputation - ? 0 - : isVisualFadeItem - ? getAudioFadeRatio(displayedVideoFadeIn, fps, clipFadeDurationFrames) - : 0, - [skipFadeComputation, clipFadeDurationFrames, displayedVideoFadeIn, fps, isVisualFadeItem], - ) - const videoFadeOutRatio = useMemo( - () => - skipFadeComputation - ? 0 - : isVisualFadeItem - ? getAudioFadeRatio(displayedVideoFadeOut, fps, clipFadeDurationFrames) - : 0, - [skipFadeComputation, clipFadeDurationFrames, displayedVideoFadeOut, fps, isVisualFadeItem], - ) - const videoFadeLineYPercent = 50 - const audioFadeInRatio = useMemo( - () => - skipFadeComputation - ? 0 - : item.type === 'audio' - ? getAudioFadeRatio(displayedAudioFadeIn, fps, clipFadeDurationFrames) - : 0, - [skipFadeComputation, clipFadeDurationFrames, displayedAudioFadeIn, fps, item.type], - ) - const audioFadeOutRatio = useMemo( - () => - skipFadeComputation - ? 0 - : item.type === 'audio' - ? getAudioFadeRatio(displayedAudioFadeOut, fps, clipFadeDurationFrames) - : 0, - [skipFadeComputation, clipFadeDurationFrames, displayedAudioFadeOut, fps, item.type], - ) - const audioFadeInHoverLabel = useMemo( - () => (skipFadeComputation ? '' : `Fade In ${displayedAudioFadeIn.toFixed(2)}s`), - [skipFadeComputation, displayedAudioFadeIn], - ) - const audioFadeOutHoverLabel = useMemo( - () => (skipFadeComputation ? '' : `Fade Out ${displayedAudioFadeOut.toFixed(2)}s`), - [skipFadeComputation, displayedAudioFadeOut], - ) - const videoFadeInHoverLabel = useMemo( - () => (skipFadeComputation ? '' : `Fade In ${displayedVideoFadeIn.toFixed(2)}s`), - [skipFadeComputation, displayedVideoFadeIn], - ) - const videoFadeOutHoverLabel = useMemo( - () => (skipFadeComputation ? '' : `Fade Out ${displayedVideoFadeOut.toFixed(2)}s`), - [skipFadeComputation, displayedVideoFadeOut], - ) - const audioVolumeEditLabel = useMemo(() => { - if (skipFadeComputation || !audioVolumeEdit) return null - const previewVolume = audioVolumePreviewRef.current - return `Volume ${previewVolume >= 0 ? '+' : ''}${previewVolume.toFixed(1)} dB` - }, [skipFadeComputation, audioVolumeEdit]) - const audioVolumeLineY = useMemo( - () => - item.type === 'audio' - ? getAudioVolumeLineY(displayedAudioVolumeDb, AUDIO_ENVELOPE_VIEWBOX_HEIGHT) - : AUDIO_ENVELOPE_VIEWBOX_HEIGHT / 2, - [displayedAudioVolumeDb, item.type], - ) - const audioVisualizationScale = useMemo( - () => (item.type === 'audio' ? getAudioVisualizationScale(displayedAudioVolumeDb) : 1), - [displayedAudioVolumeDb, item.type], - ) - const audioVolumeLineYPercent = useMemo( - () => (audioVolumeLineY / AUDIO_ENVELOPE_VIEWBOX_HEIGHT) * 100, - [audioVolumeLineY], - ) - const isAudioVolumeControlActive = - item.type === 'audio' && (isSelected || audioVolumeEdit !== null) - const audioVolumeLineStroke = isAudioVolumeControlActive - ? 'rgba(255,255,255,0.72)' - : 'rgba(255,255,255,0.42)' - const audioFadeInViewboxWidth = audioFadeInRatio * FADE_VIEWBOX_WIDTH - const audioFadeOutViewboxWidth = audioFadeOutRatio * FADE_VIEWBOX_WIDTH - const videoFadeInViewboxWidth = videoFadeInRatio * FADE_VIEWBOX_WIDTH - const videoFadeOutViewboxWidth = videoFadeOutRatio * FADE_VIEWBOX_WIDTH - const audioFadeInCurvePoint = useMemo( - () => - skipFadeComputation - ? null - : getAudioFadeCurveControlPoint({ - handle: 'in', - fadePixels: audioFadeInViewboxWidth, - clipWidthPixels: FADE_VIEWBOX_WIDTH, - curve: displayedAudioFadeInCurve, - curveX: displayedAudioFadeInCurveX, - }), - [ - skipFadeComputation, - audioFadeInViewboxWidth, - displayedAudioFadeInCurve, - displayedAudioFadeInCurveX, - ], - ) - const audioFadeOutCurvePoint = useMemo( - () => - skipFadeComputation - ? null - : getAudioFadeCurveControlPoint({ - handle: 'out', - fadePixels: audioFadeOutViewboxWidth, - clipWidthPixels: FADE_VIEWBOX_WIDTH, - curve: displayedAudioFadeOutCurve, - curveX: displayedAudioFadeOutCurveX, - }), - [ - skipFadeComputation, - audioFadeOutViewboxWidth, - displayedAudioFadeOutCurve, - displayedAudioFadeOutCurveX, - ], - ) - const audioFadeInCurvePath = useMemo( - () => - skipFadeComputation - ? '' - : getAudioFadeCurvePath({ - handle: 'in', - fadePixels: audioFadeInViewboxWidth, - clipWidthPixels: FADE_VIEWBOX_WIDTH, - curve: displayedAudioFadeInCurve, - curveX: displayedAudioFadeInCurveX, - }), - [ - skipFadeComputation, - audioFadeInViewboxWidth, - displayedAudioFadeInCurve, - displayedAudioFadeInCurveX, - ], - ) - const audioFadeOutCurvePath = useMemo( - () => - skipFadeComputation - ? '' - : getAudioFadeCurvePath({ - handle: 'out', - fadePixels: audioFadeOutViewboxWidth, - clipWidthPixels: FADE_VIEWBOX_WIDTH, - curve: displayedAudioFadeOutCurve, - curveX: displayedAudioFadeOutCurveX, - }), - [ - skipFadeComputation, - audioFadeOutViewboxWidth, - displayedAudioFadeOutCurve, - displayedAudioFadeOutCurveX, - ], - ) - const videoFadeInPath = useMemo( - () => - skipFadeComputation - ? '' - : getAudioFadeCurvePath({ - handle: 'in', - fadePixels: videoFadeInViewboxWidth, - clipWidthPixels: FADE_VIEWBOX_WIDTH, - curve: 0, - curveX: 0.52, - }), - [skipFadeComputation, videoFadeInViewboxWidth], - ) - const videoFadeOutPath = useMemo( - () => - skipFadeComputation - ? '' - : getAudioFadeCurvePath({ - handle: 'out', - fadePixels: videoFadeOutViewboxWidth, - clipWidthPixels: FADE_VIEWBOX_WIDTH, - curve: 0, - curveX: 0.52, - }), - [skipFadeComputation, videoFadeOutViewboxWidth], - ) - const videoControlsRef = useRef(null) - const audioControlsRef = useRef(null) - const volumeLineRef = useRef(null) - const snapVolumeLineTop = useCallback((ratio: number) => { - const line = volumeLineRef.current - const container = audioControlsRef.current - if (!line || !container) return - const rect = container.getBoundingClientRect() - if (rect.height <= 0) return - const docY = rect.top + rect.height * ratio - line.style.top = `${Math.round(docY) - rect.top}px` - }, []) - const applyAudioVolumeVisualPreview = useCallback( - (previewVolumeDb: number) => { - audioVolumePreviewRef.current = previewVolumeDb - - if (transformRef.current) { - transformRef.current.style.setProperty( - '--timeline-audio-volume-line-y', - `${(getAudioVolumeLineY(previewVolumeDb, AUDIO_ENVELOPE_VIEWBOX_HEIGHT) / AUDIO_ENVELOPE_VIEWBOX_HEIGHT) * 100}%`, - ) - transformRef.current.style.setProperty( - '--timeline-audio-waveform-scale', - String(getAudioVisualizationScale(previewVolumeDb)), - ) - } - - snapVolumeLineTop( - getAudioVolumeLineY(previewVolumeDb, AUDIO_ENVELOPE_VIEWBOX_HEIGHT) / - AUDIO_ENVELOPE_VIEWBOX_HEIGHT, - ) - - if (audioVolumeEditLabelRef.current) { - audioVolumeEditLabelRef.current.textContent = `Volume ${previewVolumeDb >= 0 ? '+' : ''}${previewVolumeDb.toFixed(1)} dB` - } - }, - [snapVolumeLineTop], - ) - const itemType = item.type - const itemVolume = item.volume - useEffect(() => { - if (itemType !== 'audio' || audioVolumeEdit !== null) { - return - } - - applyAudioVolumeVisualPreview(itemVolume ?? 0) - }, [applyAudioVolumeVisualPreview, audioVolumeEdit, itemType, itemVolume]) - useLayoutEffect(() => { - if (itemType !== 'audio') return - const container = audioControlsRef.current - if (!container) return - const ratio = audioVolumeLineY / AUDIO_ENVELOPE_VIEWBOX_HEIGHT - snapVolumeLineTop(ratio) - const ro = new ResizeObserver(() => snapVolumeLineTop(ratio)) - ro.observe(container) - return () => ro.disconnect() - }, [itemType, audioVolumeLineY, snapVolumeLineTop]) - const finalizeAudioVolumeChange = useCallback( - ( - nextVolume: number, - options?: { - preserveLiveGainOnCommit?: boolean - commitFromActiveEdit?: boolean - }, - ) => { - if (item.type !== 'audio') { - return - } - - const currentVolume = item.volume ?? 0 - const didChange = Math.abs(currentVolume - nextVolume) > AUDIO_VOLUME_EPSILON - - applyAudioVolumeVisualPreview(nextVolume) - - if (!didChange || !options?.preserveLiveGainOnCommit) { - clearMixerLiveGain(item.id) - } - - if (!didChange) { - setAudioVolumeEdit(null) - return - } - - if (options?.commitFromActiveEdit) { - setAudioVolumeEdit((prev) => (prev ? { ...prev, isCommitting: true } : prev)) - } else { - setAudioVolumeEdit(null) - } - - updateTimelineItem(item.id, { volume: nextVolume }) - }, - [applyAudioVolumeVisualPreview, item, updateTimelineItem], - ) - useEffect(() => { - if (!videoFadeEdit?.isCommitting || !isVisualFadeItem) { - return - } - - const committedFade = videoFadeEdit.handle === 'in' ? (item.fadeIn ?? 0) : (item.fadeOut ?? 0) - const previewFade = - videoFadeEdit.handle === 'in' ? videoFadeEdit.previewFadeIn : videoFadeEdit.previewFadeOut - - if (Math.abs(committedFade - previewFade) <= VIDEO_FADE_EPSILON) { - setVideoFadeEdit(null) - } - }, [isVisualFadeItem, item, videoFadeEdit]) - useEffect(() => { - if (!audioFadeEdit?.isCommitting || item.type !== 'audio') { - return - } - - const committedFade = - audioFadeEdit.handle === 'in' ? (item.audioFadeIn ?? 0) : (item.audioFadeOut ?? 0) - const previewFade = - audioFadeEdit.handle === 'in' ? audioFadeEdit.previewFadeIn : audioFadeEdit.previewFadeOut - - if (Math.abs(committedFade - previewFade) <= AUDIO_FADE_EPSILON) { - setAudioFadeEdit(null) - } - }, [audioFadeEdit, item]) - useEffect(() => { - if (!audioVolumeEdit?.isCommitting || item.type !== 'audio') { - return - } - - if (Math.abs((item.volume ?? 0) - audioVolumePreviewRef.current) <= AUDIO_VOLUME_EPSILON) { - setAudioVolumeEdit(null) - } - }, [audioVolumeEdit, item]) - - useEffect(() => { - if (!audioFadeCurveEdit?.isCommitting || item.type !== 'audio') { - return - } - - const committedCurve = - audioFadeCurveEdit.handle === 'in' - ? (item.audioFadeInCurve ?? 0) - : (item.audioFadeOutCurve ?? 0) - const previewCurve = - audioFadeCurveEdit.handle === 'in' - ? audioFadeCurveEdit.previewFadeInCurve - : audioFadeCurveEdit.previewFadeOutCurve - const committedCurveX = - audioFadeCurveEdit.handle === 'in' - ? (item.audioFadeInCurveX ?? 0.52) - : (item.audioFadeOutCurveX ?? 0.52) - const previewCurveX = - audioFadeCurveEdit.handle === 'in' - ? audioFadeCurveEdit.previewFadeInCurveX - : audioFadeCurveEdit.previewFadeOutCurveX - - if ( - Math.abs(committedCurve - previewCurve) <= AUDIO_FADE_EPSILON && - Math.abs(committedCurveX - previewCurveX) <= AUDIO_FADE_EPSILON - ) { - setAudioFadeCurveEdit(null) - } - }, [audioFadeCurveEdit, item]) - const handleVideoFadeHandleMouseDown = useCallback( - (e: React.MouseEvent, handle: AudioFadeHandle) => { - if (e.button !== 0) return - if ( - !isVisualFadeItem || - trackLocked || - activeTool !== 'select' || - isAnyDragActiveRef.current - ) { - return - } - - e.preventDefault() - e.stopPropagation() - - const originalFadeIn = displayedVideoFadeIn - const originalFadeOut = displayedVideoFadeOut - const persistedFadeIn = item.fadeIn ?? 0 - const persistedFadeOut = item.fadeOut ?? 0 - const computeFadeSeconds = (clientX: number) => { - const rect = - videoControlsRef.current?.getBoundingClientRect() ?? - transformRef.current?.getBoundingClientRect() - if (!rect) { - return handle === 'in' ? originalFadeIn : originalFadeOut - } - - return getAudioFadeSecondsFromOffset({ - handle, - clipWidthPixels: rect.width, - pointerOffsetPixels: clientX - rect.left, - fps, - maxDurationFrames: item.durationInFrames, - }) - } - - const applyPreview = (nextFadeSeconds: number) => { - setVideoFadeEdit({ - handle, - previewFadeIn: handle === 'in' ? nextFadeSeconds : originalFadeIn, - previewFadeOut: handle === 'out' ? nextFadeSeconds : originalFadeOut, - originalFadeIn, - originalFadeOut, - isCommitting: false, - }) - } - - const finishEdit = () => { - const latestState = videoFadeEditRef.current - const committedFade = - handle === 'in' - ? (latestState?.previewFadeIn ?? originalFadeIn) - : (latestState?.previewFadeOut ?? originalFadeOut) - videoFadeCleanupRef.current?.() - videoFadeCleanupRef.current = null - - if (handle === 'in') { - if (Math.abs(committedFade - persistedFadeIn) > VIDEO_FADE_EPSILON) { - setVideoFadeEdit((prev) => (prev ? { ...prev, isCommitting: true } : prev)) - updateTimelineItem(item.id, { fadeIn: committedFade }) - } else { - setVideoFadeEdit(null) - } - } else if (Math.abs(committedFade - persistedFadeOut) > VIDEO_FADE_EPSILON) { - setVideoFadeEdit((prev) => (prev ? { ...prev, isCommitting: true } : prev)) - updateTimelineItem(item.id, { fadeOut: committedFade }) - } else { - setVideoFadeEdit(null) - } - } - - applyPreview(computeFadeSeconds(e.clientX)) - - const handleWindowMouseMove = (event: MouseEvent) => { - applyPreview(computeFadeSeconds(event.clientX)) - } - const handleWindowMouseUp = () => { - finishEdit() - } - - window.addEventListener('mousemove', handleWindowMouseMove) - window.addEventListener('mouseup', handleWindowMouseUp, { once: true }) - videoFadeCleanupRef.current = () => { - window.removeEventListener('mousemove', handleWindowMouseMove) - window.removeEventListener('mouseup', handleWindowMouseUp) - } - }, - [ - activeTool, - displayedVideoFadeIn, - displayedVideoFadeOut, - fps, - isAnyDragActiveRef, - isVisualFadeItem, - item, - trackLocked, - updateTimelineItem, - ], - ) - const handleAudioFadeHandleMouseDown = useCallback( - (e: React.MouseEvent, handle: AudioFadeHandle) => { - if ( - item.type !== 'audio' || - trackLocked || - activeTool !== 'select' || - isAnyDragActiveRef.current - ) { - return - } - - e.preventDefault() - e.stopPropagation() - - const originalFadeIn = displayedAudioFadeIn - const originalFadeOut = displayedAudioFadeOut - const persistedFadeIn = item.audioFadeIn ?? 0 - const persistedFadeOut = item.audioFadeOut ?? 0 - const computeFadeSeconds = (clientX: number) => { - const rect = - audioControlsRef.current?.getBoundingClientRect() ?? - transformRef.current?.getBoundingClientRect() - if (!rect) { - return handle === 'in' ? originalFadeIn : originalFadeOut - } - - return getAudioFadeSecondsFromOffset({ - handle, - clipWidthPixels: rect.width, - pointerOffsetPixels: clientX - rect.left, - fps, - maxDurationFrames: item.durationInFrames, - }) - } - - const applyPreview = (nextFadeSeconds: number) => { - setAudioFadeEdit({ - handle, - previewFadeIn: handle === 'in' ? nextFadeSeconds : originalFadeIn, - previewFadeOut: handle === 'out' ? nextFadeSeconds : originalFadeOut, - originalFadeIn, - originalFadeOut, - isCommitting: false, - }) - } - - const finishEdit = () => { - const latestState = audioFadeEditRef.current - const committedFade = - handle === 'in' - ? (latestState?.previewFadeIn ?? originalFadeIn) - : (latestState?.previewFadeOut ?? originalFadeOut) - audioFadeCleanupRef.current?.() - audioFadeCleanupRef.current = null - - if (handle === 'in') { - if (Math.abs(committedFade - persistedFadeIn) > AUDIO_FADE_EPSILON) { - setAudioFadeEdit((prev) => (prev ? { ...prev, isCommitting: true } : prev)) - updateTimelineItem(item.id, { audioFadeIn: committedFade }) - } else { - setAudioFadeEdit(null) - } - } else if (Math.abs(committedFade - persistedFadeOut) > AUDIO_FADE_EPSILON) { - setAudioFadeEdit((prev) => (prev ? { ...prev, isCommitting: true } : prev)) - updateTimelineItem(item.id, { audioFadeOut: committedFade }) - } else { - setAudioFadeEdit(null) - } - } - - applyPreview(computeFadeSeconds(e.clientX)) - - const handleWindowMouseMove = (event: MouseEvent) => { - applyPreview(computeFadeSeconds(event.clientX)) - } - const handleWindowMouseUp = () => { - finishEdit() - } - - window.addEventListener('mousemove', handleWindowMouseMove) - window.addEventListener('mouseup', handleWindowMouseUp, { once: true }) - audioFadeCleanupRef.current = () => { - window.removeEventListener('mousemove', handleWindowMouseMove) - window.removeEventListener('mouseup', handleWindowMouseUp) - } - }, - [ - activeTool, - displayedAudioFadeIn, - displayedAudioFadeOut, - fps, - isAnyDragActiveRef, - item, - trackLocked, - updateTimelineItem, - ], - ) - const handleAudioFadeCurveDotMouseDown = useCallback( - (e: React.MouseEvent, handle: AudioFadeHandle) => { - if ( - item.type !== 'audio' || - trackLocked || - activeTool !== 'select' || - isAnyDragActiveRef.current - ) { - return - } - - const fadeRatio = handle === 'in' ? audioFadeInRatio : audioFadeOutRatio - if (fadeRatio <= 0) { - return - } - - e.preventDefault() - e.stopPropagation() - - const originalFadeInCurve = displayedAudioFadeInCurve - const originalFadeOutCurve = displayedAudioFadeOutCurve - const originalFadeInCurveX = displayedAudioFadeInCurveX - const originalFadeOutCurveX = displayedAudioFadeOutCurveX - const persistedFadeInCurve = item.audioFadeInCurve ?? 0 - const persistedFadeOutCurve = item.audioFadeOutCurve ?? 0 - const persistedFadeInCurveX = item.audioFadeInCurveX ?? 0.52 - const persistedFadeOutCurveX = item.audioFadeOutCurveX ?? 0.52 - - const computeCurve = (clientX: number, clientY: number) => { - const rect = audioControlsRef.current?.getBoundingClientRect() - if (!rect) { - return { - curve: handle === 'in' ? originalFadeInCurve : originalFadeOutCurve, - curveX: handle === 'in' ? originalFadeInCurveX : originalFadeOutCurveX, - } - } - - return getAudioFadeCurveFromOffset({ - handle, - pointerOffsetX: clientX - rect.left, - pointerOffsetY: clientY - rect.top, - fadePixels: fadeRatio * rect.width, - clipWidthPixels: rect.width, - rowHeight: rect.height, - }) - } - - const applyPreview = (next: { curve: number; curveX: number }) => { - setAudioFadeCurveEdit({ - handle, - previewFadeInCurve: handle === 'in' ? next.curve : originalFadeInCurve, - previewFadeOutCurve: handle === 'out' ? next.curve : originalFadeOutCurve, - previewFadeInCurveX: handle === 'in' ? next.curveX : originalFadeInCurveX, - previewFadeOutCurveX: handle === 'out' ? next.curveX : originalFadeOutCurveX, - originalFadeInCurve, - originalFadeOutCurve, - originalFadeInCurveX, - originalFadeOutCurveX, - isCommitting: false, - }) - } - - const finishEdit = () => { - const latestState = audioFadeCurveEditRef.current - const committedCurve = - handle === 'in' - ? (latestState?.previewFadeInCurve ?? originalFadeInCurve) - : (latestState?.previewFadeOutCurve ?? originalFadeOutCurve) - const committedCurveX = - handle === 'in' - ? (latestState?.previewFadeInCurveX ?? originalFadeInCurveX) - : (latestState?.previewFadeOutCurveX ?? originalFadeOutCurveX) - audioFadeCurveCleanupRef.current?.() - audioFadeCurveCleanupRef.current = null - - if (handle === 'in') { - if ( - Math.abs(committedCurve - persistedFadeInCurve) > AUDIO_FADE_EPSILON || - Math.abs(committedCurveX - persistedFadeInCurveX) > AUDIO_FADE_EPSILON - ) { - setAudioFadeCurveEdit((prev) => (prev ? { ...prev, isCommitting: true } : prev)) - updateTimelineItem(item.id, { - audioFadeInCurve: committedCurve, - audioFadeInCurveX: committedCurveX, - }) - } else { - setAudioFadeCurveEdit(null) - } - } else if ( - Math.abs(committedCurve - persistedFadeOutCurve) > AUDIO_FADE_EPSILON || - Math.abs(committedCurveX - persistedFadeOutCurveX) > AUDIO_FADE_EPSILON - ) { - setAudioFadeCurveEdit((prev) => (prev ? { ...prev, isCommitting: true } : prev)) - updateTimelineItem(item.id, { - audioFadeOutCurve: committedCurve, - audioFadeOutCurveX: committedCurveX, - }) - } else { - setAudioFadeCurveEdit(null) - } - } - - applyPreview(computeCurve(e.clientX, e.clientY)) - - const handleWindowMouseMove = (event: MouseEvent) => { - applyPreview(computeCurve(event.clientX, event.clientY)) - } - const handleWindowMouseUp = () => { - finishEdit() - } - - window.addEventListener('mousemove', handleWindowMouseMove) - window.addEventListener('mouseup', handleWindowMouseUp, { once: true }) - audioFadeCurveCleanupRef.current = () => { - window.removeEventListener('mousemove', handleWindowMouseMove) - window.removeEventListener('mouseup', handleWindowMouseUp) - } - }, - [ - activeTool, - audioFadeInRatio, - audioFadeOutRatio, - displayedAudioFadeInCurve, - displayedAudioFadeInCurveX, - displayedAudioFadeOutCurve, - displayedAudioFadeOutCurveX, - isAnyDragActiveRef, - item, - trackLocked, - updateTimelineItem, - ], - ) - const handleAudioVolumeMouseDown = useCallback( - (e: React.MouseEvent) => { - if ( - item.type !== 'audio' || - trackLocked || - activeTool !== 'select' || - isAnyDragActiveRef.current - ) { - return - } - - e.preventDefault() - e.stopPropagation() - - const originalVolume = item.volume ?? 0 - const dragStartLiveGain = getMixerLiveGain(item.id) - const startClientY = e.clientY - let latestClientY = startClientY - let latestPreviewVolume = originalVolume - let isDragActive = false - let activationTimeoutId: number | null = null - const dragAnchorY = startClientY - const dragAnchorVolume = originalVolume - - const applyPreview = (nextVolume: number) => { - latestPreviewVolume = nextVolume - applyAudioVolumeVisualPreview(nextVolume) - // Real-time audio feedback via live gain (no store write / no composition re-render) - const gainRatio = Math.pow(10, (nextVolume - originalVolume) / 20) - setMixerLiveGains([{ itemId: item.id, gain: dragStartLiveGain * gainRatio }]) - } - - const clearActivationTimeout = () => { - if (activationTimeoutId !== null) { - window.clearTimeout(activationTimeoutId) - activationTimeoutId = null - } - } - - const computeVolumeDb = (clientY: number) => { - const rect = audioControlsRef.current?.getBoundingClientRect() - if (!rect) { - return originalVolume - } - - return getAudioVolumeDbFromDragDelta({ - startVolumeDb: dragAnchorVolume, - pointerDeltaY: clientY - dragAnchorY, - height: rect.height, - }) - } - - const activateDrag = () => { - if (isDragActive) { - return - } - - isDragActive = true - setAudioVolumeEdit({ - originalVolume, - isCommitting: false, - }) - applyPreview(computeVolumeDb(latestClientY)) - } - - const finishEdit = () => { - const committedVolume = audioVolumePreviewRef.current ?? latestPreviewVolume - audioVolumeCleanupRef.current?.() - audioVolumeCleanupRef.current = null - // Keep live gain active - segment volumeDb is stale until composition - // naturally re-renders, and the audio component auto-clears via useEffect. - - finalizeAudioVolumeChange(committedVolume, { - preserveLiveGainOnCommit: true, - commitFromActiveEdit: true, - }) - } - - const handleWindowMouseMove = (event: MouseEvent) => { - latestClientY = event.clientY - - if (!isDragActive) { - if (Math.abs(event.clientY - startClientY) < AUDIO_VOLUME_DRAG_ACTIVATION_DISTANCE_PX) { - return - } - - clearActivationTimeout() - activateDrag() - return - } + ? 'cursor-trim-center' + : (activeTool === 'trim-edit' || activeTool === 'select') && + smartTrimIntent === 'ripple-start' + ? 'cursor-ripple-left' + : (activeTool === 'trim-edit' || activeTool === 'select') && + smartTrimIntent === 'ripple-end' + ? 'cursor-ripple-right' + : (activeTool === 'trim-edit' || activeTool === 'select') && + smartTrimIntent === 'trim-start' + ? 'cursor-trim-left' + : (activeTool === 'trim-edit' || activeTool === 'select') && + smartTrimIntent === 'trim-end' + ? 'cursor-trim-right' + : activeTool === 'trim-edit' && smartBodyIntent === 'slide-body' + ? 'cursor-slide-smart' + : activeTool === 'trim-edit' && smartBodyIntent === 'slip-body' + ? 'cursor-slip-smart' + : activeTool === 'trim-edit' && smartBodyIntent !== null + ? 'cursor-ew-resize' + : hoveredEdge !== null && activeTool === 'trim-edit' + ? 'cursor-ew-resize' + : activeTool === 'rate-stretch' + ? 'cursor-gauge' + : activeTool === 'slip' || activeTool === 'slide' + ? item.type === 'video' || + item.type === 'audio' || + item.type === 'composition' + ? 'cursor-ew-resize' + : 'cursor-not-allowed' + : isBeingDragged + ? 'cursor-grabbing' + : 'cursor-default' - applyPreview(computeVolumeDb(event.clientY)) - } - const handleWindowMouseUp = () => { - if (!isDragActive) { - audioVolumeCleanupRef.current?.() - audioVolumeCleanupRef.current = null - finalizeAudioVolumeChange(originalVolume) - return + // Reactive neighbor detection: recompute join indicators when adjacent items + // change (covers deletion, moves to another track, and position shifts). + // Uses itemsByTrackId for O(trackItems) instead of O(allItems) lookup. + const neighborKey = useItemsStore( + useCallback( + (s) => { + const trackItems = s.itemsByTrackId[item.trackId] + if (!trackItems) return '|' + let leftId = '' + let rightId = '' + for (const other of trackItems) { + if (other.id === item.id) continue + if (other.from + other.durationInFrames === item.from) leftId = other.id + else if (other.from === item.from + item.durationInFrames) rightId = other.id } - - finishEdit() - } - - window.addEventListener('mousemove', handleWindowMouseMove) - window.addEventListener('mouseup', handleWindowMouseUp, { once: true }) - activationTimeoutId = window.setTimeout(() => { - clearActivationTimeout() - activateDrag() - }, AUDIO_VOLUME_DRAG_ACTIVATION_DELAY_MS) - audioVolumeCleanupRef.current = () => { - clearActivationTimeout() - window.removeEventListener('mousemove', handleWindowMouseMove) - window.removeEventListener('mouseup', handleWindowMouseUp) - } - }, - [ - activeTool, - applyAudioVolumeVisualPreview, - finalizeAudioVolumeChange, - isAnyDragActiveRef, - item, - trackLocked, - ], + return leftId + '|' + rightId + }, + [item.id, item.trackId, item.from, item.durationInFrames], + ), ) - const handleAudioVolumeDoubleClick = useCallback(() => { - if (item.type !== 'audio' || trackLocked) { - return - } - audioVolumeCleanupRef.current?.() - audioVolumeCleanupRef.current = null - finalizeAudioVolumeChange(0) - }, [finalizeAudioVolumeChange, item, trackLocked]) - const handleVideoFadeHandleDoubleClick = useCallback( - (handle: AudioFadeHandle) => { - if (!isVisualFadeItem || trackLocked) { - return - } + const getNeighbors = useCallback(() => { + const trackItems = useItemsStore.getState().itemsByTrackId[item.trackId] ?? [] - videoFadeCleanupRef.current?.() - videoFadeCleanupRef.current = null - setVideoFadeEdit(null) + const left = + trackItems.find( + (other) => other.id !== item.id && other.from + other.durationInFrames === item.from, + ) ?? null - if (handle === 'in') { - if ((item.fadeIn ?? 0) > VIDEO_FADE_EPSILON) { - updateTimelineItem(item.id, { fadeIn: 0 }) - } - return - } + const right = + trackItems.find( + (other) => other.id !== item.id && other.from === item.from + item.durationInFrames, + ) ?? null - if ((item.fadeOut ?? 0) > VIDEO_FADE_EPSILON) { - updateTimelineItem(item.id, { fadeOut: 0 }) - } - }, - [isVisualFadeItem, item, trackLocked, updateTimelineItem], - ) - const handleAudioFadeHandleDoubleClick = useCallback( - (handle: AudioFadeHandle) => { - if (item.type !== 'audio' || trackLocked) { - return - } + return { + leftNeighbor: left, + rightNeighbor: right, + hasJoinableLeft: left ? canJoinItems(left, item) : false, + hasJoinableRight: right ? canJoinItems(item, right) : false, + } + }, [item]) - audioFadeCleanupRef.current?.() - audioFadeCleanupRef.current = null - setAudioFadeEdit(null) + // Recomputes when item props change OR when adjacent neighbor set changes + const { leftNeighbor, rightNeighbor, hasJoinableLeft, hasJoinableRight } = useMemo(() => { + void neighborKey + return getNeighbors() + }, [getNeighbors, neighborKey]) - if (handle === 'in') { - if ((item.audioFadeIn ?? 0) > AUDIO_FADE_EPSILON) { - updateTimelineItem(item.id, { audioFadeIn: 0 }) - } - return - } + // Gap detection: clip has empty space before it (no strictly adjacent left neighbor) + const hasGapBefore = item.from > 0 && !leftNeighbor - if ((item.audioFadeOut ?? 0) > AUDIO_FADE_EPSILON) { - updateTimelineItem(item.id, { audioFadeOut: 0 }) - } - }, - [item, trackLocked, updateTimelineItem], - ) - const handleAudioFadeCurveDotDoubleClick = useCallback( - (handle: AudioFadeHandle) => { - if (item.type !== 'audio' || trackLocked) { - return - } + // Gap width in frames - lets the track-push affordance follow zoom through CSS + // variables without forcing the entire item shell to re-render on every wheel tick. + const gapBeforeFrames = useMemo(() => { + if (!hasGapBefore) return 0 + const trackItems = useItemsStore.getState().itemsByTrackId[item.trackId] ?? [] + let prevEnd = 0 + for (const ti of trackItems) { + if (ti.id === item.id) continue + const end = ti.from + ti.durationInFrames + if (end <= item.from && end > prevEnd) prevEnd = end + } + return Math.max(0, item.from - prevEnd) + }, [hasGapBefore, item.trackId, item.id, item.from]) - audioFadeCurveCleanupRef.current?.() - audioFadeCurveCleanupRef.current = null - setAudioFadeCurveEdit(null) + const { + getCanJoinSelected, + getCanLinkSelected, + getCanUnlinkSelected, + hasSpeakableText, + isSceneDetectionActive, + isCompositionItem, + handleJoinSelected, + handleJoinLeft, + handleJoinRight, + handleDelete, + handleRippleDelete, + handleLinkSelected, + handleUnlinkSelected, + handleReverseSelected, + handleClearAllKeyframes, + handleClearPropertyKeyframes, + handleBentoLayout, + handleFreezeFrame, + handleGenerateAudioFromText, + handleCaptionsFromDialog, + handleApplyCaptionsFromTranscript, + handleCreatePreComp, + handleEnterComposition, + handleDissolveComposition, + handleDetectScenes, + handleRemoveSilence, + handleRemoveFillers, + isRemovingSilence, + isRemovingFillers, + } = useTimelineItemActions({ + item, + isBroken, + leftNeighbor, + rightNeighbor, + segmentOverlays, + }) - if (handle === 'in') { - if ( - Math.abs(item.audioFadeInCurve ?? 0) > AUDIO_FADE_EPSILON || - Math.abs((item.audioFadeInCurveX ?? 0.52) - 0.52) > AUDIO_FADE_EPSILON - ) { - updateTimelineItem(item.id, { audioFadeInCurve: 0, audioFadeInCurveX: 0.52 }) - } - return - } + const { + handleTransitionCutDragOver, + handleTransitionCutDragLeave, + handleTransitionCutDrop, + handleEffectDragEnter, + handleEffectDragOver, + handleEffectDragLeave, + handleEffectDrop, + } = useTimelineItemDropHandlers({ + item, + trackLocked, + addEffects, + }) - if ( - Math.abs(item.audioFadeOutCurve ?? 0) > AUDIO_FADE_EPSILON || - Math.abs((item.audioFadeOutCurveX ?? 0.52) - 0.52) > AUDIO_FADE_EPSILON - ) { - updateTimelineItem(item.id, { audioFadeOutCurve: 0, audioFadeOutCurveX: 0.52 }) - } - }, - [item, trackLocked, updateTimelineItem], - ) + const { + videoControlsRef, + audioControlsRef, + volumeLineRef, + audioVolumeEditLabelRef, + audioVolumePreviewRef, + isVisualFadeItem, + videoFadeEdit, + audioFadeEdit, + audioFadeCurveEdit, + audioVolumeEdit, + displayedVideoFadeIn, + displayedVideoFadeOut, + displayedAudioFadeIn, + displayedAudioFadeOut, + displayedAudioFadeInCurve, + displayedAudioFadeOutCurve, + displayedAudioFadeInCurveX, + displayedAudioFadeOutCurveX, + displayedAudioVolumeDb, + handleVideoFadeHandleMouseDown, + handleVideoFadeHandleDoubleClick, + handleAudioFadeHandleMouseDown, + handleAudioFadeHandleDoubleClick, + handleAudioFadeCurveDotMouseDown, + handleAudioFadeCurveDotDoubleClick, + handleAudioVolumeMouseDown, + handleAudioVolumeDoubleClick, + } = useFadeEditors({ + item, + fps, + activeTool, + trackLocked, + isAnyDragActiveRef, + transformRef, + updateTimelineItem, + }) + // Hoisted before fade memos so the compact guard can account for active interactions. + // A narrow clip that is selected/edited should still compute its fade ratios. + const hasActiveClipInteraction = + isSelected || + isBeingDragged || + isPartOfDrag || + isTrimming || + isStretching || + isSlipSlideActive || + isTrackPushActive || + isEffectDropTarget || + videoFadeEdit !== null || + audioFadeEdit !== null || + audioFadeCurveEdit !== null || + audioVolumeEdit !== null || + transitionDropGhost !== null || + draggedTransition !== null || + pointerHint !== null || + hoveredEdge !== null || + smartTrimIntent !== null || + smartBodyIntent !== null || + rollHoverEdge !== null || + activeEdges !== null + const skipFadeComputation = isCompactWidth && !hasActiveClipInteraction + const clipFadeDurationFrames = Math.max(1, Math.round(visualWidthFrames)) + const { + videoFadeInRatio, + videoFadeOutRatio, + audioFadeInRatio, + audioFadeOutRatio, + audioFadeInHoverLabel, + audioFadeOutHoverLabel, + videoFadeInHoverLabel, + videoFadeOutHoverLabel, + audioVolumeLineYPercent, + audioVisualizationScale, + videoFadeLineYPercent, + audioVolumeLineStroke, + audioFadeInCurvePoint, + audioFadeOutCurvePoint, + audioFadeInCurvePath, + audioFadeOutCurvePath, + videoFadeInPath, + videoFadeOutPath, + } = useFadeMath({ + item, + fps, + isVisualFadeItem, + isSelected, + audioVolumeEditActive: audioVolumeEdit !== null, + skipFadeComputation, + clipFadeDurationFrames, + displayedVideoFadeIn, + displayedVideoFadeOut, + displayedAudioFadeIn, + displayedAudioFadeOut, + displayedAudioFadeInCurve, + displayedAudioFadeOutCurve, + displayedAudioFadeInCurveX, + displayedAudioFadeOutCurveX, + displayedAudioVolumeDb, + }) + const audioVolumeEditLabel = useMemo(() => { + if (skipFadeComputation || !audioVolumeEdit) return null + const previewVolume = audioVolumePreviewRef.current + return `Volume ${previewVolume >= 0 ? '+' : ''}${previewVolume.toFixed(1)} dB` + }, [skipFadeComputation, audioVolumeEdit, audioVolumePreviewRef]) const contentVisualPreviewItem = useMemo(() => { if (supportsVisualFadeControls(contentPreviewItem) && videoFadeEdit !== null) { return { @@ -3057,7 +1441,7 @@ export const TimelineItem = memo( labelRowHeight: getTimelineClipLabelRowHeightPx(e.currentTarget), isMediaItem: item.type === 'video' || item.type === 'audio' || item.type === 'composition', - currentIntent: smartBodyIntentRef.current, + currentIntent: smartBodyIntent, }) } } @@ -3125,17 +1509,11 @@ export const TimelineItem = memo( handleSlipSlideStart, handleStretchStart, item, + smartBodyIntent, + smartTrimIntentRef, ], ) - // Track which edge is closer when right-clicking for context menu - const handleMouseLeave = useCallback(() => { - syncHoveredEdge(null) - syncSmartTrimIntent(null) - syncSmartBodyIntent(null) - useRollHoverStore.getState().clearRollHover(item.id) - }, [item.id, syncHoveredEdge, syncSmartBodyIntent, syncSmartTrimIntent]) - const handleSmartTrimStart = useCallback( (e: React.MouseEvent, handle: 'start' | 'end') => { const currentIntent = smartTrimIntentRef.current @@ -3161,39 +1539,7 @@ export const TimelineItem = memo( : undefined, ) }, - [handleTrimStart, item.id], - ) - - const handleContextMenu = useCallback( - (e: React.MouseEvent) => { - const rect = e.currentTarget.getBoundingClientRect() - const x = e.clientX - rect.left - const midpoint = rect.width / 2 - setCloserEdge(x < midpoint ? 'left' : 'right') - - const { selectedItemIds, selectItems } = useSelectionStore.getState() - const items = useTimelineStore.getState().items - const linkedSelectionEnabled = useEditorStore.getState().linkedSelectionEnabled - const targetIds = linkedSelectionEnabled ? getLinkedItemIds(items, item.id) : [item.id] - const isCurrentSelection = targetIds.some((id) => selectedItemIds.includes(id)) - - if (!isCurrentSelection) { - if ( - selectedItemIds.length === 1 && - targetIds.length === 1 && - !selectedItemIds.includes(item.id) - ) { - selectItems( - linkedSelectionEnabled - ? expandSelectionWithLinkedItems(items, [...selectedItemIds, item.id]) - : Array.from(new Set([...selectedItemIds, item.id])), - ) - } else { - selectItems(targetIds) - } - } - }, - [item.id], + [handleTrimStart, item.id, smartTrimIntentRef], ) if (isHiddenByLinkedEditPreview) { @@ -3233,26 +1579,18 @@ export const TimelineItem = memo( onFreezeFrame={handleFreezeFrame} isTextItem={item.type === 'text' && hasSpeakableText} onGenerateAudioFromText={handleGenerateAudioFromText} - canManageCaptions={canManageCaptions} + canManageCaptions={caption.canManageCaptions} hasCaptions={hasGeneratedCaptions} - hasTranscript={mediaHasTranscript} + hasTranscript={caption.mediaHasTranscript} isGeneratingCaptions={ - transcriptStatus === 'queued' || transcriptStatus === 'transcribing' + caption.transcriptStatus === 'queued' || caption.transcriptStatus === 'transcribing' } - onOpenCaptionDialog={() => { - captionStopRequestedRef.current = false - setCaptionDialogError(null) - setCaptionDialogOpen(true) - }} + onOpenCaptionDialog={caption.openDialog} onApplyCaptionsFromTranscript={handleApplyCaptionsFromTranscript} - canExtractEmbeddedSubtitles={canExtractEmbeddedSubtitles} - onExtractEmbeddedSubtitles={ - canExtractEmbeddedSubtitles ? handleExtractEmbeddedSubtitles : undefined - } - canConsolidateCaptionsToSegment={hasConsolidatablePerCueCaptions} - onConsolidateCaptionsToSegment={ - hasConsolidatablePerCueCaptions ? handleConsolidateCaptionsToSegment : undefined - } + canExtractEmbeddedSubtitles={caption.canExtractEmbeddedSubtitles} + onExtractEmbeddedSubtitles={caption.handleExtractEmbeddedSubtitles} + canConsolidateCaptionsToSegment={caption.hasConsolidatablePerCueCaptions} + onConsolidateCaptionsToSegment={caption.handleConsolidateCaptionsToSegment} isCompositionItem={isCompositionItem} onEnterComposition={handleEnterComposition} onDissolveComposition={handleDissolveComposition} @@ -3301,6 +1639,13 @@ export const TimelineItem = memo( zIndex: isBeingDragged ? 50 : undefined, transition: isBeingDragged ? 'none' : undefined, contain: 'layout style paint', + // Let the browser skip laying out/painting the interior (label, + // filmstrip, waveform, fade SVGs) of clips that are mounted in + // the cull buffer but currently off-screen. The box itself stays + // correctly sized by inset-y + explicit left/width, so the zoom + // reflow (--timeline-px-per-frame change) only pays interior + // layout for clips actually in the viewport. + contentVisibility: 'auto', '--timeline-audio-volume-line-y': `${ item.type === 'audio' && audioVolumeEdit !== null ? (getAudioVolumeLineY( @@ -3381,6 +1726,7 @@ export const TimelineItem = memo( className="absolute left-0 right-0 pointer-events-none" style={{ height: '1px', + top: `var(--timeline-audio-volume-line-y, ${audioVolumeLineYPercent}%)`, backgroundColor: audioVolumeLineStroke, }} /> @@ -3638,81 +1984,13 @@ export const TimelineItem = memo( {/* Active edge halos - top layer, above both clip and bounds box */} - {activeEdges && ( -
- {activeEdges.start && - (() => { - const constrained = - activeEdges.constrainedEdge === 'start' || activeEdges.constrainedEdge === 'both' - const colors = constrained ? CONSTRAINED_COLORS : FREE_COLORS - return ( - <> -
-
- - ) - })()} - {activeEdges.end && - (() => { - const constrained = - activeEdges.constrainedEdge === 'end' || activeEdges.constrainedEdge === 'both' - const colors = constrained ? CONSTRAINED_COLORS : FREE_COLORS - return ( - <> -
-
- - ) - })()} -
- )} + - {transitionDropGhost && ( -
-
-
-
-
-
- )} + {/* Alt-drag ghosts */} - {canManageCaptions && item.mediaId && ( - { - if (!next) setCaptionDialogError(null) - setCaptionDialogOpen(next) - }} - fileName={mediaFileName} - hasTranscript={mediaHasTranscript} - isRunning={transcriptStatus === 'queued' || transcriptStatus === 'transcribing'} - progressPercent={ - transcriptProgress - ? Math.round(getTranscriptionOverallPercent(transcriptProgress)) - : null - } - progressLabel={ - transcriptProgress - ? `${getTranscriptionStageLabel(transcriptProgress.stage)} (${Math.round( - getTranscriptionOverallPercent(transcriptProgress), - )}%)` - : 'Transcribing...' - } - errorMessage={captionDialogError} - onStart={(values: TranscribeDialogValues) => { - captionStartedRef.current = true - captionStopRequestedRef.current = false - setCaptionDialogError(null) - handleCaptionsFromDialog(values, hasGeneratedCaptions, (error) => { - captionStartedRef.current = false - const baseMessage = - error instanceof Error ? error.message : 'Failed to generate captions' - setCaptionDialogError( - isTranscriptionOutOfMemoryError(error) ? TRANSCRIPTION_OOM_HINT : baseMessage, - ) - }) - }} - onCancel={() => { - if (item.mediaId) { - captionStopRequestedRef.current = true - mediaTranscriptionService.cancelTranscription(item.mediaId) - } - }} - /> - )} + ) }, diff --git a/src/features/timeline/components/timeline-item/transcribe-dialog-controller.tsx b/src/features/timeline/components/timeline-item/transcribe-dialog-controller.tsx new file mode 100644 index 000000000..04b5e1ee6 --- /dev/null +++ b/src/features/timeline/components/timeline-item/transcribe-dialog-controller.tsx @@ -0,0 +1,95 @@ +import { memo } from 'react' +import { mediaTranscriptionService } from '@/features/timeline/deps/media-transcription-service' +import { + TranscribeDialog, + type TranscribeDialogValues, +} from '@/features/timeline/deps/transcribe-dialog' +import { + getTranscriptionOverallPercent, + getTranscriptionStageLabel, +} from '@/shared/utils/transcription-progress' +import { + isTranscriptionOutOfMemoryError, + TRANSCRIPTION_OOM_HINT, +} from '@/shared/utils/transcription-cancellation' +import type { CaptionDialogState } from './use-caption-dialog-state' + +interface TranscribeDialogControllerProps { + itemMediaId: string | undefined + hasGeneratedCaptions: boolean + caption: CaptionDialogState + onGenerate: ( + values: TranscribeDialogValues, + hasExistingCaptions: boolean, + onError?: (error: unknown) => void, + ) => void +} + +export const TranscribeDialogController = memo(function TranscribeDialogController({ + itemMediaId, + hasGeneratedCaptions, + caption, + onGenerate, +}: TranscribeDialogControllerProps) { + if (!caption.canManageCaptions || !itemMediaId) { + return null + } + + const { + dialogOpen, + setDialogOpen, + mediaFileName, + mediaHasTranscript, + transcriptStatus, + transcriptProgress, + dialogError, + setDialogError, + markCaptionStarted, + markCaptionEnded, + markCaptionStopRequested, + } = caption + + return ( + { + if (!next) setDialogError(null) + setDialogOpen(next) + }} + fileName={mediaFileName} + hasTranscript={mediaHasTranscript} + isRunning={transcriptStatus === 'queued' || transcriptStatus === 'transcribing'} + progressPercent={ + transcriptProgress ? Math.round(getTranscriptionOverallPercent(transcriptProgress)) : null + } + progressLabel={ + transcriptProgress + ? `${getTranscriptionStageLabel(transcriptProgress.stage)} (${Math.round( + getTranscriptionOverallPercent(transcriptProgress), + )}%)` + : 'Transcribing...' + } + errorMessage={dialogError} + onStart={(values: TranscribeDialogValues) => { + markCaptionStarted() + setDialogError(null) + const handleError = (error: unknown) => { + markCaptionEnded() + const baseMessage = error instanceof Error ? error.message : 'Failed to generate captions' + setDialogError( + isTranscriptionOutOfMemoryError(error) ? TRANSCRIPTION_OOM_HINT : baseMessage, + ) + } + try { + onGenerate(values, hasGeneratedCaptions, handleError) + } catch (error) { + handleError(error) + } + }} + onCancel={() => { + markCaptionStopRequested() + mediaTranscriptionService.cancelTranscription(itemMediaId) + }} + /> + ) +}) diff --git a/src/features/timeline/components/timeline-item/transition-drop-ghost.tsx b/src/features/timeline/components/timeline-item/transition-drop-ghost.tsx new file mode 100644 index 000000000..8ead778de --- /dev/null +++ b/src/features/timeline/components/timeline-item/transition-drop-ghost.tsx @@ -0,0 +1,34 @@ +import { memo } from 'react' + +interface TransitionDropGhostProps { + ghost: { + left: number + width: number + cutOffset: number + } | null +} + +export const TransitionDropGhost = memo(function TransitionDropGhost({ + ghost, +}: TransitionDropGhostProps) { + if (!ghost) return null + return ( +
+
+
+
+
+
+ ) +}) diff --git a/src/features/timeline/components/timeline-item/use-caption-dialog-state.ts b/src/features/timeline/components/timeline-item/use-caption-dialog-state.ts new file mode 100644 index 000000000..b20f6d0b5 --- /dev/null +++ b/src/features/timeline/components/timeline-item/use-caption-dialog-state.ts @@ -0,0 +1,238 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { TimelineItem as TimelineItemType } from '@/types/timeline' +import { useTimelineStore } from '../../stores/timeline-store' +import { useMediaLibraryStore } from '@/features/timeline/deps/media-library-store' +import { + mediaLibraryService as mediaLibraryServiceForSubtitles, + useEmbeddedSubtitlePickerStore, +} from '@/features/timeline/deps/media-library-service' + +function isEmbeddedSubtitleContainer(fileName: string, mimeType: string): boolean { + const name = fileName.toLowerCase() + return ( + mimeType === 'video/x-matroska' || + mimeType === 'video/matroska' || + mimeType === 'video/webm' || + name.endsWith('.mkv') || + name.endsWith('.webm') + ) +} + +interface UseCaptionDialogStateParams { + item: TimelineItemType + isBroken: boolean + linkedItemsForCaptionOwnership: TimelineItemType[] +} + +type TranscriptProgress = NonNullable< + ReturnType['transcriptProgress'] extends Map< + string, + infer V + > + ? V + : never +> + +export interface CaptionDialogState { + canManageCaptions: boolean + canExtractEmbeddedSubtitles: boolean + hasConsolidatablePerCueCaptions: boolean + mediaHasTranscript: boolean + transcriptStatus: string + transcriptProgress: TranscriptProgress | null + mediaFileName: string + dialogOpen: boolean + openDialog: () => void + setDialogOpen: (next: boolean) => void + setDialogError: (message: string | null) => void + dialogError: string | null + markCaptionStarted: () => void + markCaptionEnded: () => void + markCaptionStopRequested: () => void + handleExtractEmbeddedSubtitles: (() => Promise) | undefined + handleConsolidateCaptionsToSegment: (() => Promise) | undefined +} + +export function useCaptionDialogState({ + item, + isBroken, + linkedItemsForCaptionOwnership, +}: UseCaptionDialogStateParams): CaptionDialogState { + const transcriptStatus = useMediaLibraryStore( + useCallback( + (s) => (item.mediaId ? (s.transcriptStatus.get(item.mediaId) ?? 'idle') : 'idle'), + [item.mediaId], + ), + ) + const transcriptProgress = useMediaLibraryStore( + useCallback( + (s) => (item.mediaId ? (s.transcriptProgress.get(item.mediaId) ?? null) : null), + [item.mediaId], + ), + ) + const mediaForItem = useMediaLibraryStore( + useCallback((s) => (item.mediaId ? (s.mediaById[item.mediaId] ?? null) : null), [item.mediaId]), + ) + const mediaFileName = mediaForItem?.fileName ?? '' + const [dialogOpen, setDialogOpen] = useState(false) + const [dialogError, setDialogError] = useState(null) + const mediaHasTranscript = transcriptStatus === 'ready' + const captionStartedRef = useRef(false) + const captionStopRequestedRef = useRef(false) + + const captionIsActive = transcriptStatus === 'queued' || transcriptStatus === 'transcribing' + useEffect(() => { + if (captionStartedRef.current && !captionIsActive) { + captionStartedRef.current = false + const keepOpen = captionStopRequestedRef.current || dialogError !== null + captionStopRequestedRef.current = false + setDialogOpen((wasOpen) => wasOpen && keepOpen) + } + }, [captionIsActive, dialogError]) + + const linkedVideoCaptionOwner = useMemo(() => { + if (item.type !== 'audio' || !item.mediaId) { + return null + } + return ( + linkedItemsForCaptionOwnership.find( + (linkedItem) => + linkedItem.id !== item.id && + linkedItem.type === 'video' && + linkedItem.mediaId === item.mediaId, + ) ?? null + ) + }, [item.id, item.mediaId, item.type, linkedItemsForCaptionOwnership]) + + const canManageCaptions = + !!item.mediaId && + !isBroken && + (item.type === 'video' || (item.type === 'audio' && linkedVideoCaptionOwner === null)) + + const canExtractEmbeddedSubtitles = !!( + mediaForItem && + !isBroken && + isEmbeddedSubtitleContainer(mediaForItem.fileName, mediaForItem.mimeType) + ) + + const handleExtractEmbeddedSubtitles = useCallback(async () => { + if (!mediaForItem) return + const mediaStore = useMediaLibraryStore.getState() + try { + const handle = mediaForItem.fileHandle + if (mediaForItem.storageType === 'handle' && handle) { + const granted = + (await handle.requestPermission({ mode: 'read' }).catch(() => 'denied' as const)) === + 'granted' + if (!granted) { + mediaStore.showNotification?.({ + type: 'error', + message: `FreeCut needs permission to read "${mediaForItem.fileName}" before extracting subtitles.`, + }) + return + } + const blob = await handle.getFile() + useEmbeddedSubtitlePickerStore.getState().open(mediaForItem, blob) + return + } + const blob = await mediaLibraryServiceForSubtitles.getMediaFile(mediaForItem.id) + if (!blob) { + mediaStore.showNotification?.({ + type: 'error', + message: `FreeCut could not load "${mediaForItem.fileName}".`, + }) + return + } + useEmbeddedSubtitlePickerStore.getState().open(mediaForItem, blob) + } catch (error) { + mediaStore.showNotification?.({ + type: 'error', + message: + error instanceof Error + ? error.message + : `Failed to open "${mediaForItem.fileName}" for subtitle extraction.`, + }) + } + }, [mediaForItem]) + + const hasConsolidatablePerCueCaptions = useTimelineStore( + useCallback( + (s) => + s.items.some( + (other) => + other.type === 'text' && + (other.captionSource?.type === 'embedded-subtitles' || + other.captionSource?.type === 'subtitle-import') && + other.captionSource.clipId === item.id, + ), + [item.id], + ), + ) + + const handleConsolidateCaptionsToSegment = useCallback(async () => { + const mediaStore = useMediaLibraryStore.getState() + try { + const { subtitleSidecarService } = + await import('@/features/timeline/deps/subtitle-sidecar-service') + const result = subtitleSidecarService.consolidatePerCueCaptionsToSegments({ + clipId: item.id, + }) + mediaStore.showNotification?.({ + type: 'success', + message: + result.segmentsCreated > 0 + ? `Consolidated ${result.cuesConsolidated} caption${result.cuesConsolidated === 1 ? '' : 's'} into ${result.segmentsCreated} segment${result.segmentsCreated === 1 ? '' : 's'}.` + : 'No per-cue captions found for this clip.', + }) + } catch (error) { + mediaStore.showNotification?.({ + type: 'error', + message: + error instanceof Error ? error.message : 'Failed to consolidate captions to segment.', + }) + } + }, [item.id]) + + const openDialog = useCallback(() => { + captionStopRequestedRef.current = false + setDialogError(null) + setDialogOpen(true) + }, []) + + const markCaptionStarted = useCallback(() => { + captionStartedRef.current = true + captionStopRequestedRef.current = false + }, []) + + const markCaptionEnded = useCallback(() => { + captionStartedRef.current = false + }, []) + + const markCaptionStopRequested = useCallback(() => { + captionStopRequestedRef.current = true + }, []) + + return { + canManageCaptions, + canExtractEmbeddedSubtitles, + hasConsolidatablePerCueCaptions, + mediaHasTranscript, + transcriptStatus, + transcriptProgress, + mediaFileName, + dialogOpen, + openDialog, + setDialogOpen, + setDialogError, + dialogError, + markCaptionStarted, + markCaptionEnded, + markCaptionStopRequested, + handleExtractEmbeddedSubtitles: canExtractEmbeddedSubtitles + ? handleExtractEmbeddedSubtitles + : undefined, + handleConsolidateCaptionsToSegment: hasConsolidatablePerCueCaptions + ? handleConsolidateCaptionsToSegment + : undefined, + } +} diff --git a/src/features/timeline/components/timeline-item/use-context-menu-state.ts b/src/features/timeline/components/timeline-item/use-context-menu-state.ts new file mode 100644 index 000000000..eb6968079 --- /dev/null +++ b/src/features/timeline/components/timeline-item/use-context-menu-state.ts @@ -0,0 +1,49 @@ +import { useCallback, useState } from 'react' +import type { TimelineItem as TimelineItemType } from '@/types/timeline' +import { useEditorStore } from '@/shared/state/editor' +import { useSelectionStore } from '@/shared/state/selection' +import { useTimelineStore } from '../../stores/timeline-store' +import { expandSelectionWithLinkedItems, getLinkedItemIds } from '../../utils/linked-items' + +export interface ContextMenuState { + closerEdge: 'left' | 'right' | null + handleContextMenu: (e: React.MouseEvent) => void +} + +export function useContextMenuState(item: TimelineItemType): ContextMenuState { + const [closerEdge, setCloserEdge] = useState<'left' | 'right' | null>(null) + + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect() + const x = e.clientX - rect.left + const midpoint = rect.width / 2 + setCloserEdge(x < midpoint ? 'left' : 'right') + + const { selectedItemIds, selectItems } = useSelectionStore.getState() + const items = useTimelineStore.getState().items + const linkedSelectionEnabled = useEditorStore.getState().linkedSelectionEnabled + const targetIds = linkedSelectionEnabled ? getLinkedItemIds(items, item.id) : [item.id] + const isCurrentSelection = targetIds.every((id) => selectedItemIds.includes(id)) + + if (!isCurrentSelection) { + if ( + selectedItemIds.length === 1 && + targetIds.length === 1 && + !selectedItemIds.includes(item.id) + ) { + selectItems( + linkedSelectionEnabled + ? expandSelectionWithLinkedItems(items, [...selectedItemIds, item.id]) + : Array.from(new Set([...selectedItemIds, item.id])), + ) + } else { + selectItems(targetIds) + } + } + }, + [item.id], + ) + + return { closerEdge, handleContextMenu } +} diff --git a/src/features/timeline/components/timeline-item/use-edit-preview-shifts.ts b/src/features/timeline/components/timeline-item/use-edit-preview-shifts.ts new file mode 100644 index 000000000..1c551679d --- /dev/null +++ b/src/features/timeline/components/timeline-item/use-edit-preview-shifts.ts @@ -0,0 +1,279 @@ +import { useCallback, useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' +import type { TimelineItem as TimelineItemType } from '@/types/timeline' +import { useItemsStore } from '../../stores/items-store' +import { useLinkedEditPreviewStore } from '../../stores/linked-edit-preview-store' +import { useRippleEditPreviewStore } from '../../stores/ripple-edit-preview-store' +import { useRollingEditPreviewStore } from '../../stores/rolling-edit-preview-store' +import { useSlideEditPreviewStore } from '../../stores/slide-edit-preview-store' +import { useSlipEditPreviewStore } from '../../stores/slip-edit-preview-store' +import { useTrackPushPreviewStore } from '../../stores/track-push-preview-store' +import { getLinkedItemIds } from '../../utils/linked-items' +import type { PreviewItemUpdate } from '../../utils/item-edit-preview' + +interface UseEditPreviewShiftsParams { + item: TimelineItemType + linkedItemsForSync: TimelineItemType[] + isDragging: boolean + isPartOfDrag: boolean + gestureMode: string +} + +interface SlidePreview { + activeItemId: string | null + primaryLeftNeighborId: string | null + primaryRightNeighborId: string | null + slideDelta: number + minDelta: number + maxDelta: number + isPrimary: boolean + isLeftNeighbor: boolean + isRightNeighbor: boolean +} + +export interface EditPreviewShifts { + linkedEditPreviewUpdate: PreviewItemUpdate | null + isHiddenByLinkedEditPreview: boolean + moveDragPreviewFromDelta: number + previewBaseItem: TimelineItemType + linkedSyncPreviewUpdatesById: Record + + rollingEditDelta: number + rollingEditHandle: 'start' | 'end' | null + rollingEditConstrained: boolean + + rippleEditOffset: number + rippleEdgeDelta: number + + trackPushOffset: number + + slipEditDelta: number + isLinkedSlipCompanion: boolean + + slidePreview: SlidePreview + slideEditOffset: number + slideNeighborDelta: number + slideNeighborSide: 'left' | 'right' | null + isLinkedSlideCompanion: boolean + slideRange: { minDelta: number; maxDelta: number } | null + slideLeftNeighborIdForSlidItem: string | null + slideRightNeighborIdForSlidItem: string | null + slideLeftNeighborForSlidItem: TimelineItemType | null + slideRightNeighborForSlidItem: TimelineItemType | null +} + +export function useEditPreviewShifts({ + item, + linkedItemsForSync, + isDragging, + isPartOfDrag, + gestureMode, +}: UseEditPreviewShiftsParams): EditPreviewShifts { + const linkedEditPreviewUpdate = useLinkedEditPreviewStore( + useCallback((s) => s.updatesById[item.id] ?? null, [item.id]), + ) + const isHiddenByLinkedEditPreview = linkedEditPreviewUpdate?.hidden === true + + const moveDragPreviewFromDelta = useMemo(() => { + if (!linkedEditPreviewUpdate || !(isDragging || isPartOfDrag) || gestureMode !== 'none') { + return 0 + } + return (linkedEditPreviewUpdate.from ?? item.from) - item.from + }, [gestureMode, isDragging, isPartOfDrag, item.from, linkedEditPreviewUpdate]) + + const previewBaseItem = useMemo( + () => + linkedEditPreviewUpdate && moveDragPreviewFromDelta === 0 + ? ({ ...item, ...linkedEditPreviewUpdate } as TimelineItemType) + : item, + [item, linkedEditPreviewUpdate, moveDragPreviewFromDelta], + ) + + const linkedSyncPreviewUpdatesById = useLinkedEditPreviewStore( + useShallow( + useCallback( + (s) => { + const updatesById: Record = {} + for (const linkedItem of linkedItemsForSync) { + const linkedPreviewUpdate = s.updatesById[linkedItem.id] + if (linkedPreviewUpdate) { + updatesById[linkedItem.id] = linkedPreviewUpdate + } + } + return updatesById + }, + [linkedItemsForSync], + ), + ), + ) + + const rollingEditPreview = useRollingEditPreviewStore( + useShallow( + useCallback( + (s) => { + const isNeighbor = s.neighborItemId === item.id + return { + delta: isNeighbor ? s.neighborDelta : 0, + handle: isNeighbor ? s.handle : null, + constrained: isNeighbor && s.constrained, + } + }, + [item.id], + ), + ), + ) + + const rippleEditOffset = useRippleEditPreviewStore( + useCallback( + (s) => { + if (!s.trimmedItemId) return 0 + if (s.downstreamItemIds.has(item.id)) return s.delta + return 0 + }, + [item.id], + ), + ) + + const rippleEdgeDelta = useRippleEditPreviewStore( + useCallback( + (s) => { + if (s.trimmedItemId !== item.id) return 0 + return s.delta + }, + [item.id], + ), + ) + + const trackPushOffset = useTrackPushPreviewStore( + useCallback( + (s) => { + if (!s.anchorItemId) return 0 + if (s.shiftedItemIds.has(item.id)) return s.delta + return 0 + }, + [item.id], + ), + ) + + const slipEditDelta = useSlipEditPreviewStore( + useCallback( + (s) => { + if (s.itemId !== item.id) return 0 + return s.slipDelta + }, + [item.id], + ), + ) + + const isLinkedSlipCompanion = + useSlipEditPreviewStore( + useCallback((s) => s.itemId !== null && s.itemId !== item.id, [item.id]), + ) && + linkedEditPreviewUpdate !== null && + linkedEditPreviewUpdate.sourceStart !== undefined + + const slidePreview = useSlideEditPreviewStore( + useShallow( + useCallback( + (s): SlidePreview => { + const isPrimary = s.itemId === item.id + const isLeftNeighbor = s.leftNeighborId === item.id + const isRightNeighbor = s.rightNeighborId === item.id + const isRelated = isPrimary || isLeftNeighbor || isRightNeighbor + return { + activeItemId: s.itemId, + primaryLeftNeighborId: isPrimary ? s.leftNeighborId : null, + primaryRightNeighborId: isPrimary ? s.rightNeighborId : null, + slideDelta: isRelated ? s.slideDelta : 0, + minDelta: s.minDelta, + maxDelta: s.maxDelta, + isPrimary, + isLeftNeighbor, + isRightNeighbor, + } + }, + [item.id], + ), + ), + ) + + const slideEditOffset = slidePreview.isPrimary ? slidePreview.slideDelta : 0 + const slideNeighborDelta = + slidePreview.isLeftNeighbor || slidePreview.isRightNeighbor ? slidePreview.slideDelta : 0 + const slideNeighborSide: 'left' | 'right' | null = slidePreview.isLeftNeighbor + ? 'left' + : slidePreview.isRightNeighbor + ? 'right' + : null + + const isLinkedSlideCompanion = useMemo(() => { + if (!slidePreview.activeItemId || slidePreview.isPrimary) return false + if (slidePreview.isLeftNeighbor || slidePreview.isRightNeighbor) return false + const items = useItemsStore.getState().items + const linkedIds = getLinkedItemIds(items, slidePreview.activeItemId) + return linkedIds.includes(item.id) + }, [ + item.id, + slidePreview.activeItemId, + slidePreview.isPrimary, + slidePreview.isLeftNeighbor, + slidePreview.isRightNeighbor, + ]) + + const slideRange = useMemo( + () => + slidePreview.activeItemId !== null + ? { minDelta: slidePreview.minDelta, maxDelta: slidePreview.maxDelta } + : null, + [slidePreview.activeItemId, slidePreview.minDelta, slidePreview.maxDelta], + ) + + const slideLeftNeighborIdForSlidItem = slidePreview.primaryLeftNeighborId + const slideRightNeighborIdForSlidItem = slidePreview.primaryRightNeighborId + + const slideLeftNeighborForSlidItem = useItemsStore( + useCallback( + (s) => { + if (!slideLeftNeighborIdForSlidItem) return null + return s.itemById[slideLeftNeighborIdForSlidItem] ?? null + }, + [slideLeftNeighborIdForSlidItem], + ), + ) + + const slideRightNeighborForSlidItem = useItemsStore( + useCallback( + (s) => { + if (!slideRightNeighborIdForSlidItem) return null + return s.itemById[slideRightNeighborIdForSlidItem] ?? null + }, + [slideRightNeighborIdForSlidItem], + ), + ) + + return { + linkedEditPreviewUpdate, + isHiddenByLinkedEditPreview, + moveDragPreviewFromDelta, + previewBaseItem, + linkedSyncPreviewUpdatesById, + rollingEditDelta: rollingEditPreview.delta, + rollingEditHandle: rollingEditPreview.handle, + rollingEditConstrained: rollingEditPreview.constrained, + rippleEditOffset, + rippleEdgeDelta, + trackPushOffset, + slipEditDelta, + isLinkedSlipCompanion, + slidePreview, + slideEditOffset, + slideNeighborDelta, + slideNeighborSide, + isLinkedSlideCompanion, + slideRange, + slideLeftNeighborIdForSlidItem, + slideRightNeighborIdForSlidItem, + slideLeftNeighborForSlidItem, + slideRightNeighborForSlidItem, + } +} diff --git a/src/features/timeline/components/timeline-item/use-fade-editors.ts b/src/features/timeline/components/timeline-item/use-fade-editors.ts new file mode 100644 index 000000000..86e8daf85 --- /dev/null +++ b/src/features/timeline/components/timeline-item/use-fade-editors.ts @@ -0,0 +1,920 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type MutableRefObject, + type RefObject, +} from 'react' +import type { TimelineItem as TimelineItemType } from '@/types/timeline' +import { + clearMixerLiveGain, + getMixerLiveGain, + setMixerLiveGains, +} from '@/shared/state/mixer-live-gain' +import { + getAudioFadeRatio, + getAudioFadeSecondsFromOffset, + type AudioFadeHandle, +} from '../../utils/audio-fade' +import { getAudioFadeCurveFromOffset } from '../../utils/audio-fade-curve' +import { + getAudioVisualizationScale, + getAudioVolumeDbFromDragDelta, + getAudioVolumeLineY, +} from '../../utils/audio-volume' +import { supportsVisualFadeControls } from './visual-fade-items' + +const VIDEO_FADE_EPSILON = 0.0001 +const AUDIO_FADE_EPSILON = 0.0001 +const AUDIO_VOLUME_EPSILON = 0.05 +const AUDIO_ENVELOPE_VIEWBOX_HEIGHT = 100 +const AUDIO_VOLUME_DRAG_ACTIVATION_DELAY_MS = 120 +const AUDIO_VOLUME_DRAG_ACTIVATION_DISTANCE_PX = 4 + +export interface VideoFadeEditState { + handle: AudioFadeHandle + previewFadeIn: number + previewFadeOut: number + originalFadeIn: number + originalFadeOut: number + isCommitting: boolean +} + +export interface AudioFadeEditState { + handle: AudioFadeHandle + previewFadeIn: number + previewFadeOut: number + originalFadeIn: number + originalFadeOut: number + isCommitting: boolean +} + +export interface AudioFadeCurveEditState { + handle: AudioFadeHandle + previewFadeInCurve: number + previewFadeOutCurve: number + previewFadeInCurveX: number + previewFadeOutCurveX: number + originalFadeInCurve: number + originalFadeOutCurve: number + originalFadeInCurveX: number + originalFadeOutCurveX: number + isCommitting: boolean +} + +export interface AudioVolumeEditState { + originalVolume: number + isCommitting: boolean +} + +interface UseFadeEditorsParams { + item: TimelineItemType + fps: number + activeTool: string + trackLocked: boolean + isAnyDragActiveRef: MutableRefObject + transformRef: RefObject + updateTimelineItem: (id: string, patch: Partial) => void +} + +export interface FadeEditorsHandle { + videoControlsRef: RefObject + audioControlsRef: RefObject + volumeLineRef: RefObject + audioVolumeEditLabelRef: RefObject + + isVisualFadeItem: boolean + videoFadeEdit: VideoFadeEditState | null + audioFadeEdit: AudioFadeEditState | null + audioFadeCurveEdit: AudioFadeCurveEditState | null + audioVolumeEdit: AudioVolumeEditState | null + + displayedVideoFadeIn: number + displayedVideoFadeOut: number + displayedAudioFadeIn: number + displayedAudioFadeOut: number + displayedAudioFadeInCurve: number + displayedAudioFadeOutCurve: number + displayedAudioFadeInCurveX: number + displayedAudioFadeOutCurveX: number + displayedAudioVolumeDb: number + + audioVolumePreviewRef: MutableRefObject + + handleVideoFadeHandleMouseDown: (e: React.MouseEvent, handle: AudioFadeHandle) => void + handleVideoFadeHandleDoubleClick: (handle: AudioFadeHandle) => void + handleAudioFadeHandleMouseDown: (e: React.MouseEvent, handle: AudioFadeHandle) => void + handleAudioFadeHandleDoubleClick: (handle: AudioFadeHandle) => void + handleAudioFadeCurveDotMouseDown: (e: React.MouseEvent, handle: AudioFadeHandle) => void + handleAudioFadeCurveDotDoubleClick: (handle: AudioFadeHandle) => void + handleAudioVolumeMouseDown: (e: React.MouseEvent) => void + handleAudioVolumeDoubleClick: () => void +} + +export function useFadeEditors({ + item, + fps, + activeTool, + trackLocked, + isAnyDragActiveRef, + transformRef, + updateTimelineItem, +}: UseFadeEditorsParams): FadeEditorsHandle { + const isVisualFadeItem = supportsVisualFadeControls(item) + + const [videoFadeEdit, setVideoFadeEdit] = useState(null) + const videoFadeEditRef = useRef(videoFadeEdit) + videoFadeEditRef.current = videoFadeEdit + const videoFadeCleanupRef = useRef<(() => void) | null>(null) + + const [audioFadeEdit, setAudioFadeEdit] = useState(null) + const audioFadeEditRef = useRef(audioFadeEdit) + audioFadeEditRef.current = audioFadeEdit + const audioFadeCleanupRef = useRef<(() => void) | null>(null) + + const [audioFadeCurveEdit, setAudioFadeCurveEdit] = useState(null) + const audioFadeCurveEditRef = useRef(audioFadeCurveEdit) + audioFadeCurveEditRef.current = audioFadeCurveEdit + const audioFadeCurveCleanupRef = useRef<(() => void) | null>(null) + + const [audioVolumeEdit, setAudioVolumeEdit] = useState(null) + const audioVolumeCleanupRef = useRef<(() => void) | null>(null) + const audioVolumePreviewRef = useRef(item.type === 'audio' ? (item.volume ?? 0) : 0) + const audioVolumeEditLabelRef = useRef(null) + + const videoControlsRef = useRef(null) + const audioControlsRef = useRef(null) + const volumeLineRef = useRef(null) + + useEffect( + () => () => { + videoFadeCleanupRef.current?.() + audioFadeCleanupRef.current?.() + audioFadeCurveCleanupRef.current?.() + audioVolumeCleanupRef.current?.() + }, + [], + ) + + const displayedVideoFadeIn = isVisualFadeItem + ? (videoFadeEdit?.previewFadeIn ?? item.fadeIn ?? 0) + : 0 + const displayedVideoFadeOut = isVisualFadeItem + ? (videoFadeEdit?.previewFadeOut ?? item.fadeOut ?? 0) + : 0 + const displayedAudioFadeIn = + item.type === 'audio' ? (audioFadeEdit?.previewFadeIn ?? item.audioFadeIn ?? 0) : 0 + const displayedAudioFadeOut = + item.type === 'audio' ? (audioFadeEdit?.previewFadeOut ?? item.audioFadeOut ?? 0) : 0 + const displayedAudioFadeInCurve = + item.type === 'audio' + ? (audioFadeCurveEdit?.previewFadeInCurve ?? item.audioFadeInCurve ?? 0) + : 0 + const displayedAudioFadeOutCurve = + item.type === 'audio' + ? (audioFadeCurveEdit?.previewFadeOutCurve ?? item.audioFadeOutCurve ?? 0) + : 0 + const displayedAudioFadeInCurveX = + item.type === 'audio' + ? (audioFadeCurveEdit?.previewFadeInCurveX ?? item.audioFadeInCurveX ?? 0.52) + : 0.52 + const displayedAudioFadeOutCurveX = + item.type === 'audio' + ? (audioFadeCurveEdit?.previewFadeOutCurveX ?? item.audioFadeOutCurveX ?? 0.52) + : 0.52 + const displayedAudioVolumeDb = item.type === 'audio' ? (item.volume ?? 0) : 0 + + const audioVolumeLineY = useMemo( + () => + item.type === 'audio' + ? getAudioVolumeLineY(displayedAudioVolumeDb, AUDIO_ENVELOPE_VIEWBOX_HEIGHT) + : AUDIO_ENVELOPE_VIEWBOX_HEIGHT / 2, + [displayedAudioVolumeDb, item.type], + ) + + const snapVolumeLineTop = useCallback((ratio: number) => { + const line = volumeLineRef.current + const container = audioControlsRef.current + if (!line || !container) return + const rect = container.getBoundingClientRect() + if (rect.height <= 0) return + const docY = rect.top + rect.height * ratio + line.style.top = `${Math.round(docY) - rect.top}px` + }, []) + + const applyAudioVolumeVisualPreview = useCallback( + (previewVolumeDb: number) => { + audioVolumePreviewRef.current = previewVolumeDb + + if (transformRef.current) { + transformRef.current.style.setProperty( + '--timeline-audio-volume-line-y', + `${(getAudioVolumeLineY(previewVolumeDb, AUDIO_ENVELOPE_VIEWBOX_HEIGHT) / AUDIO_ENVELOPE_VIEWBOX_HEIGHT) * 100}%`, + ) + transformRef.current.style.setProperty( + '--timeline-audio-waveform-scale', + String(getAudioVisualizationScale(previewVolumeDb)), + ) + } + + snapVolumeLineTop( + getAudioVolumeLineY(previewVolumeDb, AUDIO_ENVELOPE_VIEWBOX_HEIGHT) / + AUDIO_ENVELOPE_VIEWBOX_HEIGHT, + ) + + if (audioVolumeEditLabelRef.current) { + audioVolumeEditLabelRef.current.textContent = `Volume ${previewVolumeDb >= 0 ? '+' : ''}${previewVolumeDb.toFixed(1)} dB` + } + }, + [snapVolumeLineTop, transformRef], + ) + + const itemType = item.type + const itemVolume = item.volume + useEffect(() => { + if (itemType !== 'audio' || audioVolumeEdit !== null) { + return + } + applyAudioVolumeVisualPreview(itemVolume ?? 0) + }, [applyAudioVolumeVisualPreview, audioVolumeEdit, itemType, itemVolume]) + + // Runs post-paint (not useLayoutEffect): the line is already correctly placed + // by its `top: var(--timeline-audio-volume-line-y)` CSS, so this only refines + // it to a crisp whole pixel. Keeping the getBoundingClientRect read out of the + // commit phase avoids a forced reflow per audio clip when many mount at once + // (e.g. zooming out brings a batch into view). + useEffect(() => { + if (itemType !== 'audio') return + const container = audioControlsRef.current + if (!container) return + const ratio = audioVolumeLineY / AUDIO_ENVELOPE_VIEWBOX_HEIGHT + snapVolumeLineTop(ratio) + const ro = new ResizeObserver(() => snapVolumeLineTop(ratio)) + ro.observe(container) + return () => ro.disconnect() + }, [itemType, audioVolumeLineY, snapVolumeLineTop]) + + const finalizeAudioVolumeChange = useCallback( + ( + nextVolume: number, + options?: { + preserveLiveGainOnCommit?: boolean + commitFromActiveEdit?: boolean + }, + ) => { + if (item.type !== 'audio') { + return + } + + const currentVolume = item.volume ?? 0 + const didChange = Math.abs(currentVolume - nextVolume) > AUDIO_VOLUME_EPSILON + + applyAudioVolumeVisualPreview(nextVolume) + + if (!didChange || !options?.preserveLiveGainOnCommit) { + clearMixerLiveGain(item.id) + } + + if (!didChange) { + setAudioVolumeEdit(null) + return + } + + if (options?.commitFromActiveEdit) { + setAudioVolumeEdit((prev) => (prev ? { ...prev, isCommitting: true } : prev)) + } else { + setAudioVolumeEdit(null) + } + + updateTimelineItem(item.id, { volume: nextVolume }) + }, + [applyAudioVolumeVisualPreview, item, updateTimelineItem], + ) + + // Auto-clear isCommitting once the persisted value matches the preview. + useEffect(() => { + if (!videoFadeEdit?.isCommitting || !isVisualFadeItem) { + return + } + const committedFade = videoFadeEdit.handle === 'in' ? (item.fadeIn ?? 0) : (item.fadeOut ?? 0) + const previewFade = + videoFadeEdit.handle === 'in' ? videoFadeEdit.previewFadeIn : videoFadeEdit.previewFadeOut + if (Math.abs(committedFade - previewFade) <= VIDEO_FADE_EPSILON) { + setVideoFadeEdit(null) + } + }, [isVisualFadeItem, item, videoFadeEdit]) + + useEffect(() => { + if (!audioFadeEdit?.isCommitting || item.type !== 'audio') { + return + } + const committedFade = + audioFadeEdit.handle === 'in' ? (item.audioFadeIn ?? 0) : (item.audioFadeOut ?? 0) + const previewFade = + audioFadeEdit.handle === 'in' ? audioFadeEdit.previewFadeIn : audioFadeEdit.previewFadeOut + if (Math.abs(committedFade - previewFade) <= AUDIO_FADE_EPSILON) { + setAudioFadeEdit(null) + } + }, [audioFadeEdit, item]) + + useEffect(() => { + if (!audioVolumeEdit?.isCommitting || item.type !== 'audio') { + return + } + if (Math.abs((item.volume ?? 0) - audioVolumePreviewRef.current) <= AUDIO_VOLUME_EPSILON) { + setAudioVolumeEdit(null) + } + }, [audioVolumeEdit, item]) + + useEffect(() => { + if (!audioFadeCurveEdit?.isCommitting || item.type !== 'audio') { + return + } + const committedCurve = + audioFadeCurveEdit.handle === 'in' + ? (item.audioFadeInCurve ?? 0) + : (item.audioFadeOutCurve ?? 0) + const previewCurve = + audioFadeCurveEdit.handle === 'in' + ? audioFadeCurveEdit.previewFadeInCurve + : audioFadeCurveEdit.previewFadeOutCurve + const committedCurveX = + audioFadeCurveEdit.handle === 'in' + ? (item.audioFadeInCurveX ?? 0.52) + : (item.audioFadeOutCurveX ?? 0.52) + const previewCurveX = + audioFadeCurveEdit.handle === 'in' + ? audioFadeCurveEdit.previewFadeInCurveX + : audioFadeCurveEdit.previewFadeOutCurveX + if ( + Math.abs(committedCurve - previewCurve) <= AUDIO_FADE_EPSILON && + Math.abs(committedCurveX - previewCurveX) <= AUDIO_FADE_EPSILON + ) { + setAudioFadeCurveEdit(null) + } + }, [audioFadeCurveEdit, item]) + + const handleVideoFadeHandleMouseDown = useCallback( + (e: React.MouseEvent, handle: AudioFadeHandle) => { + if (e.button !== 0) return + if ( + !isVisualFadeItem || + trackLocked || + activeTool !== 'select' || + isAnyDragActiveRef.current + ) { + return + } + + e.preventDefault() + e.stopPropagation() + + const originalFadeIn = displayedVideoFadeIn + const originalFadeOut = displayedVideoFadeOut + const persistedFadeIn = item.fadeIn ?? 0 + const persistedFadeOut = item.fadeOut ?? 0 + const computeFadeSeconds = (clientX: number) => { + const rect = + videoControlsRef.current?.getBoundingClientRect() ?? + transformRef.current?.getBoundingClientRect() + if (!rect) { + return handle === 'in' ? originalFadeIn : originalFadeOut + } + return getAudioFadeSecondsFromOffset({ + handle, + clipWidthPixels: rect.width, + pointerOffsetPixels: clientX - rect.left, + fps, + maxDurationFrames: item.durationInFrames, + }) + } + + const applyPreview = (nextFadeSeconds: number) => { + setVideoFadeEdit({ + handle, + previewFadeIn: handle === 'in' ? nextFadeSeconds : originalFadeIn, + previewFadeOut: handle === 'out' ? nextFadeSeconds : originalFadeOut, + originalFadeIn, + originalFadeOut, + isCommitting: false, + }) + } + + const finishEdit = () => { + const latestState = videoFadeEditRef.current + const committedFade = + handle === 'in' + ? (latestState?.previewFadeIn ?? originalFadeIn) + : (latestState?.previewFadeOut ?? originalFadeOut) + videoFadeCleanupRef.current?.() + videoFadeCleanupRef.current = null + + if (handle === 'in') { + if (Math.abs(committedFade - persistedFadeIn) > VIDEO_FADE_EPSILON) { + setVideoFadeEdit((prev) => (prev ? { ...prev, isCommitting: true } : prev)) + updateTimelineItem(item.id, { fadeIn: committedFade }) + } else { + setVideoFadeEdit(null) + } + } else if (Math.abs(committedFade - persistedFadeOut) > VIDEO_FADE_EPSILON) { + setVideoFadeEdit((prev) => (prev ? { ...prev, isCommitting: true } : prev)) + updateTimelineItem(item.id, { fadeOut: committedFade }) + } else { + setVideoFadeEdit(null) + } + } + + applyPreview(computeFadeSeconds(e.clientX)) + + const handleWindowMouseMove = (event: MouseEvent) => { + applyPreview(computeFadeSeconds(event.clientX)) + } + const handleWindowMouseUp = () => { + finishEdit() + } + + window.addEventListener('mousemove', handleWindowMouseMove) + window.addEventListener('mouseup', handleWindowMouseUp, { once: true }) + videoFadeCleanupRef.current = () => { + window.removeEventListener('mousemove', handleWindowMouseMove) + window.removeEventListener('mouseup', handleWindowMouseUp) + } + }, + [ + activeTool, + displayedVideoFadeIn, + displayedVideoFadeOut, + fps, + isAnyDragActiveRef, + isVisualFadeItem, + item, + trackLocked, + transformRef, + updateTimelineItem, + ], + ) + + const handleAudioFadeHandleMouseDown = useCallback( + (e: React.MouseEvent, handle: AudioFadeHandle) => { + if ( + item.type !== 'audio' || + trackLocked || + activeTool !== 'select' || + isAnyDragActiveRef.current + ) { + return + } + + e.preventDefault() + e.stopPropagation() + + const originalFadeIn = displayedAudioFadeIn + const originalFadeOut = displayedAudioFadeOut + const persistedFadeIn = item.audioFadeIn ?? 0 + const persistedFadeOut = item.audioFadeOut ?? 0 + const computeFadeSeconds = (clientX: number) => { + const rect = + audioControlsRef.current?.getBoundingClientRect() ?? + transformRef.current?.getBoundingClientRect() + if (!rect) { + return handle === 'in' ? originalFadeIn : originalFadeOut + } + return getAudioFadeSecondsFromOffset({ + handle, + clipWidthPixels: rect.width, + pointerOffsetPixels: clientX - rect.left, + fps, + maxDurationFrames: item.durationInFrames, + }) + } + + const applyPreview = (nextFadeSeconds: number) => { + setAudioFadeEdit({ + handle, + previewFadeIn: handle === 'in' ? nextFadeSeconds : originalFadeIn, + previewFadeOut: handle === 'out' ? nextFadeSeconds : originalFadeOut, + originalFadeIn, + originalFadeOut, + isCommitting: false, + }) + } + + const finishEdit = () => { + const latestState = audioFadeEditRef.current + const committedFade = + handle === 'in' + ? (latestState?.previewFadeIn ?? originalFadeIn) + : (latestState?.previewFadeOut ?? originalFadeOut) + audioFadeCleanupRef.current?.() + audioFadeCleanupRef.current = null + + if (handle === 'in') { + if (Math.abs(committedFade - persistedFadeIn) > AUDIO_FADE_EPSILON) { + setAudioFadeEdit((prev) => (prev ? { ...prev, isCommitting: true } : prev)) + updateTimelineItem(item.id, { audioFadeIn: committedFade }) + } else { + setAudioFadeEdit(null) + } + } else if (Math.abs(committedFade - persistedFadeOut) > AUDIO_FADE_EPSILON) { + setAudioFadeEdit((prev) => (prev ? { ...prev, isCommitting: true } : prev)) + updateTimelineItem(item.id, { audioFadeOut: committedFade }) + } else { + setAudioFadeEdit(null) + } + } + + applyPreview(computeFadeSeconds(e.clientX)) + + const handleWindowMouseMove = (event: MouseEvent) => { + applyPreview(computeFadeSeconds(event.clientX)) + } + const handleWindowMouseUp = () => { + finishEdit() + } + + window.addEventListener('mousemove', handleWindowMouseMove) + window.addEventListener('mouseup', handleWindowMouseUp, { once: true }) + audioFadeCleanupRef.current = () => { + window.removeEventListener('mousemove', handleWindowMouseMove) + window.removeEventListener('mouseup', handleWindowMouseUp) + } + }, + [ + activeTool, + displayedAudioFadeIn, + displayedAudioFadeOut, + fps, + isAnyDragActiveRef, + item, + trackLocked, + transformRef, + updateTimelineItem, + ], + ) + + const handleAudioFadeCurveDotMouseDown = useCallback( + (e: React.MouseEvent, handle: AudioFadeHandle) => { + if ( + item.type !== 'audio' || + trackLocked || + activeTool !== 'select' || + isAnyDragActiveRef.current + ) { + return + } + + // Locally compute the fade ratio for this gesture's bounds check. + // We use item.durationInFrames here (no trim/stretch preview in progress + // at mouseDown), which avoids a circular dep on visualWidthFrames. + const clipFadeDurationFrames = Math.max(1, Math.round(item.durationInFrames)) + const fadeRatio = + handle === 'in' + ? getAudioFadeRatio(displayedAudioFadeIn, fps, clipFadeDurationFrames) + : getAudioFadeRatio(displayedAudioFadeOut, fps, clipFadeDurationFrames) + if (fadeRatio <= 0) { + return + } + + e.preventDefault() + e.stopPropagation() + + const originalFadeInCurve = displayedAudioFadeInCurve + const originalFadeOutCurve = displayedAudioFadeOutCurve + const originalFadeInCurveX = displayedAudioFadeInCurveX + const originalFadeOutCurveX = displayedAudioFadeOutCurveX + const persistedFadeInCurve = item.audioFadeInCurve ?? 0 + const persistedFadeOutCurve = item.audioFadeOutCurve ?? 0 + const persistedFadeInCurveX = item.audioFadeInCurveX ?? 0.52 + const persistedFadeOutCurveX = item.audioFadeOutCurveX ?? 0.52 + + const computeCurve = (clientX: number, clientY: number) => { + const rect = audioControlsRef.current?.getBoundingClientRect() + if (!rect) { + return { + curve: handle === 'in' ? originalFadeInCurve : originalFadeOutCurve, + curveX: handle === 'in' ? originalFadeInCurveX : originalFadeOutCurveX, + } + } + return getAudioFadeCurveFromOffset({ + handle, + pointerOffsetX: clientX - rect.left, + pointerOffsetY: clientY - rect.top, + fadePixels: fadeRatio * rect.width, + clipWidthPixels: rect.width, + rowHeight: rect.height, + }) + } + + const applyPreview = (next: { curve: number; curveX: number }) => { + setAudioFadeCurveEdit({ + handle, + previewFadeInCurve: handle === 'in' ? next.curve : originalFadeInCurve, + previewFadeOutCurve: handle === 'out' ? next.curve : originalFadeOutCurve, + previewFadeInCurveX: handle === 'in' ? next.curveX : originalFadeInCurveX, + previewFadeOutCurveX: handle === 'out' ? next.curveX : originalFadeOutCurveX, + originalFadeInCurve, + originalFadeOutCurve, + originalFadeInCurveX, + originalFadeOutCurveX, + isCommitting: false, + }) + } + + const finishEdit = () => { + const latestState = audioFadeCurveEditRef.current + const committedCurve = + handle === 'in' + ? (latestState?.previewFadeInCurve ?? originalFadeInCurve) + : (latestState?.previewFadeOutCurve ?? originalFadeOutCurve) + const committedCurveX = + handle === 'in' + ? (latestState?.previewFadeInCurveX ?? originalFadeInCurveX) + : (latestState?.previewFadeOutCurveX ?? originalFadeOutCurveX) + audioFadeCurveCleanupRef.current?.() + audioFadeCurveCleanupRef.current = null + + if (handle === 'in') { + if ( + Math.abs(committedCurve - persistedFadeInCurve) > AUDIO_FADE_EPSILON || + Math.abs(committedCurveX - persistedFadeInCurveX) > AUDIO_FADE_EPSILON + ) { + setAudioFadeCurveEdit((prev) => (prev ? { ...prev, isCommitting: true } : prev)) + updateTimelineItem(item.id, { + audioFadeInCurve: committedCurve, + audioFadeInCurveX: committedCurveX, + }) + } else { + setAudioFadeCurveEdit(null) + } + } else if ( + Math.abs(committedCurve - persistedFadeOutCurve) > AUDIO_FADE_EPSILON || + Math.abs(committedCurveX - persistedFadeOutCurveX) > AUDIO_FADE_EPSILON + ) { + setAudioFadeCurveEdit((prev) => (prev ? { ...prev, isCommitting: true } : prev)) + updateTimelineItem(item.id, { + audioFadeOutCurve: committedCurve, + audioFadeOutCurveX: committedCurveX, + }) + } else { + setAudioFadeCurveEdit(null) + } + } + + applyPreview(computeCurve(e.clientX, e.clientY)) + + const handleWindowMouseMove = (event: MouseEvent) => { + applyPreview(computeCurve(event.clientX, event.clientY)) + } + const handleWindowMouseUp = () => { + finishEdit() + } + + window.addEventListener('mousemove', handleWindowMouseMove) + window.addEventListener('mouseup', handleWindowMouseUp, { once: true }) + audioFadeCurveCleanupRef.current = () => { + window.removeEventListener('mousemove', handleWindowMouseMove) + window.removeEventListener('mouseup', handleWindowMouseUp) + } + }, + [ + activeTool, + displayedAudioFadeIn, + displayedAudioFadeInCurve, + displayedAudioFadeInCurveX, + displayedAudioFadeOut, + displayedAudioFadeOutCurve, + displayedAudioFadeOutCurveX, + fps, + isAnyDragActiveRef, + item, + trackLocked, + updateTimelineItem, + ], + ) + + const handleAudioVolumeMouseDown = useCallback( + (e: React.MouseEvent) => { + if ( + item.type !== 'audio' || + trackLocked || + activeTool !== 'select' || + isAnyDragActiveRef.current + ) { + return + } + + e.preventDefault() + e.stopPropagation() + + const originalVolume = item.volume ?? 0 + const dragStartLiveGain = getMixerLiveGain(item.id) + const startClientY = e.clientY + let latestClientY = startClientY + let latestPreviewVolume = originalVolume + let isDragActive = false + let activationTimeoutId: number | null = null + const dragAnchorY = startClientY + const dragAnchorVolume = originalVolume + + const applyPreview = (nextVolume: number) => { + latestPreviewVolume = nextVolume + applyAudioVolumeVisualPreview(nextVolume) + const gainRatio = Math.pow(10, (nextVolume - originalVolume) / 20) + setMixerLiveGains([{ itemId: item.id, gain: dragStartLiveGain * gainRatio }]) + } + + const clearActivationTimeout = () => { + if (activationTimeoutId !== null) { + window.clearTimeout(activationTimeoutId) + activationTimeoutId = null + } + } + + const computeVolumeDb = (clientY: number) => { + const rect = audioControlsRef.current?.getBoundingClientRect() + if (!rect) { + return originalVolume + } + return getAudioVolumeDbFromDragDelta({ + startVolumeDb: dragAnchorVolume, + pointerDeltaY: clientY - dragAnchorY, + height: rect.height, + }) + } + + const activateDrag = () => { + if (isDragActive) { + return + } + isDragActive = true + setAudioVolumeEdit({ + originalVolume, + isCommitting: false, + }) + applyPreview(computeVolumeDb(latestClientY)) + } + + const finishEdit = () => { + const committedVolume = audioVolumePreviewRef.current ?? latestPreviewVolume + audioVolumeCleanupRef.current?.() + audioVolumeCleanupRef.current = null + finalizeAudioVolumeChange(committedVolume, { + preserveLiveGainOnCommit: true, + commitFromActiveEdit: true, + }) + } + + const handleWindowMouseMove = (event: MouseEvent) => { + latestClientY = event.clientY + if (!isDragActive) { + if (Math.abs(event.clientY - startClientY) < AUDIO_VOLUME_DRAG_ACTIVATION_DISTANCE_PX) { + return + } + clearActivationTimeout() + activateDrag() + return + } + applyPreview(computeVolumeDb(event.clientY)) + } + const handleWindowMouseUp = () => { + if (!isDragActive) { + audioVolumeCleanupRef.current?.() + audioVolumeCleanupRef.current = null + finalizeAudioVolumeChange(originalVolume) + return + } + finishEdit() + } + + window.addEventListener('mousemove', handleWindowMouseMove) + window.addEventListener('mouseup', handleWindowMouseUp, { once: true }) + activationTimeoutId = window.setTimeout(() => { + clearActivationTimeout() + activateDrag() + }, AUDIO_VOLUME_DRAG_ACTIVATION_DELAY_MS) + audioVolumeCleanupRef.current = () => { + clearActivationTimeout() + window.removeEventListener('mousemove', handleWindowMouseMove) + window.removeEventListener('mouseup', handleWindowMouseUp) + } + }, + [ + activeTool, + applyAudioVolumeVisualPreview, + finalizeAudioVolumeChange, + isAnyDragActiveRef, + item, + trackLocked, + ], + ) + + const handleAudioVolumeDoubleClick = useCallback(() => { + if (item.type !== 'audio' || trackLocked) { + return + } + audioVolumeCleanupRef.current?.() + audioVolumeCleanupRef.current = null + finalizeAudioVolumeChange(0) + }, [finalizeAudioVolumeChange, item, trackLocked]) + + const handleVideoFadeHandleDoubleClick = useCallback( + (handle: AudioFadeHandle) => { + if (!isVisualFadeItem || trackLocked) { + return + } + videoFadeCleanupRef.current?.() + videoFadeCleanupRef.current = null + setVideoFadeEdit(null) + if (handle === 'in') { + if ((item.fadeIn ?? 0) > VIDEO_FADE_EPSILON) { + updateTimelineItem(item.id, { fadeIn: 0 }) + } + return + } + if ((item.fadeOut ?? 0) > VIDEO_FADE_EPSILON) { + updateTimelineItem(item.id, { fadeOut: 0 }) + } + }, + [isVisualFadeItem, item, trackLocked, updateTimelineItem], + ) + + const handleAudioFadeHandleDoubleClick = useCallback( + (handle: AudioFadeHandle) => { + if (item.type !== 'audio' || trackLocked) { + return + } + audioFadeCleanupRef.current?.() + audioFadeCleanupRef.current = null + setAudioFadeEdit(null) + if (handle === 'in') { + if ((item.audioFadeIn ?? 0) > AUDIO_FADE_EPSILON) { + updateTimelineItem(item.id, { audioFadeIn: 0 }) + } + return + } + if ((item.audioFadeOut ?? 0) > AUDIO_FADE_EPSILON) { + updateTimelineItem(item.id, { audioFadeOut: 0 }) + } + }, + [item, trackLocked, updateTimelineItem], + ) + + const handleAudioFadeCurveDotDoubleClick = useCallback( + (handle: AudioFadeHandle) => { + if (item.type !== 'audio' || trackLocked) { + return + } + audioFadeCurveCleanupRef.current?.() + audioFadeCurveCleanupRef.current = null + setAudioFadeCurveEdit(null) + if (handle === 'in') { + if ( + Math.abs(item.audioFadeInCurve ?? 0) > AUDIO_FADE_EPSILON || + Math.abs((item.audioFadeInCurveX ?? 0.52) - 0.52) > AUDIO_FADE_EPSILON + ) { + updateTimelineItem(item.id, { audioFadeInCurve: 0, audioFadeInCurveX: 0.52 }) + } + return + } + if ( + Math.abs(item.audioFadeOutCurve ?? 0) > AUDIO_FADE_EPSILON || + Math.abs((item.audioFadeOutCurveX ?? 0.52) - 0.52) > AUDIO_FADE_EPSILON + ) { + updateTimelineItem(item.id, { audioFadeOutCurve: 0, audioFadeOutCurveX: 0.52 }) + } + }, + [item, trackLocked, updateTimelineItem], + ) + + return { + videoControlsRef, + audioControlsRef, + volumeLineRef, + audioVolumeEditLabelRef, + isVisualFadeItem, + videoFadeEdit, + audioFadeEdit, + audioFadeCurveEdit, + audioVolumeEdit, + displayedVideoFadeIn, + displayedVideoFadeOut, + displayedAudioFadeIn, + displayedAudioFadeOut, + displayedAudioFadeInCurve, + displayedAudioFadeOutCurve, + displayedAudioFadeInCurveX, + displayedAudioFadeOutCurveX, + displayedAudioVolumeDb, + audioVolumePreviewRef, + handleVideoFadeHandleMouseDown, + handleVideoFadeHandleDoubleClick, + handleAudioFadeHandleMouseDown, + handleAudioFadeHandleDoubleClick, + handleAudioFadeCurveDotMouseDown, + handleAudioFadeCurveDotDoubleClick, + handleAudioVolumeMouseDown, + handleAudioVolumeDoubleClick, + } +} diff --git a/src/features/timeline/components/timeline-item/use-fade-math.ts b/src/features/timeline/components/timeline-item/use-fade-math.ts new file mode 100644 index 000000000..52518c3b2 --- /dev/null +++ b/src/features/timeline/components/timeline-item/use-fade-math.ts @@ -0,0 +1,277 @@ +import { useMemo } from 'react' +import type { TimelineItem as TimelineItemType } from '@/types/timeline' +import { getAudioFadeRatio } from '../../utils/audio-fade' +import { getAudioFadeCurveControlPoint, getAudioFadeCurvePath } from '../../utils/audio-fade-curve' +import { getAudioVisualizationScale, getAudioVolumeLineY } from '../../utils/audio-volume' + +const AUDIO_ENVELOPE_VIEWBOX_HEIGHT = 100 +const FADE_VIEWBOX_WIDTH = 1000 +const VIDEO_FADE_LINE_Y_PERCENT = 50 + +export interface FadeMath { + videoFadeInRatio: number + videoFadeOutRatio: number + audioFadeInRatio: number + audioFadeOutRatio: number + audioFadeInHoverLabel: string + audioFadeOutHoverLabel: string + videoFadeInHoverLabel: string + videoFadeOutHoverLabel: string + audioVolumeLineY: number + audioVolumeLineYPercent: number + audioVisualizationScale: number + videoFadeLineYPercent: number + isAudioVolumeControlActive: boolean + audioVolumeLineStroke: string + audioFadeInViewboxWidth: number + audioFadeOutViewboxWidth: number + videoFadeInViewboxWidth: number + videoFadeOutViewboxWidth: number + audioFadeInCurvePoint: ReturnType | null + audioFadeOutCurvePoint: ReturnType | null + audioFadeInCurvePath: string + audioFadeOutCurvePath: string + videoFadeInPath: string + videoFadeOutPath: string +} + +interface UseFadeMathParams { + item: TimelineItemType + fps: number + isVisualFadeItem: boolean + isSelected: boolean + audioVolumeEditActive: boolean + skipFadeComputation: boolean + clipFadeDurationFrames: number + displayedVideoFadeIn: number + displayedVideoFadeOut: number + displayedAudioFadeIn: number + displayedAudioFadeOut: number + displayedAudioFadeInCurve: number + displayedAudioFadeOutCurve: number + displayedAudioFadeInCurveX: number + displayedAudioFadeOutCurveX: number + displayedAudioVolumeDb: number +} + +export function useFadeMath({ + item, + fps, + isVisualFadeItem, + isSelected, + audioVolumeEditActive, + skipFadeComputation, + clipFadeDurationFrames, + displayedVideoFadeIn, + displayedVideoFadeOut, + displayedAudioFadeIn, + displayedAudioFadeOut, + displayedAudioFadeInCurve, + displayedAudioFadeOutCurve, + displayedAudioFadeInCurveX, + displayedAudioFadeOutCurveX, + displayedAudioVolumeDb, +}: UseFadeMathParams): FadeMath { + const videoFadeInRatio = useMemo( + () => + skipFadeComputation + ? 0 + : isVisualFadeItem + ? getAudioFadeRatio(displayedVideoFadeIn, fps, clipFadeDurationFrames) + : 0, + [skipFadeComputation, clipFadeDurationFrames, displayedVideoFadeIn, fps, isVisualFadeItem], + ) + const videoFadeOutRatio = useMemo( + () => + skipFadeComputation + ? 0 + : isVisualFadeItem + ? getAudioFadeRatio(displayedVideoFadeOut, fps, clipFadeDurationFrames) + : 0, + [skipFadeComputation, clipFadeDurationFrames, displayedVideoFadeOut, fps, isVisualFadeItem], + ) + const audioFadeInRatio = useMemo( + () => + skipFadeComputation + ? 0 + : item.type === 'audio' + ? getAudioFadeRatio(displayedAudioFadeIn, fps, clipFadeDurationFrames) + : 0, + [skipFadeComputation, clipFadeDurationFrames, displayedAudioFadeIn, fps, item.type], + ) + const audioFadeOutRatio = useMemo( + () => + skipFadeComputation + ? 0 + : item.type === 'audio' + ? getAudioFadeRatio(displayedAudioFadeOut, fps, clipFadeDurationFrames) + : 0, + [skipFadeComputation, clipFadeDurationFrames, displayedAudioFadeOut, fps, item.type], + ) + const audioFadeInHoverLabel = useMemo( + () => (skipFadeComputation ? '' : `Fade In ${displayedAudioFadeIn.toFixed(2)}s`), + [skipFadeComputation, displayedAudioFadeIn], + ) + const audioFadeOutHoverLabel = useMemo( + () => (skipFadeComputation ? '' : `Fade Out ${displayedAudioFadeOut.toFixed(2)}s`), + [skipFadeComputation, displayedAudioFadeOut], + ) + const videoFadeInHoverLabel = useMemo( + () => (skipFadeComputation ? '' : `Fade In ${displayedVideoFadeIn.toFixed(2)}s`), + [skipFadeComputation, displayedVideoFadeIn], + ) + const videoFadeOutHoverLabel = useMemo( + () => (skipFadeComputation ? '' : `Fade Out ${displayedVideoFadeOut.toFixed(2)}s`), + [skipFadeComputation, displayedVideoFadeOut], + ) + const audioVolumeLineY = useMemo( + () => + item.type === 'audio' + ? getAudioVolumeLineY(displayedAudioVolumeDb, AUDIO_ENVELOPE_VIEWBOX_HEIGHT) + : AUDIO_ENVELOPE_VIEWBOX_HEIGHT / 2, + [displayedAudioVolumeDb, item.type], + ) + const audioVisualizationScale = useMemo( + () => (item.type === 'audio' ? getAudioVisualizationScale(displayedAudioVolumeDb) : 1), + [displayedAudioVolumeDb, item.type], + ) + const audioVolumeLineYPercent = useMemo( + () => (audioVolumeLineY / AUDIO_ENVELOPE_VIEWBOX_HEIGHT) * 100, + [audioVolumeLineY], + ) + const isAudioVolumeControlActive = item.type === 'audio' && (isSelected || audioVolumeEditActive) + const audioVolumeLineStroke = isAudioVolumeControlActive + ? 'rgba(255,255,255,0.72)' + : 'rgba(255,255,255,0.42)' + + const audioFadeInViewboxWidth = audioFadeInRatio * FADE_VIEWBOX_WIDTH + const audioFadeOutViewboxWidth = audioFadeOutRatio * FADE_VIEWBOX_WIDTH + const videoFadeInViewboxWidth = videoFadeInRatio * FADE_VIEWBOX_WIDTH + const videoFadeOutViewboxWidth = videoFadeOutRatio * FADE_VIEWBOX_WIDTH + + const audioFadeInCurvePoint = useMemo( + () => + skipFadeComputation + ? null + : getAudioFadeCurveControlPoint({ + handle: 'in', + fadePixels: audioFadeInViewboxWidth, + clipWidthPixels: FADE_VIEWBOX_WIDTH, + curve: displayedAudioFadeInCurve, + curveX: displayedAudioFadeInCurveX, + }), + [ + skipFadeComputation, + audioFadeInViewboxWidth, + displayedAudioFadeInCurve, + displayedAudioFadeInCurveX, + ], + ) + const audioFadeOutCurvePoint = useMemo( + () => + skipFadeComputation + ? null + : getAudioFadeCurveControlPoint({ + handle: 'out', + fadePixels: audioFadeOutViewboxWidth, + clipWidthPixels: FADE_VIEWBOX_WIDTH, + curve: displayedAudioFadeOutCurve, + curveX: displayedAudioFadeOutCurveX, + }), + [ + skipFadeComputation, + audioFadeOutViewboxWidth, + displayedAudioFadeOutCurve, + displayedAudioFadeOutCurveX, + ], + ) + const audioFadeInCurvePath = useMemo( + () => + skipFadeComputation + ? '' + : getAudioFadeCurvePath({ + handle: 'in', + fadePixels: audioFadeInViewboxWidth, + clipWidthPixels: FADE_VIEWBOX_WIDTH, + curve: displayedAudioFadeInCurve, + curveX: displayedAudioFadeInCurveX, + }), + [ + skipFadeComputation, + audioFadeInViewboxWidth, + displayedAudioFadeInCurve, + displayedAudioFadeInCurveX, + ], + ) + const audioFadeOutCurvePath = useMemo( + () => + skipFadeComputation + ? '' + : getAudioFadeCurvePath({ + handle: 'out', + fadePixels: audioFadeOutViewboxWidth, + clipWidthPixels: FADE_VIEWBOX_WIDTH, + curve: displayedAudioFadeOutCurve, + curveX: displayedAudioFadeOutCurveX, + }), + [ + skipFadeComputation, + audioFadeOutViewboxWidth, + displayedAudioFadeOutCurve, + displayedAudioFadeOutCurveX, + ], + ) + const videoFadeInPath = useMemo( + () => + skipFadeComputation + ? '' + : getAudioFadeCurvePath({ + handle: 'in', + fadePixels: videoFadeInViewboxWidth, + clipWidthPixels: FADE_VIEWBOX_WIDTH, + curve: 0, + curveX: 0.52, + }), + [skipFadeComputation, videoFadeInViewboxWidth], + ) + const videoFadeOutPath = useMemo( + () => + skipFadeComputation + ? '' + : getAudioFadeCurvePath({ + handle: 'out', + fadePixels: videoFadeOutViewboxWidth, + clipWidthPixels: FADE_VIEWBOX_WIDTH, + curve: 0, + curveX: 0.52, + }), + [skipFadeComputation, videoFadeOutViewboxWidth], + ) + + return { + videoFadeInRatio, + videoFadeOutRatio, + audioFadeInRatio, + audioFadeOutRatio, + audioFadeInHoverLabel, + audioFadeOutHoverLabel, + videoFadeInHoverLabel, + videoFadeOutHoverLabel, + audioVolumeLineY, + audioVolumeLineYPercent, + audioVisualizationScale, + videoFadeLineYPercent: VIDEO_FADE_LINE_Y_PERCENT, + isAudioVolumeControlActive, + audioVolumeLineStroke, + audioFadeInViewboxWidth, + audioFadeOutViewboxWidth, + videoFadeInViewboxWidth, + videoFadeOutViewboxWidth, + audioFadeInCurvePoint, + audioFadeOutCurvePoint, + audioFadeInCurvePath, + audioFadeOutCurvePath, + videoFadeInPath, + videoFadeOutPath, + } +} diff --git a/src/features/timeline/components/timeline-item/use-smart-trim-hover.ts b/src/features/timeline/components/timeline-item/use-smart-trim-hover.ts new file mode 100644 index 000000000..65a6cd8a7 --- /dev/null +++ b/src/features/timeline/components/timeline-item/use-smart-trim-hover.ts @@ -0,0 +1,213 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type MutableRefObject, + type RefObject, +} from 'react' +import type { TimelineItem as TimelineItemType } from '@/types/timeline' +import { useTimelineStore } from '../../stores/timeline-store' +import { useTransitionsStore } from '../../stores/transitions-store' +import { useRollHoverStore } from '../../stores/roll-hover-store' +import { + resolveSmartBodyIntent, + resolveSmartTrimIntent, + SMART_TRIM_EDGE_ZONE_PX, + SMART_TRIM_RETENTION_PX, + SMART_TRIM_ROLL_ZONE_PX, + smartTrimIntentToHandle, + type SmartBodyIntent, + type SmartTrimIntent, +} from '../../utils/smart-trim-zones' +import { hasTransitionBridgeAtHandle } from '../../utils/transition-edit-guards' +import { findHandleNeighborWithTransitions } from '../../utils/transition-linked-neighbors' +import { getTimelineClipLabelRowHeightPx } from './hover-layout' + +const EDGE_HOVER_ZONE = SMART_TRIM_EDGE_ZONE_PX + +interface UseSmartTrimHoverParams { + item: TimelineItemType + trackLocked: boolean + activeTool: string + activeToolRef: RefObject + isAnyDragActiveRef: MutableRefObject +} + +export interface SmartTrimHoverHandle { + hoveredEdge: 'start' | 'end' | null + smartTrimIntent: SmartTrimIntent + smartBodyIntent: SmartBodyIntent + smartTrimIntentRef: RefObject + handleMouseMove: (e: React.MouseEvent) => void + handleMouseLeave: () => void +} + +export function useSmartTrimHover({ + item, + trackLocked, + activeTool, + activeToolRef, + isAnyDragActiveRef, +}: UseSmartTrimHoverParams): SmartTrimHoverHandle { + const [hoveredEdge, setHoveredEdge] = useState<'start' | 'end' | null>(null) + const [smartTrimIntent, setSmartTrimIntent] = useState(null) + const [smartBodyIntent, setSmartBodyIntent] = useState(null) + + const hoveredEdgeRef = useRef(hoveredEdge) + const smartTrimIntentRef = useRef(smartTrimIntent) + const smartBodyIntentRef = useRef(smartBodyIntent) + + const syncHoveredEdge = useCallback((nextHoveredEdge: 'start' | 'end' | null) => { + hoveredEdgeRef.current = nextHoveredEdge + setHoveredEdge(nextHoveredEdge) + }, []) + + const syncSmartTrimIntent = useCallback((nextIntent: SmartTrimIntent) => { + smartTrimIntentRef.current = nextIntent + setSmartTrimIntent(nextIntent) + }, []) + + const syncSmartBodyIntent = useCallback((nextIntent: SmartBodyIntent) => { + smartBodyIntentRef.current = nextIntent + setSmartBodyIntent(nextIntent) + }, []) + + // Clear stale hover state when the active tool changes (mouse may be stationary) + useEffect(() => { + syncHoveredEdge(null) + syncSmartTrimIntent(null) + syncSmartBodyIntent(null) + useRollHoverStore.getState().clearRollHover(item.id) + }, [activeTool, item.id, syncHoveredEdge, syncSmartBodyIntent, syncSmartTrimIntent]) + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (trackLocked || activeToolRef.current === 'razor' || isAnyDragActiveRef.current) { + if (hoveredEdgeRef.current !== null) syncHoveredEdge(null) + if (smartTrimIntentRef.current !== null) syncSmartTrimIntent(null) + if (smartBodyIntentRef.current !== null) syncSmartBodyIntent(null) + return + } + + const rect = e.currentTarget.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + const itemWidth = rect.width + + if (activeToolRef.current === 'trim-edit' || activeToolRef.current === 'select') { + const items = useTimelineStore.getState().items + const transitions = useTransitionsStore.getState().transitions + const hasLeftNeighbor = !!findHandleNeighborWithTransitions( + item, + 'start', + items, + transitions, + ) + const hasRightNeighbor = !!findHandleNeighborWithTransitions( + item, + 'end', + items, + transitions, + ) + const hasStartBridge = hasTransitionBridgeAtHandle(transitions, item.id, 'start') + const hasEndBridge = hasTransitionBridgeAtHandle(transitions, item.id, 'end') + const nextIntent = resolveSmartTrimIntent({ + x, + width: itemWidth, + hasLeftNeighbor, + hasRightNeighbor, + hasStartBridge, + hasEndBridge, + preferRippleOuterEdges: activeToolRef.current === 'trim-edit', + currentIntent: smartTrimIntentRef.current, + edgeZonePx: SMART_TRIM_EDGE_ZONE_PX, + rollZonePx: SMART_TRIM_ROLL_ZONE_PX, + retentionPx: SMART_TRIM_RETENTION_PX, + }) + const nextHoveredEdge = smartTrimIntentToHandle(nextIntent) + + if (smartTrimIntentRef.current !== nextIntent) { + const prevIntent = smartTrimIntentRef.current + syncSmartTrimIntent(nextIntent) + if (nextIntent === 'roll-start') { + const neighbor = findHandleNeighborWithTransitions(item, 'start', items, transitions) + if (neighbor) useRollHoverStore.getState().setRollHover(item.id, neighbor.id, 'end') + } else if (nextIntent === 'roll-end') { + const neighbor = findHandleNeighborWithTransitions(item, 'end', items, transitions) + if (neighbor) useRollHoverStore.getState().setRollHover(item.id, neighbor.id, 'start') + } else if (prevIntent === 'roll-start' || prevIntent === 'roll-end') { + useRollHoverStore.getState().clearRollHover(item.id) + } + } + if (hoveredEdgeRef.current !== nextHoveredEdge) { + syncHoveredEdge(nextHoveredEdge) + } + + if (activeToolRef.current === 'select') { + if (smartBodyIntentRef.current !== null) syncSmartBodyIntent(null) + return + } + + if (nextIntent) { + if (smartBodyIntentRef.current !== null) syncSmartBodyIntent(null) + return + } + + const nextBodyIntent = resolveSmartBodyIntent({ + y, + height: rect.height, + labelRowHeight: getTimelineClipLabelRowHeightPx(e.currentTarget), + isMediaItem: + item.type === 'video' || item.type === 'audio' || item.type === 'composition', + currentIntent: smartBodyIntentRef.current, + }) + if (smartBodyIntentRef.current !== nextBodyIntent) { + syncSmartBodyIntent(nextBodyIntent) + } + return + } + + if (smartTrimIntentRef.current !== null) syncSmartTrimIntent(null) + if (smartBodyIntentRef.current !== null) syncSmartBodyIntent(null) + + if (activeToolRef.current === 'rate-stretch') { + if (hoveredEdgeRef.current !== null) syncHoveredEdge(null) + return + } + + if (x <= EDGE_HOVER_ZONE) { + if (hoveredEdgeRef.current !== 'start') syncHoveredEdge('start') + } else if (x >= itemWidth - EDGE_HOVER_ZONE) { + if (hoveredEdgeRef.current !== 'end') syncHoveredEdge('end') + } else { + if (hoveredEdgeRef.current !== null) syncHoveredEdge(null) + } + }, + [ + activeToolRef, + isAnyDragActiveRef, + item, + syncHoveredEdge, + syncSmartBodyIntent, + syncSmartTrimIntent, + trackLocked, + ], + ) + + const handleMouseLeave = useCallback(() => { + syncHoveredEdge(null) + syncSmartTrimIntent(null) + syncSmartBodyIntent(null) + useRollHoverStore.getState().clearRollHover(item.id) + }, [item.id, syncHoveredEdge, syncSmartBodyIntent, syncSmartTrimIntent]) + + return { + hoveredEdge, + smartTrimIntent, + smartBodyIntent, + smartTrimIntentRef, + handleMouseMove, + handleMouseLeave, + } +} diff --git a/src/features/timeline/components/timeline-item/use-timeline-item-actions.ts b/src/features/timeline/components/timeline-item/use-timeline-item-actions.ts index c26765d24..f0b94c912 100644 --- a/src/features/timeline/components/timeline-item/use-timeline-item-actions.ts +++ b/src/features/timeline/components/timeline-item/use-timeline-item-actions.ts @@ -22,6 +22,7 @@ import { } from '@/features/timeline/deps/media-transcription-service' import { useTimelineStore } from '../../stores/timeline-store' import { useItemsStore } from '../../stores/items-store' +import { selectReplaceableCaptionClipIds } from '../../stores/items-store-indexes' import { useCompositionNavigationStore } from '../../stores/composition-navigation-store' import { insertFreezeFrame, @@ -346,7 +347,7 @@ export function useTimelineItemActions({ const mediaId = item.mediaId const clipId = item.id - const replaceExisting = useItemsStore.getState().replaceableCaptionClipIds.has(clipId) + const replaceExisting = selectReplaceableCaptionClipIds(useItemsStore.getState()).has(clipId) const store = useMediaLibraryStore.getState() const run = async () => { diff --git a/src/features/timeline/components/timeline-item/use-timeline-item-bounds.ts b/src/features/timeline/components/timeline-item/use-timeline-item-bounds.ts new file mode 100644 index 000000000..1dde22fa9 --- /dev/null +++ b/src/features/timeline/components/timeline-item/use-timeline-item-bounds.ts @@ -0,0 +1,325 @@ +import { useMemo } from 'react' +import type { TimelineItem as TimelineItemType } from '@/types/timeline' +import { frameToPixelsNow } from '../../utils/zoom-conversions' +import { timelineToSourceFrames } from '../../utils/source-calculations' +import { computeSlideContinuitySourceDelta } from '../../utils/slide-utils' + +interface RateStretchVisualFeedback { + from: number + duration: number + speed: number +} + +const COMPACT_CLIP_MAX_WIDTH_PX = 36 + +interface UseTimelineItemBoundsParams { + previewBaseItem: TimelineItemType + fps: number + + // Trim state + isTrimming: boolean + trimHandle: 'start' | 'end' | null + trimDelta: number + + // Stretch state + isStretching: boolean + stretchFeedback: RateStretchVisualFeedback | null + + // Slip/slide + isSlipSlideActive: boolean + slipEditDelta: number + slideEditOffset: number + slideNeighborSide: 'left' | 'right' | null + slideNeighborDelta: number + slideLeftNeighborForSlidItem: TimelineItemType | null + slideRightNeighborForSlidItem: TimelineItemType | null + + // Rolling/ripple/track-push + rollingEditDelta: number + rollingEditHandle: 'start' | 'end' | null + rippleEditOffset: number + rippleEdgeDelta: number + trackPushOffset: number +} + +export interface TimelineItemBounds { + leftFrame: number + rightFrame: number + left: number + right: number + width: number + visualLeftFrame: number + visualWidthFrames: number + visualLeft: number + visualWidth: number + isCompactWidth: boolean + slideFromOffset: number + slideDurationOffset: number + contentPreviewItem: TimelineItemType + preferImmediateContentRendering: boolean + effectiveSourceFps: number +} + +export function useTimelineItemBounds({ + previewBaseItem, + fps, + isTrimming, + trimHandle, + trimDelta, + isStretching, + stretchFeedback, + isSlipSlideActive, + slipEditDelta, + slideEditOffset, + slideNeighborSide, + slideNeighborDelta, + slideLeftNeighborForSlidItem, + slideRightNeighborForSlidItem, + rollingEditDelta, + rollingEditHandle, + rippleEditOffset, + rippleEdgeDelta, + trackPushOffset, +}: UseTimelineItemBoundsParams): TimelineItemBounds { + const slideFromOffset = slideEditOffset + (slideNeighborSide === 'right' ? slideNeighborDelta : 0) + const slideDurationOffset = + (slideNeighborSide === 'left' ? slideNeighborDelta : 0) + + (slideNeighborSide === 'right' ? -slideNeighborDelta : 0) + + const leftFrame = previewBaseItem.from + slideFromOffset + rippleEditOffset + trackPushOffset + const rightFrame = + previewBaseItem.from + + previewBaseItem.durationInFrames + + slideDurationOffset + + slideFromOffset + + rippleEditOffset + + trackPushOffset + const left = Math.round(frameToPixelsNow(leftFrame)) + const right = Math.round(frameToPixelsNow(rightFrame)) + const width = right - left + + const effectiveSourceFps = previewBaseItem.sourceFps ?? fps + + const contentPreviewItem = useMemo(() => { + let nextItem = previewBaseItem + let previewStartTrimDelta = 0 + let previewEndTrimDelta = 0 + let previewDurationDelta = 0 + + if (isTrimming && trimHandle) { + if (trimHandle === 'start') { + previewStartTrimDelta += trimDelta + previewDurationDelta += -trimDelta + } else { + previewEndTrimDelta += trimDelta + previewDurationDelta += trimDelta + } + } + + if (rollingEditDelta !== 0) { + if (rollingEditHandle === 'end') { + previewStartTrimDelta += rollingEditDelta + previewDurationDelta += -rollingEditDelta + } else if (rollingEditHandle === 'start') { + previewEndTrimDelta += rollingEditDelta + previewDurationDelta += rollingEditDelta + } + } + + if (slideNeighborSide && slideNeighborDelta !== 0) { + if (slideNeighborSide === 'right') { + previewStartTrimDelta += slideNeighborDelta + previewDurationDelta += -slideNeighborDelta + } else { + previewEndTrimDelta += slideNeighborDelta + previewDurationDelta += slideNeighborDelta + } + } + + if ((nextItem.type === 'video' || nextItem.type === 'audio') && slideEditOffset !== 0) { + const sourceDelta = computeSlideContinuitySourceDelta( + nextItem, + slideLeftNeighborForSlidItem, + slideRightNeighborForSlidItem, + slideEditOffset, + fps, + ) + if (sourceDelta !== 0 && nextItem.sourceEnd !== undefined) { + nextItem = { + ...nextItem, + sourceStart: (nextItem.sourceStart ?? 0) + sourceDelta, + sourceEnd: nextItem.sourceEnd + sourceDelta, + } + } + } + + if ( + (previewBaseItem.type === 'video' || previewBaseItem.type === 'audio') && + slipEditDelta !== 0 + ) { + const nextSourceStart = Math.max(0, (nextItem.sourceStart ?? 0) + slipEditDelta) + const nextSourceEnd = + nextItem.sourceEnd !== undefined + ? Math.max(nextSourceStart + 1, nextItem.sourceEnd + slipEditDelta) + : undefined + + nextItem = { + ...nextItem, + sourceStart: nextSourceStart, + sourceEnd: nextSourceEnd, + } + } + + const isCompositionWrapper = + nextItem.type === 'composition' || (nextItem.type === 'audio' && !!nextItem.compositionId) + + const supportsStartTrimSourceShift = + previewBaseItem.type === 'video' || previewBaseItem.type === 'audio' || isCompositionWrapper + if (supportsStartTrimSourceShift && previewStartTrimDelta !== 0) { + const sourceFramesDelta = timelineToSourceFrames( + previewStartTrimDelta, + nextItem.speed ?? 1, + fps, + effectiveSourceFps, + ) + nextItem = { + ...nextItem, + sourceStart: Math.max(0, (nextItem.sourceStart ?? 0) + sourceFramesDelta), + } + } + + if (previewDurationDelta !== 0) { + nextItem = { + ...nextItem, + durationInFrames: Math.max(1, nextItem.durationInFrames + previewDurationDelta), + } + } + + if (isCompositionWrapper && previewEndTrimDelta !== 0 && nextItem.sourceEnd !== undefined) { + const endSourceFramesDelta = timelineToSourceFrames( + previewEndTrimDelta, + nextItem.speed ?? 1, + fps, + effectiveSourceFps, + ) + nextItem = { + ...nextItem, + sourceEnd: Math.max( + (nextItem.sourceStart ?? 0) + 1, + nextItem.sourceEnd + endSourceFramesDelta, + ), + } + } + + return nextItem + }, [ + previewBaseItem, + isTrimming, + trimHandle, + trimDelta, + rollingEditDelta, + rollingEditHandle, + slipEditDelta, + slideEditOffset, + slideNeighborSide, + slideNeighborDelta, + slideLeftNeighborForSlidItem, + slideRightNeighborForSlidItem, + fps, + effectiveSourceFps, + ]) + + const preferImmediateContentRendering = + isTrimming || + isSlipSlideActive || + rollingEditDelta !== 0 || + rippleEditOffset !== 0 || + rippleEdgeDelta !== 0 || + slideEditOffset !== 0 || + slideNeighborDelta !== 0 + + const { visualLeftFrame, visualWidthFrames } = useMemo(() => { + let trimVisualLeftFrame = leftFrame + let trimVisualRightFrame = rightFrame + + if (rippleEdgeDelta !== 0) { + trimVisualRightFrame = + previewBaseItem.from + previewBaseItem.durationInFrames + rippleEdgeDelta + } else if (isTrimming && trimHandle) { + if (trimHandle === 'start') { + trimVisualLeftFrame = previewBaseItem.from + trimDelta + } else { + trimVisualRightFrame = previewBaseItem.from + previewBaseItem.durationInFrames + trimDelta + } + } + + if (rollingEditDelta !== 0) { + if (rollingEditHandle === 'end') { + trimVisualLeftFrame = previewBaseItem.from + rollingEditDelta + } else if (rollingEditHandle === 'start') { + trimVisualRightFrame = + previewBaseItem.from + previewBaseItem.durationInFrames + rollingEditDelta + } + } + + let stretchVisualLeftFrame = trimVisualLeftFrame + let stretchVisualRightFrame = trimVisualRightFrame + + if (isStretching && stretchFeedback) { + stretchVisualLeftFrame = stretchFeedback.from + stretchVisualRightFrame = stretchFeedback.from + stretchFeedback.duration + } + + const isActive = rippleEdgeDelta !== 0 || isTrimming || rollingEditDelta !== 0 + const nextVisualLeftFrame = isStretching + ? stretchVisualLeftFrame + : isActive + ? trimVisualLeftFrame + : leftFrame + const nextVisualRightFrame = isStretching + ? stretchVisualRightFrame + : isActive + ? trimVisualRightFrame + : rightFrame + + return { + visualLeftFrame: nextVisualLeftFrame, + visualWidthFrames: Math.max(1, nextVisualRightFrame - nextVisualLeftFrame), + } + }, [ + isTrimming, + trimHandle, + isStretching, + stretchFeedback, + previewBaseItem.from, + previewBaseItem.durationInFrames, + trimDelta, + rollingEditDelta, + rollingEditHandle, + rippleEdgeDelta, + leftFrame, + rightFrame, + ]) + + const visualLeft = Math.round(frameToPixelsNow(visualLeftFrame)) + const visualWidth = Math.round(frameToPixelsNow(visualWidthFrames)) + const isCompactWidth = visualWidth > 0 && visualWidth <= COMPACT_CLIP_MAX_WIDTH_PX + + return { + leftFrame, + rightFrame, + left, + right, + width, + visualLeftFrame, + visualWidthFrames, + visualLeft, + visualWidth, + isCompactWidth, + slideFromOffset, + slideDurationOffset, + contentPreviewItem, + preferImmediateContentRendering, + effectiveSourceFps, + } +} diff --git a/src/features/timeline/components/timeline-markers.tsx b/src/features/timeline/components/timeline-markers.tsx index ea4a2a72b..7235c6556 100644 --- a/src/features/timeline/components/timeline-markers.tsx +++ b/src/features/timeline/components/timeline-markers.tsx @@ -6,6 +6,7 @@ import { useTimelineStore } from '../stores/timeline-store' import { setInOutPointsWithoutHistory } from '../stores/actions/marker-actions' import { usePlaybackStore } from '@/shared/state/playback' import { useSelectionStore } from '@/shared/state/selection' +import { perfMarkRender } from '@/shared/logging/perf-marks' // Components import { TimelineInOutMarkers } from './timeline-in-out-markers' @@ -301,6 +302,7 @@ export const TimelineMarkers = memo(function TimelineMarkers({ duration, width, }: TimelineMarkersProps) { + perfMarkRender('TimelineMarkers') const editorDensity = useSettingsStore((s) => s.editorDensity) const editorLayout = getEditorLayout(editorDensity) const { timeToPixels, pixelsPerSecond, pixelsToFrame } = useTimelineZoomContext() diff --git a/src/features/timeline/components/timeline-media-drop-zone.tsx b/src/features/timeline/components/timeline-media-drop-zone.tsx index afb00773a..d4bb64b72 100644 --- a/src/features/timeline/components/timeline-media-drop-zone.tsx +++ b/src/features/timeline/components/timeline-media-drop-zone.tsx @@ -15,6 +15,7 @@ import { import { useTrackDropPreviewStore } from '../stores/track-drop-preview-store' import { useMediaLibraryStore } from '@/features/timeline/deps/media-library-store' import { useProjectStore } from '@/features/timeline/deps/projects' +import { DEFAULT_PROJECT_HEIGHT, DEFAULT_PROJECT_WIDTH } from '@/shared/projects/defaults' import { mediaLibraryService } from '@/features/timeline/deps/media-library-service' import { resolveMediaUrl, @@ -216,8 +217,8 @@ export const TimelineMediaDropZone = memo(function TimelineMediaDropZone({ const getCurrentCanvasSize = useCallback(() => { const liveProject = useProjectStore.getState().currentProject return { - width: liveProject?.metadata.width ?? 1920, - height: liveProject?.metadata.height ?? 1080, + width: liveProject?.metadata.width ?? DEFAULT_PROJECT_WIDTH, + height: liveProject?.metadata.height ?? DEFAULT_PROJECT_HEIGHT, } }, []) diff --git a/src/features/timeline/components/timeline-playhead.tsx b/src/features/timeline/components/timeline-playhead.tsx index bb97cb510..0cb238a3c 100644 --- a/src/features/timeline/components/timeline-playhead.tsx +++ b/src/features/timeline/components/timeline-playhead.tsx @@ -8,6 +8,7 @@ import { useSelectionStore } from '@/shared/state/selection' // Utilities and hooks import { useTimelineZoomContext } from '../contexts/timeline-zoom-context' import { createScrubThrottleState, shouldCommitScrubFrame } from '../utils/scrub-throttle' +import { withPerfMeasure, perfMarkRender } from '@/shared/logging/perf-marks' interface TimelinePlayheadProps { inRuler?: boolean // If true, shows diamond indicator for ruler @@ -24,6 +25,7 @@ interface TimelinePlayheadProps { * - Draggable for scrubbing through timeline */ export function TimelinePlayhead({ inRuler = false, maxFrame }: TimelinePlayheadProps) { + perfMarkRender('TimelinePlayhead') // Don't subscribe to currentFrame - use ref + manual subscription instead const setScrubFrame = usePlaybackStore((s) => s.setScrubFrame) const { frameToPixels, pixelsToFrame, pixelsPerSecond } = useTimelineZoomContext() @@ -189,21 +191,23 @@ export function TimelinePlayhead({ inRuler = false, maxFrame }: TimelinePlayhead if (rafIdRef.current === null) { rafIdRef.current = requestAnimationFrame(() => { rafIdRef.current = null - if (pendingFrameRef.current !== null && pendingPointerXRef.current !== null) { - const targetFrame = pendingFrameRef.current - const pointerX = pendingPointerXRef.current - if ( - shouldCommitScrubFrame({ - state: scrubThrottleStateRef.current, - pointerX, - targetFrame, - pixelsPerSecond: pixelsPerSecondRef.current, - nowMs: performance.now(), - }) - ) { - setScrubFrameRef.current(targetFrame) + withPerfMeasure('tl.raf.playheadScrub', () => { + if (pendingFrameRef.current !== null && pendingPointerXRef.current !== null) { + const targetFrame = pendingFrameRef.current + const pointerX = pendingPointerXRef.current + if ( + shouldCommitScrubFrame({ + state: scrubThrottleStateRef.current, + pointerX, + targetFrame, + pixelsPerSecond: pixelsPerSecondRef.current, + nowMs: performance.now(), + }) + ) { + setScrubFrameRef.current(targetFrame) + } } - } + }) }) } } diff --git a/src/features/timeline/components/timeline-track.tsx b/src/features/timeline/components/timeline-track.tsx index 83d805a72..ade8d11aa 100644 --- a/src/features/timeline/components/timeline-track.tsx +++ b/src/features/timeline/components/timeline-track.tsx @@ -1,6 +1,7 @@ import { useState, useRef, memo, useCallback, useEffect, useLayoutEffect } from 'react' import { useTranslation } from 'react-i18next' import { createLogger } from '@/shared/logging/logger' +import { perfMarkRender } from '@/shared/logging/perf-marks' const logger = createLogger('TimelineTrack') import type { @@ -23,6 +24,7 @@ import { useCompositionsStore } from '../stores/compositions-store' import { useSelectionStore } from '@/shared/state/selection' import { useMediaLibraryStore } from '@/features/timeline/deps/media-library-store' import { useProjectStore } from '@/features/timeline/deps/projects' +import { DEFAULT_PROJECT_HEIGHT, DEFAULT_PROJECT_WIDTH } from '@/shared/projects/defaults' import { mediaLibraryService } from '@/features/timeline/deps/media-library-service' import { resolveMediaUrl, @@ -34,7 +36,6 @@ import { findNearestAvailableSpaceInTrackItems, type CollisionRect, } from '../utils/collision-utils' -import { resolveEffectiveTrackStates } from '../utils/group-utils' import { mapWithConcurrency } from '@/shared/utils/async-utils' import { useExternalDragPreview } from '../hooks/use-external-drag-preview' import { useCompositionNavigationStore } from '../stores/composition-navigation-store' @@ -251,6 +252,7 @@ const TimelineTrackItems = memo(function TimelineTrackItems({ */ export const TimelineTrack = memo(function TimelineTrack({ track }: TimelineTrackProps) { + perfMarkRender('TimelineTrack') const { t } = useTranslation() const previewOwnerId = `track:${track.id}` const [gapContextMenuRequest, setGapContextMenuRequest] = @@ -289,10 +291,18 @@ export const TimelineTrack = memo(function TimelineTrack({ track }: TimelineTrac const dragOverFlagsRef = useRef({ isDragOver: false, isExternalDragOver: false }) // Resolve whether this track is effectively disabled for rendering or drops. - // Uses the shared resolveEffectiveTrackStates helper so group-inherited - // locked/visible/muted flags stay consistent with the rest of the timeline. + // Direct parent-group lookup keeps this O(1); calling resolveEffectiveTrackStates + // here would scan the whole tracks array per row on every store mutation. const trackInteractionState = useTimelineStore((s) => { - const effective = resolveEffectiveTrackStates(s.tracks).find((t) => t.id === track.id) ?? track + const parentGroup = track.parentTrackId + ? s.tracks.find((t) => t.id === track.parentTrackId && t.isGroup) + : undefined + const effectiveLocked = track.locked || parentGroup?.locked || false + const effectiveMuted = track.muted || parentGroup?.muted || false + const effectiveVisible = track.visible !== false && parentGroup?.visible !== false + const effective: TimelineTrackType = parentGroup + ? { ...track, locked: effectiveLocked, muted: effectiveMuted, visible: effectiveVisible } + : track return (effective.locked ? 1 : 0) | (getIsTrackDisabled(effective) ? 2 : 0) }) const isTrackLocked = (trackInteractionState & 1) !== 0 @@ -334,8 +344,8 @@ export const TimelineTrack = memo(function TimelineTrack({ track }: TimelineTrac const getCurrentCanvasSize = useCallback(() => { const liveProject = useProjectStore.getState().currentProject return { - width: liveProject?.metadata.width ?? 1920, - height: liveProject?.metadata.height ?? 1080, + width: liveProject?.metadata.width ?? DEFAULT_PROJECT_WIDTH, + height: liveProject?.metadata.height ?? DEFAULT_PROJECT_HEIGHT, } }, []) diff --git a/src/features/timeline/components/transition-item.tsx b/src/features/timeline/components/transition-item.tsx index 8340749b9..ae0ce6f39 100644 --- a/src/features/timeline/components/transition-item.tsx +++ b/src/features/timeline/components/transition-item.tsx @@ -1,6 +1,7 @@ import { memo, useCallback, useMemo, useState, useRef, useEffect } from 'react' import type { Transition } from '@/types/transition' import { useShallow } from 'zustand/react/shallow' +import { perfMarkRender } from '@/shared/logging/perf-marks' import { useTimelineStore } from '../stores/timeline-store' import { useItemsStore } from '../stores/items-store' import { useRollingEditPreviewStore } from '../stores/rolling-edit-preview-store' @@ -73,6 +74,7 @@ export const TransitionItem = memo(function TransitionItem({ transition, trackHidden = false, }: TransitionItemProps) { + perfMarkRender('TransitionItem') const { frameToPixels } = useTimelineZoomContext() const fps = useTimelineStore((s: TimelineState) => s.fps) const removeTransition = useTimelineStore((s: TimelineActions) => s.removeTransition) diff --git a/src/features/timeline/hooks/use-filmstrip.ts b/src/features/timeline/hooks/use-filmstrip.ts index 9bb7b0ab1..3f763a6ae 100644 --- a/src/features/timeline/hooks/use-filmstrip.ts +++ b/src/features/timeline/hooks/use-filmstrip.ts @@ -157,14 +157,23 @@ export function useFilmstrip({ // and parallel, so prefetching off-viewport clips lets them paint instantly // when scrolled into view. The extraction effect below still no-ops when it // finds a complete cache. + const hasInMemoryFrames = (filmstrip?.frames?.length ?? 0) > 0 useEffect(() => { if (!enabled || !duration || duration <= 0) return if (filmstrip?.isComplete) return + // Frames already live in the (singleton) in-memory cache, so re-reading + // every tile from disk on remount would only re-mint identical object URLs + // and thrash OPFS — which is exactly what happens while scrolling an + // incomplete (long-video) filmstrip in and out of view. The live extraction + // path keeps the in-memory cache current via notifyUpdate, and a fully + // evicted entry resets this to 0 (so a genuine reload still runs), so + // skipping here is safe. + if (hasInMemoryFrames) return void filmstripCache.loadFromDisk(mediaId, duration).catch(() => { // Swallow: extraction path below is the fallback once blobUrl arrives. }) - }, [mediaId, enabled, duration, filmstrip?.isComplete]) + }, [mediaId, enabled, duration, filmstrip?.isComplete, hasInMemoryFrames]) // Once a clip leaves the active workset, stop spending background decode time on it. useEffect(() => { diff --git a/src/features/timeline/services/filmstrip-cache-config.ts b/src/features/timeline/services/filmstrip-cache-config.ts new file mode 100644 index 000000000..423c2b97d --- /dev/null +++ b/src/features/timeline/services/filmstrip-cache-config.ts @@ -0,0 +1,45 @@ +/** + * Tunable configuration for {@link FilmstripCacheService}. + * + * Keeps the cold-start path intentionally conservative so dropping several + * clips into a fresh timeline does not fan out into a large parallel decode + * burst. Threshold knobs (priority/background stride, memory budgets) shape + * which frames get extracted first and how aggressively the cache evicts. + */ + +/** Frame rate of the extraction worker — must match the worker constant. */ +export const FRAME_RATE = 1 +/** Don't fan out to multiple workers unless a clip has at least this many frames. */ +export const MIN_FRAMES_PER_WORKER = 120 +/** Hard cap on workers per extraction on high-core devices. */ +export const MAX_WORKERS = 2 +/** Below this core count, never run extractions in parallel. */ +export const MIN_CORES_FOR_PARALLEL_WORKERS = 8 +/** Devices with at least this many cores get an extra concurrent extraction slot. */ +export const HIGH_CORE_THRESHOLD = 12 +export const MAX_CONCURRENT_EXTRACTIONS_BASE = 1 +export const MAX_CONCURRENT_EXTRACTIONS_HIGH_CORE = 2 +export const MIN_FILMSTRIP_TARGET_FRAMES = 60 +export const MAX_FILMSTRIP_TARGET_FRAMES = 160 +export const TARGET_FRAME_BUDGET_SCALE = 6 +export const MAX_PRIORITY_DENSE_FRAMES = 180 +/** Background stride (in frames) for clips longer than {@link MEDIUM_CLIP_FRAME_THRESHOLD}. */ +export const BACKGROUND_STRIDE_MEDIUM = 2 +export const BACKGROUND_STRIDE_LONG = 3 +export const BACKGROUND_STRIDE_VERY_LONG = 4 +export const MEDIUM_CLIP_FRAME_THRESHOLD = 300 +export const LONG_CLIP_FRAME_THRESHOLD = 1200 +export const VERY_LONG_CLIP_FRAME_THRESHOLD = 2400 +/** Idle time before a cache entry with no subscribers is evicted. */ +export const CACHE_EVICT_IDLE_MS = 15_000 +export const MEMORY_TARGET_BYTES = 500 * 1024 * 1024 +export const MEMORY_SOFT_LIMIT_BYTES = 420 * 1024 * 1024 +export const METRICS_HISTORY_LIMIT = 120 +export const PROGRESS_NOTIFY_INTERVAL_MS = 200 +export const PROGRESS_NOTIFY_FRAME_DELTA = 4 +export const IMAGE_FORMAT = 'image/jpeg' +export const IMAGE_QUALITY = 0.7 +export const MAX_IDLE_WORKERS_BASE = 2 +export const WORKER_PARALLEL_SAVES_BASE = 2 +export const WORKER_PARALLEL_SAVES_MEMORY_PRESSURE = 2 +export const MEMORY_CHECK_INTERVAL_MS = 500 diff --git a/src/features/timeline/services/filmstrip-cache-metrics.ts b/src/features/timeline/services/filmstrip-cache-metrics.ts new file mode 100644 index 000000000..de1fd49df --- /dev/null +++ b/src/features/timeline/services/filmstrip-cache-metrics.ts @@ -0,0 +1,176 @@ +/** + * Per-extraction metrics tracking for the filmstrip cache. + * + * Owns a ring buffer of recent extraction samples and the lifetime totals + * (`started/completed/failed/aborted`). The cache layer constructs an + * {@link ExtractionMetrics} when it begins extracting a clip and finalizes + * it on completion/failure; the accumulator then exposes an aggregated + * {@link FilmstripMetricsSnapshot} for the debug UI. + * + * Memory-related fields in the snapshot are supplied by the cache itself — + * this module owns only the extraction-timing portion. + */ +import { METRICS_HISTORY_LIMIT } from './filmstrip-cache-config' + +export type ExtractionOutcome = 'completed' | 'failed' | 'aborted' + +export interface ExtractionMetrics { + id: string + mediaId: string + startedAtMs: number + firstFrameAtMs: number | null + targetFrames: number + existingTargetFrames: number + framesToExtract: number + priorityFrames: number + backgroundStride: number + workerCount: number + usedVideoFallback: boolean +} + +export interface ExtractionMetricSample { + id: string + mediaId: string + startedAtMs: number + durationMs: number + timeToFirstFrameMs: number | null + targetFrames: number + existingTargetFrames: number + framesToExtract: number + priorityFrames: number + backgroundStride: number + workerCount: number + usedVideoFallback: boolean + extractedFrames: number + outcome: ExtractionOutcome +} + +export interface FilmstripMetricsTotals { + started: number + completed: number + failed: number + aborted: number +} + +export interface FilmstripMetricsAverages { + durationMs: number + timeToFirstFrameMs: number + extractFramesPerSecond: number +} + +export interface FilmstripMetricsMemory { + cacheBytes: number + cacheEntries: number + activeExtractions: number + queuedExtractions: number + usedJSHeapBytes: number | null + maxConcurrentExtractions: number +} + +export interface FilmstripMetricsSnapshot { + totals: FilmstripMetricsTotals + averages: FilmstripMetricsAverages + memory: FilmstripMetricsMemory + recent: ExtractionMetricSample[] +} + +export class FilmstripMetricsAccumulator { + private totals: FilmstripMetricsTotals = { + started: 0, + completed: 0, + failed: 0, + aborted: 0, + } + private history: ExtractionMetricSample[] = [] + + noteExtractionStarted(): void { + this.totals.started++ + } + + /** + * Marks the time the first frame was decoded — used to compute + * time-to-first-pixel. If the clip already had cached frames at start, + * the caller seeds this immediately. + */ + noteFirstFrame(metrics: ExtractionMetrics): void { + if (metrics.firstFrameAtMs === null) { + metrics.firstFrameAtMs = Date.now() + } + } + + finalize(metrics: ExtractionMetrics, outcome: ExtractionOutcome, extractedFrames: number): void { + const now = Date.now() + const sample: ExtractionMetricSample = { + id: metrics.id, + mediaId: metrics.mediaId, + startedAtMs: metrics.startedAtMs, + durationMs: Math.max(0, now - metrics.startedAtMs), + timeToFirstFrameMs: + metrics.firstFrameAtMs === null + ? null + : Math.max(0, metrics.firstFrameAtMs - metrics.startedAtMs), + targetFrames: metrics.targetFrames, + existingTargetFrames: metrics.existingTargetFrames, + framesToExtract: metrics.framesToExtract, + priorityFrames: metrics.priorityFrames, + backgroundStride: metrics.backgroundStride, + workerCount: metrics.workerCount, + usedVideoFallback: metrics.usedVideoFallback, + extractedFrames, + outcome, + } + + this.history.push(sample) + if (this.history.length > METRICS_HISTORY_LIMIT) { + this.history.shift() + } + + if (outcome === 'completed') this.totals.completed++ + if (outcome === 'failed') this.totals.failed++ + if (outcome === 'aborted') this.totals.aborted++ + } + + snapshot(memory: FilmstripMetricsMemory): FilmstripMetricsSnapshot { + const recent = [...this.history] + const completed = recent.filter((sample) => sample.outcome === 'completed') + // Exclude trivial extractions (single-frame or sub-250ms) from averages + // so a burst of cache hits doesn't skew throughput numbers downward. + const completedForAverages = completed.filter( + (sample) => sample.framesToExtract > 1 && sample.durationMs >= 250, + ) + const averageSamples = completedForAverages.length > 0 ? completedForAverages : completed + const durationAvg = + averageSamples.length > 0 + ? averageSamples.reduce((sum, sample) => sum + sample.durationMs, 0) / averageSamples.length + : 0 + const ttfpSamples = averageSamples.filter((sample) => sample.timeToFirstFrameMs !== null) + const ttfpAvg = + ttfpSamples.length > 0 + ? ttfpSamples.reduce((sum, sample) => sum + (sample.timeToFirstFrameMs ?? 0), 0) / + ttfpSamples.length + : 0 + const throughputAvg = + averageSamples.length > 0 + ? averageSamples.reduce((sum, sample) => { + const seconds = Math.max(0.001, sample.durationMs / 1000) + return sum + sample.framesToExtract / seconds + }, 0) / averageSamples.length + : 0 + + return { + totals: { ...this.totals }, + averages: { + durationMs: Math.round(durationAvg), + timeToFirstFrameMs: Math.round(ttfpAvg), + extractFramesPerSecond: Math.round(throughputAvg * 100) / 100, + }, + memory, + recent, + } + } + + clear(): void { + this.totals = { started: 0, completed: 0, failed: 0, aborted: 0 } + this.history = [] + } +} diff --git a/src/features/timeline/services/filmstrip-cache.ts b/src/features/timeline/services/filmstrip-cache.ts index cdcc1b352..e02a08591 100644 --- a/src/features/timeline/services/filmstrip-cache.ts +++ b/src/features/timeline/services/filmstrip-cache.ts @@ -32,7 +32,44 @@ import type { WarmRequest, WorkerResponse, } from '../workers/filmstrip-extraction-worker' +import { + BACKGROUND_STRIDE_LONG, + BACKGROUND_STRIDE_MEDIUM, + BACKGROUND_STRIDE_VERY_LONG, + CACHE_EVICT_IDLE_MS, + FRAME_RATE, + HIGH_CORE_THRESHOLD, + IMAGE_FORMAT, + IMAGE_QUALITY, + LONG_CLIP_FRAME_THRESHOLD, + MAX_CONCURRENT_EXTRACTIONS_BASE, + MAX_CONCURRENT_EXTRACTIONS_HIGH_CORE, + MAX_FILMSTRIP_TARGET_FRAMES, + MAX_IDLE_WORKERS_BASE, + MAX_PRIORITY_DENSE_FRAMES, + MAX_WORKERS, + MEDIUM_CLIP_FRAME_THRESHOLD, + MEMORY_CHECK_INTERVAL_MS, + MEMORY_SOFT_LIMIT_BYTES, + MEMORY_TARGET_BYTES, + MIN_CORES_FOR_PARALLEL_WORKERS, + MIN_FILMSTRIP_TARGET_FRAMES, + MIN_FRAMES_PER_WORKER, + PROGRESS_NOTIFY_FRAME_DELTA, + PROGRESS_NOTIFY_INTERVAL_MS, + TARGET_FRAME_BUDGET_SCALE, + VERY_LONG_CLIP_FRAME_THRESHOLD, + WORKER_PARALLEL_SAVES_BASE, + WORKER_PARALLEL_SAVES_MEMORY_PRESSURE, +} from './filmstrip-cache-config' +import { + FilmstripMetricsAccumulator, + type ExtractionMetrics, + type ExtractionOutcome, + type FilmstripMetricsSnapshot, +} from './filmstrip-cache-metrics' +export type { FilmstripMetricsSnapshot } export { THUMBNAIL_WIDTH } export type { FilmstripFrame } @@ -45,39 +82,6 @@ export interface Filmstrip { type FilmstripUpdateCallback = (filmstrip: Filmstrip) => void -// Configuration for extraction throughput. -// Keep the cold-start path intentionally conservative so dropping several clips -// into a fresh timeline does not fan out into a large parallel decode burst. -const FRAME_RATE = 1 // Must match worker - 1fps for filmstrip thumbnails -const MIN_FRAMES_PER_WORKER = 120 // Avoid over-parallelizing small/medium extractions -const MAX_WORKERS = 2 // Max workers per extraction on high-core devices -const MIN_CORES_FOR_PARALLEL_WORKERS = 8 // Enable worker parallelism on mid/high-end CPUs -const HIGH_CORE_THRESHOLD = 12 -const MAX_CONCURRENT_EXTRACTIONS_BASE = 1 -const MAX_CONCURRENT_EXTRACTIONS_HIGH_CORE = 2 -const MIN_FILMSTRIP_TARGET_FRAMES = 60 -const MAX_FILMSTRIP_TARGET_FRAMES = 160 -const TARGET_FRAME_BUDGET_SCALE = 6 -const MAX_PRIORITY_DENSE_FRAMES = 180 -const BACKGROUND_STRIDE_MEDIUM = 2 // 0.5fps equivalent outside priority range -const BACKGROUND_STRIDE_LONG = 3 -const BACKGROUND_STRIDE_VERY_LONG = 4 -const MEDIUM_CLIP_FRAME_THRESHOLD = 300 -const LONG_CLIP_FRAME_THRESHOLD = 1200 -const VERY_LONG_CLIP_FRAME_THRESHOLD = 2400 -const CACHE_EVICT_IDLE_MS = 15_000 -const MEMORY_TARGET_BYTES = 500 * 1024 * 1024 -const MEMORY_SOFT_LIMIT_BYTES = 420 * 1024 * 1024 -const METRICS_HISTORY_LIMIT = 120 -const PROGRESS_NOTIFY_INTERVAL_MS = 200 -const PROGRESS_NOTIFY_FRAME_DELTA = 4 -const IMAGE_FORMAT = 'image/jpeg' -const IMAGE_QUALITY = 0.7 -const MAX_IDLE_WORKERS_BASE = 2 -const WORKER_PARALLEL_SAVES_BASE = 2 -const WORKER_PARALLEL_SAVES_MEMORY_PRESSURE = 2 -const MEMORY_CHECK_INTERVAL_MS = 500 - interface WorkerState { worker: Worker requestId: string @@ -121,62 +125,6 @@ interface PriorityFrameRange { endIndex: number } -type ExtractionOutcome = 'completed' | 'failed' | 'aborted' - -interface ExtractionMetrics { - id: string - mediaId: string - startedAtMs: number - firstFrameAtMs: number | null - targetFrames: number - existingTargetFrames: number - framesToExtract: number - priorityFrames: number - backgroundStride: number - workerCount: number - usedVideoFallback: boolean -} - -interface ExtractionMetricSample { - id: string - mediaId: string - startedAtMs: number - durationMs: number - timeToFirstFrameMs: number | null - targetFrames: number - existingTargetFrames: number - framesToExtract: number - priorityFrames: number - backgroundStride: number - workerCount: number - usedVideoFallback: boolean - extractedFrames: number - outcome: ExtractionOutcome -} - -export interface FilmstripMetricsSnapshot { - totals: { - started: number - completed: number - failed: number - aborted: number - } - averages: { - durationMs: number - timeToFirstFrameMs: number - extractFramesPerSecond: number - } - memory: { - cacheBytes: number - cacheEntries: number - activeExtractions: number - queuedExtractions: number - usedJSHeapBytes: number | null - maxConcurrentExtractions: number - } - recent: ExtractionMetricSample[] -} - interface PriorityTimeWindow { startTime: number endTime: number @@ -209,13 +157,7 @@ class FilmstripCacheService { worker.onerror = null }, }) - private metricsTotals = { - started: 0, - completed: 0, - failed: 0, - aborted: 0, - } - private metricsHistory: ExtractionMetricSample[] = [] + private readonly metrics = new FilmstripMetricsAccumulator() private lastMemoryCheckAt = 0 private prewarmStarted = false // Generation counters guard against clearMedia/clearAll racing with an @@ -637,9 +579,7 @@ class FilmstripCacheService { } private noteFirstFrame(metrics: ExtractionMetrics): void { - if (metrics.firstFrameAtMs === null) { - metrics.firstFrameAtMs = Date.now() - } + this.metrics.noteFirstFrame(metrics) } private finalizeExtractionMetrics( @@ -647,84 +587,22 @@ class FilmstripCacheService { outcome: ExtractionOutcome, extractedFrames: number, ): void { - const now = Date.now() - const sample: ExtractionMetricSample = { - id: metrics.id, - mediaId: metrics.mediaId, - startedAtMs: metrics.startedAtMs, - durationMs: Math.max(0, now - metrics.startedAtMs), - timeToFirstFrameMs: - metrics.firstFrameAtMs === null - ? null - : Math.max(0, metrics.firstFrameAtMs - metrics.startedAtMs), - targetFrames: metrics.targetFrames, - existingTargetFrames: metrics.existingTargetFrames, - framesToExtract: metrics.framesToExtract, - priorityFrames: metrics.priorityFrames, - backgroundStride: metrics.backgroundStride, - workerCount: metrics.workerCount, - usedVideoFallback: metrics.usedVideoFallback, - extractedFrames, - outcome, - } - - this.metricsHistory.push(sample) - if (this.metricsHistory.length > METRICS_HISTORY_LIMIT) { - this.metricsHistory.shift() - } - - if (outcome === 'completed') this.metricsTotals.completed++ - if (outcome === 'failed') this.metricsTotals.failed++ - if (outcome === 'aborted') this.metricsTotals.aborted++ + this.metrics.finalize(metrics, outcome, extractedFrames) } getMetricsSnapshot(): FilmstripMetricsSnapshot { - const recent = [...this.metricsHistory] - const completed = recent.filter((sample) => sample.outcome === 'completed') - const completedForAverages = completed.filter( - (sample) => sample.framesToExtract > 1 && sample.durationMs >= 250, - ) - const averageSamples = completedForAverages.length > 0 ? completedForAverages : completed - const durationAvg = - averageSamples.length > 0 - ? averageSamples.reduce((sum, sample) => sum + sample.durationMs, 0) / averageSamples.length - : 0 - const ttfpSamples = averageSamples.filter((sample) => sample.timeToFirstFrameMs !== null) - const ttfpAvg = - ttfpSamples.length > 0 - ? ttfpSamples.reduce((sum, sample) => sum + (sample.timeToFirstFrameMs ?? 0), 0) / - ttfpSamples.length - : 0 - const throughputAvg = - averageSamples.length > 0 - ? averageSamples.reduce((sum, sample) => { - const seconds = Math.max(0.001, sample.durationMs / 1000) - return sum + sample.framesToExtract / seconds - }, 0) / averageSamples.length - : 0 - - return { - totals: { ...this.metricsTotals }, - averages: { - durationMs: Math.round(durationAvg), - timeToFirstFrameMs: Math.round(ttfpAvg), - extractFramesPerSecond: Math.round(throughputAvg * 100) / 100, - }, - memory: { - cacheBytes: this.cacheBytes, - cacheEntries: this.cache.size, - activeExtractions: this.activeExtractions.size, - queuedExtractions: this.extractionQueue.length, - usedJSHeapBytes: this.getUsedJsHeapBytes(), - maxConcurrentExtractions: this.getMaxConcurrentExtractions(), - }, - recent, - } + return this.metrics.snapshot({ + cacheBytes: this.cacheBytes, + cacheEntries: this.cache.size, + activeExtractions: this.activeExtractions.size, + queuedExtractions: this.extractionQueue.length, + usedJSHeapBytes: this.getUsedJsHeapBytes(), + maxConcurrentExtractions: this.getMaxConcurrentExtractions(), + }) } clearMetrics(): void { - this.metricsTotals = { started: 0, completed: 0, failed: 0, aborted: 0 } - this.metricsHistory = [] + this.metrics.clear() } private buildTargetIndices( @@ -1411,7 +1289,7 @@ class FilmstripCacheService { this.pendingExtractions.set(mediaId, pending) if (framesToExtract === 0) { - this.metricsTotals.started++ + this.metrics.noteExtractionStarted() const targetFrames = [...existingFrames].sort((a, b) => a.index - b.index) const settled = this.buildSettledFilmstrip(pending, targetFrames) if (settled.isComplete && this.shouldPersistCompletionMetadata(pending)) { @@ -1433,7 +1311,7 @@ class FilmstripCacheService { return } - this.metricsTotals.started++ + this.metrics.noteExtractionStarted() // Persist extraction session metadata once. Workers should focus on frame // writes; centralizing meta writes avoids cross-worker file contention. @@ -2074,7 +1952,7 @@ class FilmstripCacheService { pending.metrics.usedVideoFallback = true this.pendingExtractions.set(mediaId, pending) - this.metricsTotals.started++ + this.metrics.noteExtractionStarted() logger.warn(`Falling back to HTMLVideoElement extraction for ${mediaId}`) this.enqueueExtraction(mediaId) diff --git a/src/features/timeline/services/reverse-conform-service.ts b/src/features/timeline/services/reverse-conform-service.ts index 140b0cb88..228ecaa2e 100644 --- a/src/features/timeline/services/reverse-conform-service.ts +++ b/src/features/timeline/services/reverse-conform-service.ts @@ -13,6 +13,7 @@ import { reverseConformFilePath } from '@/infrastructure/storage/workspace-fs/pa import { opfsService } from '../deps/media-library-service' import { resolveMediaUrls } from '../deps/media-library-resolver' import { useMediaLibraryStore } from '../deps/media-library-store' +import { DEFAULT_PROJECT_HEIGHT, DEFAULT_PROJECT_WIDTH } from '@/shared/projects/defaults' export interface ReverseConformResult { itemId: string @@ -167,8 +168,8 @@ function getConformDimensions( item: VideoItem, quality: ReverseConformQuality, ): { width: number; height: number } { - const sourceWidth = Math.max(2, item.sourceWidth ?? 1920) - const sourceHeight = Math.max(2, item.sourceHeight ?? 1080) + const sourceWidth = Math.max(2, item.sourceWidth ?? DEFAULT_PROJECT_WIDTH) + const sourceHeight = Math.max(2, item.sourceHeight ?? DEFAULT_PROJECT_HEIGHT) if (quality === 'full') { return { width: sourceWidth, height: sourceHeight } } diff --git a/src/features/timeline/stores/actions/composition-actions.ts b/src/features/timeline/stores/actions/composition-actions.ts index b1ffbc846..9100da745 100644 --- a/src/features/timeline/stores/actions/composition-actions.ts +++ b/src/features/timeline/stores/actions/composition-actions.ts @@ -21,6 +21,7 @@ import { useCompositionsStore, type SubComposition } from '../compositions-store import { useEditorStore } from '@/shared/state/editor' import { useSelectionStore } from '@/shared/state/selection' import { DEFAULT_TRACK_HEIGHT } from '../../constants' +import { DEFAULT_PROJECT_HEIGHT, DEFAULT_PROJECT_WIDTH } from '@/shared/projects/defaults' import { useCompositionNavigationStore } from '../composition-navigation-store' import { useProjectStore } from '@/features/timeline/deps/projects' import { @@ -475,8 +476,8 @@ export function createPreComp(name?: string, itemIds?: string[]): TimelineItem | // --- 2. Determine canvas dimensions from project settings --- // Compound/pre-comp timelines should inherit the current project canvas. const projectMetadata = useProjectStore.getState().currentProject?.metadata - const width = projectMetadata?.width ?? 1920 - const height = projectMetadata?.height ?? 1080 + const width = projectMetadata?.width ?? DEFAULT_PROJECT_WIDTH + const height = projectMetadata?.height ?? DEFAULT_PROJECT_HEIGHT const backgroundColor = projectMetadata?.backgroundColor // --- 3. Collect distinct source tracks and build sub-comp tracks --- diff --git a/src/features/timeline/stores/actions/edit/freeze-frame-actions.ts b/src/features/timeline/stores/actions/edit/freeze-frame-actions.ts index c0db25812..ab23008fe 100644 --- a/src/features/timeline/stores/actions/edit/freeze-frame-actions.ts +++ b/src/features/timeline/stores/actions/edit/freeze-frame-actions.ts @@ -44,8 +44,7 @@ export async function insertFreezeFrame(itemId: string, playheadFrame: number): const sourceFrame = sourceStart + timelineToSourceFrames(timelineOffset, speed, fps, sourceFps) // Get media metadata for resolution and fps info - const mediaItems = useMediaLibraryStore.getState().mediaItems - const media = mediaItems.find((m) => m.id === item.mediaId) + const media = item.mediaId ? useMediaLibraryStore.getState().mediaById[item.mediaId] : undefined if (!media) { getLogger().error('[insertFreezeFrame] Media not found for item:', item.mediaId) return false diff --git a/src/features/timeline/stores/actions/shared.ts b/src/features/timeline/stores/actions/shared.ts index 8dea7e5ef..a820d853d 100644 --- a/src/features/timeline/stores/actions/shared.ts +++ b/src/features/timeline/stores/actions/shared.ts @@ -6,6 +6,7 @@ import type { TimelineItem, TimelineTrack } from '@/types/timeline' import type { Transition } from '@/types/transition' import type { ItemKeyframes } from '@/types/keyframe' import { createLogger } from '@/shared/logging/logger' +import { withPerfMeasure } from '@/shared/logging/perf-marks' import { emitDomainEvent } from '@/shared/utils/domain-events' import { useTimelineCommandStore } from '../timeline-command-store' import { useItemsStore } from '../items-store' @@ -23,7 +24,9 @@ export function getLogger() { } export function execute(type: string, action: () => T, payload?: Record): T { - return useTimelineCommandStore.getState().execute({ type, payload }, action) + return withPerfMeasure(`tl.action.${type}`, () => + useTimelineCommandStore.getState().execute({ type, payload }, action), + ) } /** @@ -36,11 +39,8 @@ export function applyTransitionRepairs( ): void { const items = useItemsStore.getState().items const transitions = useTransitionsStore.getState().transitions - const { valid, repaired, broken } = repairTransitions( - changedClipIds, - items, - transitions, - deletedClipIds, + const { valid, repaired, broken } = withPerfMeasure('tl.repairTransitions', () => + repairTransitions(changedClipIds, items, transitions, deletedClipIds), ) // Merge valid + repaired transitions diff --git a/src/features/timeline/stores/actions/source-edit-actions.ts b/src/features/timeline/stores/actions/source-edit-actions.ts index 6094dfe41..90ee1efc0 100644 --- a/src/features/timeline/stores/actions/source-edit-actions.ts +++ b/src/features/timeline/stores/actions/source-edit-actions.ts @@ -18,6 +18,7 @@ import { execute, applyTransitionRepairs, getLogger } from './shared' import { resolveSourceEditTrackTargets } from '../../utils/source-edit-targeting' import { buildMediaTimelineItems } from '../../utils/media-timeline-item-builder' import { DEFAULT_TRACK_HEIGHT } from '../../constants' +import { DEFAULT_PROJECT_HEIGHT, DEFAULT_PROJECT_WIDTH } from '@/shared/projects/defaults' interface SourceEditContext { sourceMediaId: string @@ -72,8 +73,7 @@ async function resolveSourceEditContext(): Promise { : null const referenceTrack = activeTrack ?? preferredVideoTrack ?? preferredAudioTrack ?? null - const mediaItems = useMediaLibraryStore.getState().mediaItems - const media = mediaItems.find((m) => m.id === sourceMediaId) + const media = useMediaLibraryStore.getState().mediaById[sourceMediaId] if (!media) { getLogger().warn('Source edit: Source media not found') return null @@ -103,8 +103,8 @@ async function resolveSourceEditContext(): Promise { const insertFrame = usePlaybackStore.getState().currentFrame const currentProject = useProjectStore.getState().currentProject - const canvasWidth = currentProject?.metadata.width ?? 1920 - const canvasHeight = currentProject?.metadata.height ?? 1080 + const canvasWidth = currentProject?.metadata.width ?? DEFAULT_PROJECT_WIDTH + const canvasHeight = currentProject?.metadata.height ?? DEFAULT_PROJECT_HEIGHT const hasAudio = mediaType === 'video' && !!media.audioCodec const resolvedTargets = resolveSourceEditTrackTargets({ tracks, diff --git a/src/features/timeline/stores/items-store-indexes.ts b/src/features/timeline/stores/items-store-indexes.ts new file mode 100644 index 000000000..6316bd747 --- /dev/null +++ b/src/features/timeline/stores/items-store-indexes.ts @@ -0,0 +1,348 @@ +import type { AudioItem, TextItem, TimelineItem, VideoItem } from '@/types/timeline' +import { getTextItemPlainText } from '@/shared/utils/text-item-spans' +import { getLinkedItems } from '../utils/linked-items' +import { useTransitionsStore } from './transitions-store' + +export interface ItemsIndexState { + items: TimelineItem[] + itemsByTrackId: Record + itemById: Record + itemsByLinkedGroupId: Record + linkedItemsByItemId: Record + maxItemEndFrame: number +} + +function areItemArraysEqual(a: TimelineItem[] | undefined, b: TimelineItem[]): boolean { + if (!a || a.length !== b.length) return false + for (let i = 0; i < b.length; i++) { + if (a[i] !== b[i]) return false + } + return true +} + +export function buildItemsByTrackId( + items: TimelineItem[], + previous: Record, +): Record { + const grouped: Record = {} + for (const item of items) { + ;(grouped[item.trackId] ??= []).push(item) + } + + const next: Record = {} + for (const [trackId, trackItems] of Object.entries(grouped)) { + const previousTrackItems = previous[trackId] + next[trackId] = + previousTrackItems && areItemArraysEqual(previousTrackItems, trackItems) + ? previousTrackItems + : trackItems + } + + return next +} + +export function buildItemsByLinkedGroupId( + items: TimelineItem[], + previous: Record, +): Record { + const grouped: Record = {} + for (const item of items) { + if (item.linkedGroupId) { + ;(grouped[item.linkedGroupId] ??= []).push(item) + } + } + + const next: Record = {} + for (const [groupId, groupItems] of Object.entries(grouped)) { + const previousGroupItems = previous[groupId] + next[groupId] = + previousGroupItems && areItemArraysEqual(previousGroupItems, groupItems) + ? previousGroupItems + : groupItems + } + + return next +} + +function isCaptionableClip(item: TimelineItem): item is AudioItem | VideoItem { + return ( + (item.type === 'audio' || item.type === 'video') && + typeof item.mediaId === 'string' && + item.mediaId.length > 0 + ) +} + +function isLegacyGeneratedCaptionItem(item: TimelineItem): item is TextItem { + const plainText = item.type === 'text' ? getTextItemPlainText(item) : '' + return ( + item.type === 'text' && + !item.captionSource && + typeof item.mediaId === 'string' && + item.mediaId.length > 0 && + plainText.trim().length > 0 && + item.label === plainText.slice(0, 48) + ) +} + +// Lazy, items-keyed memoization. The legacy caption-detection pass is O(N) with +// string slicing + per-mediaId sorts; running it inside withItemIndexes makes +// every drag-frame mutation pay for it. Callers go through +// `selectReplaceableCaptionClipIds` instead, which rebuilds only when `items` +// changes identity. +let captionCacheItems: TimelineItem[] | null = null +let captionCacheSet: Set = new Set() + +export function selectReplaceableCaptionClipIds(state: { items: TimelineItem[] }): Set { + if (captionCacheItems === state.items) return captionCacheSet + captionCacheItems = state.items + captionCacheSet = buildReplaceableCaptionClipIds(state.items) + return captionCacheSet +} + +export function buildReplaceableCaptionClipIds(items: TimelineItem[]): Set { + const ids = new Set() + const clipsByMediaId: Record> = {} + + for (const item of items) { + if ( + item.type === 'text' && + item.captionSource?.type === 'transcript' && + item.captionSource.clipId + ) { + ids.add(item.captionSource.clipId) + continue + } + + if (item.type === 'subtitle' && item.source.type === 'transcript' && item.source.clipId) { + ids.add(item.source.clipId) + continue + } + + if (isCaptionableClip(item)) { + const mediaId = item.mediaId + if (!mediaId) continue + ;(clipsByMediaId[mediaId] ??= []).push(item) + } + } + + for (const clips of Object.values(clipsByMediaId)) { + clips.sort((left, right) => left.from - right.from) + } + + for (const item of items) { + if (!isLegacyGeneratedCaptionItem(item) || !item.mediaId) { + continue + } + + const mediaId = item.mediaId + const itemEnd = item.from + item.durationInFrames + const candidateClips = clipsByMediaId[mediaId] + if (!candidateClips) { + continue + } + + for (const clip of candidateClips) { + if (clip.from > item.from) { + break + } + + const clipEnd = clip.from + clip.durationInFrames + if (item.from >= clip.from && itemEnd <= clipEnd) { + ids.add(clip.id) + } + } + } + + return ids +} + +function isMediaPair(left: TimelineItem, right: TimelineItem): boolean { + return ( + (left.type === 'video' && right.type === 'audio') || + (left.type === 'audio' && right.type === 'video') + ) +} + +function isLegacyLinkCandidate(item: TimelineItem): item is AudioItem | VideoItem { + return ( + !item.linkedGroupId && + isCaptionableClip(item) && + typeof item.originId === 'string' && + item.originId.length > 0 + ) +} + +function isLegacyLinkedPair(anchor: TimelineItem, candidate: TimelineItem): boolean { + if (!isMediaPair(anchor, candidate)) return false + if (!anchor.originId || anchor.originId !== candidate.originId) return false + if (!anchor.mediaId || anchor.mediaId !== candidate.mediaId) return false + return anchor.from === candidate.from && anchor.durationInFrames === candidate.durationInFrames +} + +export function buildLinkedItemsByItemId( + items: TimelineItem[], + itemsByLinkedGroupId: Record, + previous: Record, +): Record { + const next: Record = {} + + for (const groupItems of Object.values(itemsByLinkedGroupId)) { + if (groupItems.length <= 1) { + continue + } + + for (const item of groupItems) { + next[item.id] = groupItems + } + } + + const legacyGroups: Record = {} + for (const item of items) { + if (!isLegacyLinkCandidate(item)) { + continue + } + + const key = `${item.originId}|${item.mediaId}|${item.from}|${item.durationInFrames}` + ;(legacyGroups[key] ??= []).push(item) + } + + for (const groupItems of Object.values(legacyGroups)) { + if (groupItems.length <= 1) { + continue + } + + for (const anchor of groupItems) { + const linkedItems = groupItems.filter( + (candidate) => candidate.id === anchor.id || isLegacyLinkedPair(anchor, candidate), + ) + + if (linkedItems.length <= 1) { + continue + } + + const previousLinkedItems = previous[anchor.id] + next[anchor.id] = + previousLinkedItems && areItemArraysEqual(previousLinkedItems, linkedItems) + ? previousLinkedItems + : linkedItems + } + } + + return next +} + +export function buildItemById( + items: TimelineItem[], + previous: Record, +): Record { + const next: Record = {} + for (const item of items) { + const previousItem = previous[item.id] + next[item.id] = previousItem !== undefined && previousItem === item ? previousItem : item + } + return next +} + +export function buildItemsMediaDependencyIds(items: TimelineItem[]): string[] { + const mediaIds = new Set() + for (const item of items) { + if (item.mediaId) { + mediaIds.add(item.mediaId) + } + } + return [...mediaIds].sort() +} + +export function buildMediaDependencyKey(mediaDependencyIds: string[]): string { + return mediaDependencyIds.join('|') +} + +export function computeMaxItemEndFrame(items: TimelineItem[]): number { + let max = 0 + for (const item of items) { + const end = item.from + item.durationInFrames + if (end > max) max = end + } + return max +} + +export function withItemIndexes( + items: TimelineItem[], + previous: Pick< + ItemsIndexState, + 'itemsByTrackId' | 'itemById' | 'itemsByLinkedGroupId' | 'linkedItemsByItemId' + >, +): ItemsIndexState { + const itemsByLinkedGroupId = buildItemsByLinkedGroupId(items, previous.itemsByLinkedGroupId) + return { + items, + itemsByTrackId: buildItemsByTrackId(items, previous.itemsByTrackId), + itemById: buildItemById(items, previous.itemById), + itemsByLinkedGroupId, + linkedItemsByItemId: buildLinkedItemsByItemId( + items, + itemsByLinkedGroupId, + previous.linkedItemsByItemId, + ), + maxItemEndFrame: computeMaxItemEndFrame(items), + } +} + +/** + * Get IDs of clips that have a transition with the given item. + * These clips are allowed to overlap during trim operations. + */ +export function getTransitionLinkedIds(itemId: string): Set { + const transitions = useTransitionsStore.getState().transitions + const linkedIds = new Set() + for (const t of transitions) { + if (t.leftClipId === itemId) linkedIds.add(t.rightClipId) + if (t.rightClipId === itemId) linkedIds.add(t.leftClipId) + } + return linkedIds +} + +export function buildRippleShiftByItemId( + items: TimelineItem[], + deletedItems: TimelineItem[], +): Map { + const shiftByItemId = new Map() + + for (const item of items) { + let shiftAmount = 0 + for (const deletedItem of deletedItems) { + if ( + deletedItem.trackId === item.trackId && + deletedItem.from + deletedItem.durationInFrames <= item.from + ) { + shiftAmount += deletedItem.durationInFrames + } + } + shiftByItemId.set(item.id, shiftAmount) + } + + const visited = new Set() + for (const item of items) { + if (visited.has(item.id)) continue + + const linkedItems = getLinkedItems(items, item.id) + for (const linkedItem of linkedItems) { + visited.add(linkedItem.id) + } + + if (linkedItems.length <= 1) continue + + let groupShift = 0 + for (const linkedItem of linkedItems) { + groupShift = Math.max(groupShift, shiftByItemId.get(linkedItem.id) ?? 0) + } + + if (groupShift <= 0) continue + + for (const linkedItem of linkedItems) { + shiftByItemId.set(linkedItem.id, groupShift) + } + } + + return shiftByItemId +} diff --git a/src/features/timeline/stores/items-store-normalize.test.ts b/src/features/timeline/stores/items-store-normalize.test.ts new file mode 100644 index 000000000..d7f2a631d --- /dev/null +++ b/src/features/timeline/stores/items-store-normalize.test.ts @@ -0,0 +1,351 @@ +import { describe, expect, it } from 'vite-plus/test' +import type { AudioItem, SubtitleSegmentItem, VideoItem } from '@/types/timeline' +import { + normalizeFrameFields, + normalizeItemUpdates, + normalizeOptionalFps, + normalizeTrack, + roundDuration, + roundFrame, + roundOptionalFrame, + trimSubtitleCuesAtEnd, + trimSubtitleCuesAtStart, +} from './items-store-normalize' + +function makeVideo(overrides: Partial = {}): VideoItem { + return { + id: 'clip-1', + type: 'video', + trackId: 'track-1', + from: 0, + durationInFrames: 100, + label: 'clip.mp4', + src: 'blob:test', + mediaId: 'media-1', + ...overrides, + } +} + +function makeAudio(overrides: Partial = {}): AudioItem { + return { + id: 'audio-1', + type: 'audio', + trackId: 'track-1', + from: 0, + durationInFrames: 100, + label: 'clip.wav', + src: 'blob:test', + mediaId: 'media-1', + ...overrides, + } +} + +describe('roundFrame', () => { + it('rounds half-up to the nearest non-negative integer', () => { + expect(roundFrame(12.4)).toBe(12) + expect(roundFrame(12.6)).toBe(13) + }) + + it('clamps negative values to zero', () => { + expect(roundFrame(-5)).toBe(0) + }) + + it('falls back when the value is not finite', () => { + expect(roundFrame(Number.NaN)).toBe(0) + expect(roundFrame(Number.POSITIVE_INFINITY)).toBe(0) + expect(roundFrame(Number.NaN, 42)).toBe(42) + }) +}) + +describe('roundDuration', () => { + it('rounds to the nearest integer with a minimum of 1', () => { + expect(roundDuration(0)).toBe(1) + expect(roundDuration(0.4)).toBe(1) + expect(roundDuration(5.6)).toBe(6) + }) + + it('falls back for non-finite values', () => { + expect(roundDuration(Number.NaN)).toBe(1) + expect(roundDuration(Number.NaN, 10)).toBe(10) + }) +}) + +describe('roundOptionalFrame', () => { + it('passes undefined through', () => { + expect(roundOptionalFrame(undefined)).toBeUndefined() + }) + + it('rounds defined numbers', () => { + expect(roundOptionalFrame(7.4)).toBe(7) + }) +}) + +describe('normalizeOptionalFps', () => { + it('rounds to three decimals', () => { + expect(normalizeOptionalFps(29.970029970029)).toBe(29.97) + }) + + it('drops invalid fps values', () => { + expect(normalizeOptionalFps(undefined)).toBeUndefined() + expect(normalizeOptionalFps(0)).toBeUndefined() + expect(normalizeOptionalFps(-1)).toBeUndefined() + expect(normalizeOptionalFps(Number.NaN)).toBeUndefined() + }) +}) + +describe('normalizeFrameFields', () => { + it('rounds the required frame fields', () => { + const result = normalizeFrameFields( + makeVideo({ from: 10.6, durationInFrames: 5.4 } as VideoItem), + ) + expect(result.from).toBe(11) + expect(result.durationInFrames).toBe(5) + }) + + it('rounds optional source/trim fields when present', () => { + const result = normalizeFrameFields( + makeVideo({ + trimStart: 1.6, + trimEnd: 2.6, + sourceStart: 3.4, + sourceEnd: 8.5, + sourceDuration: 12.49, + sourceFps: 29.970029970029, + } as VideoItem), + ) + expect(result.trimStart).toBe(2) + expect(result.trimEnd).toBe(3) + expect(result.sourceStart).toBe(3) + expect(result.sourceEnd).toBe(9) + expect(result.sourceDuration).toBe(12) + expect(result.sourceFps).toBe(29.97) + }) + + it('infers sourceStart=0 for legacy clips that only have sourceEnd', () => { + const result = normalizeFrameFields( + makeVideo({ sourceEnd: 120, sourceStart: undefined } as VideoItem), + ) + expect(result.sourceStart).toBe(0) + expect(result.sourceEnd).toBe(120) + }) + + it('clamps audio EQ gain to ±20 dB', () => { + const result = normalizeFrameFields( + makeAudio({ + audioEqOutputGainDb: 50, + audioEqLowGainDb: -50, + audioEqHighGainDb: 5, + } as AudioItem), + ) + expect(result.audioEqOutputGainDb).toBe(20) + expect(result.audioEqLowGainDb).toBe(-20) + expect(result.audioEqHighGainDb).toBe(5) + }) + + it('clamps audio EQ Q values into the supported range', () => { + const result = normalizeFrameFields( + makeAudio({ + audioEqLowQ: 0.01, + audioEqHighQ: 100, + } as AudioItem), + ) + expect(result.audioEqLowQ).toBe(0.3) + expect(result.audioEqHighQ).toBe(10.3) + }) + + it('clamps low-cut and high-cut frequencies into their distinct ranges', () => { + const result = normalizeFrameFields( + makeAudio({ + audioEqLowCutFrequencyHz: 5, // below 20 Hz floor + audioEqHighCutFrequencyHz: 50000, // above 22 kHz ceiling + } as AudioItem), + ) + expect(result.audioEqLowCutFrequencyHz).toBe(20) + expect(result.audioEqHighCutFrequencyHz).toBe(22000) + }) + + it('coerces enabled flags to booleans', () => { + const result = normalizeFrameFields( + makeAudio({ + // The schema is typed but the runtime can see truthy non-booleans + // when projects are restored from disk. + audioEqLowEnabled: 1 as unknown as boolean, + audioEqHighEnabled: 0 as unknown as boolean, + } as AudioItem), + ) + expect(result.audioEqLowEnabled).toBe(true) + expect(result.audioEqHighEnabled).toBe(false) + }) + + it('snaps cut slopes to the supported values', () => { + const result = normalizeFrameFields( + makeAudio({ + audioEqLowCutSlopeDbPerOct: 7 as unknown as 6, + audioEqHighCutSlopeDbPerOct: 12, + } as AudioItem), + ) + // 7 is not in {6,12,18,24} so clampAudioEqCutSlopeDbPerOct returns the + // default slope; 12 is valid and passes through unchanged. + expect([6, 12, 18, 24]).toContain(result.audioEqLowCutSlopeDbPerOct) + expect(result.audioEqHighCutSlopeDbPerOct).toBe(12) + }) + + it('leaves untouched fields alone (does not introduce undefined optional EQ fields)', () => { + const result = normalizeFrameFields(makeAudio({})) + expect(result.audioEqLowGainDb).toBeUndefined() + expect(result.audioEqHighQ).toBeUndefined() + expect(result.audioEqOutputGainDb).toBeUndefined() + }) + + it('forces shape masks to use the normal blend mode', () => { + const masked = normalizeFrameFields({ + id: 'shape-1', + type: 'shape', + trackId: 'track-1', + from: 0, + durationInFrames: 30, + label: 'rect', + shape: 'rectangle', + isMask: true, + blendMode: 'multiply', + } as unknown as VideoItem) + expect((masked as unknown as { blendMode: string }).blendMode).toBe('normal') + }) +}) + +describe('normalizeItemUpdates', () => { + it('only rounds the fields that were provided', () => { + const result = normalizeItemUpdates({ + from: 10.6, + durationInFrames: 4.4, + }) + expect(result.from).toBe(11) + expect(result.durationInFrames).toBe(4) + expect('trimStart' in result).toBe(false) + }) + + it('passes undefined-only updates through without inventing fields', () => { + const result = normalizeItemUpdates({ label: 'rename' }) + expect(result.label).toBe('rename') + expect('from' in result).toBe(false) + expect('audioEqLowGainDb' in result).toBe(false) + }) + + it('clamps any provided EQ values', () => { + const result = normalizeItemUpdates({ + audioEqOutputGainDb: 99, + audioEqLowQ: 0, + audioEqHighCutFrequencyHz: 100000, + }) + expect(result.audioEqOutputGainDb).toBe(20) + expect(result.audioEqLowQ).toBe(0.3) + expect(result.audioEqHighCutFrequencyHz).toBe(22000) + }) + + it('back-fills sourceStart=0 when only sourceEnd is updated (legacy clips)', () => { + const result = normalizeItemUpdates({ sourceEnd: 240 }) + expect(result.sourceStart).toBe(0) + expect(result.sourceEnd).toBe(240) + }) + + it('does not insert sourceStart when neither bound is provided', () => { + const result = normalizeItemUpdates({ from: 5 }) + expect('sourceStart' in result).toBe(false) + }) +}) + +describe('normalizeTrack', () => { + it('clamps track volume to -60..12 dB and normalizes EQ settings', () => { + const clampedHigh = normalizeTrack({ + id: 't1', + name: 'A', + order: 0, + volume: 999, + } as never) + expect(clampedHigh.volume).toBe(12) + + const clampedLow = normalizeTrack({ + id: 't1', + name: 'A', + order: 0, + volume: -999, + } as never) + expect(clampedLow.volume).toBe(-60) + }) + + it('leaves volume undefined for tracks that did not opt in', () => { + const track = normalizeTrack({ id: 't1', name: 'A', order: 0 } as never) + expect(track.volume).toBeUndefined() + }) +}) + +describe('trimSubtitleCuesAtStart', () => { + function makeSegment(cues: SubtitleSegmentItem['cues']): SubtitleSegmentItem { + return { + id: 'sub-1', + type: 'subtitle', + trackId: 'track-1', + from: 0, + durationInFrames: 300, + label: 'sub', + cues, + source: { type: 'transcript', mediaId: 'media-1', clipId: 'clip-1' }, + color: '#ffffff', + } as SubtitleSegmentItem + } + + it('returns null when no trim is requested', () => { + const result = trimSubtitleCuesAtStart( + makeSegment([{ id: 'c1', startSeconds: 0, endSeconds: 1, text: 'a' }]), + 0, + 30, + ) + expect(result).toBeNull() + }) + + it('drops cues entirely before the new boundary', () => { + const segment = makeSegment([ + { id: 'gone', startSeconds: 0, endSeconds: 0.5, text: 'a' }, + { id: 'keep', startSeconds: 1.5, endSeconds: 2, text: 'b' }, + ]) + const result = trimSubtitleCuesAtStart(segment, 30, 30)! + expect(result.cues.map((c) => c.id)).toEqual(['keep']) + expect(result.cues[0]!.startSeconds).toBe(0.5) + expect(result.cues[0]!.endSeconds).toBe(1) + }) + + it('clamps the start of cues that straddle the boundary', () => { + const segment = makeSegment([{ id: 'split', startSeconds: 0.4, endSeconds: 2, text: 'a' }]) + const result = trimSubtitleCuesAtStart(segment, 30, 30)! + expect(result.cues).toHaveLength(1) + expect(result.cues[0]!.startSeconds).toBe(0) + expect(result.cues[0]!.endSeconds).toBe(1) + }) +}) + +describe('trimSubtitleCuesAtEnd', () => { + function makeSegment(cues: SubtitleSegmentItem['cues']): SubtitleSegmentItem { + return { + id: 'sub-1', + type: 'subtitle', + trackId: 'track-1', + from: 0, + durationInFrames: 300, + label: 'sub', + cues, + source: { type: 'transcript', mediaId: 'media-1', clipId: 'clip-1' }, + color: '#ffffff', + } as SubtitleSegmentItem + } + + it('drops cues that start after the new duration and truncates straddlers', () => { + const segment = makeSegment([ + { id: 'keep', startSeconds: 0, endSeconds: 0.5, text: 'a' }, + { id: 'cut', startSeconds: 0.9, endSeconds: 2, text: 'b' }, + { id: 'gone', startSeconds: 2, endSeconds: 3, text: 'c' }, + ]) + const result = trimSubtitleCuesAtEnd(segment, 30, 30)! + expect(result.cues.map((c) => c.id)).toEqual(['keep', 'cut']) + expect(result.cues.find((c) => c.id === 'cut')!.endSeconds).toBe(1) + }) +}) diff --git a/src/features/timeline/stores/items-store-normalize.ts b/src/features/timeline/stores/items-store-normalize.ts new file mode 100644 index 000000000..bdabfc27d --- /dev/null +++ b/src/features/timeline/stores/items-store-normalize.ts @@ -0,0 +1,134 @@ +import type { SubtitleSegmentItem, TimelineItem, TimelineTrack } from '@/types/timeline' +import { normalizeAudioEqSettings } from '@/shared/utils/audio-eq' +import { resolveCornerPinTargetRect } from '@/features/timeline/deps/composition-runtime' +import { + applyOptionalClamps, + roundDuration, + roundFrame, + roundOptionalFrame, + normalizeOptionalFps, +} from '@/shared/timeline/item-clamps' + +export { roundFrame, roundDuration, roundOptionalFrame, normalizeOptionalFps } + +export function normalizeFrameFields(item: T): T { + // Start from a shallow copy so the optional-clamp loop can rewrite fields + // in place without mutating the caller's object. + const normalized = { ...item } as Record + normalized.from = roundFrame(item.from) + normalized.durationInFrames = roundDuration(item.durationInFrames) + applyOptionalClamps(normalized) + + const result = normalized as TimelineItem + + if (result.cornerPin) { + const cornerPinTargetRect = resolveCornerPinTargetRect( + result.transform?.width ?? 0, + result.transform?.height ?? 0, + result.type === 'video' || result.type === 'image' + ? { + sourceWidth: result.sourceWidth, + sourceHeight: result.sourceHeight, + crop: result.crop, + } + : undefined, + ) + result.cornerPin = { + ...result.cornerPin, + referenceWidth: + result.cornerPin.referenceWidth ?? + (cornerPinTargetRect.width > 0 ? cornerPinTargetRect.width : undefined), + referenceHeight: + result.cornerPin.referenceHeight ?? + (cornerPinTargetRect.height > 0 ? cornerPinTargetRect.height : undefined), + } + } + + if (result.type === 'shape' && result.isMask) { + result.blendMode = 'normal' + } + + // Legacy split clips can have sourceEnd without sourceStart. + // Treat them as explicitly bounded from 0 to sourceEnd so rate stretch + // operates on the split segment rather than the full media duration. + if ( + (result.type === 'video' || result.type === 'audio') && + result.sourceEnd !== undefined && + result.sourceStart === undefined + ) { + result.sourceStart = 0 + } + + return result as T +} + +export function normalizeItemUpdates(updates: Partial): Partial { + const normalized = { ...updates } as Record + + if (normalized.from !== undefined) normalized.from = roundFrame(normalized.from as number) + if (normalized.durationInFrames !== undefined) { + normalized.durationInFrames = roundDuration(normalized.durationInFrames as number) + } + + applyOptionalClamps(normalized) + + // Keep legacy end-only bounds explicit and stable. + if (normalized.sourceEnd !== undefined && normalized.sourceStart === undefined) { + normalized.sourceStart = 0 + } + + return normalized as Partial +} + +export function normalizeTrack(track: TimelineTrack): TimelineTrack { + return { + ...track, + volume: track.volume === undefined ? undefined : Math.max(-60, Math.min(12, track.volume)), + audioEq: normalizeAudioEqSettings(track.audioEq), + } +} + +/** + * Trim a subtitle segment from its start: re-anchor every cue's time so the + * new `from` becomes 0, dropping cues entirely before the new boundary and + * clamping cues that straddle it. + * + * `clampedAmount` is in timeline frames — positive means trimming inward. + */ +export function trimSubtitleCuesAtStart( + item: SubtitleSegmentItem, + clampedAmount: number, + timelineFps: number, +): { cues: SubtitleSegmentItem['cues'] } | null { + if (clampedAmount === 0) return null + const offsetSeconds = clampedAmount / timelineFps + const nextCues: SubtitleSegmentItem['cues'] = [] + for (const cue of item.cues) { + if (cue.endSeconds <= offsetSeconds) continue // entirely outside new window + const startSeconds = Math.max(0, cue.startSeconds - offsetSeconds) + const endSeconds = cue.endSeconds - offsetSeconds + if (endSeconds <= startSeconds) continue + nextCues.push({ ...cue, startSeconds, endSeconds }) + } + return { cues: nextCues } +} + +/** + * Trim a subtitle segment from its end: drop cues past the new duration and + * clamp cues that straddle the boundary. + */ +export function trimSubtitleCuesAtEnd( + item: SubtitleSegmentItem, + newDurationFrames: number, + timelineFps: number, +): { cues: SubtitleSegmentItem['cues'] } | null { + const newEndSeconds = newDurationFrames / timelineFps + const nextCues: SubtitleSegmentItem['cues'] = [] + for (const cue of item.cues) { + if (cue.startSeconds >= newEndSeconds) continue + const endSeconds = Math.min(cue.endSeconds, newEndSeconds) + if (endSeconds <= cue.startSeconds) continue + nextCues.push({ ...cue, endSeconds }) + } + return { cues: nextCues } +} diff --git a/src/features/timeline/stores/items-store.test.ts b/src/features/timeline/stores/items-store.test.ts index b4c5489fc..e5645eded 100644 --- a/src/features/timeline/stores/items-store.test.ts +++ b/src/features/timeline/stores/items-store.test.ts @@ -7,6 +7,7 @@ import type { VideoItem, } from '@/types/timeline' import { useItemsStore } from './items-store' +import { selectReplaceableCaptionClipIds } from './items-store-indexes' import { useTimelineSettingsStore } from './timeline-settings-store' import { timelineToSourceFrames } from '../utils/source-calculations' import { rollingTrimItems } from './actions/item-actions' @@ -562,7 +563,7 @@ describe('items-store indexes', () => { useItemsStore.getState().setItems([clip, legacyCaption]) - expect(useItemsStore.getState().replaceableCaptionClipIds.has('legacy-clip')).toBe(true) + expect(selectReplaceableCaptionClipIds(useItemsStore.getState()).has('legacy-clip')).toBe(true) }) it('indexes transcript subtitle segments as replaceable for their source clip', () => { @@ -581,7 +582,9 @@ describe('items-store indexes', () => { useItemsStore.getState().setItems([clip, transcriptSegment]) - expect(useItemsStore.getState().replaceableCaptionClipIds.has('transcript-clip')).toBe(true) + expect(selectReplaceableCaptionClipIds(useItemsStore.getState()).has('transcript-clip')).toBe( + true, + ) }) it('indexes legacy linked audio/video pairs for O(1) lookups', () => { diff --git a/src/features/timeline/stores/items-store.ts b/src/features/timeline/stores/items-store.ts index 7c6d30646..70c25f933 100644 --- a/src/features/timeline/stores/items-store.ts +++ b/src/features/timeline/stores/items-store.ts @@ -1,7 +1,6 @@ import { create } from 'zustand' import { createLogger } from '@/shared/logging/logger' -import type { AudioItem, TextItem, TimelineItem, TimelineTrack, VideoItem } from '@/types/timeline' -import { getTextItemPlainText } from '@/shared/utils/text-item-spans' +import type { TimelineItem, TimelineTrack } from '@/types/timeline' import type { TransformProperties } from '@/types/transform' import type { VisualEffect, ItemEffect } from '@/types/effects' import { @@ -17,881 +16,33 @@ import { calculateSpeed, clampSpeed, } from '../utils/source-calculations' -import { getLinkedItems } from '../utils/linked-items' import { isCompositionWrapperItem, wouldCreateCompositionCycle } from '../utils/composition-graph' import { useCompositionNavigationStore } from './composition-navigation-store' import { useCompositionsStore } from './compositions-store' import { useTimelineSettingsStore } from './timeline-settings-store' -import { useTransitionsStore } from './transitions-store' import { useMarkersStore } from './markers-store' -import { clampAudioFadeCurve, clampAudioFadeCurveX } from '@/shared/utils/audio-fade-curve' -import { - AUDIO_EQ_HIGH_CUT_FREQUENCY_HZ, - AUDIO_EQ_HIGH_CUT_MAX_FREQUENCY_HZ, - AUDIO_EQ_HIGH_CUT_MIN_FREQUENCY_HZ, - AUDIO_EQ_HIGH_FREQUENCY_HZ, - AUDIO_EQ_HIGH_MAX_FREQUENCY_HZ, - AUDIO_EQ_HIGH_MID_FREQUENCY_HZ, - AUDIO_EQ_HIGH_MID_MAX_FREQUENCY_HZ, - AUDIO_EQ_HIGH_MID_MIN_FREQUENCY_HZ, - AUDIO_EQ_HIGH_MID_Q, - AUDIO_EQ_HIGH_MIN_FREQUENCY_HZ, - AUDIO_EQ_LOW_CUT_FREQUENCY_HZ, - AUDIO_EQ_LOW_CUT_MAX_FREQUENCY_HZ, - AUDIO_EQ_LOW_CUT_MIN_FREQUENCY_HZ, - AUDIO_EQ_LOW_FREQUENCY_HZ, - AUDIO_EQ_LOW_MAX_FREQUENCY_HZ, - AUDIO_EQ_LOW_MID_FREQUENCY_HZ, - AUDIO_EQ_LOW_MID_MAX_FREQUENCY_HZ, - AUDIO_EQ_LOW_MID_MIN_FREQUENCY_HZ, - AUDIO_EQ_LOW_MID_Q, - AUDIO_EQ_LOW_MIN_FREQUENCY_HZ, - clampAudioEqCutSlopeDbPerOct, - clampAudioEqFrequencyHz, - clampAudioEqGainDb, - clampAudioEqQ, - normalizeAudioEqSettings, -} from '@/shared/utils/audio-eq' -import { normalizeCropSettings } from '@/shared/utils/media-crop' -import { clampAudioPitchCents, clampAudioPitchSemitones } from '@/shared/utils/audio-pitch' import { getEffectiveTimelineMaxFrame, sanitizeInOutPoints } from '../utils/in-out-points' -import { resolveCornerPinTargetRect } from '@/features/timeline/deps/composition-runtime' +import { + normalizeFrameFields, + normalizeItemUpdates, + normalizeTrack, + roundDuration, + roundFrame, + trimSubtitleCuesAtEnd, + trimSubtitleCuesAtStart, +} from './items-store-normalize' +import { + buildItemsMediaDependencyIds, + buildMediaDependencyKey, + buildRippleShiftByItemId, + getTransitionLinkedIds, + withItemIndexes, +} from './items-store-indexes' function getLog() { return createLogger('ItemsStore') } -function roundFrame(value: number, fallback = 0): number { - if (!Number.isFinite(value)) return fallback - return Math.max(0, Math.round(value)) -} - -function roundDuration(value: number, fallback = 1): number { - if (!Number.isFinite(value)) return fallback - return Math.max(1, Math.round(value)) -} - -function roundOptionalFrame(value: number | undefined): number | undefined { - if (value === undefined) return undefined - return roundFrame(value) -} - -function normalizeOptionalFps(value: number | undefined): number | undefined { - if (value === undefined || !Number.isFinite(value) || value <= 0) return undefined - return Math.round(value * 1000) / 1000 -} - -function normalizeFrameFields(item: T): T { - const normalized = { - ...item, - from: roundFrame(item.from), - durationInFrames: roundDuration(item.durationInFrames), - trimStart: roundOptionalFrame(item.trimStart), - trimEnd: roundOptionalFrame(item.trimEnd), - sourceStart: roundOptionalFrame(item.sourceStart), - sourceEnd: roundOptionalFrame(item.sourceEnd), - sourceDuration: roundOptionalFrame(item.sourceDuration), - sourceFps: normalizeOptionalFps(item.sourceFps), - crop: normalizeCropSettings(item.crop), - audioFadeInCurve: - item.audioFadeInCurve === undefined ? undefined : clampAudioFadeCurve(item.audioFadeInCurve), - audioFadeOutCurve: - item.audioFadeOutCurve === undefined - ? undefined - : clampAudioFadeCurve(item.audioFadeOutCurve), - audioFadeInCurveX: - item.audioFadeInCurveX === undefined - ? undefined - : clampAudioFadeCurveX(item.audioFadeInCurveX), - audioFadeOutCurveX: - item.audioFadeOutCurveX === undefined - ? undefined - : clampAudioFadeCurveX(item.audioFadeOutCurveX), - audioPitchSemitones: - item.audioPitchSemitones === undefined - ? undefined - : clampAudioPitchSemitones(item.audioPitchSemitones), - audioPitchCents: - item.audioPitchCents === undefined ? undefined : clampAudioPitchCents(item.audioPitchCents), - audioEqOutputGainDb: - item.audioEqOutputGainDb === undefined - ? undefined - : clampAudioEqGainDb(item.audioEqOutputGainDb), - audioEqBand1Enabled: - item.audioEqBand1Enabled === undefined ? undefined : !!item.audioEqBand1Enabled, - audioEqBand1Type: item.audioEqBand1Type, - audioEqBand1FrequencyHz: - item.audioEqBand1FrequencyHz === undefined - ? undefined - : clampAudioEqFrequencyHz( - item.audioEqBand1FrequencyHz, - AUDIO_EQ_LOW_CUT_MIN_FREQUENCY_HZ, - AUDIO_EQ_LOW_CUT_MAX_FREQUENCY_HZ, - AUDIO_EQ_LOW_CUT_FREQUENCY_HZ, - ), - audioEqBand1GainDb: - item.audioEqBand1GainDb === undefined - ? undefined - : clampAudioEqGainDb(item.audioEqBand1GainDb), - audioEqBand1Q: - item.audioEqBand1Q === undefined - ? undefined - : clampAudioEqQ(item.audioEqBand1Q, AUDIO_EQ_LOW_MID_Q), - audioEqBand1SlopeDbPerOct: - item.audioEqBand1SlopeDbPerOct === undefined - ? undefined - : clampAudioEqCutSlopeDbPerOct(item.audioEqBand1SlopeDbPerOct), - audioEqLowCutEnabled: - item.audioEqLowCutEnabled === undefined ? undefined : !!item.audioEqLowCutEnabled, - audioEqLowCutFrequencyHz: - item.audioEqLowCutFrequencyHz === undefined - ? undefined - : clampAudioEqFrequencyHz( - item.audioEqLowCutFrequencyHz, - AUDIO_EQ_LOW_CUT_MIN_FREQUENCY_HZ, - AUDIO_EQ_LOW_CUT_MAX_FREQUENCY_HZ, - AUDIO_EQ_LOW_CUT_FREQUENCY_HZ, - ), - audioEqLowCutSlopeDbPerOct: - item.audioEqLowCutSlopeDbPerOct === undefined - ? undefined - : clampAudioEqCutSlopeDbPerOct(item.audioEqLowCutSlopeDbPerOct), - audioEqLowEnabled: item.audioEqLowEnabled === undefined ? undefined : !!item.audioEqLowEnabled, - audioEqLowType: item.audioEqLowType, - audioEqLowGainDb: - item.audioEqLowGainDb === undefined ? undefined : clampAudioEqGainDb(item.audioEqLowGainDb), - audioEqLowFrequencyHz: - item.audioEqLowFrequencyHz === undefined - ? undefined - : clampAudioEqFrequencyHz( - item.audioEqLowFrequencyHz, - AUDIO_EQ_LOW_MIN_FREQUENCY_HZ, - AUDIO_EQ_LOW_MAX_FREQUENCY_HZ, - AUDIO_EQ_LOW_FREQUENCY_HZ, - ), - audioEqLowQ: - item.audioEqLowQ === undefined - ? undefined - : clampAudioEqQ(item.audioEqLowQ, AUDIO_EQ_LOW_MID_Q), - audioEqLowMidEnabled: - item.audioEqLowMidEnabled === undefined ? undefined : !!item.audioEqLowMidEnabled, - audioEqLowMidType: item.audioEqLowMidType, - audioEqLowMidGainDb: - item.audioEqLowMidGainDb === undefined - ? undefined - : clampAudioEqGainDb(item.audioEqLowMidGainDb), - audioEqLowMidFrequencyHz: - item.audioEqLowMidFrequencyHz === undefined - ? undefined - : clampAudioEqFrequencyHz( - item.audioEqLowMidFrequencyHz, - AUDIO_EQ_LOW_MID_MIN_FREQUENCY_HZ, - AUDIO_EQ_LOW_MID_MAX_FREQUENCY_HZ, - AUDIO_EQ_LOW_MID_FREQUENCY_HZ, - ), - audioEqLowMidQ: - item.audioEqLowMidQ === undefined - ? undefined - : clampAudioEqQ(item.audioEqLowMidQ, AUDIO_EQ_LOW_MID_Q), - audioEqMidGainDb: - item.audioEqMidGainDb === undefined ? undefined : clampAudioEqGainDb(item.audioEqMidGainDb), - audioEqHighMidEnabled: - item.audioEqHighMidEnabled === undefined ? undefined : !!item.audioEqHighMidEnabled, - audioEqHighMidType: item.audioEqHighMidType, - audioEqHighMidGainDb: - item.audioEqHighMidGainDb === undefined - ? undefined - : clampAudioEqGainDb(item.audioEqHighMidGainDb), - audioEqHighMidFrequencyHz: - item.audioEqHighMidFrequencyHz === undefined - ? undefined - : clampAudioEqFrequencyHz( - item.audioEqHighMidFrequencyHz, - AUDIO_EQ_HIGH_MID_MIN_FREQUENCY_HZ, - AUDIO_EQ_HIGH_MID_MAX_FREQUENCY_HZ, - AUDIO_EQ_HIGH_MID_FREQUENCY_HZ, - ), - audioEqHighMidQ: - item.audioEqHighMidQ === undefined - ? undefined - : clampAudioEqQ(item.audioEqHighMidQ, AUDIO_EQ_HIGH_MID_Q), - audioEqHighEnabled: - item.audioEqHighEnabled === undefined ? undefined : !!item.audioEqHighEnabled, - audioEqHighType: item.audioEqHighType, - audioEqHighGainDb: - item.audioEqHighGainDb === undefined ? undefined : clampAudioEqGainDb(item.audioEqHighGainDb), - audioEqHighFrequencyHz: - item.audioEqHighFrequencyHz === undefined - ? undefined - : clampAudioEqFrequencyHz( - item.audioEqHighFrequencyHz, - AUDIO_EQ_HIGH_MIN_FREQUENCY_HZ, - AUDIO_EQ_HIGH_MAX_FREQUENCY_HZ, - AUDIO_EQ_HIGH_FREQUENCY_HZ, - ), - audioEqHighQ: - item.audioEqHighQ === undefined - ? undefined - : clampAudioEqQ(item.audioEqHighQ, AUDIO_EQ_HIGH_MID_Q), - audioEqBand6Enabled: - item.audioEqBand6Enabled === undefined ? undefined : !!item.audioEqBand6Enabled, - audioEqBand6Type: item.audioEqBand6Type, - audioEqBand6FrequencyHz: - item.audioEqBand6FrequencyHz === undefined - ? undefined - : clampAudioEqFrequencyHz( - item.audioEqBand6FrequencyHz, - AUDIO_EQ_HIGH_CUT_MIN_FREQUENCY_HZ, - AUDIO_EQ_HIGH_CUT_MAX_FREQUENCY_HZ, - AUDIO_EQ_HIGH_CUT_FREQUENCY_HZ, - ), - audioEqBand6GainDb: - item.audioEqBand6GainDb === undefined - ? undefined - : clampAudioEqGainDb(item.audioEqBand6GainDb), - audioEqBand6Q: - item.audioEqBand6Q === undefined - ? undefined - : clampAudioEqQ(item.audioEqBand6Q, AUDIO_EQ_HIGH_MID_Q), - audioEqBand6SlopeDbPerOct: - item.audioEqBand6SlopeDbPerOct === undefined - ? undefined - : clampAudioEqCutSlopeDbPerOct(item.audioEqBand6SlopeDbPerOct), - audioEqHighCutEnabled: - item.audioEqHighCutEnabled === undefined ? undefined : !!item.audioEqHighCutEnabled, - audioEqHighCutFrequencyHz: - item.audioEqHighCutFrequencyHz === undefined - ? undefined - : clampAudioEqFrequencyHz( - item.audioEqHighCutFrequencyHz, - AUDIO_EQ_HIGH_CUT_MIN_FREQUENCY_HZ, - AUDIO_EQ_HIGH_CUT_MAX_FREQUENCY_HZ, - AUDIO_EQ_HIGH_CUT_FREQUENCY_HZ, - ), - audioEqHighCutSlopeDbPerOct: - item.audioEqHighCutSlopeDbPerOct === undefined - ? undefined - : clampAudioEqCutSlopeDbPerOct(item.audioEqHighCutSlopeDbPerOct), - } - - if (normalized.cornerPin) { - const cornerPinTargetRect = resolveCornerPinTargetRect( - normalized.transform?.width ?? 0, - normalized.transform?.height ?? 0, - normalized.type === 'video' || normalized.type === 'image' - ? { - sourceWidth: normalized.sourceWidth, - sourceHeight: normalized.sourceHeight, - crop: normalized.crop, - } - : undefined, - ) - normalized.cornerPin = { - ...normalized.cornerPin, - referenceWidth: - normalized.cornerPin.referenceWidth ?? - (cornerPinTargetRect.width > 0 ? cornerPinTargetRect.width : undefined), - referenceHeight: - normalized.cornerPin.referenceHeight ?? - (cornerPinTargetRect.height > 0 ? cornerPinTargetRect.height : undefined), - } - } - - if (normalized.type === 'shape' && normalized.isMask) { - normalized.blendMode = 'normal' - } - - // Legacy split clips can have sourceEnd without sourceStart. - // Treat them as explicitly bounded from 0 to sourceEnd so rate stretch - // operates on the split segment rather than the full media duration. - if ( - (normalized.type === 'video' || normalized.type === 'audio') && - normalized.sourceEnd !== undefined && - normalized.sourceStart === undefined - ) { - normalized.sourceStart = 0 - } - - return normalized as T -} - -/** - * Trim a subtitle segment from its start: re-anchor every cue's time so the - * new `from` becomes 0, dropping cues entirely before the new boundary and - * clamping cues that straddle it. - * - * `clampedAmount` is in timeline frames — positive means trimming inward. - */ -function trimSubtitleCuesAtStart( - item: import('@/types/timeline').SubtitleSegmentItem, - clampedAmount: number, - timelineFps: number, -): { cues: import('@/types/timeline').SubtitleSegmentItem['cues'] } | null { - if (clampedAmount === 0) return null - const offsetSeconds = clampedAmount / timelineFps - const nextCues: import('@/types/timeline').SubtitleSegmentItem['cues'] = [] - for (const cue of item.cues) { - if (cue.endSeconds <= offsetSeconds) continue // entirely outside new window - const startSeconds = Math.max(0, cue.startSeconds - offsetSeconds) - const endSeconds = cue.endSeconds - offsetSeconds - if (endSeconds <= startSeconds) continue - nextCues.push({ ...cue, startSeconds, endSeconds }) - } - return { cues: nextCues } -} - -/** - * Trim a subtitle segment from its end: drop cues past the new duration and - * clamp cues that straddle the boundary. - */ -function trimSubtitleCuesAtEnd( - item: import('@/types/timeline').SubtitleSegmentItem, - newDurationFrames: number, - timelineFps: number, -): { cues: import('@/types/timeline').SubtitleSegmentItem['cues'] } | null { - const newEndSeconds = newDurationFrames / timelineFps - const nextCues: import('@/types/timeline').SubtitleSegmentItem['cues'] = [] - for (const cue of item.cues) { - if (cue.startSeconds >= newEndSeconds) continue - const endSeconds = Math.min(cue.endSeconds, newEndSeconds) - if (endSeconds <= cue.startSeconds) continue - nextCues.push({ ...cue, endSeconds }) - } - return { cues: nextCues } -} - -function normalizeItemUpdates(updates: Partial): Partial { - const normalized = { ...updates } as Partial - - if (normalized.from !== undefined) normalized.from = roundFrame(normalized.from) - if (normalized.durationInFrames !== undefined) - normalized.durationInFrames = roundDuration(normalized.durationInFrames) - if (normalized.trimStart !== undefined) normalized.trimStart = roundFrame(normalized.trimStart) - if (normalized.trimEnd !== undefined) normalized.trimEnd = roundFrame(normalized.trimEnd) - if (normalized.sourceStart !== undefined) - normalized.sourceStart = roundFrame(normalized.sourceStart) - if (normalized.sourceEnd !== undefined) normalized.sourceEnd = roundFrame(normalized.sourceEnd) - if (normalized.sourceDuration !== undefined) - normalized.sourceDuration = roundFrame(normalized.sourceDuration) - if (normalized.sourceFps !== undefined) - normalized.sourceFps = normalizeOptionalFps(normalized.sourceFps) - if (normalized.crop !== undefined) normalized.crop = normalizeCropSettings(normalized.crop) - - // Keep legacy end-only bounds explicit and stable. - if (normalized.sourceEnd !== undefined && normalized.sourceStart === undefined) { - normalized.sourceStart = 0 - } - - if (normalized.audioFadeInCurve !== undefined) { - normalized.audioFadeInCurve = clampAudioFadeCurve(normalized.audioFadeInCurve) - } - if (normalized.audioFadeOutCurve !== undefined) { - normalized.audioFadeOutCurve = clampAudioFadeCurve(normalized.audioFadeOutCurve) - } - if (normalized.audioFadeInCurveX !== undefined) { - normalized.audioFadeInCurveX = clampAudioFadeCurveX(normalized.audioFadeInCurveX) - } - if (normalized.audioFadeOutCurveX !== undefined) { - normalized.audioFadeOutCurveX = clampAudioFadeCurveX(normalized.audioFadeOutCurveX) - } - if (normalized.audioPitchSemitones !== undefined) { - normalized.audioPitchSemitones = clampAudioPitchSemitones(normalized.audioPitchSemitones) - } - if (normalized.audioPitchCents !== undefined) { - normalized.audioPitchCents = clampAudioPitchCents(normalized.audioPitchCents) - } - if (normalized.audioEqOutputGainDb !== undefined) { - normalized.audioEqOutputGainDb = clampAudioEqGainDb(normalized.audioEqOutputGainDb) - } - if (normalized.audioEqBand1Enabled !== undefined) { - normalized.audioEqBand1Enabled = !!normalized.audioEqBand1Enabled - } - if (normalized.audioEqBand1FrequencyHz !== undefined) { - normalized.audioEqBand1FrequencyHz = clampAudioEqFrequencyHz( - normalized.audioEqBand1FrequencyHz, - AUDIO_EQ_LOW_CUT_MIN_FREQUENCY_HZ, - AUDIO_EQ_LOW_CUT_MAX_FREQUENCY_HZ, - AUDIO_EQ_LOW_CUT_FREQUENCY_HZ, - ) - } - if (normalized.audioEqBand1GainDb !== undefined) { - normalized.audioEqBand1GainDb = clampAudioEqGainDb(normalized.audioEqBand1GainDb) - } - if (normalized.audioEqBand1Q !== undefined) { - normalized.audioEqBand1Q = clampAudioEqQ(normalized.audioEqBand1Q, AUDIO_EQ_LOW_MID_Q) - } - if (normalized.audioEqBand1SlopeDbPerOct !== undefined) { - normalized.audioEqBand1SlopeDbPerOct = clampAudioEqCutSlopeDbPerOct( - normalized.audioEqBand1SlopeDbPerOct, - ) - } - if (normalized.audioEqLowCutEnabled !== undefined) { - normalized.audioEqLowCutEnabled = !!normalized.audioEqLowCutEnabled - } - if (normalized.audioEqLowCutFrequencyHz !== undefined) { - normalized.audioEqLowCutFrequencyHz = clampAudioEqFrequencyHz( - normalized.audioEqLowCutFrequencyHz, - AUDIO_EQ_LOW_CUT_MIN_FREQUENCY_HZ, - AUDIO_EQ_LOW_CUT_MAX_FREQUENCY_HZ, - AUDIO_EQ_LOW_CUT_FREQUENCY_HZ, - ) - } - if (normalized.audioEqLowCutSlopeDbPerOct !== undefined) { - normalized.audioEqLowCutSlopeDbPerOct = clampAudioEqCutSlopeDbPerOct( - normalized.audioEqLowCutSlopeDbPerOct, - ) - } - if (normalized.audioEqLowEnabled !== undefined) { - normalized.audioEqLowEnabled = !!normalized.audioEqLowEnabled - } - if (normalized.audioEqLowGainDb !== undefined) { - normalized.audioEqLowGainDb = clampAudioEqGainDb(normalized.audioEqLowGainDb) - } - if (normalized.audioEqLowFrequencyHz !== undefined) { - normalized.audioEqLowFrequencyHz = clampAudioEqFrequencyHz( - normalized.audioEqLowFrequencyHz, - AUDIO_EQ_LOW_MIN_FREQUENCY_HZ, - AUDIO_EQ_LOW_MAX_FREQUENCY_HZ, - AUDIO_EQ_LOW_FREQUENCY_HZ, - ) - } - if (normalized.audioEqLowQ !== undefined) { - normalized.audioEqLowQ = clampAudioEqQ(normalized.audioEqLowQ, AUDIO_EQ_LOW_MID_Q) - } - if (normalized.audioEqLowMidEnabled !== undefined) { - normalized.audioEqLowMidEnabled = !!normalized.audioEqLowMidEnabled - } - if (normalized.audioEqLowMidGainDb !== undefined) { - normalized.audioEqLowMidGainDb = clampAudioEqGainDb(normalized.audioEqLowMidGainDb) - } - if (normalized.audioEqLowMidFrequencyHz !== undefined) { - normalized.audioEqLowMidFrequencyHz = clampAudioEqFrequencyHz( - normalized.audioEqLowMidFrequencyHz, - AUDIO_EQ_LOW_MID_MIN_FREQUENCY_HZ, - AUDIO_EQ_LOW_MID_MAX_FREQUENCY_HZ, - AUDIO_EQ_LOW_MID_FREQUENCY_HZ, - ) - } - if (normalized.audioEqLowMidQ !== undefined) { - normalized.audioEqLowMidQ = clampAudioEqQ(normalized.audioEqLowMidQ, AUDIO_EQ_LOW_MID_Q) - } - if (normalized.audioEqMidGainDb !== undefined) { - normalized.audioEqMidGainDb = clampAudioEqGainDb(normalized.audioEqMidGainDb) - } - if (normalized.audioEqHighMidEnabled !== undefined) { - normalized.audioEqHighMidEnabled = !!normalized.audioEqHighMidEnabled - } - if (normalized.audioEqHighMidGainDb !== undefined) { - normalized.audioEqHighMidGainDb = clampAudioEqGainDb(normalized.audioEqHighMidGainDb) - } - if (normalized.audioEqHighMidFrequencyHz !== undefined) { - normalized.audioEqHighMidFrequencyHz = clampAudioEqFrequencyHz( - normalized.audioEqHighMidFrequencyHz, - AUDIO_EQ_HIGH_MID_MIN_FREQUENCY_HZ, - AUDIO_EQ_HIGH_MID_MAX_FREQUENCY_HZ, - AUDIO_EQ_HIGH_MID_FREQUENCY_HZ, - ) - } - if (normalized.audioEqHighMidQ !== undefined) { - normalized.audioEqHighMidQ = clampAudioEqQ(normalized.audioEqHighMidQ, AUDIO_EQ_HIGH_MID_Q) - } - if (normalized.audioEqHighEnabled !== undefined) { - normalized.audioEqHighEnabled = !!normalized.audioEqHighEnabled - } - if (normalized.audioEqHighGainDb !== undefined) { - normalized.audioEqHighGainDb = clampAudioEqGainDb(normalized.audioEqHighGainDb) - } - if (normalized.audioEqHighFrequencyHz !== undefined) { - normalized.audioEqHighFrequencyHz = clampAudioEqFrequencyHz( - normalized.audioEqHighFrequencyHz, - AUDIO_EQ_HIGH_MIN_FREQUENCY_HZ, - AUDIO_EQ_HIGH_MAX_FREQUENCY_HZ, - AUDIO_EQ_HIGH_FREQUENCY_HZ, - ) - } - if (normalized.audioEqHighQ !== undefined) { - normalized.audioEqHighQ = clampAudioEqQ(normalized.audioEqHighQ, AUDIO_EQ_HIGH_MID_Q) - } - if (normalized.audioEqBand6Enabled !== undefined) { - normalized.audioEqBand6Enabled = !!normalized.audioEqBand6Enabled - } - if (normalized.audioEqBand6FrequencyHz !== undefined) { - normalized.audioEqBand6FrequencyHz = clampAudioEqFrequencyHz( - normalized.audioEqBand6FrequencyHz, - AUDIO_EQ_HIGH_CUT_MIN_FREQUENCY_HZ, - AUDIO_EQ_HIGH_CUT_MAX_FREQUENCY_HZ, - AUDIO_EQ_HIGH_CUT_FREQUENCY_HZ, - ) - } - if (normalized.audioEqBand6GainDb !== undefined) { - normalized.audioEqBand6GainDb = clampAudioEqGainDb(normalized.audioEqBand6GainDb) - } - if (normalized.audioEqBand6Q !== undefined) { - normalized.audioEqBand6Q = clampAudioEqQ(normalized.audioEqBand6Q, AUDIO_EQ_HIGH_MID_Q) - } - if (normalized.audioEqBand6SlopeDbPerOct !== undefined) { - normalized.audioEqBand6SlopeDbPerOct = clampAudioEqCutSlopeDbPerOct( - normalized.audioEqBand6SlopeDbPerOct, - ) - } - if (normalized.audioEqHighCutEnabled !== undefined) { - normalized.audioEqHighCutEnabled = !!normalized.audioEqHighCutEnabled - } - if (normalized.audioEqHighCutFrequencyHz !== undefined) { - normalized.audioEqHighCutFrequencyHz = clampAudioEqFrequencyHz( - normalized.audioEqHighCutFrequencyHz, - AUDIO_EQ_HIGH_CUT_MIN_FREQUENCY_HZ, - AUDIO_EQ_HIGH_CUT_MAX_FREQUENCY_HZ, - AUDIO_EQ_HIGH_CUT_FREQUENCY_HZ, - ) - } - if (normalized.audioEqHighCutSlopeDbPerOct !== undefined) { - normalized.audioEqHighCutSlopeDbPerOct = clampAudioEqCutSlopeDbPerOct( - normalized.audioEqHighCutSlopeDbPerOct, - ) - } - - return normalized -} - -function normalizeTrack(track: TimelineTrack): TimelineTrack { - return { - ...track, - volume: track.volume === undefined ? undefined : Math.max(-60, Math.min(12, track.volume)), - audioEq: normalizeAudioEqSettings(track.audioEq), - } -} - -function areItemArraysEqual(a: TimelineItem[] | undefined, b: TimelineItem[]): boolean { - if (!a || a.length !== b.length) return false - for (let i = 0; i < b.length; i++) { - if (a[i] !== b[i]) return false - } - return true -} - -function buildItemsByTrackId( - items: TimelineItem[], - previous: Record, -): Record { - const grouped: Record = {} - for (const item of items) { - ;(grouped[item.trackId] ??= []).push(item) - } - - const next: Record = {} - for (const [trackId, trackItems] of Object.entries(grouped)) { - const previousTrackItems = previous[trackId] - next[trackId] = - previousTrackItems && areItemArraysEqual(previousTrackItems, trackItems) - ? previousTrackItems - : trackItems - } - - return next -} - -function buildItemsByLinkedGroupId( - items: TimelineItem[], - previous: Record, -): Record { - const grouped: Record = {} - for (const item of items) { - if (item.linkedGroupId) { - ;(grouped[item.linkedGroupId] ??= []).push(item) - } - } - - const next: Record = {} - for (const [groupId, groupItems] of Object.entries(grouped)) { - const previousGroupItems = previous[groupId] - next[groupId] = - previousGroupItems && areItemArraysEqual(previousGroupItems, groupItems) - ? previousGroupItems - : groupItems - } - - return next -} - -function isCaptionableClip(item: TimelineItem): item is AudioItem | VideoItem { - return ( - (item.type === 'audio' || item.type === 'video') && - typeof item.mediaId === 'string' && - item.mediaId.length > 0 - ) -} - -function isLegacyGeneratedCaptionItem(item: TimelineItem): item is TextItem { - const plainText = item.type === 'text' ? getTextItemPlainText(item) : '' - return ( - item.type === 'text' && - !item.captionSource && - typeof item.mediaId === 'string' && - item.mediaId.length > 0 && - plainText.trim().length > 0 && - item.label === plainText.slice(0, 48) - ) -} - -function buildReplaceableCaptionClipIds(items: TimelineItem[]): Set { - const ids = new Set() - const clipsByMediaId: Record> = {} - - for (const item of items) { - if ( - item.type === 'text' && - item.captionSource?.type === 'transcript' && - item.captionSource.clipId - ) { - ids.add(item.captionSource.clipId) - continue - } - - if (item.type === 'subtitle' && item.source.type === 'transcript' && item.source.clipId) { - ids.add(item.source.clipId) - continue - } - - if (isCaptionableClip(item)) { - const mediaId = item.mediaId - if (!mediaId) continue - ;(clipsByMediaId[mediaId] ??= []).push(item) - } - } - - for (const clips of Object.values(clipsByMediaId)) { - clips.sort((left, right) => left.from - right.from) - } - - for (const item of items) { - if (!isLegacyGeneratedCaptionItem(item) || !item.mediaId) { - continue - } - - const mediaId = item.mediaId - const itemEnd = item.from + item.durationInFrames - const candidateClips = clipsByMediaId[mediaId] - if (!candidateClips) { - continue - } - - for (const clip of candidateClips) { - if (clip.from > item.from) { - break - } - - const clipEnd = clip.from + clip.durationInFrames - if (item.from >= clip.from && itemEnd <= clipEnd) { - ids.add(clip.id) - } - } - } - - return ids -} - -function isMediaPair(left: TimelineItem, right: TimelineItem): boolean { - return ( - (left.type === 'video' && right.type === 'audio') || - (left.type === 'audio' && right.type === 'video') - ) -} - -function isLegacyLinkCandidate(item: TimelineItem): item is AudioItem | VideoItem { - return ( - !item.linkedGroupId && - isCaptionableClip(item) && - typeof item.originId === 'string' && - item.originId.length > 0 - ) -} - -function isLegacyLinkedPair(anchor: TimelineItem, candidate: TimelineItem): boolean { - if (!isMediaPair(anchor, candidate)) return false - if (!anchor.originId || anchor.originId !== candidate.originId) return false - if (!anchor.mediaId || anchor.mediaId !== candidate.mediaId) return false - return anchor.from === candidate.from && anchor.durationInFrames === candidate.durationInFrames -} - -function buildLinkedItemsByItemId( - items: TimelineItem[], - itemsByLinkedGroupId: Record, - previous: Record, -): Record { - const next: Record = {} - - for (const groupItems of Object.values(itemsByLinkedGroupId)) { - if (groupItems.length <= 1) { - continue - } - - for (const item of groupItems) { - next[item.id] = groupItems - } - } - - const legacyGroups: Record = {} - for (const item of items) { - if (!isLegacyLinkCandidate(item)) { - continue - } - - const key = `${item.originId}|${item.mediaId}|${item.from}|${item.durationInFrames}` - ;(legacyGroups[key] ??= []).push(item) - } - - for (const groupItems of Object.values(legacyGroups)) { - if (groupItems.length <= 1) { - continue - } - - for (const anchor of groupItems) { - const linkedItems = groupItems.filter( - (candidate) => candidate.id === anchor.id || isLegacyLinkedPair(anchor, candidate), - ) - - if (linkedItems.length <= 1) { - continue - } - - const previousLinkedItems = previous[anchor.id] - next[anchor.id] = - previousLinkedItems && areItemArraysEqual(previousLinkedItems, linkedItems) - ? previousLinkedItems - : linkedItems - } - } - - return next -} - -function buildItemById( - items: TimelineItem[], - previous: Record, -): Record { - const next: Record = {} - for (const item of items) { - const previousItem = previous[item.id] - next[item.id] = previousItem !== undefined && previousItem === item ? previousItem : item - } - return next -} - -function buildItemsMediaDependencyIds(items: TimelineItem[]): string[] { - const mediaIds = new Set() - for (const item of items) { - if (item.mediaId) { - mediaIds.add(item.mediaId) - } - } - return [...mediaIds].sort() -} - -function buildMediaDependencyKey(mediaDependencyIds: string[]): string { - return mediaDependencyIds.join('|') -} - -function computeMaxItemEndFrame(items: TimelineItem[]): number { - let max = 0 - for (const item of items) { - const end = item.from + item.durationInFrames - if (end > max) max = end - } - return max -} - -function withItemIndexes( - items: TimelineItem[], - previous: Pick< - ItemsState, - 'itemsByTrackId' | 'itemById' | 'itemsByLinkedGroupId' | 'linkedItemsByItemId' - >, -): Pick< - ItemsState, - | 'items' - | 'itemsByTrackId' - | 'itemById' - | 'itemsByLinkedGroupId' - | 'linkedItemsByItemId' - | 'replaceableCaptionClipIds' - | 'maxItemEndFrame' -> { - const itemsByLinkedGroupId = buildItemsByLinkedGroupId(items, previous.itemsByLinkedGroupId) - return { - items, - itemsByTrackId: buildItemsByTrackId(items, previous.itemsByTrackId), - itemById: buildItemById(items, previous.itemById), - itemsByLinkedGroupId, - linkedItemsByItemId: buildLinkedItemsByItemId( - items, - itemsByLinkedGroupId, - previous.linkedItemsByItemId, - ), - replaceableCaptionClipIds: buildReplaceableCaptionClipIds(items), - maxItemEndFrame: computeMaxItemEndFrame(items), - } -} - -/** - * Get IDs of clips that have a transition with the given item. - * These clips are allowed to overlap during trim operations. - */ -function getTransitionLinkedIds(itemId: string): Set { - const transitions = useTransitionsStore.getState().transitions - const linkedIds = new Set() - for (const t of transitions) { - if (t.leftClipId === itemId) linkedIds.add(t.rightClipId) - if (t.rightClipId === itemId) linkedIds.add(t.leftClipId) - } - return linkedIds -} - -function buildRippleShiftByItemId( - items: TimelineItem[], - deletedItems: TimelineItem[], -): Map { - const shiftByItemId = new Map() - - for (const item of items) { - let shiftAmount = 0 - for (const deletedItem of deletedItems) { - if ( - deletedItem.trackId === item.trackId && - deletedItem.from + deletedItem.durationInFrames <= item.from - ) { - shiftAmount += deletedItem.durationInFrames - } - } - shiftByItemId.set(item.id, shiftAmount) - } - - const visited = new Set() - for (const item of items) { - if (visited.has(item.id)) continue - - const linkedItems = getLinkedItems(items, item.id) - for (const linkedItem of linkedItems) { - visited.add(linkedItem.id) - } - - if (linkedItems.length <= 1) continue - - let groupShift = 0 - for (const linkedItem of linkedItems) { - groupShift = Math.max(groupShift, shiftByItemId.get(linkedItem.id) ?? 0) - } - - if (groupShift <= 0) continue - - for (const linkedItem of linkedItems) { - shiftByItemId.set(linkedItem.id, groupShift) - } - } - - return shiftByItemId -} - /** * Items state - timeline clips/items and tracks. * This is the core timeline content. Complex cross-domain operations @@ -905,8 +56,6 @@ interface ItemsState { itemById: Record itemsByLinkedGroupId: Record linkedItemsByItemId: Record - /** Set of clip IDs that can regenerate captions, including legacy generated captions */ - replaceableCaptionClipIds: Set maxItemEndFrame: number mediaDependencyIds: string[] mediaDependencyVersion: number @@ -971,7 +120,6 @@ export const useItemsStore = create()((set, get) => ( itemById: {}, itemsByLinkedGroupId: {}, linkedItemsByItemId: {}, - replaceableCaptionClipIds: new Set(), maxItemEndFrame: 0, mediaDependencyIds: [], mediaDependencyVersion: 0, diff --git a/src/features/timeline/stores/timeline-persistence.ts b/src/features/timeline/stores/timeline-persistence.ts index b2011485a..aaa896903 100644 --- a/src/features/timeline/stores/timeline-persistence.ts +++ b/src/features/timeline/stores/timeline-persistence.ts @@ -8,6 +8,7 @@ import { createLogger, createOperationId } from '@/shared/logging/logger' import { usePreviewBridgeStore } from '@/shared/state/preview-bridge' import { usePlaybackStore } from '@/shared/state/playback' import { DEFAULT_TRACK_HEIGHT } from '../constants' +import { DEFAULT_PROJECT_HEIGHT, DEFAULT_PROJECT_WIDTH } from '@/shared/projects/defaults' import { createClassicTrack, createDefaultClassicTracks, @@ -734,8 +735,8 @@ export async function saveTimeline(projectId: string): Promise { let thumbnailId: string | undefined if (itemsState.items.length > 0) { try { - const width = project.metadata?.width || 1920 - const height = project.metadata?.height || 1080 + const width = project.metadata?.width || DEFAULT_PROJECT_WIDTH + const height = project.metadata?.height || DEFAULT_PROJECT_HEIGHT // Calculate thumbnail dimensions preserving project aspect ratio const maxThumbWidth = 320 diff --git a/src/features/timeline/utils/compound-clip-waveform.ts b/src/features/timeline/utils/compound-clip-waveform.ts index 06100455c..9cd0f7105 100644 --- a/src/features/timeline/utils/compound-clip-waveform.ts +++ b/src/features/timeline/utils/compound-clip-waveform.ts @@ -43,7 +43,9 @@ export function mixCompoundClipWaveformPeaks(params: { const existing = peaks[outputSample] ?? 0 const next = waveformPeaks[waveformIndex] ?? 0 - peaks[outputSample] = Math.min(1, Math.hypot(existing, next)) + // Peaks are normalized to [0,1], so there is no overflow risk and this is + // equivalent to Math.hypot — but Math.sqrt is several times faster in V8. + peaks[outputSample] = Math.min(1, Math.sqrt(existing * existing + next * next)) } } diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 195a1bb50..84d81981b 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -69,15 +69,9 @@ function normalizePartialSlice(path: string, slice: LocaleTree): LocaleTree { return slice } -const orderedPartialModules = Object.entries(partialModules).sort(([leftPath], [rightPath]) => { - const leftIsFallback = leftPath.endsWith('/missing.json') - const rightIsFallback = rightPath.endsWith('/missing.json') - if (leftIsFallback !== rightIsFallback) { - return leftIsFallback ? -1 : 1 - } - - return leftPath.localeCompare(rightPath) -}) +const orderedPartialModules = Object.entries(partialModules).sort(([leftPath], [rightPath]) => + leftPath.localeCompare(rightPath), +) for (const [path, mod] of orderedPartialModules) { const partial = mod.default ?? {} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 83e5b9254..80af3415a 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -86,7 +86,9 @@ "captionEstimate": "~{{sceneCount}} {{scenes}} pro 1-Minuten-Clip bei {{fps}} fps", "scene_one": "Szene", "scene_other": "Szenen", - "captionIntervalHint": "{{estimate}}. Kleinere Intervalle erzeugen dichtere Szenen, brauchen aber länger zur Generierung." + "captionIntervalHint": "{{estimate}}. Kleinere Intervalle erzeugen dichtere Szenen, brauchen aber länger zur Generierung.", + "defaultCaptionStyle": "Standard-Untertitelstil", + "defaultCaptionStyleDescription": "Stil für Untertitel, die aus Transkripten oder KI-Untertitelung erzeugt werden." }, "timeline": { "snapByDefault": "Standardmäßig einrasten", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 09b0e295e..a108a308d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -86,7 +86,9 @@ "captionEstimate": "~{{sceneCount}} {{scenes}} per 1-min clip at {{fps}}fps", "scene_one": "scene", "scene_other": "scenes", - "captionIntervalHint": "{{estimate}}. Smaller intervals produce denser scenes but take longer to generate." + "captionIntervalHint": "{{estimate}}. Smaller intervals produce denser scenes but take longer to generate.", + "defaultCaptionStyle": "Default caption style", + "defaultCaptionStyleDescription": "Style applied to captions generated from transcripts or AI captioning." }, "timeline": { "snapByDefault": "Snap by Default", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index c0d8dc785..2d2b96563 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -86,7 +86,9 @@ "captionEstimate": "~{{sceneCount}} {{scenes}} por clip de 1 min a {{fps}} fps", "scene_one": "escena", "scene_other": "escenas", - "captionIntervalHint": "{{estimate}}. Los intervalos más pequeños producen escenas más densas pero tardan más en generarse." + "captionIntervalHint": "{{estimate}}. Los intervalos más pequeños producen escenas más densas pero tardan más en generarse.", + "defaultCaptionStyle": "Estilo de subtítulos predeterminado", + "defaultCaptionStyleDescription": "Estilo aplicado a los subtítulos generados a partir de transcripciones o subtitulado con IA." }, "timeline": { "snapByDefault": "Ajuste por defecto", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 2d3be4738..8305dc465 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -86,7 +86,9 @@ "captionEstimate": "~{{sceneCount}} {{scenes}} par clip d'1 min à {{fps}} ips", "scene_one": "scène", "scene_other": "scènes", - "captionIntervalHint": "{{estimate}}. Des intervalles plus petits produisent des scènes plus denses mais prennent plus de temps à générer." + "captionIntervalHint": "{{estimate}}. Des intervalles plus petits produisent des scènes plus denses mais prennent plus de temps à générer.", + "defaultCaptionStyle": "Style de sous-titres par défaut", + "defaultCaptionStyleDescription": "Style appliqué aux sous-titres générés à partir de transcriptions ou du sous-titrage par IA." }, "timeline": { "snapByDefault": "Aimantation par défaut", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 1d15cd1a3..8299dbaeb 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -86,7 +86,9 @@ "captionEstimate": "{{fps}} fps の 1 分間のクリップあたり約 {{sceneCount}} {{scenes}}", "scene_one": "シーン", "scene_other": "シーン", - "captionIntervalHint": "{{estimate}}。間隔が小さいほどシーンは密になりますが、生成に時間がかかります。" + "captionIntervalHint": "{{estimate}}。間隔が小さいほどシーンは密になりますが、生成に時間がかかります。", + "defaultCaptionStyle": "デフォルトの字幕スタイル", + "defaultCaptionStyleDescription": "文字起こしまたはAI字幕から生成された字幕に適用されるスタイル。" }, "timeline": { "snapByDefault": "デフォルトでスナップ", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 542c21fd3..f81368ccf 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -86,7 +86,9 @@ "captionEstimate": "{{fps}} fps에서 1분 클립당 약 {{sceneCount}}{{scenes}}", "scene_one": "개 장면", "scene_other": "개 장면", - "captionIntervalHint": "{{estimate}}. 간격이 작을수록 장면이 더 촘촘해지지만 생성에 시간이 더 걸립니다." + "captionIntervalHint": "{{estimate}}. 간격이 작을수록 장면이 더 촘촘해지지만 생성에 시간이 더 걸립니다.", + "defaultCaptionStyle": "기본 자막 스타일", + "defaultCaptionStyleDescription": "전사 또는 AI 자막으로 생성된 자막에 적용되는 스타일입니다." }, "timeline": { "snapByDefault": "기본적으로 스냅", diff --git a/src/i18n/locales/partials/editor.json b/src/i18n/locales/partials/editor.json index d413a1e9e..af93e6e4b 100644 --- a/src/i18n/locales/partials/editor.json +++ b/src/i18n/locales/partials/editor.json @@ -14,7 +14,8 @@ "error": "Local AI Error", "ready": "Local AI Ready", "jobs_one": "{{count}} job", - "jobs_other": "{{count}} jobs" + "jobs_other": "{{count}} jobs", + "jobs": "{{count}} job(s)" }, "clearKeyframesDialog": { "titleProperty": "Clear {{property}} Keyframes", @@ -24,7 +25,9 @@ "descriptionAll_one": "Are you sure you want to clear all keyframes from {{count}} clip?", "descriptionAll_other": "Are you sure you want to clear all keyframes from {{count}} clips?", "undoHint": "This action can be undone with Ctrl+Z.", - "confirm": "Clear Keyframes" + "confirm": "Clear Keyframes", + "descriptionProperty": "Clear {{property}} keyframes from {{count}} selected item(s)?", + "descriptionAll": "Clear all keyframes from {{count}} selected item(s)?" }, "projectUpgradeDialog": { "title": "Upgrade Project Before Opening", @@ -113,7 +116,8 @@ "clipsSelected_other": "{{count}} clips selected", "dockToPreview": "Dock to preview", "expandFullColumn": "Expand full column", - "showPanel": "Show Properties Panel" + "showPanel": "Show Properties Panel", + "clipsSelected": "{{count}} clips selected" }, "markerPanel": { "notFound": "Marker not found", @@ -245,7 +249,8 @@ "italic": "Italic", "bold": "Bold", "underline": "Underline", - "cuePosition": "Cue position: {{vertical}} {{horizontal}}" + "cuePosition": "Cue position: {{vertical}} {{horizontal}}", + "cueCount": "{{count}} cue(s)" }, "audioSection": { "audio": "Audio", @@ -322,6 +327,227 @@ "panelMode": "Panel mode", "audioMeter": "Audio meter", "audioMixer": "Audio mixer" + }, + "videoSection": { + "cropBottom": "Crop Bottom", + "cropLeft": "Crop Left", + "cropRight": "Crop Right", + "cropTop": "Crop Top", + "cropping": "Cropping", + "fadeIn": "Fade In", + "fadeOut": "Fade Out", + "playback": "Playback", + "resetCropBottom": "Reset Crop Bottom", + "resetCropLeft": "Reset Crop Left", + "resetCropRight": "Reset Crop Right", + "resetCropTop": "Reset Crop Top", + "resetSoftness": "Reset Softness", + "resetSpeed": "Reset Speed", + "resetToZero": "Reset to zero", + "softness": "Softness", + "speed": "Speed" + }, + "mediaSidebar": { + "media": "Media", + "text": "Text", + "shapes": "Shapes", + "effects": "Effects", + "transitions": "Transitions", + "ai": "AI", + "collapsePanel": "Collapse panel", + "expandPanel": "Expand panel", + "keyframeEditor": "Keyframe editor", + "hideKeyframeEditor": "Hide keyframe editor", + "showKeyframeEditor": "Show keyframe editor", + "templates": "Templates", + "textGroupSingle": "Single", + "textGroupTwoSpans": "2 spans", + "textGroupThreeSpans": "3 spans", + "addText": "Add text", + "pen": "Pen", + "penToolHint": "Draw a custom path shape with the pen tool", + "adjustmentLayer": "Adjustment layer", + "blankAdjustmentLayer": "Blank adjustment layer", + "presets": "Presets" + }, + "textSection": { + "sectionTitle": "Text", + "content": "Content", + "single": "Single", + "twoSpans": "2 Spans", + "threeSpans": "3 Spans", + "mixedNone": "Mixed / None", + "selectPreset": "Select preset", + "span": "Span {{count}}", + "spanText": "Span {{count}} text", + "eyebrow": "Eyebrow", + "eyebrowText": "Eyebrow text", + "title": "Title", + "titleText": "Title text", + "subtitle": "Subtitle", + "subtitleText": "Subtitle text", + "text": "Text", + "enterText": "Enter text...", + "defaultText": "Your Text Here", + "selectFont": "Select font", + "size": "Size", + "spacing": "Spacing", + "scale": "Scale", + "font": "Font", + "weight": "Weight", + "style": "Style", + "align": "Align", + "color": "Color", + "background": "Background", + "clear": "Clear", + "clearBackground": "Clear background", + "lineHeightShort": "Line H.", + "padding": "Padding", + "radius": "Radius", + "mixed": "Mixed", + "selectWeight": "Select weight", + "bold": "Bold", + "boldUnavailable": "Bold is not available for this font", + "italic": "Italic", + "underline": "Underline", + "italicSpan": "Italic {{label}}", + "underlineSpan": "Underline {{label}}", + "alignLeft": "Align Left", + "alignCenter": "Align Center", + "alignRight": "Align Right", + "alignTop": "Align Top", + "alignMiddle": "Align Middle", + "alignBottom": "Align Bottom", + "effects": "Effects", + "presets": "Presets", + "fontWeights": { + "regular": "Regular", + "medium": "Medium", + "semibold": "Semibold", + "bold": "Bold" + }, + "effectPresets": { + "none": "None", + "shadow": "Shadow", + "outline": "Outline", + "glow": "Glow" + }, + "shadow": "Shadow", + "shadowX": "Shadow X", + "shadowY": "Shadow Y", + "shadowBlur": "Shadow B.", + "strokeWidth": "Stroke W.", + "stroke": "Stroke", + "animation": "Animation", + "intro": "Intro", + "outro": "Outro", + "animationFooter": "Applies short ease-out text motion at the start or end of each selected clip.", + "animationPresets": { + "none": "None", + "fade": "Fade", + "rise": "Rise", + "drop": "Drop", + "left": "Left", + "right": "Right", + "tilt": "Tilt", + "pop": "Pop", + "swing": "Swing" + } + }, + "tts": { + "seek": "Seek", + "dialogTitle": "Generate audio from text", + "dialogDescription": "Generate speech and insert it at the text clip's position.", + "kokoroUnsupported": "WebGPU is not available in this browser. Kokoro TTS needs Chrome 113+, Edge 113+, or Safari 26+.", + "mossUnsupported": "Browser-managed storage is not available in this browser. MOSS multilingual TTS works best in a recent Chromium browser.", + "supertonicUnsupported": "This browser cannot run the local Supertonic TTS runtime. Try a recent Chrome or Edge browser.", + "engine": "Engine", + "engineSupportDetails": "TTS engine support details", + "kokoroDescription": "English voices on WebGPU.", + "supertonicDescription": "31-language local ONNX voices on WebGPU or WASM.", + "supportedLanguages": "Supported languages: {{languages}}.", + "kokoroOption": "Kokoro (English, WebGPU)", + "mossOption": "MOSS Nano (20 languages, CPU)", + "supertonicOption": "Supertonic 3 (31 languages, local ONNX)", + "language": "Language", + "autoDetectLanguage": "Auto detect", + "voice": "Voice", + "text": "Text", + "textPlaceholder": "Enter the text you want to hear spoken...", + "expressiveTags": "Expressive tags", + "speed": "Speed", + "progressPreparing": "Preparing local TTS...", + "insertedAndLinked": "Inserted and linked", + "generate": "Generate", + "generating": "Generating...", + "regenerate": "Regenerate", + "insertAndLink": "Insert and link", + "inserting": "Inserting...", + "errors": { + "openProject": "Open a project before generating audio.", + "enterText": "Enter some text to synthesize.", + "kokoroUnsupported": "WebGPU is required for Kokoro TTS. Try Chrome 113+, Edge 113+, or Safari 26+.", + "mossUnsupported": "Browser-managed storage is required for MOSS multilingual TTS. Try a recent Chromium browser.", + "supertonicUnsupported": "This browser cannot run the local Supertonic TTS runtime. Try a recent Chrome or Edge browser.", + "generateFailed": "Failed to generate speech.", + "insertFailed": "Failed to save and insert audio." + }, + "notifications": { + "addedAndLinked": "Added \"{{fileName}}\" to timeline and linked with text.", + "savedNoTrack": "Saved \"{{fileName}}\" but no audio track is available." + } + }, + "aiPanel": { + "textToSpeech": "Text to Speech", + "collapseTextToSpeech": "Collapse text to speech", + "expandTextToSpeech": "Expand text to speech", + "runsLocally": "{{runtime}} runs locally in the browser on {{backend}}.", + "history": "History ({{count}}) - {{size}}", + "clearAll": "Clear all", + "musicGeneration": "Music Generation", + "collapseMusicGeneration": "Collapse music generation", + "expandMusicGeneration": "Expand music generation", + "musicGenerationInfo": "Music generation info", + "musicgenDescription": "Uses Xenova's browser-ready MusicGen model through Transformers.js. The first download is large, then it stays cached locally.", + "musicgenPromptHint": "Prompt with genre, mood, tempo, and instrumentation. Shorter clips finish much faster.", + "musicgenUnsupported": "WebGPU is not available in this browser. MusicGen needs Chrome 113+, Edge 113+, or Safari 26+.", + "prompt": "Prompt", + "presets": "Presets", + "musicPromptPlaceholder": "Describe the kind of music you want to generate...", + "length": "Length", + "generateMusic": "Generate Music", + "musicHistory": "Music History ({{count}}) - {{size}}", + "remove": "Remove", + "saved": "Saved", + "saving": "Saving...", + "saveAndInsert": "Save & Insert", + "saveToLibrary": "Save to Library", + "progressPreparingMusic": "Preparing local music generation...", + "errors": { + "describeMusic": "Describe the music you want to generate.", + "musicgenUnsupported": "WebGPU is required for MusicGen. Try Chrome 113+, Edge 113+, or Safari 26+.", + "generateMusicFailed": "Failed to generate music.", + "saveAudioFailed": "Failed to save audio to the media library." + }, + "notifications": { + "savedToLibrary": "Saved \"{{fileName}}\" to the media library.", + "savedAndAdded": "Saved \"{{fileName}}\" and added to timeline." + }, + "defaultTtsPrompt": "Welcome to freecut. This voice was generated locally in the browser.", + "musicPresets": { + "lofiChillLabel": "Lo-fi Chill", + "lofiChillPrompt": "Warm lo-fi beat with dusty drums, mellow bass, and a dreamy synth lead", + "pop80sLabel": "80s Pop", + "pop80sPrompt": "80s pop track with bassy drums and synth", + "rock90sLabel": "90s Rock", + "rock90sPrompt": "90s rock song with loud guitars and heavy drums", + "upbeatEdmLabel": "Upbeat EDM", + "upbeatEdmPrompt": "A light and cheery EDM track, with syncopated drums, airy pads, and strong emotions bpm: 130", + "countryLabel": "Country", + "countryPrompt": "A cheerful country song with acoustic guitars", + "lofiElectroLabel": "Lo-fi Electro", + "lofiElectroPrompt": "Lofi slow bpm electro chill with organic samples" + } } } }, @@ -340,7 +566,8 @@ "error": "Error de IA local", "ready": "IA local lista", "jobs_one": "{{count}} tarea", - "jobs_other": "{{count}} tareas" + "jobs_other": "{{count}} tareas", + "jobs": "{{count}} tarea(s)" }, "clearKeyframesDialog": { "titleProperty": "Borrar fotogramas clave de {{property}}", @@ -350,7 +577,9 @@ "descriptionAll_one": "¿Seguro que quieres borrar todos los fotogramas clave de {{count}} clip?", "descriptionAll_other": "¿Seguro que quieres borrar todos los fotogramas clave de {{count}} clips?", "undoHint": "Esta acción se puede deshacer con Ctrl+Z.", - "confirm": "Borrar fotogramas clave" + "confirm": "Borrar fotogramas clave", + "descriptionProperty": "¿Borrar los fotogramas clave de {{property}} de {{count}} elemento(s) seleccionado(s)?", + "descriptionAll": "¿Borrar todos los fotogramas clave de {{count}} elemento(s) seleccionado(s)?" }, "projectUpgradeDialog": { "title": "Actualizar el proyecto antes de abrirlo", @@ -439,7 +668,8 @@ "clipsSelected_other": "{{count}} clips seleccionados", "dockToPreview": "Acoplar a la vista previa", "expandFullColumn": "Expandir columna completa", - "showPanel": "Mostrar panel de propiedades" + "showPanel": "Mostrar panel de propiedades", + "clipsSelected": "{{count}} clips seleccionados" }, "markerPanel": { "notFound": "Marcador no encontrado", @@ -571,7 +801,8 @@ "italic": "Cursiva", "bold": "Negrita", "underline": "Subrayado", - "cuePosition": "Posición del subtítulo: {{vertical}} {{horizontal}}" + "cuePosition": "Posición del subtítulo: {{vertical}} {{horizontal}}", + "cueCount": "{{count}} subtítulo(s)" }, "audioSection": { "audio": "Audio", @@ -648,6 +879,227 @@ "panelMode": "Modo del panel", "audioMeter": "Medidor de audio", "audioMixer": "Mezclador de audio" + }, + "videoSection": { + "cropBottom": "Recorte inferior", + "cropLeft": "Recorte izquierdo", + "cropRight": "Recorte derecho", + "cropTop": "Recorte superior", + "cropping": "Recorte", + "fadeIn": "Fundido de entrada", + "fadeOut": "Fundido de salida", + "playback": "Reproduccion", + "resetCropBottom": "Restablecer recorte inferior", + "resetCropLeft": "Restablecer recorte izquierdo", + "resetCropRight": "Restablecer recorte derecho", + "resetCropTop": "Restablecer recorte superior", + "resetSoftness": "Restablecer suavidad", + "resetSpeed": "Restablecer velocidad", + "resetToZero": "Restablecer a cero", + "softness": "Suavidad", + "speed": "Velocidad" + }, + "mediaSidebar": { + "media": "Medios", + "text": "Texto", + "shapes": "Formas", + "effects": "Efectos", + "transitions": "Transiciones", + "ai": "IA", + "collapsePanel": "Contraer panel", + "expandPanel": "Expandir panel", + "keyframeEditor": "Editor de fotogramas clave", + "hideKeyframeEditor": "Ocultar editor de fotogramas clave", + "showKeyframeEditor": "Mostrar editor de fotogramas clave", + "templates": "Plantillas", + "textGroupSingle": "Simple", + "textGroupTwoSpans": "2 fragmentos", + "textGroupThreeSpans": "3 fragmentos", + "addText": "Añadir texto", + "pen": "Pluma", + "penToolHint": "Dibuja una forma de ruta personalizada con la herramienta pluma", + "adjustmentLayer": "Capa de ajuste", + "blankAdjustmentLayer": "Capa de ajuste en blanco", + "presets": "Preajustes" + }, + "textSection": { + "sectionTitle": "Texto", + "content": "Contenido", + "single": "Simple", + "twoSpans": "2 fragmentos", + "threeSpans": "3 fragmentos", + "mixedNone": "Mixto / Ninguno", + "selectPreset": "Seleccionar preajuste", + "span": "Fragmento {{count}}", + "spanText": "Texto del fragmento {{count}}", + "eyebrow": "Antetítulo", + "eyebrowText": "Texto del antetítulo", + "title": "Título", + "titleText": "Texto del título", + "subtitle": "Subtítulo", + "subtitleText": "Texto del subtítulo", + "text": "Texto", + "enterText": "Introduce texto...", + "defaultText": "Tu texto aquí", + "selectFont": "Seleccionar fuente", + "size": "Tamaño", + "spacing": "Espaciado", + "scale": "Escala", + "font": "Fuente", + "weight": "Peso", + "style": "Estilo", + "align": "Alinear", + "color": "Color", + "background": "Fondo", + "clear": "Quitar", + "clearBackground": "Quitar fondo", + "lineHeightShort": "Alt. línea", + "padding": "Relleno", + "radius": "Radio", + "mixed": "Mixto", + "selectWeight": "Seleccionar peso", + "bold": "Negrita", + "boldUnavailable": "La negrita no está disponible para esta fuente", + "italic": "Cursiva", + "underline": "Subrayado", + "italicSpan": "Cursiva en {{label}}", + "underlineSpan": "Subrayar {{label}}", + "alignLeft": "Alinear a la izquierda", + "alignCenter": "Alinear al centro", + "alignRight": "Alinear a la derecha", + "alignTop": "Alinear arriba", + "alignMiddle": "Alinear al medio", + "alignBottom": "Alinear abajo", + "effects": "Efectos", + "presets": "Preajustes", + "fontWeights": { + "regular": "Regular", + "medium": "Medio", + "semibold": "Seminegrita", + "bold": "Negrita" + }, + "effectPresets": { + "none": "Ninguno", + "shadow": "Sombra", + "outline": "Contorno", + "glow": "Brillo" + }, + "shadow": "Sombra", + "shadowX": "Sombra X", + "shadowY": "Sombra Y", + "shadowBlur": "Desenfoque s.", + "strokeWidth": "Ancho c.", + "stroke": "Contorno", + "animation": "Animación", + "intro": "Entrada", + "outro": "Salida", + "animationFooter": "Aplica una breve animación con ease-out al inicio o final de cada clip seleccionado.", + "animationPresets": { + "none": "Ninguno", + "fade": "Fundido", + "rise": "Subir", + "drop": "Caer", + "left": "Izquierda", + "right": "Derecha", + "tilt": "Inclinar", + "pop": "Aparecer", + "swing": "Balancear" + } + }, + "tts": { + "seek": "Buscar", + "dialogTitle": "Generar audio desde texto", + "dialogDescription": "Genera voz e insértala en la posición del clip de texto.", + "kokoroUnsupported": "WebGPU no está disponible en este navegador. Kokoro TTS necesita Chrome 113+, Edge 113+ o Safari 26+.", + "mossUnsupported": "El almacenamiento gestionado por el navegador no está disponible. MOSS TTS multilingüe funciona mejor en un navegador Chromium reciente.", + "supertonicUnsupported": "Este navegador no puede ejecutar el runtime local de Supertonic TTS. Prueba con una versión reciente de Chrome o Edge.", + "engine": "Motor", + "engineSupportDetails": "Detalles de compatibilidad del motor TTS", + "kokoroDescription": "Voces en inglés con WebGPU.", + "supertonicDescription": "Voces ONNX locales en 31 idiomas con WebGPU o WASM.", + "supportedLanguages": "Idiomas compatibles: {{languages}}.", + "kokoroOption": "Kokoro (inglés, WebGPU)", + "mossOption": "MOSS Nano (20 idiomas, CPU)", + "supertonicOption": "Supertonic 3 (31 idiomas, ONNX local)", + "language": "Idioma", + "autoDetectLanguage": "Detectar automáticamente", + "voice": "Voz", + "text": "Texto", + "textPlaceholder": "Escribe el texto que quieres escuchar...", + "expressiveTags": "Etiquetas expresivas", + "speed": "Velocidad", + "progressPreparing": "Preparando TTS local...", + "insertedAndLinked": "Insertado y vinculado", + "generate": "Generar", + "generating": "Generando...", + "regenerate": "Regenerar", + "insertAndLink": "Insertar y vincular", + "inserting": "Insertando...", + "errors": { + "openProject": "Abre un proyecto antes de generar audio.", + "enterText": "Introduce texto para sintetizar.", + "kokoroUnsupported": "WebGPU es necesario para Kokoro TTS. Prueba Chrome 113+, Edge 113+ o Safari 26+.", + "mossUnsupported": "El almacenamiento gestionado por el navegador es necesario para MOSS TTS multilingüe. Prueba un navegador Chromium reciente.", + "supertonicUnsupported": "Este navegador no puede ejecutar el runtime local de Supertonic TTS. Prueba con una versión reciente de Chrome o Edge.", + "generateFailed": "No se pudo generar la voz.", + "insertFailed": "No se pudo guardar e insertar el audio." + }, + "notifications": { + "addedAndLinked": "Se añadió \"{{fileName}}\" a la línea de tiempo y se vinculó con el texto.", + "savedNoTrack": "Se guardó \"{{fileName}}\", pero no hay una pista de audio disponible." + } + }, + "aiPanel": { + "textToSpeech": "Texto a voz", + "collapseTextToSpeech": "Contraer texto a voz", + "expandTextToSpeech": "Expandir texto a voz", + "runsLocally": "{{runtime}} se ejecuta localmente en el navegador con {{backend}}.", + "history": "Historial ({{count}}) - {{size}}", + "clearAll": "Borrar todo", + "musicGeneration": "Generación de música", + "collapseMusicGeneration": "Contraer generación de música", + "expandMusicGeneration": "Expandir generación de música", + "musicGenerationInfo": "Información de generación de música", + "musicgenDescription": "Usa el modelo MusicGen preparado para navegador de Xenova mediante Transformers.js. La primera descarga es grande; después queda en caché local.", + "musicgenPromptHint": "Describe género, ánimo, tempo e instrumentación. Los clips más cortos terminan mucho más rápido.", + "musicgenUnsupported": "WebGPU no está disponible en este navegador. MusicGen necesita Chrome 113+, Edge 113+ o Safari 26+.", + "prompt": "Prompt", + "presets": "Preajustes", + "musicPromptPlaceholder": "Describe el tipo de música que quieres generar...", + "length": "Duración", + "generateMusic": "Generar música", + "musicHistory": "Historial de música ({{count}}) - {{size}}", + "remove": "Quitar", + "saved": "Guardado", + "saving": "Guardando...", + "saveAndInsert": "Guardar e insertar", + "saveToLibrary": "Guardar en biblioteca", + "progressPreparingMusic": "Preparando generación local de música...", + "errors": { + "describeMusic": "Describe la música que quieres generar.", + "musicgenUnsupported": "WebGPU es necesario para MusicGen. Prueba Chrome 113+, Edge 113+ o Safari 26+.", + "generateMusicFailed": "No se pudo generar la música.", + "saveAudioFailed": "No se pudo guardar el audio en la biblioteca multimedia." + }, + "notifications": { + "savedToLibrary": "Se guardó \"{{fileName}}\" en la biblioteca multimedia.", + "savedAndAdded": "Se guardó \"{{fileName}}\" y se añadió a la línea de tiempo." + }, + "defaultTtsPrompt": "Welcome to freecut. This voice was generated locally in the browser.", + "musicPresets": { + "lofiChillLabel": "Lo-fi Chill", + "lofiChillPrompt": "Warm lo-fi beat with dusty drums, mellow bass, and a dreamy synth lead", + "pop80sLabel": "80s Pop", + "pop80sPrompt": "80s pop track with bassy drums and synth", + "rock90sLabel": "90s Rock", + "rock90sPrompt": "90s rock song with loud guitars and heavy drums", + "upbeatEdmLabel": "Upbeat EDM", + "upbeatEdmPrompt": "A light and cheery EDM track, with syncopated drums, airy pads, and strong emotions bpm: 130", + "countryLabel": "Country", + "countryPrompt": "A cheerful country song with acoustic guitars", + "lofiElectroLabel": "Lo-fi Electro", + "lofiElectroPrompt": "Lofi slow bpm electro chill with organic samples" + } } } }, @@ -666,7 +1118,8 @@ "error": "Erreur d'IA locale", "ready": "IA locale prête", "jobs_one": "{{count}} tâche", - "jobs_other": "{{count}} tâches" + "jobs_other": "{{count}} tâches", + "jobs": "{{count}} tâche(s)" }, "clearKeyframesDialog": { "titleProperty": "Effacer les images clés de {{property}}", @@ -676,7 +1129,9 @@ "descriptionAll_one": "Voulez-vous vraiment effacer toutes les images clés de {{count}} clip ?", "descriptionAll_other": "Voulez-vous vraiment effacer toutes les images clés de {{count}} clips ?", "undoHint": "Cette action peut être annulée avec Ctrl+Z.", - "confirm": "Effacer les images clés" + "confirm": "Effacer les images clés", + "descriptionProperty": "Effacer les images clés {{property}} de {{count}} élément(s) sélectionné(s) ?", + "descriptionAll": "Effacer toutes les images clés de {{count}} élément(s) sélectionné(s) ?" }, "projectUpgradeDialog": { "title": "Mettre à jour le projet avant de l'ouvrir", @@ -765,7 +1220,8 @@ "clipsSelected_other": "{{count}} clips sélectionnés", "dockToPreview": "Ancrer à l'aperçu", "expandFullColumn": "Étendre en colonne complète", - "showPanel": "Afficher le panneau de propriétés" + "showPanel": "Afficher le panneau de propriétés", + "clipsSelected": "{{count}} clips sélectionnés" }, "markerPanel": { "notFound": "Marqueur introuvable", @@ -897,7 +1353,8 @@ "italic": "Italique", "bold": "Gras", "underline": "Souligné", - "cuePosition": "Position du sous-titre : {{vertical}} {{horizontal}}" + "cuePosition": "Position du sous-titre : {{vertical}} {{horizontal}}", + "cueCount": "{{count}} réplique(s)" }, "audioSection": { "audio": "Audio", @@ -974,6 +1431,227 @@ "panelMode": "Mode du panneau", "audioMeter": "Vu-mètre audio", "audioMixer": "Mixeur audio" + }, + "videoSection": { + "cropBottom": "Recadrage bas", + "cropLeft": "Recadrage gauche", + "cropRight": "Recadrage droit", + "cropTop": "Recadrage haut", + "cropping": "Recadrage", + "fadeIn": "Fondu entrant", + "fadeOut": "Fondu sortant", + "playback": "Lecture", + "resetCropBottom": "Reinitialiser le recadrage bas", + "resetCropLeft": "Reinitialiser le recadrage gauche", + "resetCropRight": "Reinitialiser le recadrage droit", + "resetCropTop": "Reinitialiser le recadrage haut", + "resetSoftness": "Reinitialiser la douceur", + "resetSpeed": "Reinitialiser la vitesse", + "resetToZero": "Remettre a zero", + "softness": "Douceur", + "speed": "Vitesse" + }, + "mediaSidebar": { + "media": "Médias", + "text": "Texte", + "shapes": "Formes", + "effects": "Effets", + "transitions": "Transitions", + "ai": "IA", + "collapsePanel": "Réduire le panneau", + "expandPanel": "Développer le panneau", + "keyframeEditor": "Éditeur d'images clés", + "hideKeyframeEditor": "Masquer l'éditeur d'images clés", + "showKeyframeEditor": "Afficher l'éditeur d'images clés", + "templates": "Modèles", + "textGroupSingle": "Simple", + "textGroupTwoSpans": "2 segments", + "textGroupThreeSpans": "3 segments", + "addText": "Ajouter du texte", + "pen": "Plume", + "penToolHint": "Dessiner une forme de tracé personnalisée avec l'outil plume", + "adjustmentLayer": "Calque d'ajustement", + "blankAdjustmentLayer": "Calque d'ajustement vide", + "presets": "Préréglages" + }, + "textSection": { + "sectionTitle": "Texte", + "content": "Contenu", + "single": "Simple", + "twoSpans": "2 segments", + "threeSpans": "3 segments", + "mixedNone": "Mixte / Aucun", + "selectPreset": "Sélectionner un préréglage", + "span": "Segment {{count}}", + "spanText": "Texte du segment {{count}}", + "eyebrow": "Accroche", + "eyebrowText": "Texte de l'accroche", + "title": "Titre", + "titleText": "Texte du titre", + "subtitle": "Sous-titre", + "subtitleText": "Texte du sous-titre", + "text": "Texte", + "enterText": "Saisir du texte...", + "defaultText": "Votre texte ici", + "selectFont": "Sélectionner une police", + "size": "Taille", + "spacing": "Espacement", + "scale": "Échelle", + "font": "Police", + "weight": "Graisse", + "style": "Style", + "align": "Aligner", + "color": "Couleur", + "background": "Arrière-plan", + "clear": "Effacer", + "clearBackground": "Effacer l'arrière-plan", + "lineHeightShort": "Interligne", + "padding": "Marge", + "radius": "Rayon", + "mixed": "Mixte", + "selectWeight": "Sélectionner une graisse", + "bold": "Gras", + "boldUnavailable": "Le gras n'est pas disponible pour cette police", + "italic": "Italique", + "underline": "Souligné", + "italicSpan": "Italique {{label}}", + "underlineSpan": "Souligner {{label}}", + "alignLeft": "Aligner à gauche", + "alignCenter": "Aligner au centre", + "alignRight": "Aligner à droite", + "alignTop": "Aligner en haut", + "alignMiddle": "Aligner au milieu", + "alignBottom": "Aligner en bas", + "effects": "Effets", + "presets": "Préréglages", + "fontWeights": { + "regular": "Normal", + "medium": "Moyen", + "semibold": "Demi-gras", + "bold": "Gras" + }, + "effectPresets": { + "none": "Aucun", + "shadow": "Ombre", + "outline": "Contour", + "glow": "Lueur" + }, + "shadow": "Ombre", + "shadowX": "Ombre X", + "shadowY": "Ombre Y", + "shadowBlur": "Flou ombre", + "strokeWidth": "Cont. ép.", + "stroke": "Contour", + "animation": "Animation", + "intro": "Entrée", + "outro": "Sortie", + "animationFooter": "Applique un court mouvement de texte en ease-out au début ou à la fin de chaque clip sélectionné.", + "animationPresets": { + "none": "Aucun", + "fade": "Fondu", + "rise": "Monter", + "drop": "Descendre", + "left": "Gauche", + "right": "Droite", + "tilt": "Incliner", + "pop": "Apparaître", + "swing": "Balancer" + } + }, + "tts": { + "seek": "Parcourir", + "dialogTitle": "Générer de l'audio depuis le texte", + "dialogDescription": "Générez une voix et insérez-la à la position du clip de texte.", + "kokoroUnsupported": "WebGPU n'est pas disponible dans ce navigateur. Kokoro TTS nécessite Chrome 113+, Edge 113+ ou Safari 26+.", + "mossUnsupported": "Le stockage géré par le navigateur n'est pas disponible. MOSS TTS multilingue fonctionne mieux dans un navigateur Chromium récent.", + "supertonicUnsupported": "Ce navigateur ne peut pas exécuter le runtime TTS local de Supertonic. Essayez une version récente de Chrome ou Edge.", + "engine": "Moteur", + "engineSupportDetails": "Détails de compatibilité du moteur TTS", + "kokoroDescription": "Voix anglaises sur WebGPU.", + "supertonicDescription": "Voix ONNX locales en 31 langues sur WebGPU ou WASM.", + "supportedLanguages": "Langues prises en charge : {{languages}}.", + "kokoroOption": "Kokoro (anglais, WebGPU)", + "mossOption": "MOSS Nano (20 langues, CPU)", + "supertonicOption": "Supertonic 3 (31 langues, ONNX local)", + "language": "Langue", + "autoDetectLanguage": "Détection automatique", + "voice": "Voix", + "text": "Texte", + "textPlaceholder": "Saisissez le texte à lire...", + "expressiveTags": "Balises expressives", + "speed": "Vitesse", + "progressPreparing": "Préparation du TTS local...", + "insertedAndLinked": "Inséré et lié", + "generate": "Générer", + "generating": "Génération...", + "regenerate": "Régénérer", + "insertAndLink": "Insérer et lier", + "inserting": "Insertion...", + "errors": { + "openProject": "Ouvrez un projet avant de générer de l'audio.", + "enterText": "Saisissez du texte à synthétiser.", + "kokoroUnsupported": "WebGPU est requis pour Kokoro TTS. Essayez Chrome 113+, Edge 113+ ou Safari 26+.", + "mossUnsupported": "Le stockage géré par le navigateur est requis pour MOSS TTS multilingue. Essayez un navigateur Chromium récent.", + "supertonicUnsupported": "Ce navigateur ne peut pas exécuter le runtime TTS local de Supertonic. Essayez une version récente de Chrome ou Edge.", + "generateFailed": "Échec de la génération vocale.", + "insertFailed": "Échec de l'enregistrement et de l'insertion de l'audio." + }, + "notifications": { + "addedAndLinked": "« {{fileName}} » a été ajouté à la timeline et lié au texte.", + "savedNoTrack": "« {{fileName}} » a été enregistré, mais aucune piste audio n'est disponible." + } + }, + "aiPanel": { + "textToSpeech": "Texte vers voix", + "collapseTextToSpeech": "Réduire le texte vers voix", + "expandTextToSpeech": "Développer le texte vers voix", + "runsLocally": "{{runtime}} s'exécute localement dans le navigateur avec {{backend}}.", + "history": "Historique ({{count}}) - {{size}}", + "clearAll": "Tout effacer", + "musicGeneration": "Génération musicale", + "collapseMusicGeneration": "Réduire la génération musicale", + "expandMusicGeneration": "Développer la génération musicale", + "musicGenerationInfo": "Infos de génération musicale", + "musicgenDescription": "Utilise le modèle MusicGen de Xenova prêt pour le navigateur via Transformers.js. Le premier téléchargement est volumineux, puis il reste en cache local.", + "musicgenPromptHint": "Décrivez le genre, l'ambiance, le tempo et les instruments. Les clips plus courts finissent beaucoup plus vite.", + "musicgenUnsupported": "WebGPU n'est pas disponible dans ce navigateur. MusicGen nécessite Chrome 113+, Edge 113+ ou Safari 26+.", + "prompt": "Prompt", + "presets": "Préréglages", + "musicPromptPlaceholder": "Décrivez le type de musique à générer...", + "length": "Durée", + "generateMusic": "Générer la musique", + "musicHistory": "Historique musical ({{count}}) - {{size}}", + "remove": "Supprimer", + "saved": "Enregistré", + "saving": "Enregistrement...", + "saveAndInsert": "Enregistrer et insérer", + "saveToLibrary": "Enregistrer dans la bibliothèque", + "progressPreparingMusic": "Préparation de la génération musicale locale...", + "errors": { + "describeMusic": "Décrivez la musique à générer.", + "musicgenUnsupported": "WebGPU est requis pour MusicGen. Essayez Chrome 113+, Edge 113+ ou Safari 26+.", + "generateMusicFailed": "Échec de la génération musicale.", + "saveAudioFailed": "Impossible d'enregistrer l'audio dans la bibliothèque multimédia." + }, + "notifications": { + "savedToLibrary": "« {{fileName}} » a été enregistré dans la bibliothèque multimédia.", + "savedAndAdded": "« {{fileName}} » a été enregistré et ajouté à la timeline." + }, + "defaultTtsPrompt": "Welcome to freecut. This voice was generated locally in the browser.", + "musicPresets": { + "lofiChillLabel": "Lo-fi Chill", + "lofiChillPrompt": "Warm lo-fi beat with dusty drums, mellow bass, and a dreamy synth lead", + "pop80sLabel": "80s Pop", + "pop80sPrompt": "80s pop track with bassy drums and synth", + "rock90sLabel": "90s Rock", + "rock90sPrompt": "90s rock song with loud guitars and heavy drums", + "upbeatEdmLabel": "Upbeat EDM", + "upbeatEdmPrompt": "A light and cheery EDM track, with syncopated drums, airy pads, and strong emotions bpm: 130", + "countryLabel": "Country", + "countryPrompt": "A cheerful country song with acoustic guitars", + "lofiElectroLabel": "Lo-fi Electro", + "lofiElectroPrompt": "Lofi slow bpm electro chill with organic samples" + } } } }, @@ -992,7 +1670,8 @@ "error": "Fehler der lokalen KI", "ready": "Lokale KI bereit", "jobs_one": "{{count}} Aufgabe", - "jobs_other": "{{count}} Aufgaben" + "jobs_other": "{{count}} Aufgaben", + "jobs": "{{count}} Job(s)" }, "clearKeyframesDialog": { "titleProperty": "{{property}}-Keyframes löschen", @@ -1002,7 +1681,9 @@ "descriptionAll_one": "Möchtest du wirklich alle Keyframes von {{count}} Clip löschen?", "descriptionAll_other": "Möchtest du wirklich alle Keyframes von {{count}} Clips löschen?", "undoHint": "Diese Aktion kann mit Strg+Z rückgängig gemacht werden.", - "confirm": "Keyframes löschen" + "confirm": "Keyframes löschen", + "descriptionProperty": "{{property}}-Keyframes von {{count}} ausgewählten Element(en) löschen?", + "descriptionAll": "Alle Keyframes von {{count}} ausgewählten Element(en) löschen?" }, "projectUpgradeDialog": { "title": "Projekt vor dem Öffnen aktualisieren", @@ -1091,7 +1772,8 @@ "clipsSelected_other": "{{count}} Clips ausgewählt", "dockToPreview": "An Vorschau andocken", "expandFullColumn": "Auf volle Spalte erweitern", - "showPanel": "Eigenschaftenleiste anzeigen" + "showPanel": "Eigenschaftenleiste anzeigen", + "clipsSelected": "{{count}} Clips ausgewählt" }, "markerPanel": { "notFound": "Marker nicht gefunden", @@ -1223,7 +1905,8 @@ "italic": "Kursiv", "bold": "Fett", "underline": "Unterstrichen", - "cuePosition": "Untertitelposition: {{vertical}} {{horizontal}}" + "cuePosition": "Untertitelposition: {{vertical}} {{horizontal}}", + "cueCount": "{{count}} Cue(s)" }, "audioSection": { "audio": "Audio", @@ -1300,6 +1983,227 @@ "panelMode": "Panelmodus", "audioMeter": "Audio-Pegelanzeige", "audioMixer": "Audio-Mixer" + }, + "videoSection": { + "cropBottom": "Unten zuschneiden", + "cropLeft": "Links zuschneiden", + "cropRight": "Rechts zuschneiden", + "cropTop": "Oben zuschneiden", + "cropping": "Zuschneiden", + "fadeIn": "Einblenden", + "fadeOut": "Ausblenden", + "playback": "Wiedergabe", + "resetCropBottom": "Unteren Zuschnitt zuruecksetzen", + "resetCropLeft": "Linken Zuschnitt zuruecksetzen", + "resetCropRight": "Rechten Zuschnitt zuruecksetzen", + "resetCropTop": "Oberen Zuschnitt zuruecksetzen", + "resetSoftness": "Weichheit zuruecksetzen", + "resetSpeed": "Geschwindigkeit zuruecksetzen", + "resetToZero": "Auf null zuruecksetzen", + "softness": "Weichheit", + "speed": "Geschwindigkeit" + }, + "mediaSidebar": { + "media": "Medien", + "text": "Text", + "shapes": "Formen", + "effects": "Effekte", + "transitions": "Übergänge", + "ai": "KI", + "collapsePanel": "Panel einklappen", + "expandPanel": "Panel ausklappen", + "keyframeEditor": "Keyframe-Editor", + "hideKeyframeEditor": "Keyframe-Editor ausblenden", + "showKeyframeEditor": "Keyframe-Editor anzeigen", + "templates": "Vorlagen", + "textGroupSingle": "Einzeln", + "textGroupTwoSpans": "2 Abschnitte", + "textGroupThreeSpans": "3 Abschnitte", + "addText": "Text hinzufügen", + "pen": "Stift", + "penToolHint": "Eine eigene Pfadform mit dem Stiftwerkzeug zeichnen", + "adjustmentLayer": "Einstellungsebene", + "blankAdjustmentLayer": "Leere Einstellungsebene", + "presets": "Vorgaben" + }, + "textSection": { + "sectionTitle": "Text", + "content": "Inhalt", + "single": "Einzeln", + "twoSpans": "2 Abschnitte", + "threeSpans": "3 Abschnitte", + "mixedNone": "Gemischt / Keine", + "selectPreset": "Vorgabe auswählen", + "span": "Abschnitt {{count}}", + "spanText": "Text für Abschnitt {{count}}", + "eyebrow": "Kicker", + "eyebrowText": "Kicker-Text", + "title": "Titel", + "titleText": "Titeltext", + "subtitle": "Untertitel", + "subtitleText": "Untertiteltext", + "text": "Text", + "enterText": "Text eingeben...", + "defaultText": "Dein Text hier", + "selectFont": "Schrift auswählen", + "size": "Größe", + "spacing": "Abstand", + "scale": "Skalierung", + "font": "Schrift", + "weight": "Stärke", + "style": "Stil", + "align": "Ausrichten", + "color": "Farbe", + "background": "Hintergrund", + "clear": "Leeren", + "clearBackground": "Hintergrund entfernen", + "lineHeightShort": "Zeilenh.", + "padding": "Innenabstand", + "radius": "Radius", + "mixed": "Gemischt", + "selectWeight": "Stärke auswählen", + "bold": "Fett", + "boldUnavailable": "Fett ist für diese Schrift nicht verfügbar", + "italic": "Kursiv", + "underline": "Unterstrichen", + "italicSpan": "{{label}} kursiv", + "underlineSpan": "{{label}} unterstreichen", + "alignLeft": "Links ausrichten", + "alignCenter": "Zentrieren", + "alignRight": "Rechts ausrichten", + "alignTop": "Oben ausrichten", + "alignMiddle": "Mittig ausrichten", + "alignBottom": "Unten ausrichten", + "effects": "Effekte", + "presets": "Vorgaben", + "fontWeights": { + "regular": "Normal", + "medium": "Mittel", + "semibold": "Halbfett", + "bold": "Fett" + }, + "effectPresets": { + "none": "Keine", + "shadow": "Schatten", + "outline": "Kontur", + "glow": "Leuchten" + }, + "shadow": "Schatten", + "shadowX": "Schatten X", + "shadowY": "Schatten Y", + "shadowBlur": "Schatten W.", + "strokeWidth": "Kontur S.", + "stroke": "Kontur", + "animation": "Animation", + "intro": "Intro", + "outro": "Outro", + "animationFooter": "Wendet am Anfang oder Ende jedes ausgewählten Clips eine kurze Ease-out-Textbewegung an.", + "animationPresets": { + "none": "Keine", + "fade": "Einblenden", + "rise": "Aufsteigen", + "drop": "Absinken", + "left": "Links", + "right": "Rechts", + "tilt": "Neigen", + "pop": "Pop", + "swing": "Schwingen" + } + }, + "tts": { + "seek": "Suchen", + "dialogTitle": "Audio aus Text generieren", + "dialogDescription": "Sprache erzeugen und an der Position des Textclips einfügen.", + "kokoroUnsupported": "WebGPU ist in diesem Browser nicht verfügbar. Kokoro TTS benötigt Chrome 113+, Edge 113+ oder Safari 26+.", + "mossUnsupported": "Browserverwalteter Speicher ist nicht verfügbar. MOSS mehrsprachiges TTS funktioniert am besten in einem aktuellen Chromium-Browser.", + "supertonicUnsupported": "Dieser Browser kann die lokale Supertonic-TTS-Laufzeit nicht ausführen. Versuche es mit einer aktuellen Version von Chrome oder Edge.", + "engine": "Engine", + "engineSupportDetails": "Details zur TTS-Engine-Unterstützung", + "kokoroDescription": "Englische Stimmen über WebGPU.", + "supertonicDescription": "Lokale ONNX-Stimmen in 31 Sprachen mit WebGPU oder WASM.", + "supportedLanguages": "Unterstützte Sprachen: {{languages}}.", + "kokoroOption": "Kokoro (Englisch, WebGPU)", + "mossOption": "MOSS Nano (20 Sprachen, CPU)", + "supertonicOption": "Supertonic 3 (31 Sprachen, lokales ONNX)", + "language": "Sprache", + "autoDetectLanguage": "Automatisch erkennen", + "voice": "Stimme", + "text": "Text", + "textPlaceholder": "Gib den Text ein, den du hören möchtest...", + "expressiveTags": "Ausdrucks-Tags", + "speed": "Geschwindigkeit", + "progressPreparing": "Lokales TTS wird vorbereitet...", + "insertedAndLinked": "Eingefügt und verknüpft", + "generate": "Generieren", + "generating": "Generieren...", + "regenerate": "Neu generieren", + "insertAndLink": "Einfügen und verknüpfen", + "inserting": "Einfügen...", + "errors": { + "openProject": "Öffne ein Projekt, bevor du Audio generierst.", + "enterText": "Gib Text zum Synthetisieren ein.", + "kokoroUnsupported": "WebGPU ist für Kokoro TTS erforderlich. Versuche Chrome 113+, Edge 113+ oder Safari 26+.", + "mossUnsupported": "Browserverwalteter Speicher ist für MOSS mehrsprachiges TTS erforderlich. Versuche einen aktuellen Chromium-Browser.", + "supertonicUnsupported": "Dieser Browser kann die lokale Supertonic-TTS-Laufzeit nicht ausführen. Versuche es mit einer aktuellen Version von Chrome oder Edge.", + "generateFailed": "Sprache konnte nicht generiert werden.", + "insertFailed": "Audio konnte nicht gespeichert und eingefügt werden." + }, + "notifications": { + "addedAndLinked": "\"{{fileName}}\" wurde zur Timeline hinzugefügt und mit Text verknüpft.", + "savedNoTrack": "\"{{fileName}}\" wurde gespeichert, aber es ist keine Audiospur verfügbar." + } + }, + "aiPanel": { + "textToSpeech": "Text zu Sprache", + "collapseTextToSpeech": "Text zu Sprache einklappen", + "expandTextToSpeech": "Text zu Sprache ausklappen", + "runsLocally": "{{runtime}} läuft lokal im Browser mit {{backend}}.", + "history": "Verlauf ({{count}}) - {{size}}", + "clearAll": "Alle löschen", + "musicGeneration": "Musik generieren", + "collapseMusicGeneration": "Musikgenerierung einklappen", + "expandMusicGeneration": "Musikgenerierung ausklappen", + "musicGenerationInfo": "Infos zur Musikgenerierung", + "musicgenDescription": "Verwendet Xenovas browserfähiges MusicGen-Modell über Transformers.js. Der erste Download ist groß, danach bleibt es lokal im Cache.", + "musicgenPromptHint": "Beschreibe Genre, Stimmung, Tempo und Instrumentierung. Kürzere Clips sind deutlich schneller fertig.", + "musicgenUnsupported": "WebGPU ist in diesem Browser nicht verfügbar. MusicGen benötigt Chrome 113+, Edge 113+ oder Safari 26+.", + "prompt": "Prompt", + "presets": "Vorgaben", + "musicPromptPlaceholder": "Beschreibe, welche Musik du erzeugen möchtest...", + "length": "Länge", + "generateMusic": "Musik generieren", + "musicHistory": "Musikverlauf ({{count}}) - {{size}}", + "remove": "Entfernen", + "saved": "Gespeichert", + "saving": "Speichern...", + "saveAndInsert": "Speichern und einfügen", + "saveToLibrary": "In Bibliothek speichern", + "progressPreparingMusic": "Lokale Musikgenerierung wird vorbereitet...", + "errors": { + "describeMusic": "Beschreibe die Musik, die du erzeugen möchtest.", + "musicgenUnsupported": "WebGPU ist für MusicGen erforderlich. Versuche Chrome 113+, Edge 113+ oder Safari 26+.", + "generateMusicFailed": "Musik konnte nicht generiert werden.", + "saveAudioFailed": "Audio konnte nicht in der Medienbibliothek gespeichert werden." + }, + "notifications": { + "savedToLibrary": "\"{{fileName}}\" wurde in der Medienbibliothek gespeichert.", + "savedAndAdded": "\"{{fileName}}\" wurde gespeichert und zur Timeline hinzugefügt." + }, + "defaultTtsPrompt": "Welcome to freecut. This voice was generated locally in the browser.", + "musicPresets": { + "lofiChillLabel": "Lo-fi Chill", + "lofiChillPrompt": "Warm lo-fi beat with dusty drums, mellow bass, and a dreamy synth lead", + "pop80sLabel": "80s Pop", + "pop80sPrompt": "80s pop track with bassy drums and synth", + "rock90sLabel": "90s Rock", + "rock90sPrompt": "90s rock song with loud guitars and heavy drums", + "upbeatEdmLabel": "Upbeat EDM", + "upbeatEdmPrompt": "A light and cheery EDM track, with syncopated drums, airy pads, and strong emotions bpm: 130", + "countryLabel": "Country", + "countryPrompt": "A cheerful country song with acoustic guitars", + "lofiElectroLabel": "Lo-fi Electro", + "lofiElectroPrompt": "Lofi slow bpm electro chill with organic samples" + } } } }, @@ -1318,7 +2222,8 @@ "error": "Erro da IA local", "ready": "IA local pronta", "jobs_one": "{{count}} tarefa", - "jobs_other": "{{count}} tarefas" + "jobs_other": "{{count}} tarefas", + "jobs": "{{count}} tarefa(s)" }, "clearKeyframesDialog": { "titleProperty": "Limpar quadros-chave de {{property}}", @@ -1328,7 +2233,9 @@ "descriptionAll_one": "Tem certeza de que deseja limpar todos os quadros-chave de {{count}} clipe?", "descriptionAll_other": "Tem certeza de que deseja limpar todos os quadros-chave de {{count}} clipes?", "undoHint": "Esta ação pode ser desfeita com Ctrl+Z.", - "confirm": "Limpar quadros-chave" + "confirm": "Limpar quadros-chave", + "descriptionProperty": "Limpar keyframes de {{property}} de {{count}} item(ns) selecionado(s)?", + "descriptionAll": "Limpar todos os keyframes de {{count}} item(ns) selecionado(s)?" }, "projectUpgradeDialog": { "title": "Atualizar o projeto antes de abrir", @@ -1417,7 +2324,8 @@ "clipsSelected_other": "{{count}} clipes selecionados", "dockToPreview": "Encaixar na pré-visualização", "expandFullColumn": "Expandir coluna inteira", - "showPanel": "Mostrar painel de propriedades" + "showPanel": "Mostrar painel de propriedades", + "clipsSelected": "{{count}} clipes selecionados" }, "markerPanel": { "notFound": "Marcador não encontrado", @@ -1549,7 +2457,8 @@ "italic": "Itálico", "bold": "Negrito", "underline": "Sublinhado", - "cuePosition": "Posição da legenda: {{vertical}} {{horizontal}}" + "cuePosition": "Posição da legenda: {{vertical}} {{horizontal}}", + "cueCount": "{{count}} legenda(s)" }, "audioSection": { "audio": "Áudio", @@ -1626,1310 +2535,2435 @@ "panelMode": "Modo do painel", "audioMeter": "Medidor de áudio", "audioMixer": "Mixer de áudio" + }, + "videoSection": { + "cropBottom": "Cortar parte inferior", + "cropLeft": "Cortar esquerda", + "cropRight": "Cortar direita", + "cropTop": "Cortar parte superior", + "cropping": "Recorte", + "fadeIn": "Fade de entrada", + "fadeOut": "Fade de saida", + "playback": "Reproducao", + "resetCropBottom": "Redefinir corte inferior", + "resetCropLeft": "Redefinir corte esquerdo", + "resetCropRight": "Redefinir corte direito", + "resetCropTop": "Redefinir corte superior", + "resetSoftness": "Redefinir suavidade", + "resetSpeed": "Redefinir velocidade", + "resetToZero": "Redefinir para zero", + "softness": "Suavidade", + "speed": "Velocidade" + }, + "mediaSidebar": { + "media": "Mídia", + "text": "Texto", + "shapes": "Formas", + "effects": "Efeitos", + "transitions": "Transições", + "ai": "IA", + "collapsePanel": "Recolher painel", + "expandPanel": "Expandir painel", + "keyframeEditor": "Editor de keyframes", + "hideKeyframeEditor": "Ocultar editor de keyframes", + "showKeyframeEditor": "Mostrar editor de keyframes", + "templates": "Modelos", + "textGroupSingle": "Único", + "textGroupTwoSpans": "2 trechos", + "textGroupThreeSpans": "3 trechos", + "addText": "Adicionar texto", + "pen": "Caneta", + "penToolHint": "Desenhe uma forma de caminho personalizada com a ferramenta caneta", + "adjustmentLayer": "Camada de ajuste", + "blankAdjustmentLayer": "Camada de ajuste em branco", + "presets": "Predefinições" + }, + "textSection": { + "sectionTitle": "Texto", + "content": "Conteúdo", + "single": "Único", + "twoSpans": "2 trechos", + "threeSpans": "3 trechos", + "mixedNone": "Misto / Nenhum", + "selectPreset": "Selecionar predefinição", + "span": "Trecho {{count}}", + "spanText": "Texto do trecho {{count}}", + "eyebrow": "Chamada", + "eyebrowText": "Texto da chamada", + "title": "Título", + "titleText": "Texto do título", + "subtitle": "Subtítulo", + "subtitleText": "Texto do subtítulo", + "text": "Texto", + "enterText": "Digite o texto...", + "defaultText": "Seu texto aqui", + "selectFont": "Selecionar fonte", + "size": "Tamanho", + "spacing": "Espaçamento", + "scale": "Escala", + "font": "Fonte", + "weight": "Peso", + "style": "Estilo", + "align": "Alinhar", + "color": "Cor", + "background": "Fundo", + "clear": "Limpar", + "clearBackground": "Limpar fundo", + "lineHeightShort": "Alt. linha", + "padding": "Margem", + "radius": "Raio", + "mixed": "Misto", + "selectWeight": "Selecionar peso", + "bold": "Negrito", + "boldUnavailable": "Negrito não está disponível para esta fonte", + "italic": "Itálico", + "underline": "Sublinhado", + "italicSpan": "Itálico em {{label}}", + "underlineSpan": "Sublinhar {{label}}", + "alignLeft": "Alinhar à esquerda", + "alignCenter": "Alinhar ao centro", + "alignRight": "Alinhar à direita", + "alignTop": "Alinhar ao topo", + "alignMiddle": "Alinhar ao meio", + "alignBottom": "Alinhar à base", + "effects": "Efeitos", + "presets": "Predefinições", + "fontWeights": { + "regular": "Regular", + "medium": "Médio", + "semibold": "Seminegrito", + "bold": "Negrito" + }, + "effectPresets": { + "none": "Nenhum", + "shadow": "Sombra", + "outline": "Contorno", + "glow": "Brilho" + }, + "shadow": "Sombra", + "shadowX": "Sombra X", + "shadowY": "Sombra Y", + "shadowBlur": "Desfoque s.", + "strokeWidth": "Largura c.", + "stroke": "Contorno", + "animation": "Animação", + "intro": "Entrada", + "outro": "Saída", + "animationFooter": "Aplica um breve movimento de texto com ease-out no início ou no fim de cada clipe selecionado.", + "animationPresets": { + "none": "Nenhum", + "fade": "Fade", + "rise": "Subir", + "drop": "Cair", + "left": "Esquerda", + "right": "Direita", + "tilt": "Inclinar", + "pop": "Surgir", + "swing": "Balançar" + } + }, + "tts": { + "seek": "Buscar", + "dialogTitle": "Gerar áudio a partir de texto", + "dialogDescription": "Gere fala e insira na posição do clipe de texto.", + "kokoroUnsupported": "WebGPU não está disponível neste navegador. Kokoro TTS precisa do Chrome 113+, Edge 113+ ou Safari 26+.", + "mossUnsupported": "O armazenamento gerenciado pelo navegador não está disponível. O MOSS TTS multilíngue funciona melhor em um navegador Chromium recente.", + "engine": "Motor", + "engineSupportDetails": "Detalhes de suporte do motor TTS", + "kokoroDescription": "Vozes em inglês no WebGPU.", + "supportedLanguages": "Idiomas compatíveis: {{languages}}.", + "kokoroOption": "Kokoro (inglês, WebGPU)", + "mossOption": "MOSS Nano (20 idiomas, CPU)", + "voice": "Voz", + "text": "Texto", + "textPlaceholder": "Digite o texto que você quer ouvir...", + "speed": "Velocidade", + "progressPreparing": "Preparando TTS local...", + "insertedAndLinked": "Inserido e vinculado", + "generate": "Gerar", + "generating": "Gerando...", + "regenerate": "Gerar novamente", + "insertAndLink": "Inserir e vincular", + "inserting": "Inserindo...", + "errors": { + "openProject": "Abra um projeto antes de gerar áudio.", + "enterText": "Digite algum texto para sintetizar.", + "kokoroUnsupported": "WebGPU é necessário para Kokoro TTS. Tente Chrome 113+, Edge 113+ ou Safari 26+.", + "mossUnsupported": "O armazenamento gerenciado pelo navegador é necessário para MOSS TTS multilíngue. Tente um navegador Chromium recente.", + "generateFailed": "Falha ao gerar fala.", + "insertFailed": "Falha ao salvar e inserir áudio.", + "supertonicUnsupported": "Este navegador não consegue executar o runtime local do Supertonic TTS. Tente uma versão recente do Chrome ou Edge." + }, + "notifications": { + "addedAndLinked": "\"{{fileName}}\" foi adicionado à linha do tempo e vinculado ao texto.", + "savedNoTrack": "\"{{fileName}}\" foi salvo, mas nenhuma faixa de áudio está disponível." + }, + "supertonicUnsupported": "Este navegador não consegue executar o runtime local do Supertonic TTS. Tente uma versão recente do Chrome ou Edge.", + "supertonicDescription": "Vozes ONNX locais em 31 idiomas no WebGPU ou WASM.", + "supertonicOption": "Supertonic 3 (31 idiomas, ONNX local)", + "language": "Idioma", + "autoDetectLanguage": "Detectar automaticamente", + "expressiveTags": "Tags expressivas" + }, + "aiPanel": { + "textToSpeech": "Texto para fala", + "collapseTextToSpeech": "Recolher texto para fala", + "expandTextToSpeech": "Expandir texto para fala", + "runsLocally": "{{runtime}} roda localmente no navegador em {{backend}}.", + "history": "Histórico ({{count}}) - {{size}}", + "clearAll": "Limpar tudo", + "musicGeneration": "Geração de música", + "collapseMusicGeneration": "Recolher geração de música", + "expandMusicGeneration": "Expandir geração de música", + "musicGenerationInfo": "Informações da geração de música", + "musicgenDescription": "Usa o modelo MusicGen da Xenova pronto para navegador via Transformers.js. O primeiro download é grande; depois ele fica em cache local.", + "musicgenPromptHint": "Descreva gênero, clima, tempo e instrumentos. Clipes mais curtos terminam muito mais rápido.", + "musicgenUnsupported": "WebGPU não está disponível neste navegador. MusicGen precisa do Chrome 113+, Edge 113+ ou Safari 26+.", + "prompt": "Comando", + "presets": "Predefinições", + "musicPromptPlaceholder": "Descreva o tipo de música que você quer gerar...", + "length": "Duração", + "generateMusic": "Gerar música", + "musicHistory": "Histórico de música ({{count}}) - {{size}}", + "remove": "Remover", + "saved": "Salvo", + "saving": "Salvando...", + "saveAndInsert": "Salvar e inserir", + "saveToLibrary": "Salvar na biblioteca", + "progressPreparingMusic": "Preparando geração local de música...", + "errors": { + "describeMusic": "Descreva a música que você quer gerar.", + "musicgenUnsupported": "WebGPU é necessário para MusicGen. Tente Chrome 113+, Edge 113+ ou Safari 26+.", + "generateMusicFailed": "Não foi possível gerar a música.", + "saveAudioFailed": "Não foi possível salvar o áudio na biblioteca de mídia." + }, + "notifications": { + "savedToLibrary": "\"{{fileName}}\" foi salvo na biblioteca de mídia.", + "savedAndAdded": "\"{{fileName}}\" foi salvo e adicionado à linha do tempo." + }, + "defaultTtsPrompt": "Welcome to freecut. This voice was generated locally in the browser.", + "musicPresets": { + "lofiChillLabel": "Lo-fi tranquilo", + "lofiChillPrompt": "Batida lo-fi quente com bateria vintage, baixo suave e lead de sintetizador sonhador", + "pop80sLabel": "Pop anos 80", + "pop80sPrompt": "Faixa pop dos anos 80 com bateria grave e sintetizador", + "rock90sLabel": "Rock anos 90", + "rock90sPrompt": "Música de rock dos anos 90 com guitarras altas e bateria pesada", + "upbeatEdmLabel": "EDM animado", + "upbeatEdmPrompt": "Faixa EDM leve e alegre, com bateria sincopada, pads aéreos e emoção forte bpm: 130", + "countryLabel": "Country / sertanejo", + "countryPrompt": "Música country alegre com violão acústico", + "lofiElectroLabel": "Lo-fi eletrônico", + "lofiElectroPrompt": "Lo-fi electro chill de BPM lento com samples orgânicos" + } } } }, - "ja": { + "tr": { "editor": { "shortcutsDialog": { - "title": "キーボードショートカット", - "description": "キーボードショートカットの割り当てを編集します。" + "title": "Klavye Kısayolları", + "description": "Klavye kısayol atamalarını düzenleyin." }, "interactionLock": { - "maskEditing": "続行するにはマスク編集を完了または終了してください" + "maskEditing": "Devam etmek için maske düzenlemeyi bitirin veya çıkın" }, "localInferencePill": { - "loading": "ローカルAIを読み込み中", - "active": "ローカルAI稼働中", - "error": "ローカルAIエラー", - "ready": "ローカルAI準備完了", - "jobs_one": "{{count}}件のジョブ", - "jobs_other": "{{count}}件のジョブ" + "loading": "Yerel YZ Yükleniyor", + "active": "Yerel YZ Etkin", + "error": "Yerel YZ Hatası", + "ready": "Yerel YZ Hazır", + "jobs_one": "{{count}} iş", + "jobs_other": "{{count}} iş", + "jobs": "{{count}} iş" }, "clearKeyframesDialog": { - "titleProperty": "{{property}}のキーフレームを消去", - "titleAll": "すべてのキーフレームを消去", - "descriptionProperty_one": "{{count}}個のクリップから{{property}}のキーフレームをすべて消去してもよろしいですか?", - "descriptionProperty_other": "{{count}}個のクリップから{{property}}のキーフレームをすべて消去してもよろしいですか?", - "descriptionAll_one": "{{count}}個のクリップからキーフレームをすべて消去してもよろしいですか?", - "descriptionAll_other": "{{count}}個のクリップからキーフレームをすべて消去してもよろしいですか?", - "undoHint": "この操作はCtrl+Zで元に戻せます。", - "confirm": "キーフレームを消去" + "titleProperty": "{{property}} Anahtar Karelerini Temizle", + "titleAll": "Tüm Anahtar Kareleri Temizle", + "descriptionProperty_one": "{{count}} klipten tüm {{property}} anahtar karelerini temizlemek istediğinizden emin misiniz?", + "descriptionProperty_other": "{{count}} klipten tüm {{property}} anahtar karelerini temizlemek istediğinizden emin misiniz?", + "descriptionAll_one": "{{count}} klipten tüm anahtar kareleri temizlemek istediğinizden emin misiniz?", + "descriptionAll_other": "{{count}} klipten tüm anahtar kareleri temizlemek istediğinizden emin misiniz?", + "undoHint": "Bu işlem Ctrl+Z ile geri alınabilir.", + "confirm": "Anahtar Kareleri Temizle", + "descriptionProperty": "{{count}} seçili öğeden {{property}} anahtar kareleri temizlensin mi?", + "descriptionAll": "{{count}} seçili öğeden tüm anahtar kareler temizlensin mi?" }, "projectUpgradeDialog": { - "title": "開く前にプロジェクトをアップグレード", - "versionMismatch": "{{projectName}}はプロジェクトスキーマ v{{storedSchemaVersion}} で保存されていますが、このビルドは v{{currentSchemaVersion}} を必要とします。", - "explanation": "FreeCutはエディターを読み込む前にアップグレードできます。問題があった場合に元のデータを復元できるよう、まずアップグレード前のバックアップを作成します。", - "backupCopy": "バックアップコピー:{{backupName}}", - "creatingBackup": "バックアップを作成中...", - "confirm": "バックアップを作成してアップグレード" + "title": "Açmadan Önce Projeyi Yükselt", + "versionMismatch": "{{projectName}} proje şeması v{{storedSchemaVersion}} ile kaydedilmiş, ancak bu sürüm v{{currentSchemaVersion}} bekliyor.", + "explanation": "FreeCut, editörü yüklemeden önce projeyi sizin için yükseltebilir. Bir şey ters görünürse eski veriye dönebilmeniz için önce yükseltme öncesi bir yedek oluşturulur.", + "backupCopy": "Yedek kopya: {{backupName}}", + "creatingBackup": "Yedek Oluşturuluyor...", + "confirm": "Yedek Oluştur ve Yükselt" }, "autoSave": { - "failed": "自動保存に失敗しました" + "failed": "Otomatik kaydetme başarısız" }, "whatsNew": { - "title": "新着情報", - "thisWeek": "今週", - "asOf": "{{date}}時点", - "weekOf": "{{range}}の週", - "newBadge": "新着", - "released": "リリース:v{{version}}", - "preRelease": "プレリリース", - "fullChangelog": "変更履歴の全文", - "groupAdded": "追加", - "groupFixed": "修正", - "groupImproved": "改善" + "title": "Yenilikler", + "thisWeek": "Bu Hafta", + "asOf": "{{date}} itibarıyla", + "weekOf": "{{range}} haftası", + "newBadge": "Yeni", + "released": "Yayınlandı: v{{version}}", + "preRelease": "Ön sürüm", + "fullChangelog": "Tam değişiklik günlüğü", + "groupAdded": "Eklendi", + "groupFixed": "Düzeltildi", + "groupImproved": "İyileştirildi" }, "transitions": { - "hintClickToApply": "クリックして選択中のカットに適用するか、トランジションをタイムライン上の有効なカットにドラッグしてください。", - "hintUnavailable": "トランジションを有効なカットにドラッグしてください。ここではクリックでの適用は利用できません:{{reason}}。", - "hintSelectOne": "トランジションを有効なカットにドラッグするか、クリップを隣り合わせに配置して1つを選択するとクリックで適用できます。", - "hintSelectSingle": "トランジションを有効なカットにドラッグするか、1つの動画または画像クリップを選択するとクリックで適用できます。", - "hintSelectClip": "トランジションを有効なカットにドラッグするか、動画または画像クリップを選択して隣のクリップとの間にトランジションを追加してください。", + "hintClickToApply": "Seçili kesime uygulamak için tıklayın veya geçişi zaman çizelgesindeki geçerli bir kesime sürükleyin.", + "hintUnavailable": "Geçişi geçerli bir kesime sürükleyin. Burada tıklayarak uygulama kullanılamaz: {{reason}}.", + "hintSelectOne": "Geçişi geçerli bir kesime sürükleyin veya klipleri yan yana koyup tıklayarak uygulamak için bir klip seçin.", + "hintSelectSingle": "Geçişi geçerli bir kesime sürükleyin veya tıklayarak uygulamak için tek bir video ya da görsel klip seçin.", + "hintSelectClip": "Geçişi geçerli bir kesime sürükleyin veya komşusuna geçiş eklemek için bir video ya da görsel klip seçin.", "category": { - "basic": "ベーシック", - "dissolve": "ディゾルブ", - "motion": "モーション", - "wipe": "ワイプ", - "slide": "スライド", - "flip": "フリップ", - "mask": "マスク", - "iris": "アイリス", - "shape": "シェイプ", - "light": "ライト", - "chromatic": "色収差", - "custom": "カスタム" + "basic": "Temel", + "dissolve": "Çözülme", + "motion": "Hareket", + "wipe": "Silme", + "slide": "Kaydırma", + "flip": "Çevirme", + "mask": "Maske", + "iris": "İris", + "shape": "Şekil", + "light": "Işık", + "chromatic": "Kromatik", + "custom": "Özel" } }, "transitionPanel": { - "sectionTitle": "トランジション", - "preset": "プリセット", - "presetTooltip": "トランジションのスタイルプリセット", - "selectPreset": "プリセットを選択", - "searchTransitions": "トランジションを検索", - "noTransitionsFound": "トランジションが見つかりません", - "notFound": "トランジションが見つかりません", - "duration": "長さ", - "durationTooltip": "トランジションの長さ", - "resetToDefault": "1秒にリセット", - "placement": "配置", - "placementTooltip": "カットの前・上・後にトランジションを配置", - "placementLeft": "左", - "placementCenter": "中央", - "placementRight": "右", - "placementLeftTooltip": "カットの前にトランジションを配置", - "placementCenterTooltip": "カット上にトランジションを中央配置", - "placementRightTooltip": "カットの後にトランジションを配置", - "placementAria": "{{label}}配置", - "placementDisabled": "{{title}}(ソースの余白が足りません)", - "ease": "イージング", - "easeTooltip": "トランジションのイージングカーブ", - "easeLinear": "リニア", - "easeIn": "イン", - "easeOut": "アウト", - "easeInOut": "イン&アウト", - "direction": "方向", - "directionTooltip": "トランジションの動きの方向", - "directionLeft": "左", - "directionRight": "右", - "directionTop": "上", - "directionBottom": "下", - "resetParam": "{{label}}をリセット", - "paramColorAria": "{{label}}の色" - }, - "propertiesSidebar": { - "title": "プロパティ", - "clipsSelected_one": "{{count}}個のクリップを選択中", - "clipsSelected_other": "{{count}}個のクリップを選択中", - "dockToPreview": "プレビューにドッキング", - "expandFullColumn": "フルカラムに展開", - "showPanel": "プロパティパネルを表示" - }, - "markerPanel": { - "notFound": "マーカーが見つかりません", - "title": "マーカー", - "frame": "フレーム", - "time": "時間", - "label": "ラベル", - "labelPlaceholder": "ラベルを入力...", - "color": "色", - "deleteMarker": "マーカーを削除" - }, - "canvasPanel": { - "updateFailed": "キャンバス設定の更新に失敗しました", - "tryAgain": "もう一度お試しください。", - "noProjectLoaded": "プロジェクトが読み込まれていません", - "canvas": "キャンバス", - "swap": "入れ替え", - "background": "背景", - "resetToBlack": "黒にリセット", - "duration": "長さ", - "frameRate": "フレームレート", - "totalFrames": "総フレーム数" + "sectionTitle": "Geçiş", + "preset": "Ön ayar", + "presetTooltip": "Geçiş stili ön ayarı", + "selectPreset": "Ön ayar seç", + "searchTransitions": "Geçişlerde ara", + "noTransitionsFound": "Geçiş bulunamadı", + "notFound": "Geçiş bulunamadı", + "duration": "Süre", + "durationTooltip": "Geçiş süresi", + "resetToDefault": "1 sn'ye sıfırla", + "placement": "Yerleşim", + "placementTooltip": "Geçişi kesimin önüne, üzerine veya sonrasına konumlandır", + "placementLeft": "Sol", + "placementCenter": "Merkez", + "placementRight": "Sağ", + "placementLeftTooltip": "Geçişi kesimden önce yerleştir", + "placementCenterTooltip": "Geçişi kesimin ortasına yerleştir", + "placementRightTooltip": "Geçişi kesimden sonra yerleştir", + "placementAria": "{{label}} yerleşimi", + "placementDisabled": "{{title}} (yeterli kaynak payı yok)", + "ease": "Yumuşatma", + "easeTooltip": "Geçiş için yumuşatma eğrisi", + "easeLinear": "Doğrusal", + "easeIn": "Giriş", + "easeOut": "Çıkış", + "easeInOut": "Giriş ve Çıkış", + "direction": "Yön", + "directionTooltip": "Geçiş hareketinin yönü", + "directionLeft": "Sol", + "directionRight": "Sağ", + "directionTop": "Üst", + "directionBottom": "Alt", + "resetParam": "{{label}} sıfırla", + "paramColorAria": "{{label}} rengi" + }, + "propertiesSidebar": { + "title": "Özellikler", + "clipsSelected_one": "{{count}} klip seçildi", + "clipsSelected_other": "{{count}} klip seçildi", + "dockToPreview": "Önizlemeye sabitle", + "expandFullColumn": "Tam sütuna genişlet", + "showPanel": "Özellikler Panelini Göster", + "clipsSelected": "{{count}} klip seçildi" + }, + "markerPanel": { + "notFound": "İşaretçi bulunamadı", + "title": "İşaretçi", + "frame": "Kare", + "time": "Zaman", + "label": "Etiket", + "labelPlaceholder": "Etiket girin...", + "color": "Renk", + "deleteMarker": "İşaretçiyi Sil" + }, + "canvasPanel": { + "updateFailed": "Tuval ayarları güncellenemedi", + "tryAgain": "Lütfen tekrar deneyin.", + "noProjectLoaded": "Proje yüklenmedi", + "canvas": "Tuval", + "swap": "Değiştir", + "background": "Arka plan", + "resetToBlack": "Siyaha sıfırla", + "duration": "Süre", + "frameRate": "Kare Hızı", + "totalFrames": "Toplam Kare" }, "clipPanel": { - "tabVideo": "ビデオ", - "tabAudio": "オーディオ", - "tabEffects": "エフェクト", - "adjustmentLayerHint": "調整レイヤーのエフェクトは、上のトラックのすべてのアイテムに適用されます。" + "tabVideo": "Video", + "tabAudio": "Ses", + "tabEffects": "Efektler", + "adjustmentLayerHint": "Ayar katmanlarındaki efektler, üst izlerdeki tüm öğelere uygulanır." }, "gifSection": { - "animation": "アニメーション", - "speed": "速度", - "resetSpeed": "1xにリセット" + "animation": "Animasyon", + "speed": "Hız", + "resetSpeed": "1x'e sıfırla" }, "cornerPinSection": { - "title": "コーナーピン", - "exitEditor": "コーナーピンエディターを終了", - "editOnPreview": "プレビューでコーナーを編集", - "editing": "編集中...", - "edit": "編集", - "reset": "コーナーピンをリセット" + "title": "Köşe Sabitleme", + "exitEditor": "Köşe sabitleme düzenleyicisinden çık", + "editOnPreview": "Köşeleri önizlemede düzenle", + "editing": "Düzenleniyor...", + "edit": "Düzenle", + "reset": "Köşe sabitlemeyi sıfırla" }, "fillSection": { - "composite": "合成", - "opacity": "不透明度", - "resetOpacity": "100%にリセット", - "blend": "描画モード", - "blendNormal": "通常", - "blendMixed": "混在", - "radius": "角丸", - "resetRadius": "0にリセット" + "composite": "Bileşik", + "opacity": "Opaklık", + "resetOpacity": "%100'e sıfırla", + "blend": "Karışım", + "blendNormal": "Normal", + "blendMixed": "Karışık", + "radius": "Yarıçap", + "resetRadius": "0'a sıfırla" }, "fontPicker": { - "selectFont": "フォントを選択", - "searchFonts": "フォントを検索...", - "livePreview": "ライブプレビュー", - "allFonts": "すべてのフォント", - "fontOptions": "フォントの選択肢", - "noMatch": "検索に一致するフォントがありません。", - "previewPangram": "いろはにほへと ちりぬるを わかよたれそ" + "selectFont": "Yazı tipi seç", + "searchFonts": "Yazı tiplerinde ara...", + "livePreview": "Canlı Önizleme", + "allFonts": "Tüm Yazı Tipleri", + "fontOptions": "Yazı tipi seçenekleri", + "noMatch": "Aramanızla eşleşen yazı tipi yok.", + "previewPangram": "Pijamalı hasta yağız şoföre çabucak güvendi" }, "layoutSection": { - "transform": "変形", - "position": "位置", - "resetPosition": "中央にリセット", - "size": "サイズ", - "unlockAspect": "縦横比のロックを解除", - "lockAspect": "縦横比をロック", - "resetSize": "元のサイズにリセット", - "rotation": "回転", - "resetRotation": "回転をリセット", - "anchor": "アンカー", - "resetAnchor": "アンカーを中央にリセット", - "flip": "反転", - "flipHorizontalAria": "ビデオを左右反転", - "flipVerticalAria": "ビデオを上下反転", - "horizontal": "左右", - "horizontalMixed": "左右(混在)", - "vertical": "上下", - "verticalMixed": "上下(混在)" + "transform": "Dönüşüm", + "position": "Konum", + "resetPosition": "Ortaya sıfırla", + "size": "Boyut", + "unlockAspect": "En-boy oranı kilidini aç", + "lockAspect": "En-boy oranını kilitle", + "resetSize": "Özgün boyuta sıfırla", + "rotation": "Döndürme", + "resetRotation": "Döndürmeyi sıfırla", + "anchor": "Bağlantı noktası", + "resetAnchor": "Bağlantı noktasını ortaya sıfırla", + "flip": "Çevir", + "flipHorizontalAria": "Videoyu yatay çevir", + "flipVerticalAria": "Videoyu dikey çevir", + "horizontal": "Yatay", + "horizontalMixed": "Yatay (karışık)", + "vertical": "Dikey", + "verticalMixed": "Dikey (karışık)" }, "shapeSection": { - "shape": "シェイプ", - "type": "種類", - "mixed": "混在", - "selectShape": "シェイプを選択", - "typeRectangle": "長方形", - "typeCircle": "円", - "typeTriangle": "三角形", - "typeEllipse": "楕円", - "typeStar": "星", - "typePolygon": "多角形", - "typeHeart": "ハート", - "path": "パス", - "editPath": "パスを編集", - "editPathHint": "プレビューでポイントとハンドルをドラッグします。", - "fill": "塗りつぶし", - "strokeWidth": "線の太さ", - "stroke": "線", - "radius": "角丸", - "direction": "向き", - "directionUp": "上", - "directionDown": "下", - "directionLeft": "左", - "directionRight": "右", - "points": "頂点数", - "innerRadius": "内側の半径", - "useAsMask": "マスクとして使用", - "on": "オン", - "off": "オフ", - "maskType": "マスクの種類", - "selectType": "種類を選択", - "maskTypeClip": "クリップ(くっきりした端)", - "maskTypeAlpha": "アルファ(やわらかい端)", - "feather": "ぼかし", - "resetFeather": "10pxにリセット", - "invert": "反転" + "shape": "Şekil", + "type": "Tür", + "mixed": "Karışık", + "selectShape": "Şekil seç", + "typeRectangle": "Dikdörtgen", + "typeCircle": "Daire", + "typeTriangle": "Üçgen", + "typeEllipse": "Elips", + "typeStar": "Yıldız", + "typePolygon": "Çokgen", + "typeHeart": "Kalp", + "path": "Yol", + "editPath": "Yolu Düzenle", + "editPathHint": "Önizlemede noktaları ve tutamaçları sürükleyin.", + "fill": "Dolgu", + "strokeWidth": "Kontur G.", + "stroke": "Kontur", + "radius": "Yarıçap", + "direction": "Yön", + "directionUp": "Yukarı", + "directionDown": "Aşağı", + "directionLeft": "Sol", + "directionRight": "Sağ", + "points": "Noktalar", + "innerRadius": "İç Y.", + "useAsMask": "Maske Olarak Kullan", + "on": "Açık", + "off": "Kapalı", + "maskType": "Maske Türü", + "selectType": "Tür seç", + "maskTypeClip": "Klip (Sert kenarlar)", + "maskTypeAlpha": "Alfa (Yumuşak kenarlar)", + "feather": "Yumuşatma", + "resetFeather": "10px'e sıfırla", + "invert": "Ters çevir" }, "subtitleSection": { - "title": "字幕", - "multiSelectHint": "{{segments}}個のセグメントを選択中・合計{{cues}}個の字幕。スタイルはすべてに適用されます。個々の字幕を編集するには1つのセグメントを選択してください。", - "trackLabel": "トラック{{number}}", - "transcript": "文字起こし", - "cueCount_one": "{{count}}個の字幕", - "cueCount_other": "{{count}}個の字幕", - "seekToCue": "この字幕に再生ヘッドを移動", - "start": "開始", - "end": "終了", - "italic": "斜体", - "bold": "太字", - "underline": "下線", - "cuePosition": "字幕の位置:{{vertical}} {{horizontal}}" + "title": "Altyazı", + "multiSelectHint": "{{segments}} segment seçildi · toplam {{cues}} ipucu. Stil hepsine uygulanır. Tek tek ipuçlarını düzenlemek için tek bir segment seçin.", + "trackLabel": "İz {{number}}", + "transcript": "Döküm", + "cueCount_one": "{{count}} ipucu", + "cueCount_other": "{{count}} ipucu", + "seekToCue": "Oynatma kafasını bu ipucuna taşı", + "start": "Başlangıç", + "end": "Bitiş", + "italic": "İtalik", + "bold": "Kalın", + "underline": "Altı çizili", + "cuePosition": "İpucu konumu: {{vertical}} {{horizontal}}", + "cueCount": "{{count}} ipucu" }, "audioSection": { - "audio": "オーディオ", - "gain": "ゲイン", - "resetGain": "0 dBにリセット", - "fadeIn": "フェードイン", - "fadeOut": "フェードアウト", - "resetToZero": "0にリセット", - "pitch": "ピッチ", - "semitones": "半音", - "resetSemitones": "半音ピッチをリセット", - "cents": "セント", - "resetCents": "セントピッチをリセット", - "equalizer": "イコライザー" + "audio": "Ses", + "gain": "Kazanç", + "resetGain": "0 dB'ye sıfırla", + "fadeIn": "Açılma", + "fadeOut": "Kapanma", + "resetToZero": "0'a sıfırla", + "pitch": "Perde", + "semitones": "Yarım Sesler", + "resetSemitones": "Yarım ses perdesini sıfırla", + "cents": "Sent", + "resetCents": "Sent perdesini sıfırla", + "equalizer": "Ekolayzer" }, "captionStyleControls": { - "stylePreset": "スタイルプリセット", - "color": "色", - "size": "サイズ", - "vertical": "上下位置", - "background": "背景", - "on": "オン", - "off": "オフ", - "padding": "余白" + "stylePreset": "Stil ön ayarı", + "color": "Renk", + "size": "Boyut", + "vertical": "Dikey", + "background": "Arka plan", + "on": "Açık", + "off": "Kapalı", + "padding": "Dolgu" }, "captionPresets": { - "netflixHint": "角丸の暗いボックスに Inter、下三分の一 — 放送品質のニュートラル。", - "youtubeHint": "Roboto に柔らかいドロップシャドウ、ボックスなし — 自動字幕の雰囲気。", - "boldYellowHint": "シネマイエローの Roboto Slab に黒いドロップシャドウ — DVD時代の定番。", - "outlinedHint": "細いアウトラインの Manrope — すっきりとモダン、影なし。", - "tiktokHint": "Anton ディスプレイ、特大で中央寄せ — 縦型動画のバイラルな見た目。" + "netflixHint": "Yuvarlatılmış koyu kutuda Inter, alt üçte birlik konum — yayın kalitesinde nötr.", + "youtubeHint": "Yumuşak gölgeli Roboto, kutusuz — otomatik altyazı havası.", + "boldYellowHint": "Siyah gölgeli sinema sarısı Roboto Slab — DVD dönemi klasiği.", + "outlinedHint": "İnce konturlu Manrope — temiz, modern, gölgesiz.", + "tiktokHint": "Anton display, büyük ve ortalanmış — dikey video viral görünümü." }, "alignment": { - "left": "左揃え", - "centerHorizontally": "水平方向に中央揃え", - "right": "右揃え", - "top": "上揃え", - "centerVertically": "垂直方向に中央揃え", - "bottom": "下揃え", - "distributeHorizontally": "水平方向に分布", - "distributeVertically": "垂直方向に分布" + "left": "Sola Hizala", + "centerHorizontally": "Yatay Ortala", + "right": "Sağa Hizala", + "top": "Üste Hizala", + "centerVertically": "Dikey Ortala", + "bottom": "Alta Hizala", + "distributeHorizontally": "Yatay Dağıt", + "distributeVertically": "Dikey Dağıt" }, "projectMediaMatch": { - "updateFailed": "プロジェクト設定の更新に失敗しました", - "tryAgain": "もう一度お試しください。", - "title": "プロジェクトを最初のビデオに合わせますか?", - "descriptionWithFile": "「{{fileName}}」はこのプロジェクトに最初に追加されたビデオです。", - "description": "最初にインポートしたビデオでプロジェクトのサイズとフレームレートを定義できます。", - "current": "現在", - "clip": "クリップ", - "size": "サイズ", - "frameRate": "フレームレート", - "fpsRoundedHint": "FreeCutはインポートしたビデオを最も近い対応プロジェクトフレームレートに合わせます。このクリップは {{fps}} fps を使用します。", - "keepCurrent": "現在のままにする", - "fpsOnly": "FPSのみ", - "sizeOnly": "サイズのみ", - "matchBoth": "両方を合わせる", - "matchFps": "FPSを合わせる", - "matchSize": "サイズを合わせる" + "updateFailed": "Proje ayarları güncellenemedi", + "tryAgain": "Lütfen tekrar deneyin.", + "title": "Proje İlk Videoya Uydurulsun mu?", + "descriptionWithFile": "\"{{fileName}}\" bu projeye eklenen ilk video.", + "description": "İlk içe aktarılan video proje boyutunu ve kare hızını belirleyebilir.", + "current": "Geçerli", + "clip": "Klip", + "size": "Boyut", + "frameRate": "Kare hızı", + "fpsRoundedHint": "FreeCut içe aktarılan videoyu en yakın desteklenen proje kare hızına eşler. Bu klip {{fps}} fps kullanır.", + "keepCurrent": "Geçerli Olanı Koru", + "fpsOnly": "Yalnızca FPS", + "sizeOnly": "Yalnızca Boyut", + "matchBoth": "İkisini de Eşle", + "matchFps": "FPS'yi Eşle", + "matchSize": "Boyutu Eşle" }, "editor": { - "appLabel": "FreeCut ビデオエディター", - "backupCreated": "アップグレード前にバックアップを作成しました", - "backupFailed": "アップグレード前のバックアップ作成に失敗しました", - "tryAgain": "もう一度お試しください。", - "projectSaved": "プロジェクトを保存しました", - "projectSaveFailed": "プロジェクトの保存に失敗しました", - "projectBundle": "FreeCut プロジェクトバンドル" + "appLabel": "FreeCut Video Editörü", + "backupCreated": "Yükseltmeden önce yedek oluşturuldu", + "backupFailed": "Yükseltmeden önce yedek oluşturulamadı", + "tryAgain": "Lütfen tekrar deneyin.", + "projectSaved": "Proje kaydedildi", + "projectSaveFailed": "Proje kaydedilemedi", + "projectBundle": "FreeCut Proje Paketi" }, "audioMeters": { - "meters": "メーター", - "mixer": "ミキサー", - "floatMixer": "ミキサーをフロート", - "panelMode": "パネルモード", - "audioMeter": "オーディオメーター", - "audioMixer": "オーディオミキサー" + "meters": "Sayaçlar", + "mixer": "Mikser", + "floatMixer": "Mikseri Ayır", + "panelMode": "Panel modu", + "audioMeter": "Ses sayacı", + "audioMixer": "Ses mikseri" + }, + "videoSection": { + "cropBottom": "Altı kırp", + "cropLeft": "Solu kırp", + "cropRight": "Sağı kırp", + "cropTop": "Üstü kırp", + "cropping": "Kırpma", + "fadeIn": "Giriş yumuşatması", + "fadeOut": "Çıkış yumuşatması", + "playback": "Oynatma", + "resetCropBottom": "Alt kırpmayı sıfırla", + "resetCropLeft": "Sol kırpmayı sıfırla", + "resetCropRight": "Sağ kırpmayı sıfırla", + "resetCropTop": "Üst kırpmayı sıfırla", + "resetSoftness": "Yumuşaklığı sıfırla", + "resetSpeed": "Hızı sıfırla", + "resetToZero": "Sıfıra ayarla", + "softness": "Yumuşaklık", + "speed": "Hız" + }, + "mediaSidebar": { + "media": "Medya", + "text": "Metin", + "shapes": "Şekiller", + "effects": "Efektler", + "transitions": "Geçişler", + "ai": "YZ", + "collapsePanel": "Paneli daralt", + "expandPanel": "Paneli genişlet", + "keyframeEditor": "Anahtar kare düzenleyici", + "hideKeyframeEditor": "Anahtar kare düzenleyiciyi gizle", + "showKeyframeEditor": "Anahtar kare düzenleyiciyi göster", + "templates": "Şablonlar", + "textGroupSingle": "Tek", + "textGroupTwoSpans": "2 aralık", + "textGroupThreeSpans": "3 aralık", + "addText": "Metin ekle", + "pen": "Kalem", + "penToolHint": "Kalem aracıyla özel bir yol şekli çizin", + "adjustmentLayer": "Ayar katmanı", + "blankAdjustmentLayer": "Boş ayar katmanı", + "presets": "Ön Ayarlar" + }, + "textSection": { + "sectionTitle": "Metin", + "content": "İçerik", + "single": "Tek", + "twoSpans": "2 aralık", + "threeSpans": "3 aralık", + "mixedNone": "Karışık / Yok", + "selectPreset": "Ön ayar seç", + "span": "Aralık {{count}}", + "spanText": "Aralık {{count}} metni", + "eyebrow": "Üst başlık", + "eyebrowText": "Üst başlık metni", + "title": "Başlık", + "titleText": "Başlık metni", + "subtitle": "Alt başlık", + "subtitleText": "Alt başlık metni", + "text": "Metin", + "enterText": "Metin gir...", + "defaultText": "Metninizi buraya yazın", + "selectFont": "Yazı tipi seç", + "size": "Boyut", + "spacing": "Aralık", + "scale": "Ölçek", + "font": "Yazı tipi", + "weight": "Ağırlık", + "style": "Stil", + "align": "Hizala", + "color": "Renk", + "background": "Arka plan", + "clear": "Temizle", + "clearBackground": "Arka planı temizle", + "lineHeightShort": "Satır y.", + "padding": "Dolgu", + "radius": "Yarıçap", + "mixed": "Karışık", + "selectWeight": "Ağırlık seç", + "bold": "Kalın", + "boldUnavailable": "Bu yazı tipinde kalın kullanılamaz", + "italic": "İtalik", + "underline": "Altı çizili", + "italicSpan": "{{label}} italik", + "underlineSpan": "{{label}} altı çizili", + "alignLeft": "Sola hizala", + "alignCenter": "Ortala", + "alignRight": "Sağa hizala", + "alignTop": "Üste hizala", + "alignMiddle": "Ortaya hizala", + "alignBottom": "Alta hizala", + "effects": "Efektler", + "presets": "Ön Ayarlar", + "fontWeights": { + "regular": "Normal", + "medium": "Orta", + "semibold": "Yarı kalın", + "bold": "Kalın" + }, + "effectPresets": { + "none": "Yok", + "shadow": "Gölge", + "outline": "Kontur", + "glow": "Parıltı" + }, + "shadow": "Gölge", + "shadowX": "Gölge X", + "shadowY": "Gölge Y", + "shadowBlur": "Gölge bul.", + "strokeWidth": "Kontur k.", + "stroke": "Kontur", + "animation": "Animasyon", + "intro": "Giriş", + "outro": "Çıkış", + "animationFooter": "Seçili her klibin başına veya sonuna kısa bir ease-out metin hareketi uygular.", + "animationPresets": { + "none": "Yok", + "fade": "Soldur", + "rise": "Yüksel", + "drop": "Düş", + "left": "Sol", + "right": "Sağ", + "tilt": "Eğ", + "pop": "Pop", + "swing": "Salla" + } + }, + "tts": { + "seek": "Git", + "dialogTitle": "Metinden ses oluştur", + "dialogDescription": "Konuşma oluşturup metin klibinin konumuna ekleyin.", + "kokoroUnsupported": "Bu tarayıcıda WebGPU kullanılamıyor. Kokoro TTS için Chrome 113+, Edge 113+ veya Safari 26+ gerekir.", + "mossUnsupported": "Bu tarayıcıda tarayıcı tarafından yönetilen depolama kullanılamıyor. MOSS çok dilli TTS en iyi güncel bir Chromium tarayıcıda çalışır.", + "engine": "Motor", + "engineSupportDetails": "TTS motoru destek ayrıntıları", + "kokoroDescription": "WebGPU üzerinde İngilizce sesler.", + "supportedLanguages": "Desteklenen diller: {{languages}}.", + "kokoroOption": "Kokoro (İngilizce, WebGPU)", + "mossOption": "MOSS Nano (20 dil, CPU)", + "voice": "Ses", + "text": "Metin", + "textPlaceholder": "Seslendirilmesini istediğiniz metni girin...", + "speed": "Hız", + "progressPreparing": "Yerel TTS hazırlanıyor...", + "insertedAndLinked": "Eklendi ve bağlandı", + "generate": "Oluştur", + "generating": "Oluşturuluyor...", + "regenerate": "Yeniden oluştur", + "insertAndLink": "Ekle ve bağla", + "inserting": "Ekleniyor...", + "errors": { + "openProject": "Ses oluşturmadan önce bir proje açın.", + "enterText": "Sentezlemek için metin girin.", + "kokoroUnsupported": "Kokoro TTS için WebGPU gerekir. Chrome 113+, Edge 113+ veya Safari 26+ deneyin.", + "mossUnsupported": "MOSS çok dilli TTS için tarayıcı tarafından yönetilen depolama gerekir. Güncel bir Chromium tarayıcı deneyin.", + "generateFailed": "Konuşma oluşturulamadı.", + "insertFailed": "Ses kaydedilip eklenemedi.", + "supertonicUnsupported": "Bu tarayıcı yerel Supertonic TTS çalışma zamanını çalıştıramıyor. Güncel bir Chrome veya Edge deneyin." + }, + "notifications": { + "addedAndLinked": "\"{{fileName}}\" zaman çizelgesine eklendi ve metinle bağlandı.", + "savedNoTrack": "\"{{fileName}}\" kaydedildi ancak kullanılabilir ses izi yok." + }, + "supertonicUnsupported": "Bu tarayıcı yerel Supertonic TTS çalışma zamanını çalıştıramıyor. Güncel bir Chrome veya Edge deneyin.", + "supertonicDescription": "WebGPU veya WASM üzerinde 31 dilde yerel ONNX sesleri.", + "supertonicOption": "Supertonic 3 (31 dil, yerel ONNX)", + "language": "Dil", + "autoDetectLanguage": "Otomatik algıla", + "expressiveTags": "İfade etiketleri" + }, + "aiPanel": { + "textToSpeech": "Metinden Sese", + "collapseTextToSpeech": "Metinden sese bölümünü daralt", + "expandTextToSpeech": "Metinden sese bölümünü genişlet", + "runsLocally": "{{runtime}}, tarayıcıda {{backend}} üzerinde yerel çalışır.", + "history": "Geçmiş ({{count}}) - {{size}}", + "clearAll": "Tümünü temizle", + "musicGeneration": "Müzik Oluşturma", + "collapseMusicGeneration": "Müzik oluşturmayı daralt", + "expandMusicGeneration": "Müzik oluşturmayı genişlet", + "musicGenerationInfo": "Müzik oluşturma bilgisi", + "musicgenDescription": "Transformers.js üzerinden Xenova'nın tarayıcıya hazır MusicGen modelini kullanır. İlk indirme büyüktür, sonra yerelde önbellekte kalır.", + "musicgenPromptHint": "Tür, ruh hali, tempo ve enstrümantasyon içeren istem yazın. Daha kısa klipler çok daha hızlı biter.", + "musicgenUnsupported": "Bu tarayıcıda WebGPU kullanılamıyor. MusicGen için Chrome 113+, Edge 113+ veya Safari 26+ gerekir.", + "prompt": "İstem", + "presets": "Ön Ayarlar", + "musicPromptPlaceholder": "Oluşturmak istediğiniz müziği açıklayın...", + "length": "Uzunluk", + "generateMusic": "Müzik Oluştur", + "musicHistory": "Müzik Geçmişi ({{count}}) - {{size}}", + "remove": "Kaldır", + "saved": "Kaydedildi", + "saving": "Kaydediliyor...", + "saveAndInsert": "Kaydet ve Ekle", + "saveToLibrary": "Kitaplığa Kaydet", + "progressPreparingMusic": "Yerel müzik oluşturma hazırlanıyor...", + "errors": { + "describeMusic": "Oluşturmak istediğiniz müziği açıklayın.", + "musicgenUnsupported": "MusicGen için WebGPU gerekir. Chrome 113+, Edge 113+ veya Safari 26+ deneyin.", + "generateMusicFailed": "Müzik oluşturulamadı.", + "saveAudioFailed": "Ses medya kitaplığına kaydedilemedi." + }, + "notifications": { + "savedToLibrary": "\"{{fileName}}\" medya kitaplığına kaydedildi.", + "savedAndAdded": "\"{{fileName}}\" kaydedildi ve zaman çizelgesine eklendi." + }, + "defaultTtsPrompt": "FreeCut'a hoş geldiniz. Bu ses tarayıcıda yerel olarak oluşturuldu.", + "musicPresets": { + "lofiChillLabel": "Lo-fi Rahat", + "lofiChillPrompt": "Tozlu davullar, yumuşak bas ve rüya gibi synth melodisiyle sıcak lo-fi beat", + "pop80sLabel": "80'ler Pop", + "pop80sPrompt": "Baslı davullar ve synth içeren 80'ler pop parçası", + "rock90sLabel": "90'lar Rock", + "rock90sPrompt": "Gür gitarlar ve güçlü davullarla 90'lar rock şarkısı", + "upbeatEdmLabel": "Enerjik EDM", + "upbeatEdmPrompt": "Senkoplu davullar, havadar pad'ler ve güçlü duygular içeren hafif ve neşeli EDM parçası bpm: 130", + "countryLabel": "Country", + "countryPrompt": "Akustik gitarlarla neşeli bir country şarkısı", + "lofiElectroLabel": "Lo-fi Elektro", + "lofiElectroPrompt": "Organik örneklerle yavaş bpm lo-fi elektro chill" + } } } }, - "ko": { + "ja": { "editor": { "shortcutsDialog": { - "title": "키보드 단축키", - "description": "키보드 단축키 할당을 편집합니다." + "title": "キーボードショートカット", + "description": "キーボードショートカットの割り当てを編集します。" }, "interactionLock": { - "maskEditing": "계속하려면 마스크 편집을 완료하거나 종료하세요" + "maskEditing": "続行するにはマスク編集を完了または終了してください" }, "localInferencePill": { - "loading": "로컬 AI 로딩 중", - "active": "로컬 AI 활성", - "error": "로컬 AI 오류", - "ready": "로컬 AI 준비됨", - "jobs_one": "작업 {{count}}개", - "jobs_other": "작업 {{count}}개" + "loading": "ローカルAIを読み込み中", + "active": "ローカルAI稼働中", + "error": "ローカルAIエラー", + "ready": "ローカルAI準備完了", + "jobs_one": "{{count}}件のジョブ", + "jobs_other": "{{count}}件のジョブ", + "jobs": "{{count}} 件のジョブ" }, "clearKeyframesDialog": { - "titleProperty": "{{property}} 키프레임 지우기", - "titleAll": "모든 키프레임 지우기", - "descriptionProperty_one": "클립 {{count}}개에서 {{property}} 키프레임을 모두 지우시겠습니까?", - "descriptionProperty_other": "클립 {{count}}개에서 {{property}} 키프레임을 모두 지우시겠습니까?", - "descriptionAll_one": "클립 {{count}}개에서 키프레임을 모두 지우시겠습니까?", - "descriptionAll_other": "클립 {{count}}개에서 키프레임을 모두 지우시겠습니까?", - "undoHint": "이 작업은 Ctrl+Z로 실행 취소할 수 있습니다.", - "confirm": "키프레임 지우기" + "titleProperty": "{{property}}のキーフレームを消去", + "titleAll": "すべてのキーフレームを消去", + "descriptionProperty_one": "{{count}}個のクリップから{{property}}のキーフレームをすべて消去してもよろしいですか?", + "descriptionProperty_other": "{{count}}個のクリップから{{property}}のキーフレームをすべて消去してもよろしいですか?", + "descriptionAll_one": "{{count}}個のクリップからキーフレームをすべて消去してもよろしいですか?", + "descriptionAll_other": "{{count}}個のクリップからキーフレームをすべて消去してもよろしいですか?", + "undoHint": "この操作はCtrl+Zで元に戻せます。", + "confirm": "キーフレームを消去", + "descriptionProperty": "選択した {{count}} 項目から {{property}} のキーフレームをクリアしますか?", + "descriptionAll": "選択した {{count}} 項目からすべてのキーフレームをクリアしますか?" }, "projectUpgradeDialog": { - "title": "열기 전에 프로젝트 업그레이드", - "versionMismatch": "{{projectName}}은(는) 프로젝트 스키마 v{{storedSchemaVersion}}으로 저장되었지만 이 빌드는 v{{currentSchemaVersion}}을(를) 필요로 합니다.", - "explanation": "FreeCut은 편집기를 로드하기 전에 업그레이드할 수 있습니다. 문제가 있을 경우 이전 데이터를 복원할 수 있도록 먼저 업그레이드 전 백업이 생성됩니다.", - "backupCopy": "백업 사본: {{backupName}}", - "creatingBackup": "백업 생성 중...", - "confirm": "백업 생성 후 업그레이드" + "title": "開く前にプロジェクトをアップグレード", + "versionMismatch": "{{projectName}}はプロジェクトスキーマ v{{storedSchemaVersion}} で保存されていますが、このビルドは v{{currentSchemaVersion}} を必要とします。", + "explanation": "FreeCutはエディターを読み込む前にアップグレードできます。問題があった場合に元のデータを復元できるよう、まずアップグレード前のバックアップを作成します。", + "backupCopy": "バックアップコピー:{{backupName}}", + "creatingBackup": "バックアップを作成中...", + "confirm": "バックアップを作成してアップグレード" }, "autoSave": { - "failed": "자동 저장 실패" + "failed": "自動保存に失敗しました" }, "whatsNew": { - "title": "새로운 기능", - "thisWeek": "이번 주", - "asOf": "{{date}} 기준", - "weekOf": "{{range}} 주", - "newBadge": "신규", - "released": "출시: v{{version}}", - "preRelease": "사전 출시", - "fullChangelog": "전체 변경 로그", - "groupAdded": "추가됨", - "groupFixed": "수정됨", - "groupImproved": "개선됨" - }, - "transitions": { - "hintClickToApply": "클릭하여 선택한 컷에 적용하거나, 트랜지션을 타임라인의 유효한 컷 위로 드래그하세요.", - "hintUnavailable": "트랜지션을 유효한 컷 위로 드래그하세요. 여기서는 클릭 적용을 사용할 수 없습니다: {{reason}}.", - "hintSelectOne": "트랜지션을 유효한 컷 위로 드래그하거나, 클립을 나란히 배치하고 하나를 선택하면 클릭으로 적용할 수 있습니다.", - "hintSelectSingle": "트랜지션을 유효한 컷 위로 드래그하거나, 비디오 또는 이미지 클립 하나를 선택하면 클릭으로 적용할 수 있습니다.", - "hintSelectClip": "트랜지션을 유효한 컷 위로 드래그하거나, 비디오 또는 이미지 클립을 선택하여 인접 클립과의 사이에 트랜지션을 추가하세요.", - "category": { - "basic": "기본", - "dissolve": "디졸브", - "motion": "모션", - "wipe": "와이프", - "slide": "슬라이드", - "flip": "플립", - "mask": "마스크", - "iris": "아이리스", - "shape": "도형", - "light": "라이트", - "chromatic": "색수차", - "custom": "사용자 지정" - } - }, - "transitionPanel": { - "sectionTitle": "트랜지션", - "preset": "프리셋", - "presetTooltip": "트랜지션 스타일 프리셋", - "selectPreset": "프리셋 선택", - "searchTransitions": "트랜지션 검색", - "noTransitionsFound": "트랜지션을 찾을 수 없습니다", - "notFound": "트랜지션을 찾을 수 없습니다", - "duration": "길이", - "durationTooltip": "트랜지션 길이", - "resetToDefault": "1초로 재설정", - "placement": "배치", - "placementTooltip": "컷 앞, 위 또는 뒤에 트랜지션 배치", - "placementLeft": "왼쪽", - "placementCenter": "가운데", - "placementRight": "오른쪽", - "placementLeftTooltip": "컷 앞에 트랜지션 배치", - "placementCenterTooltip": "컷 위에 트랜지션을 가운데 배치", - "placementRightTooltip": "컷 뒤에 트랜지션 배치", - "placementAria": "{{label}} 배치", - "placementDisabled": "{{title}} (소스 여유분 부족)", - "ease": "이징", - "easeTooltip": "트랜지션의 이징 곡선", - "easeLinear": "선형", - "easeIn": "인", - "easeOut": "아웃", - "easeInOut": "인 & 아웃", - "direction": "방향", - "directionTooltip": "트랜지션 움직임 방향", - "directionLeft": "왼쪽", - "directionRight": "오른쪽", - "directionTop": "위", - "directionBottom": "아래", - "resetParam": "{{label}} 재설정", - "paramColorAria": "{{label}} 색상" + "title": "新着情報", + "thisWeek": "今週", + "asOf": "{{date}}時点", + "weekOf": "{{range}}の週", + "newBadge": "新着", + "released": "リリース:v{{version}}", + "preRelease": "プレリリース", + "fullChangelog": "変更履歴の全文", + "groupAdded": "追加", + "groupFixed": "修正", + "groupImproved": "改善" + }, + "transitions": { + "hintClickToApply": "クリックして選択中のカットに適用するか、トランジションをタイムライン上の有効なカットにドラッグしてください。", + "hintUnavailable": "トランジションを有効なカットにドラッグしてください。ここではクリックでの適用は利用できません:{{reason}}。", + "hintSelectOne": "トランジションを有効なカットにドラッグするか、クリップを隣り合わせに配置して1つを選択するとクリックで適用できます。", + "hintSelectSingle": "トランジションを有効なカットにドラッグするか、1つの動画または画像クリップを選択するとクリックで適用できます。", + "hintSelectClip": "トランジションを有効なカットにドラッグするか、動画または画像クリップを選択して隣のクリップとの間にトランジションを追加してください。", + "category": { + "basic": "ベーシック", + "dissolve": "ディゾルブ", + "motion": "モーション", + "wipe": "ワイプ", + "slide": "スライド", + "flip": "フリップ", + "mask": "マスク", + "iris": "アイリス", + "shape": "シェイプ", + "light": "ライト", + "chromatic": "色収差", + "custom": "カスタム" + } + }, + "transitionPanel": { + "sectionTitle": "トランジション", + "preset": "プリセット", + "presetTooltip": "トランジションのスタイルプリセット", + "selectPreset": "プリセットを選択", + "searchTransitions": "トランジションを検索", + "noTransitionsFound": "トランジションが見つかりません", + "notFound": "トランジションが見つかりません", + "duration": "長さ", + "durationTooltip": "トランジションの長さ", + "resetToDefault": "1秒にリセット", + "placement": "配置", + "placementTooltip": "カットの前・上・後にトランジションを配置", + "placementLeft": "左", + "placementCenter": "中央", + "placementRight": "右", + "placementLeftTooltip": "カットの前にトランジションを配置", + "placementCenterTooltip": "カット上にトランジションを中央配置", + "placementRightTooltip": "カットの後にトランジションを配置", + "placementAria": "{{label}}配置", + "placementDisabled": "{{title}}(ソースの余白が足りません)", + "ease": "イージング", + "easeTooltip": "トランジションのイージングカーブ", + "easeLinear": "リニア", + "easeIn": "イン", + "easeOut": "アウト", + "easeInOut": "イン&アウト", + "direction": "方向", + "directionTooltip": "トランジションの動きの方向", + "directionLeft": "左", + "directionRight": "右", + "directionTop": "上", + "directionBottom": "下", + "resetParam": "{{label}}をリセット", + "paramColorAria": "{{label}}の色" }, "propertiesSidebar": { - "title": "속성", - "clipsSelected_one": "클립 {{count}}개 선택됨", - "clipsSelected_other": "클립 {{count}}개 선택됨", - "dockToPreview": "미리보기에 도킹", - "expandFullColumn": "전체 열로 확장", - "showPanel": "속성 패널 표시" + "title": "プロパティ", + "clipsSelected_one": "{{count}}個のクリップを選択中", + "clipsSelected_other": "{{count}}個のクリップを選択中", + "dockToPreview": "プレビューにドッキング", + "expandFullColumn": "フルカラムに展開", + "showPanel": "プロパティパネルを表示", + "clipsSelected": "{{count}} クリップを選択中" }, "markerPanel": { - "notFound": "마커를 찾을 수 없습니다", - "title": "마커", - "frame": "프레임", - "time": "시간", - "label": "레이블", - "labelPlaceholder": "레이블 입력...", - "color": "색상", - "deleteMarker": "마커 삭제" + "notFound": "マーカーが見つかりません", + "title": "マーカー", + "frame": "フレーム", + "time": "時間", + "label": "ラベル", + "labelPlaceholder": "ラベルを入力...", + "color": "色", + "deleteMarker": "マーカーを削除" }, "canvasPanel": { - "updateFailed": "캔버스 설정을 업데이트하지 못했습니다", - "tryAgain": "다시 시도하세요.", - "noProjectLoaded": "로드된 프로젝트 없음", - "canvas": "캔버스", - "swap": "교체", - "background": "배경", - "resetToBlack": "검정으로 재설정", - "duration": "길이", - "frameRate": "프레임 레이트", - "totalFrames": "총 프레임 수" + "updateFailed": "キャンバス設定の更新に失敗しました", + "tryAgain": "もう一度お試しください。", + "noProjectLoaded": "プロジェクトが読み込まれていません", + "canvas": "キャンバス", + "swap": "入れ替え", + "background": "背景", + "resetToBlack": "黒にリセット", + "duration": "長さ", + "frameRate": "フレームレート", + "totalFrames": "総フレーム数" }, "clipPanel": { - "tabVideo": "비디오", - "tabAudio": "오디오", - "tabEffects": "효과", - "adjustmentLayerHint": "조정 레이어의 효과는 위쪽 트랙의 모든 항목에 적용됩니다." + "tabVideo": "ビデオ", + "tabAudio": "オーディオ", + "tabEffects": "エフェクト", + "adjustmentLayerHint": "調整レイヤーのエフェクトは、上のトラックのすべてのアイテムに適用されます。" }, "gifSection": { - "animation": "애니메이션", - "speed": "속도", - "resetSpeed": "1x로 재설정" + "animation": "アニメーション", + "speed": "速度", + "resetSpeed": "1xにリセット" }, "cornerPinSection": { - "title": "코너 핀", - "exitEditor": "코너 핀 편집기 종료", - "editOnPreview": "미리보기에서 코너 편집", - "editing": "편집 중...", - "edit": "편집", - "reset": "코너 핀 재설정" + "title": "コーナーピン", + "exitEditor": "コーナーピンエディターを終了", + "editOnPreview": "プレビューでコーナーを編集", + "editing": "編集中...", + "edit": "編集", + "reset": "コーナーピンをリセット" }, "fillSection": { - "composite": "합성", - "opacity": "불투명도", - "resetOpacity": "100%로 재설정", - "blend": "혼합", - "blendNormal": "표준", - "blendMixed": "혼합됨", - "radius": "반경", - "resetRadius": "0으로 재설정" + "composite": "合成", + "opacity": "不透明度", + "resetOpacity": "100%にリセット", + "blend": "描画モード", + "blendNormal": "通常", + "blendMixed": "混在", + "radius": "角丸", + "resetRadius": "0にリセット" }, "fontPicker": { - "selectFont": "글꼴 선택", - "searchFonts": "글꼴 검색...", - "livePreview": "실시간 미리보기", - "allFonts": "모든 글꼴", - "fontOptions": "글꼴 옵션", - "noMatch": "검색과 일치하는 글꼴이 없습니다.", - "previewPangram": "다람쥐 헌 쳇바퀴에 타고파" + "selectFont": "フォントを選択", + "searchFonts": "フォントを検索...", + "livePreview": "ライブプレビュー", + "allFonts": "すべてのフォント", + "fontOptions": "フォントの選択肢", + "noMatch": "検索に一致するフォントがありません。", + "previewPangram": "いろはにほへと ちりぬるを わかよたれそ" }, "layoutSection": { - "transform": "변형", - "position": "위치", - "resetPosition": "가운데로 재설정", - "size": "크기", - "unlockAspect": "종횡비 잠금 해제", - "lockAspect": "종횡비 잠금", - "resetSize": "원래 크기로 재설정", - "rotation": "회전", - "resetRotation": "회전 재설정", - "anchor": "기준점", - "resetAnchor": "기준점을 가운데로 재설정", - "flip": "뒤집기", - "flipHorizontalAria": "비디오를 좌우로 뒤집기", - "flipVerticalAria": "비디오를 상하로 뒤집기", - "horizontal": "좌우", - "horizontalMixed": "좌우(혼합)", - "vertical": "상하", - "verticalMixed": "상하(혼합)" + "transform": "変形", + "position": "位置", + "resetPosition": "中央にリセット", + "size": "サイズ", + "unlockAspect": "縦横比のロックを解除", + "lockAspect": "縦横比をロック", + "resetSize": "元のサイズにリセット", + "rotation": "回転", + "resetRotation": "回転をリセット", + "anchor": "アンカー", + "resetAnchor": "アンカーを中央にリセット", + "flip": "反転", + "flipHorizontalAria": "ビデオを左右反転", + "flipVerticalAria": "ビデオを上下反転", + "horizontal": "左右", + "horizontalMixed": "左右(混在)", + "vertical": "上下", + "verticalMixed": "上下(混在)" }, "shapeSection": { - "shape": "도형", - "type": "유형", - "mixed": "혼합됨", - "selectShape": "도형 선택", - "typeRectangle": "직사각형", - "typeCircle": "원", - "typeTriangle": "삼각형", - "typeEllipse": "타원", - "typeStar": "별", - "typePolygon": "다각형", - "typeHeart": "하트", - "path": "패스", - "editPath": "패스 편집", - "editPathHint": "미리보기에서 점과 핸들을 드래그하세요.", - "fill": "채우기", - "strokeWidth": "선 두께", - "stroke": "선", - "radius": "반경", - "direction": "방향", - "directionUp": "위", - "directionDown": "아래", - "directionLeft": "왼쪽", - "directionRight": "오른쪽", - "points": "꼭짓점 수", - "innerRadius": "내부 반경", - "useAsMask": "마스크로 사용", - "on": "켜짐", - "off": "꺼짐", - "maskType": "마스크 유형", - "selectType": "유형 선택", - "maskTypeClip": "클립(선명한 가장자리)", - "maskTypeAlpha": "알파(부드러운 가장자리)", - "feather": "페더", - "resetFeather": "10px로 재설정", - "invert": "반전" + "shape": "シェイプ", + "type": "種類", + "mixed": "混在", + "selectShape": "シェイプを選択", + "typeRectangle": "長方形", + "typeCircle": "円", + "typeTriangle": "三角形", + "typeEllipse": "楕円", + "typeStar": "星", + "typePolygon": "多角形", + "typeHeart": "ハート", + "path": "パス", + "editPath": "パスを編集", + "editPathHint": "プレビューでポイントとハンドルをドラッグします。", + "fill": "塗りつぶし", + "strokeWidth": "線の太さ", + "stroke": "線", + "radius": "角丸", + "direction": "向き", + "directionUp": "上", + "directionDown": "下", + "directionLeft": "左", + "directionRight": "右", + "points": "頂点数", + "innerRadius": "内側の半径", + "useAsMask": "マスクとして使用", + "on": "オン", + "off": "オフ", + "maskType": "マスクの種類", + "selectType": "種類を選択", + "maskTypeClip": "クリップ(くっきりした端)", + "maskTypeAlpha": "アルファ(やわらかい端)", + "feather": "ぼかし", + "resetFeather": "10pxにリセット", + "invert": "反転" }, "subtitleSection": { - "title": "자막", - "multiSelectHint": "세그먼트 {{segments}}개 선택됨 · 총 자막 {{cues}}개. 스타일은 모두에 적용됩니다. 개별 자막을 편집하려면 세그먼트 하나를 선택하세요.", - "trackLabel": "트랙 {{number}}", - "transcript": "전사", - "cueCount_one": "자막 {{count}}개", - "cueCount_other": "자막 {{count}}개", - "seekToCue": "재생 헤드를 이 자막으로 이동", - "start": "시작", - "end": "끝", - "italic": "기울임꼴", - "bold": "굵게", - "underline": "밑줄", - "cuePosition": "자막 위치: {{vertical}} {{horizontal}}" + "title": "字幕", + "multiSelectHint": "{{segments}}個のセグメントを選択中・合計{{cues}}個の字幕。スタイルはすべてに適用されます。個々の字幕を編集するには1つのセグメントを選択してください。", + "trackLabel": "トラック{{number}}", + "transcript": "文字起こし", + "cueCount_one": "{{count}}個の字幕", + "cueCount_other": "{{count}}個の字幕", + "seekToCue": "この字幕に再生ヘッドを移動", + "start": "開始", + "end": "終了", + "italic": "斜体", + "bold": "太字", + "underline": "下線", + "cuePosition": "字幕の位置:{{vertical}} {{horizontal}}", + "cueCount": "{{count}} 件のキュー" }, "audioSection": { - "audio": "오디오", - "gain": "게인", - "resetGain": "0 dB로 재설정", - "fadeIn": "페이드 인", - "fadeOut": "페이드 아웃", - "resetToZero": "0으로 재설정", - "pitch": "피치", - "semitones": "반음", - "resetSemitones": "반음 피치 재설정", - "cents": "센트", - "resetCents": "센트 피치 재설정", - "equalizer": "이퀄라이저" + "audio": "オーディオ", + "gain": "ゲイン", + "resetGain": "0 dBにリセット", + "fadeIn": "フェードイン", + "fadeOut": "フェードアウト", + "resetToZero": "0にリセット", + "pitch": "ピッチ", + "semitones": "半音", + "resetSemitones": "半音ピッチをリセット", + "cents": "セント", + "resetCents": "セントピッチをリセット", + "equalizer": "イコライザー" }, "captionStyleControls": { - "stylePreset": "스타일 프리셋", - "color": "색상", - "size": "크기", - "vertical": "상하 위치", - "background": "배경", - "on": "켜짐", - "off": "꺼짐", - "padding": "여백" + "stylePreset": "スタイルプリセット", + "color": "色", + "size": "サイズ", + "vertical": "上下位置", + "background": "背景", + "on": "オン", + "off": "オフ", + "padding": "余白" }, "captionPresets": { - "netflixHint": "둥근 어두운 박스 위 Inter, 하단 3분의 1 — 방송 품질의 중립적인 스타일.", - "youtubeHint": "부드러운 그림자가 있는 Roboto, 박스 없음 — 자동 자막 느낌.", - "boldYellowHint": "검은 그림자가 있는 시네마 옐로의 Roboto Slab — DVD 시대의 클래식.", - "outlinedHint": "가는 외곽선의 Manrope — 깔끔하고 현대적, 그림자 없음.", - "tiktokHint": "Anton 디스플레이, 큼직하고 가운데 정렬 — 세로 영상의 바이럴 스타일." + "netflixHint": "角丸の暗いボックスに Inter、下三分の一 — 放送品質のニュートラル。", + "youtubeHint": "Roboto に柔らかいドロップシャドウ、ボックスなし — 自動字幕の雰囲気。", + "boldYellowHint": "シネマイエローの Roboto Slab に黒いドロップシャドウ — DVD時代の定番。", + "outlinedHint": "細いアウトラインの Manrope — すっきりとモダン、影なし。", + "tiktokHint": "Anton ディスプレイ、特大で中央寄せ — 縦型動画のバイラルな見た目。" }, "alignment": { - "left": "왼쪽 정렬", - "centerHorizontally": "가로 가운데 정렬", - "right": "오른쪽 정렬", - "top": "위쪽 정렬", - "centerVertically": "세로 가운데 정렬", - "bottom": "아래쪽 정렬", - "distributeHorizontally": "가로로 분포", - "distributeVertically": "세로로 분포" + "left": "左揃え", + "centerHorizontally": "水平方向に中央揃え", + "right": "右揃え", + "top": "上揃え", + "centerVertically": "垂直方向に中央揃え", + "bottom": "下揃え", + "distributeHorizontally": "水平方向に分布", + "distributeVertically": "垂直方向に分布" }, "projectMediaMatch": { - "updateFailed": "프로젝트 설정을 업데이트하지 못했습니다", - "tryAgain": "다시 시도하세요.", - "title": "프로젝트를 첫 번째 비디오에 맞추시겠습니까?", - "descriptionWithFile": "\"{{fileName}}\"은(는) 이 프로젝트에 처음 추가된 비디오입니다.", - "description": "처음 가져온 비디오로 프로젝트 크기와 프레임 레이트를 정의할 수 있습니다.", - "current": "현재", - "clip": "클립", - "size": "크기", - "frameRate": "프레임 레이트", - "fpsRoundedHint": "FreeCut은 가져온 비디오를 가장 가까운 지원 프로젝트 프레임 레이트에 맞춥니다. 이 클립은 {{fps}} fps를 사용합니다.", - "keepCurrent": "현재 유지", - "fpsOnly": "FPS만", - "sizeOnly": "크기만", - "matchBoth": "둘 다 맞추기", - "matchFps": "FPS 맞추기", - "matchSize": "크기 맞추기" + "updateFailed": "プロジェクト設定の更新に失敗しました", + "tryAgain": "もう一度お試しください。", + "title": "プロジェクトを最初のビデオに合わせますか?", + "descriptionWithFile": "「{{fileName}}」はこのプロジェクトに最初に追加されたビデオです。", + "description": "最初にインポートしたビデオでプロジェクトのサイズとフレームレートを定義できます。", + "current": "現在", + "clip": "クリップ", + "size": "サイズ", + "frameRate": "フレームレート", + "fpsRoundedHint": "FreeCutはインポートしたビデオを最も近い対応プロジェクトフレームレートに合わせます。このクリップは {{fps}} fps を使用します。", + "keepCurrent": "現在のままにする", + "fpsOnly": "FPSのみ", + "sizeOnly": "サイズのみ", + "matchBoth": "両方を合わせる", + "matchFps": "FPSを合わせる", + "matchSize": "サイズを合わせる" }, "editor": { - "appLabel": "FreeCut 비디오 편집기", - "backupCreated": "업그레이드 전 백업이 생성되었습니다", - "backupFailed": "업그레이드 전 백업 생성에 실패했습니다", - "tryAgain": "다시 시도하세요.", - "projectSaved": "프로젝트가 저장되었습니다", - "projectSaveFailed": "프로젝트 저장에 실패했습니다", - "projectBundle": "FreeCut 프로젝트 번들" + "appLabel": "FreeCut ビデオエディター", + "backupCreated": "アップグレード前にバックアップを作成しました", + "backupFailed": "アップグレード前のバックアップ作成に失敗しました", + "tryAgain": "もう一度お試しください。", + "projectSaved": "プロジェクトを保存しました", + "projectSaveFailed": "プロジェクトの保存に失敗しました", + "projectBundle": "FreeCut プロジェクトバンドル" }, "audioMeters": { - "meters": "미터", - "mixer": "믹서", - "floatMixer": "믹서 분리", - "panelMode": "패널 모드", - "audioMeter": "오디오 미터", - "audioMixer": "오디오 믹서" + "meters": "メーター", + "mixer": "ミキサー", + "floatMixer": "ミキサーをフロート", + "panelMode": "パネルモード", + "audioMeter": "オーディオメーター", + "audioMixer": "オーディオミキサー" + }, + "videoSection": { + "cropBottom": "下をクロップ", + "cropLeft": "左をクロップ", + "cropRight": "右をクロップ", + "cropTop": "上をクロップ", + "cropping": "クロップ", + "fadeIn": "フェードイン", + "fadeOut": "フェードアウト", + "playback": "再生", + "resetCropBottom": "下のクロップをリセット", + "resetCropLeft": "左のクロップをリセット", + "resetCropRight": "右のクロップをリセット", + "resetCropTop": "上のクロップをリセット", + "resetSoftness": "ソフトさをリセット", + "resetSpeed": "速度をリセット", + "resetToZero": "ゼロにリセット", + "softness": "ソフトさ", + "speed": "速度" + }, + "mediaSidebar": { + "media": "メディア", + "text": "テキスト", + "shapes": "図形", + "effects": "エフェクト", + "transitions": "トランジション", + "ai": "AI", + "collapsePanel": "パネルを折りたたむ", + "expandPanel": "パネルを展開", + "keyframeEditor": "キーフレームエディター", + "hideKeyframeEditor": "キーフレームエディターを非表示", + "showKeyframeEditor": "キーフレームエディターを表示", + "templates": "テンプレート", + "textGroupSingle": "単一", + "textGroupTwoSpans": "2スパン", + "textGroupThreeSpans": "3スパン", + "addText": "テキストを追加", + "pen": "ペン", + "penToolHint": "ペンツールでカスタムパス図形を描画", + "adjustmentLayer": "調整レイヤー", + "blankAdjustmentLayer": "空の調整レイヤー", + "presets": "プリセット" + }, + "textSection": { + "sectionTitle": "テキスト", + "content": "内容", + "single": "単一", + "twoSpans": "2スパン", + "threeSpans": "3スパン", + "mixedNone": "混在 / なし", + "selectPreset": "プリセットを選択", + "span": "スパン {{count}}", + "spanText": "スパン {{count}} のテキスト", + "eyebrow": "アイブロウ", + "eyebrowText": "アイブロウのテキスト", + "title": "タイトル", + "titleText": "タイトルのテキスト", + "subtitle": "サブタイトル", + "subtitleText": "サブタイトルのテキスト", + "text": "テキスト", + "enterText": "テキストを入力...", + "defaultText": "ここにテキスト", + "selectFont": "フォントを選択", + "size": "サイズ", + "spacing": "間隔", + "scale": "スケール", + "font": "フォント", + "weight": "太さ", + "style": "スタイル", + "align": "整列", + "color": "色", + "background": "背景", + "clear": "クリア", + "clearBackground": "背景をクリア", + "lineHeightShort": "行の高さ", + "padding": "余白", + "radius": "角丸", + "mixed": "混在", + "selectWeight": "太さを選択", + "bold": "太字", + "boldUnavailable": "このフォントでは太字を使用できません", + "italic": "斜体", + "underline": "下線", + "italicSpan": "{{label}} を斜体", + "underlineSpan": "{{label}} に下線", + "alignLeft": "左揃え", + "alignCenter": "中央揃え", + "alignRight": "右揃え", + "alignTop": "上揃え", + "alignMiddle": "中央揃え(縦)", + "alignBottom": "下揃え", + "effects": "エフェクト", + "presets": "プリセット", + "fontWeights": { + "regular": "標準", + "medium": "中", + "semibold": "セミボールド", + "bold": "太字" + }, + "effectPresets": { + "none": "なし", + "shadow": "影", + "outline": "アウトライン", + "glow": "発光" + }, + "shadow": "影", + "shadowX": "影 X", + "shadowY": "影 Y", + "shadowBlur": "影ぼかし", + "strokeWidth": "縁取り幅", + "stroke": "縁取り", + "animation": "アニメーション", + "intro": "イントロ", + "outro": "アウトロ", + "animationFooter": "選択した各クリップの開始または終了に短いイーズアウトのテキストモーションを適用します。", + "animationPresets": { + "none": "なし", + "fade": "フェード", + "rise": "ライズ", + "drop": "ドロップ", + "left": "左", + "right": "右", + "tilt": "ティルト", + "pop": "ポップ", + "swing": "スイング" + } + }, + "tts": { + "seek": "シーク", + "dialogTitle": "テキストから音声を生成", + "dialogDescription": "音声を生成してテキストクリップの位置に挿入します。", + "kokoroUnsupported": "このブラウザでは WebGPU を利用できません。Kokoro TTS には Chrome 113+、Edge 113+、または Safari 26+ が必要です。", + "mossUnsupported": "このブラウザではブラウザ管理ストレージを利用できません。MOSS 多言語 TTS は新しい Chromium ブラウザで最適に動作します。", + "engine": "エンジン", + "engineSupportDetails": "TTS エンジンの対応状況", + "kokoroDescription": "WebGPU の英語音声。", + "supportedLanguages": "対応言語: {{languages}}。", + "kokoroOption": "Kokoro (英語、WebGPU)", + "mossOption": "MOSS Nano (20言語、CPU)", + "voice": "音声", + "text": "テキスト", + "textPlaceholder": "読み上げたいテキストを入力...", + "speed": "速度", + "progressPreparing": "ローカル TTS を準備中...", + "insertedAndLinked": "挿入してリンク済み", + "generate": "生成", + "generating": "生成中...", + "regenerate": "再生成", + "insertAndLink": "挿入してリンク", + "inserting": "挿入中...", + "errors": { + "openProject": "音声を生成する前にプロジェクトを開いてください。", + "enterText": "合成するテキストを入力してください。", + "kokoroUnsupported": "Kokoro TTS には WebGPU が必要です。Chrome 113+、Edge 113+、または Safari 26+ をお試しください。", + "mossUnsupported": "MOSS 多言語 TTS にはブラウザ管理ストレージが必要です。新しい Chromium ブラウザをお試しください。", + "generateFailed": "音声を生成できませんでした。", + "insertFailed": "音声を保存して挿入できませんでした。", + "supertonicUnsupported": "このブラウザーではローカルの Supertonic TTS ランタイムを実行できません。最新の Chrome または Edge をお試しください。" + }, + "notifications": { + "addedAndLinked": "\"{{fileName}}\" をタイムラインに追加し、テキストとリンクしました。", + "savedNoTrack": "\"{{fileName}}\" を保存しましたが、利用可能な音声トラックがありません。" + }, + "supertonicUnsupported": "このブラウザーではローカルの Supertonic TTS ランタイムを実行できません。最新の Chrome または Edge をお試しください。", + "supertonicDescription": "WebGPU または WASM 上で動作する 31 言語対応のローカル ONNX 音声。", + "supertonicOption": "Supertonic 3(31 言語、ローカル ONNX)", + "language": "言語", + "autoDetectLanguage": "自動検出", + "expressiveTags": "表現タグ" + }, + "aiPanel": { + "textToSpeech": "テキスト読み上げ", + "collapseTextToSpeech": "テキスト読み上げを折りたたむ", + "expandTextToSpeech": "テキスト読み上げを展開", + "runsLocally": "{{runtime}} はブラウザ内で {{backend}} を使ってローカル実行されます。", + "history": "履歴 ({{count}}) - {{size}}", + "clearAll": "すべてクリア", + "musicGeneration": "音楽生成", + "collapseMusicGeneration": "音楽生成を折りたたむ", + "expandMusicGeneration": "音楽生成を展開", + "musicGenerationInfo": "音楽生成情報", + "musicgenDescription": "Transformers.js 経由で、Xenova のブラウザ対応 MusicGen モデルを使用します。初回ダウンロードは大きめですが、その後はローカルにキャッシュされます。", + "musicgenPromptHint": "ジャンル、ムード、テンポ、楽器編成を指定してください。短いクリップほどかなり速く完了します。", + "musicgenUnsupported": "このブラウザでは WebGPU を利用できません。MusicGen には Chrome 113+、Edge 113+、または Safari 26+ が必要です。", + "prompt": "プロンプト", + "presets": "プリセット", + "musicPromptPlaceholder": "生成したい音楽の種類を説明してください...", + "length": "長さ", + "generateMusic": "音楽を生成", + "musicHistory": "音楽履歴 ({{count}}) - {{size}}", + "remove": "削除", + "saved": "保存済み", + "saving": "保存中...", + "saveAndInsert": "保存して挿入", + "saveToLibrary": "ライブラリに保存", + "progressPreparingMusic": "ローカル音楽生成を準備中...", + "errors": { + "describeMusic": "生成したい音楽を説明してください。", + "musicgenUnsupported": "MusicGen には WebGPU が必要です。Chrome 113+、Edge 113+、または Safari 26+ をお試しください。", + "generateMusicFailed": "音楽を生成できませんでした。", + "saveAudioFailed": "メディアライブラリに音声を保存できませんでした。" + }, + "notifications": { + "savedToLibrary": "\"{{fileName}}\" をメディアライブラリに保存しました。", + "savedAndAdded": "\"{{fileName}}\" を保存してタイムラインに追加しました。" + }, + "defaultTtsPrompt": "Welcome to freecut. This voice was generated locally in the browser.", + "musicPresets": { + "lofiChillLabel": "Lo-fi チル", + "lofiChillPrompt": "温かい lo-fi ビート、ダスティなドラム、メロウなベース、夢のようなシンセリード", + "pop80sLabel": "80年代ポップ", + "pop80sPrompt": "太いドラムとシンセが印象的な80年代ポップトラック", + "rock90sLabel": "90年代ロック", + "rock90sPrompt": "大きなギターと重いドラムの90年代ロックソング", + "upbeatEdmLabel": "アップビート EDM", + "upbeatEdmPrompt": "明るく爽やかな EDM トラック、シンコペーションのドラム、空気感のあるパッド、強い感情 bpm: 130", + "countryLabel": "カントリー", + "countryPrompt": "アコースティックギターの入った明るいカントリーソング", + "lofiElectroLabel": "Lo-fi エレクトロ", + "lofiElectroPrompt": "オーガニックなサンプルを使った低速 BPM の lo-fi エレクトロチル" + } } } }, - "zh": { + "ko": { "editor": { "shortcutsDialog": { - "title": "键盘快捷键", - "description": "编辑键盘快捷键绑定。" + "title": "키보드 단축키", + "description": "키보드 단축키 할당을 편집합니다." }, "interactionLock": { - "maskEditing": "完成或退出蒙版编辑以继续" + "maskEditing": "계속하려면 마스크 편집을 완료하거나 종료하세요" }, "localInferencePill": { - "loading": "本地 AI 加载中", - "active": "本地 AI 运行中", - "error": "本地 AI 错误", - "ready": "本地 AI 就绪", - "jobs_one": "{{count}} 个任务", - "jobs_other": "{{count}} 个任务" + "loading": "로컬 AI 로딩 중", + "active": "로컬 AI 활성", + "error": "로컬 AI 오류", + "ready": "로컬 AI 준비됨", + "jobs_one": "작업 {{count}}개", + "jobs_other": "작업 {{count}}개", + "jobs": "{{count}}개 작업" }, "clearKeyframesDialog": { - "titleProperty": "清除 {{property}} 关键帧", - "titleAll": "清除所有关键帧", - "descriptionProperty_one": "确定要从 {{count}} 个片段中清除所有 {{property}} 关键帧吗?", - "descriptionProperty_other": "确定要从 {{count}} 个片段中清除所有 {{property}} 关键帧吗?", - "descriptionAll_one": "确定要从 {{count}} 个片段中清除所有关键帧吗?", - "descriptionAll_other": "确定要从 {{count}} 个片段中清除所有关键帧吗?", - "undoHint": "此操作可通过 Ctrl+Z 撤销。", - "confirm": "清除关键帧" + "titleProperty": "{{property}} 키프레임 지우기", + "titleAll": "모든 키프레임 지우기", + "descriptionProperty_one": "클립 {{count}}개에서 {{property}} 키프레임을 모두 지우시겠습니까?", + "descriptionProperty_other": "클립 {{count}}개에서 {{property}} 키프레임을 모두 지우시겠습니까?", + "descriptionAll_one": "클립 {{count}}개에서 키프레임을 모두 지우시겠습니까?", + "descriptionAll_other": "클립 {{count}}개에서 키프레임을 모두 지우시겠습니까?", + "undoHint": "이 작업은 Ctrl+Z로 실행 취소할 수 있습니다.", + "confirm": "키프레임 지우기", + "descriptionProperty": "선택한 {{count}}개 항목에서 {{property}} 키프레임을 지우시겠습니까?", + "descriptionAll": "선택한 {{count}}개 항목에서 모든 키프레임을 지우시겠습니까?" }, "projectUpgradeDialog": { - "title": "打开前升级项目", - "versionMismatch": "{{projectName}} 使用项目架构 v{{storedSchemaVersion}} 保存,但此版本需要 v{{currentSchemaVersion}}。", - "explanation": "FreeCut 可以在加载编辑器之前为你升级它。会先创建升级前的备份,以便在出现问题时恢复旧数据。", - "backupCopy": "备份副本:{{backupName}}", - "creatingBackup": "正在创建备份...", - "confirm": "创建备份并升级" + "title": "열기 전에 프로젝트 업그레이드", + "versionMismatch": "{{projectName}}은(는) 프로젝트 스키마 v{{storedSchemaVersion}}으로 저장되었지만 이 빌드는 v{{currentSchemaVersion}}을(를) 필요로 합니다.", + "explanation": "FreeCut은 편집기를 로드하기 전에 업그레이드할 수 있습니다. 문제가 있을 경우 이전 데이터를 복원할 수 있도록 먼저 업그레이드 전 백업이 생성됩니다.", + "backupCopy": "백업 사본: {{backupName}}", + "creatingBackup": "백업 생성 중...", + "confirm": "백업 생성 후 업그레이드" }, "autoSave": { - "failed": "自动保存失败" + "failed": "자동 저장 실패" }, "whatsNew": { - "title": "新功能", - "thisWeek": "本周", - "asOf": "截至 {{date}}", - "weekOf": "{{range}} 那一周", - "newBadge": "新", - "released": "已发布:v{{version}}", - "preRelease": "预发布", - "fullChangelog": "完整更新日志", - "groupAdded": "新增", - "groupFixed": "修复", - "groupImproved": "改进" + "title": "새로운 기능", + "thisWeek": "이번 주", + "asOf": "{{date}} 기준", + "weekOf": "{{range}} 주", + "newBadge": "신규", + "released": "출시: v{{version}}", + "preRelease": "사전 출시", + "fullChangelog": "전체 변경 로그", + "groupAdded": "추가됨", + "groupFixed": "수정됨", + "groupImproved": "개선됨" }, "transitions": { - "hintClickToApply": "点击应用到所选的剪切点,或将转场拖到时间线上任何有效的剪切点上。", - "hintUnavailable": "将转场拖到有效的剪切点上。此处无法点击应用:{{reason}}。", - "hintSelectOne": "将转场拖到有效的剪切点上,或将片段并排放置并选择一个以点击应用。", - "hintSelectSingle": "将转场拖到有效的剪切点上,或选择单个视频或图片片段以点击应用。", - "hintSelectClip": "将转场拖到有效的剪切点上,或选择一个视频或图片片段以在其相邻片段之间添加转场。", + "hintClickToApply": "클릭하여 선택한 컷에 적용하거나, 트랜지션을 타임라인의 유효한 컷 위로 드래그하세요.", + "hintUnavailable": "트랜지션을 유효한 컷 위로 드래그하세요. 여기서는 클릭 적용을 사용할 수 없습니다: {{reason}}.", + "hintSelectOne": "트랜지션을 유효한 컷 위로 드래그하거나, 클립을 나란히 배치하고 하나를 선택하면 클릭으로 적용할 수 있습니다.", + "hintSelectSingle": "트랜지션을 유효한 컷 위로 드래그하거나, 비디오 또는 이미지 클립 하나를 선택하면 클릭으로 적용할 수 있습니다.", + "hintSelectClip": "트랜지션을 유효한 컷 위로 드래그하거나, 비디오 또는 이미지 클립을 선택하여 인접 클립과의 사이에 트랜지션을 추가하세요.", "category": { - "basic": "基础", - "dissolve": "溶解", - "motion": "运动", - "wipe": "擦除", - "slide": "滑动", - "flip": "翻转", - "mask": "蒙版", - "iris": "光圈", - "shape": "形状", - "light": "光", - "chromatic": "色差", - "custom": "自定义" + "basic": "기본", + "dissolve": "디졸브", + "motion": "모션", + "wipe": "와이프", + "slide": "슬라이드", + "flip": "플립", + "mask": "마스크", + "iris": "아이리스", + "shape": "도형", + "light": "라이트", + "chromatic": "색수차", + "custom": "사용자 지정" } }, "transitionPanel": { - "sectionTitle": "转场", - "preset": "预设", - "presetTooltip": "转场样式预设", - "selectPreset": "选择预设", - "searchTransitions": "搜索转场", - "noTransitionsFound": "未找到转场", - "notFound": "未找到转场", - "duration": "时长", - "durationTooltip": "转场时长", - "resetToDefault": "重置为 1 秒", - "placement": "位置", - "placementTooltip": "将转场放在剪切点之前、之上或之后", - "placementLeft": "左", - "placementCenter": "居中", - "placementRight": "右", - "placementLeftTooltip": "将转场放在剪切点之前", - "placementCenterTooltip": "将转场居中于剪切点", - "placementRightTooltip": "将转场放在剪切点之后", - "placementAria": "{{label}}位置", - "placementDisabled": "{{title}}(源余量不足)", - "ease": "缓动", - "easeTooltip": "转场的缓动曲线", - "easeLinear": "线性", - "easeIn": "缓入", - "easeOut": "缓出", - "easeInOut": "缓入缓出", - "direction": "方向", - "directionTooltip": "转场运动方向", - "directionLeft": "左", - "directionRight": "右", - "directionTop": "上", - "directionBottom": "下", - "resetParam": "重置{{label}}", - "paramColorAria": "{{label}}颜色" + "sectionTitle": "트랜지션", + "preset": "프리셋", + "presetTooltip": "트랜지션 스타일 프리셋", + "selectPreset": "프리셋 선택", + "searchTransitions": "트랜지션 검색", + "noTransitionsFound": "트랜지션을 찾을 수 없습니다", + "notFound": "트랜지션을 찾을 수 없습니다", + "duration": "길이", + "durationTooltip": "트랜지션 길이", + "resetToDefault": "1초로 재설정", + "placement": "배치", + "placementTooltip": "컷 앞, 위 또는 뒤에 트랜지션 배치", + "placementLeft": "왼쪽", + "placementCenter": "가운데", + "placementRight": "오른쪽", + "placementLeftTooltip": "컷 앞에 트랜지션 배치", + "placementCenterTooltip": "컷 위에 트랜지션을 가운데 배치", + "placementRightTooltip": "컷 뒤에 트랜지션 배치", + "placementAria": "{{label}} 배치", + "placementDisabled": "{{title}} (소스 여유분 부족)", + "ease": "이징", + "easeTooltip": "트랜지션의 이징 곡선", + "easeLinear": "선형", + "easeIn": "인", + "easeOut": "아웃", + "easeInOut": "인 & 아웃", + "direction": "방향", + "directionTooltip": "트랜지션 움직임 방향", + "directionLeft": "왼쪽", + "directionRight": "오른쪽", + "directionTop": "위", + "directionBottom": "아래", + "resetParam": "{{label}} 재설정", + "paramColorAria": "{{label}} 색상" }, "propertiesSidebar": { - "title": "属性", - "clipsSelected_one": "已选择 {{count}} 个片段", - "clipsSelected_other": "已选择 {{count}} 个片段", - "dockToPreview": "停靠到预览", - "expandFullColumn": "展开为整列", - "showPanel": "显示属性面板" + "title": "속성", + "clipsSelected_one": "클립 {{count}}개 선택됨", + "clipsSelected_other": "클립 {{count}}개 선택됨", + "dockToPreview": "미리보기에 도킹", + "expandFullColumn": "전체 열로 확장", + "showPanel": "속성 패널 표시", + "clipsSelected": "{{count}}개 클립 선택됨" }, "markerPanel": { - "notFound": "未找到标记", - "title": "标记", - "frame": "帧", - "time": "时间", - "label": "标签", - "labelPlaceholder": "输入标签...", - "color": "颜色", - "deleteMarker": "删除标记" + "notFound": "마커를 찾을 수 없습니다", + "title": "마커", + "frame": "프레임", + "time": "시간", + "label": "레이블", + "labelPlaceholder": "레이블 입력...", + "color": "색상", + "deleteMarker": "마커 삭제" }, "canvasPanel": { - "updateFailed": "更新画布设置失败", - "tryAgain": "请重试。", - "noProjectLoaded": "未加载项目", - "canvas": "画布", - "swap": "交换", - "background": "背景", - "resetToBlack": "重置为黑色", - "duration": "时长", - "frameRate": "帧率", - "totalFrames": "总帧数" + "updateFailed": "캔버스 설정을 업데이트하지 못했습니다", + "tryAgain": "다시 시도하세요.", + "noProjectLoaded": "로드된 프로젝트 없음", + "canvas": "캔버스", + "swap": "교체", + "background": "배경", + "resetToBlack": "검정으로 재설정", + "duration": "길이", + "frameRate": "프레임 레이트", + "totalFrames": "총 프레임 수" }, "clipPanel": { - "tabVideo": "视频", - "tabAudio": "音频", - "tabEffects": "效果", - "adjustmentLayerHint": "调整图层上的效果会应用到上方所有轨道的项目。" + "tabVideo": "비디오", + "tabAudio": "오디오", + "tabEffects": "효과", + "adjustmentLayerHint": "조정 레이어의 효과는 위쪽 트랙의 모든 항목에 적용됩니다." }, "gifSection": { - "animation": "动画", - "speed": "速度", - "resetSpeed": "重置为 1x" + "animation": "애니메이션", + "speed": "속도", + "resetSpeed": "1x로 재설정" }, "cornerPinSection": { - "title": "边角定位", - "exitEditor": "退出边角定位编辑器", - "editOnPreview": "在预览中编辑边角", - "editing": "编辑中...", - "edit": "编辑", - "reset": "重置边角定位" + "title": "코너 핀", + "exitEditor": "코너 핀 편집기 종료", + "editOnPreview": "미리보기에서 코너 편집", + "editing": "편집 중...", + "edit": "편집", + "reset": "코너 핀 재설정" }, "fillSection": { - "composite": "合成", - "opacity": "不透明度", - "resetOpacity": "重置为 100%", - "blend": "混合", - "blendNormal": "正常", - "blendMixed": "混合", - "radius": "圆角", - "resetRadius": "重置为 0" + "composite": "합성", + "opacity": "불투명도", + "resetOpacity": "100%로 재설정", + "blend": "혼합", + "blendNormal": "표준", + "blendMixed": "혼합됨", + "radius": "반경", + "resetRadius": "0으로 재설정" }, "fontPicker": { - "selectFont": "选择字体", - "searchFonts": "搜索字体...", - "livePreview": "实时预览", - "allFonts": "所有字体", - "fontOptions": "字体选项", - "noMatch": "没有字体与你的搜索匹配。", - "previewPangram": "天地玄黄 宇宙洪荒 日月盈昃" + "selectFont": "글꼴 선택", + "searchFonts": "글꼴 검색...", + "livePreview": "실시간 미리보기", + "allFonts": "모든 글꼴", + "fontOptions": "글꼴 옵션", + "noMatch": "검색과 일치하는 글꼴이 없습니다.", + "previewPangram": "다람쥐 헌 쳇바퀴에 타고파" }, "layoutSection": { - "transform": "变换", - "position": "位置", - "resetPosition": "重置到中心", - "size": "尺寸", - "unlockAspect": "解锁宽高比", - "lockAspect": "锁定宽高比", - "resetSize": "重置到原始尺寸", - "rotation": "旋转", - "resetRotation": "重置旋转", - "anchor": "锚点", - "resetAnchor": "将锚点重置到中心", - "flip": "翻转", - "flipHorizontalAria": "水平翻转视频", - "flipVerticalAria": "垂直翻转视频", - "horizontal": "水平", - "horizontalMixed": "水平(混合)", - "vertical": "垂直", - "verticalMixed": "垂直(混合)" - }, - "shapeSection": { - "shape": "形状", - "type": "类型", - "mixed": "混合", - "selectShape": "选择形状", - "typeRectangle": "矩形", - "typeCircle": "圆形", - "typeTriangle": "三角形", - "typeEllipse": "椭圆", - "typeStar": "星形", - "typePolygon": "多边形", - "typeHeart": "心形", - "path": "路径", - "editPath": "编辑路径", - "editPathHint": "在预览中拖动点和手柄。", - "fill": "填充", - "strokeWidth": "描边宽度", - "stroke": "描边", - "radius": "圆角", - "direction": "方向", - "directionUp": "上", - "directionDown": "下", - "directionLeft": "左", - "directionRight": "右", - "points": "顶点数", - "innerRadius": "内半径", - "useAsMask": "用作蒙版", - "on": "开", - "off": "关", - "maskType": "蒙版类型", - "selectType": "选择类型", - "maskTypeClip": "裁剪(硬边)", - "maskTypeAlpha": "Alpha(柔边)", - "feather": "羽化", - "resetFeather": "重置为 10px", - "invert": "反转" + "transform": "변형", + "position": "위치", + "resetPosition": "가운데로 재설정", + "size": "크기", + "unlockAspect": "종횡비 잠금 해제", + "lockAspect": "종횡비 잠금", + "resetSize": "원래 크기로 재설정", + "rotation": "회전", + "resetRotation": "회전 재설정", + "anchor": "기준점", + "resetAnchor": "기준점을 가운데로 재설정", + "flip": "뒤집기", + "flipHorizontalAria": "비디오를 좌우로 뒤집기", + "flipVerticalAria": "비디오를 상하로 뒤집기", + "horizontal": "좌우", + "horizontalMixed": "좌우(혼합)", + "vertical": "상하", + "verticalMixed": "상하(혼합)" + }, + "shapeSection": { + "shape": "도형", + "type": "유형", + "mixed": "혼합됨", + "selectShape": "도형 선택", + "typeRectangle": "직사각형", + "typeCircle": "원", + "typeTriangle": "삼각형", + "typeEllipse": "타원", + "typeStar": "별", + "typePolygon": "다각형", + "typeHeart": "하트", + "path": "패스", + "editPath": "패스 편집", + "editPathHint": "미리보기에서 점과 핸들을 드래그하세요.", + "fill": "채우기", + "strokeWidth": "선 두께", + "stroke": "선", + "radius": "반경", + "direction": "방향", + "directionUp": "위", + "directionDown": "아래", + "directionLeft": "왼쪽", + "directionRight": "오른쪽", + "points": "꼭짓점 수", + "innerRadius": "내부 반경", + "useAsMask": "마스크로 사용", + "on": "켜짐", + "off": "꺼짐", + "maskType": "마스크 유형", + "selectType": "유형 선택", + "maskTypeClip": "클립(선명한 가장자리)", + "maskTypeAlpha": "알파(부드러운 가장자리)", + "feather": "페더", + "resetFeather": "10px로 재설정", + "invert": "반전" }, "subtitleSection": { - "title": "字幕", - "multiSelectHint": "已选择 {{segments}} 个段 · 共 {{cues}} 条字幕。样式应用于全部。选择单个段以编辑单条字幕。", - "trackLabel": "轨道 {{number}}", - "transcript": "转录", - "cueCount_one": "{{count}} 条字幕", - "cueCount_other": "{{count}} 条字幕", - "seekToCue": "将播放头移动到此字幕", - "start": "开始", - "end": "结束", - "italic": "斜体", - "bold": "粗体", - "underline": "下划线", - "cuePosition": "字幕位置:{{vertical}} {{horizontal}}" + "title": "자막", + "multiSelectHint": "세그먼트 {{segments}}개 선택됨 · 총 자막 {{cues}}개. 스타일은 모두에 적용됩니다. 개별 자막을 편집하려면 세그먼트 하나를 선택하세요.", + "trackLabel": "트랙 {{number}}", + "transcript": "전사", + "cueCount_one": "자막 {{count}}개", + "cueCount_other": "자막 {{count}}개", + "seekToCue": "재생 헤드를 이 자막으로 이동", + "start": "시작", + "end": "끝", + "italic": "기울임꼴", + "bold": "굵게", + "underline": "밑줄", + "cuePosition": "자막 위치: {{vertical}} {{horizontal}}", + "cueCount": "{{count}}개 큐" }, "audioSection": { - "audio": "音频", - "gain": "增益", - "resetGain": "重置为 0 dB", - "fadeIn": "淡入", - "fadeOut": "淡出", - "resetToZero": "重置为 0", - "pitch": "音高", - "semitones": "半音", - "resetSemitones": "重置半音音高", - "cents": "音分", - "resetCents": "重置音分音高", - "equalizer": "均衡器" + "audio": "오디오", + "gain": "게인", + "resetGain": "0 dB로 재설정", + "fadeIn": "페이드 인", + "fadeOut": "페이드 아웃", + "resetToZero": "0으로 재설정", + "pitch": "피치", + "semitones": "반음", + "resetSemitones": "반음 피치 재설정", + "cents": "센트", + "resetCents": "센트 피치 재설정", + "equalizer": "이퀄라이저" }, "captionStyleControls": { - "stylePreset": "样式预设", - "color": "颜色", - "size": "尺寸", - "vertical": "垂直位置", - "background": "背景", - "on": "开", - "off": "关", - "padding": "内边距" + "stylePreset": "스타일 프리셋", + "color": "색상", + "size": "크기", + "vertical": "상하 위치", + "background": "배경", + "on": "켜짐", + "off": "꺼짐", + "padding": "여백" }, "captionPresets": { - "netflixHint": "圆角深色框上的 Inter,画面下三分之一——广播级中性。", - "youtubeHint": "带柔和投影的 Roboto,无背景框——自动字幕的感觉。", - "boldYellowHint": "影院黄的 Roboto Slab 配黑色投影——DVD 时代的经典。", - "outlinedHint": "带细描边的 Manrope——简洁、现代、无阴影。", - "tiktokHint": "Anton 展示字体,超大居中——竖屏视频的爆款外观。" + "netflixHint": "둥근 어두운 박스 위 Inter, 하단 3분의 1 — 방송 품질의 중립적인 스타일.", + "youtubeHint": "부드러운 그림자가 있는 Roboto, 박스 없음 — 자동 자막 느낌.", + "boldYellowHint": "검은 그림자가 있는 시네마 옐로의 Roboto Slab — DVD 시대의 클래식.", + "outlinedHint": "가는 외곽선의 Manrope — 깔끔하고 현대적, 그림자 없음.", + "tiktokHint": "Anton 디스플레이, 큼직하고 가운데 정렬 — 세로 영상의 바이럴 스타일." }, "alignment": { - "left": "左对齐", - "centerHorizontally": "水平居中", - "right": "右对齐", - "top": "顶对齐", - "centerVertically": "垂直居中", - "bottom": "底对齐", - "distributeHorizontally": "水平分布", - "distributeVertically": "垂直分布" + "left": "왼쪽 정렬", + "centerHorizontally": "가로 가운데 정렬", + "right": "오른쪽 정렬", + "top": "위쪽 정렬", + "centerVertically": "세로 가운데 정렬", + "bottom": "아래쪽 정렬", + "distributeHorizontally": "가로로 분포", + "distributeVertically": "세로로 분포" }, "projectMediaMatch": { - "updateFailed": "更新项目设置失败", - "tryAgain": "请重试。", - "title": "将项目匹配到第一个视频?", - "descriptionWithFile": "\"{{fileName}}\" 是添加到此项目的第一个视频。", - "description": "第一个导入的视频可以定义项目的尺寸和帧率。", - "current": "当前", - "clip": "片段", - "size": "尺寸", - "frameRate": "帧率", - "fpsRoundedHint": "FreeCut 会将导入的视频匹配到最接近的受支持项目帧率。此片段将使用 {{fps}} fps。", - "keepCurrent": "保持当前", - "fpsOnly": "仅帧率", - "sizeOnly": "仅尺寸", - "matchBoth": "都匹配", - "matchFps": "匹配帧率", - "matchSize": "匹配尺寸" + "updateFailed": "프로젝트 설정을 업데이트하지 못했습니다", + "tryAgain": "다시 시도하세요.", + "title": "프로젝트를 첫 번째 비디오에 맞추시겠습니까?", + "descriptionWithFile": "\"{{fileName}}\"은(는) 이 프로젝트에 처음 추가된 비디오입니다.", + "description": "처음 가져온 비디오로 프로젝트 크기와 프레임 레이트를 정의할 수 있습니다.", + "current": "현재", + "clip": "클립", + "size": "크기", + "frameRate": "프레임 레이트", + "fpsRoundedHint": "FreeCut은 가져온 비디오를 가장 가까운 지원 프로젝트 프레임 레이트에 맞춥니다. 이 클립은 {{fps}} fps를 사용합니다.", + "keepCurrent": "현재 유지", + "fpsOnly": "FPS만", + "sizeOnly": "크기만", + "matchBoth": "둘 다 맞추기", + "matchFps": "FPS 맞추기", + "matchSize": "크기 맞추기" }, "editor": { - "appLabel": "FreeCut 视频编辑器", - "backupCreated": "升级前已创建备份", - "backupFailed": "升级前创建备份失败", - "tryAgain": "请重试。", - "projectSaved": "项目已保存", - "projectSaveFailed": "保存项目失败", - "projectBundle": "FreeCut 项目包" + "appLabel": "FreeCut 비디오 편집기", + "backupCreated": "업그레이드 전 백업이 생성되었습니다", + "backupFailed": "업그레이드 전 백업 생성에 실패했습니다", + "tryAgain": "다시 시도하세요.", + "projectSaved": "프로젝트가 저장되었습니다", + "projectSaveFailed": "프로젝트 저장에 실패했습니다", + "projectBundle": "FreeCut 프로젝트 번들" }, "audioMeters": { - "meters": "电平表", - "mixer": "混音器", - "floatMixer": "浮动混音器", - "panelMode": "面板模式", - "audioMeter": "音频电平表", - "audioMixer": "音频混音器" + "meters": "미터", + "mixer": "믹서", + "floatMixer": "믹서 분리", + "panelMode": "패널 모드", + "audioMeter": "오디오 미터", + "audioMixer": "오디오 믹서" + }, + "videoSection": { + "cropBottom": "아래 자르기", + "cropLeft": "왼쪽 자르기", + "cropRight": "오른쪽 자르기", + "cropTop": "위 자르기", + "cropping": "자르기", + "fadeIn": "페이드 인", + "fadeOut": "페이드 아웃", + "playback": "재생", + "resetCropBottom": "아래 자르기 재설정", + "resetCropLeft": "왼쪽 자르기 재설정", + "resetCropRight": "오른쪽 자르기 재설정", + "resetCropTop": "위 자르기 재설정", + "resetSoftness": "부드러움 재설정", + "resetSpeed": "속도 재설정", + "resetToZero": "0으로 재설정", + "softness": "부드러움", + "speed": "속도" + }, + "mediaSidebar": { + "media": "미디어", + "text": "텍스트", + "shapes": "도형", + "effects": "효과", + "transitions": "전환", + "ai": "AI", + "collapsePanel": "패널 접기", + "expandPanel": "패널 펼치기", + "keyframeEditor": "키프레임 편집기", + "hideKeyframeEditor": "키프레임 편집기 숨기기", + "showKeyframeEditor": "키프레임 편집기 표시", + "templates": "템플릿", + "textGroupSingle": "단일", + "textGroupTwoSpans": "2개 구간", + "textGroupThreeSpans": "3개 구간", + "addText": "텍스트 추가", + "pen": "펜", + "penToolHint": "펜 도구로 사용자 지정 경로 도형 그리기", + "adjustmentLayer": "조정 레이어", + "blankAdjustmentLayer": "빈 조정 레이어", + "presets": "프리셋" + }, + "textSection": { + "sectionTitle": "텍스트", + "content": "내용", + "single": "단일", + "twoSpans": "2개 구간", + "threeSpans": "3개 구간", + "mixedNone": "혼합 / 없음", + "selectPreset": "프리셋 선택", + "span": "구간 {{count}}", + "spanText": "구간 {{count}} 텍스트", + "eyebrow": "보조 제목", + "eyebrowText": "보조 제목 텍스트", + "title": "제목", + "titleText": "제목 텍스트", + "subtitle": "부제목", + "subtitleText": "부제목 텍스트", + "text": "텍스트", + "enterText": "텍스트 입력...", + "defaultText": "여기에 텍스트 입력", + "selectFont": "글꼴 선택", + "size": "크기", + "spacing": "간격", + "scale": "배율", + "font": "글꼴", + "weight": "두께", + "style": "스타일", + "align": "정렬", + "color": "색상", + "background": "배경", + "clear": "지우기", + "clearBackground": "배경 지우기", + "lineHeightShort": "줄 높이", + "padding": "여백", + "radius": "반경", + "mixed": "혼합", + "selectWeight": "두께 선택", + "bold": "굵게", + "boldUnavailable": "이 글꼴에서는 굵게를 사용할 수 없습니다", + "italic": "기울임", + "underline": "밑줄", + "italicSpan": "{{label}} 기울임", + "underlineSpan": "{{label}} 밑줄", + "alignLeft": "왼쪽 정렬", + "alignCenter": "가운데 정렬", + "alignRight": "오른쪽 정렬", + "alignTop": "위쪽 정렬", + "alignMiddle": "세로 가운데 정렬", + "alignBottom": "아래쪽 정렬", + "effects": "효과", + "presets": "프리셋", + "fontWeights": { + "regular": "일반", + "medium": "중간", + "semibold": "세미볼드", + "bold": "굵게" + }, + "effectPresets": { + "none": "없음", + "shadow": "그림자", + "outline": "외곽선", + "glow": "광선" + }, + "shadow": "그림자", + "shadowX": "그림자 X", + "shadowY": "그림자 Y", + "shadowBlur": "그림자 흐림", + "strokeWidth": "외곽선 굵기", + "stroke": "외곽선", + "animation": "애니메이션", + "intro": "인트로", + "outro": "아웃트로", + "animationFooter": "선택한 각 클립의 시작 또는 끝에 짧은 이즈아웃 텍스트 모션을 적용합니다.", + "animationPresets": { + "none": "없음", + "fade": "페이드", + "rise": "위로", + "drop": "아래로", + "left": "왼쪽", + "right": "오른쪽", + "tilt": "기울임", + "pop": "팝", + "swing": "스윙" + } + }, + "tts": { + "seek": "탐색", + "dialogTitle": "텍스트에서 오디오 생성", + "dialogDescription": "음성을 생성하고 텍스트 클립 위치에 삽입합니다.", + "kokoroUnsupported": "이 브라우저에서는 WebGPU를 사용할 수 없습니다. Kokoro TTS에는 Chrome 113+, Edge 113+ 또는 Safari 26+가 필요합니다.", + "mossUnsupported": "이 브라우저에서는 브라우저 관리 저장소를 사용할 수 없습니다. MOSS 다국어 TTS는 최신 Chromium 브라우저에서 가장 잘 작동합니다.", + "engine": "엔진", + "engineSupportDetails": "TTS 엔진 지원 정보", + "kokoroDescription": "WebGPU 기반 영어 음성입니다.", + "supportedLanguages": "지원 언어: {{languages}}.", + "kokoroOption": "Kokoro (영어, WebGPU)", + "mossOption": "MOSS Nano (20개 언어, CPU)", + "voice": "음성", + "text": "텍스트", + "textPlaceholder": "읽어 줄 텍스트를 입력하세요...", + "speed": "속도", + "progressPreparing": "로컬 TTS 준비 중...", + "insertedAndLinked": "삽입 및 연결됨", + "generate": "생성", + "generating": "생성 중...", + "regenerate": "다시 생성", + "insertAndLink": "삽입 및 연결", + "inserting": "삽입 중...", + "errors": { + "openProject": "오디오를 생성하기 전에 프로젝트를 여세요.", + "enterText": "합성할 텍스트를 입력하세요.", + "kokoroUnsupported": "Kokoro TTS에는 WebGPU가 필요합니다. Chrome 113+, Edge 113+ 또는 Safari 26+를 사용해 보세요.", + "mossUnsupported": "MOSS 다국어 TTS에는 브라우저 관리 저장소가 필요합니다. 최신 Chromium 브라우저를 사용해 보세요.", + "generateFailed": "음성을 생성하지 못했습니다.", + "insertFailed": "오디오를 저장하고 삽입하지 못했습니다.", + "supertonicUnsupported": "이 브라우저에서는 로컬 Supertonic TTS 런타임을 실행할 수 없습니다. 최신 Chrome 또는 Edge를 사용해 보세요." + }, + "notifications": { + "addedAndLinked": "\"{{fileName}}\"을(를) 타임라인에 추가하고 텍스트와 연결했습니다.", + "savedNoTrack": "\"{{fileName}}\"을(를) 저장했지만 사용 가능한 오디오 트랙이 없습니다." + }, + "supertonicUnsupported": "이 브라우저에서는 로컬 Supertonic TTS 런타임을 실행할 수 없습니다. 최신 Chrome 또는 Edge를 사용해 보세요.", + "supertonicDescription": "WebGPU 또는 WASM에서 실행되는 31개 언어 로컬 ONNX 음성입니다.", + "supertonicOption": "Supertonic 3 (31개 언어, 로컬 ONNX)", + "language": "언어", + "autoDetectLanguage": "자동 감지", + "expressiveTags": "표현 태그" + }, + "aiPanel": { + "textToSpeech": "텍스트 음성 변환", + "collapseTextToSpeech": "텍스트 음성 변환 접기", + "expandTextToSpeech": "텍스트 음성 변환 펼치기", + "runsLocally": "{{runtime}}은(는) 브라우저에서 {{backend}}로 로컬 실행됩니다.", + "history": "기록 ({{count}}) - {{size}}", + "clearAll": "모두 지우기", + "musicGeneration": "음악 생성", + "collapseMusicGeneration": "음악 생성 접기", + "expandMusicGeneration": "음악 생성 펼치기", + "musicGenerationInfo": "음악 생성 정보", + "musicgenDescription": "Transformers.js를 통해 Xenova의 브라우저용 MusicGen 모델을 사용합니다. 첫 다운로드는 크지만 이후에는 로컬에 캐시됩니다.", + "musicgenPromptHint": "장르, 분위기, 템포, 악기 구성을 입력하세요. 짧은 클립일수록 훨씬 빠르게 끝납니다.", + "musicgenUnsupported": "이 브라우저에서는 WebGPU를 사용할 수 없습니다. MusicGen에는 Chrome 113+, Edge 113+ 또는 Safari 26+가 필요합니다.", + "prompt": "프롬프트", + "presets": "프리셋", + "musicPromptPlaceholder": "생성할 음악의 종류를 설명하세요...", + "length": "길이", + "generateMusic": "음악 생성", + "musicHistory": "음악 기록 ({{count}}) - {{size}}", + "remove": "제거", + "saved": "저장됨", + "saving": "저장 중...", + "saveAndInsert": "저장 및 삽입", + "saveToLibrary": "라이브러리에 저장", + "progressPreparingMusic": "로컬 음악 생성을 준비 중...", + "errors": { + "describeMusic": "생성할 음악을 설명하세요.", + "musicgenUnsupported": "MusicGen에는 WebGPU가 필요합니다. Chrome 113+, Edge 113+ 또는 Safari 26+를 사용해 보세요.", + "generateMusicFailed": "음악을 생성하지 못했습니다.", + "saveAudioFailed": "오디오를 미디어 라이브러리에 저장하지 못했습니다." + }, + "notifications": { + "savedToLibrary": "\"{{fileName}}\"이(가) 미디어 라이브러리에 저장되었습니다.", + "savedAndAdded": "\"{{fileName}}\"이(가) 저장되어 타임라인에 추가되었습니다." + }, + "defaultTtsPrompt": "Welcome to freecut. This voice was generated locally in the browser.", + "musicPresets": { + "lofiChillLabel": "Lo-fi 칠", + "lofiChillPrompt": "따뜻한 lo-fi 비트, 먼지 느낌의 드럼, 부드러운 베이스, 꿈같은 신스 리드", + "pop80sLabel": "80년대 팝", + "pop80sPrompt": "묵직한 드럼과 신스가 있는 80년대 팝 트랙", + "rock90sLabel": "90년대 록", + "rock90sPrompt": "큰 기타와 묵직한 드럼이 있는 90년대 록 노래", + "upbeatEdmLabel": "신나는 EDM", + "upbeatEdmPrompt": "밝고 경쾌한 EDM 트랙, 싱코페이트 드럼, 포근한 패드, 강한 감정 bpm: 130", + "countryLabel": "컨트리", + "countryPrompt": "어쿠스틱 기타가 있는 밝은 컨트리 노래", + "lofiElectroLabel": "Lo-fi 일렉트로", + "lofiElectroPrompt": "오가닉 샘플이 있는 느린 BPM의 lo-fi 일렉트로 칠" + } } } }, - "tr": { + "zh": { "editor": { "shortcutsDialog": { - "title": "Klavye Kısayolları", - "description": "Klavye kısayol atamalarını düzenleyin." + "title": "键盘快捷键", + "description": "编辑键盘快捷键绑定。" }, "interactionLock": { - "maskEditing": "Devam etmek için maske düzenlemeyi bitirin veya çıkın" + "maskEditing": "完成或退出蒙版编辑以继续" }, "localInferencePill": { - "loading": "Yerel YZ Yükleniyor", - "active": "Yerel YZ Etkin", - "error": "Yerel YZ Hatası", - "ready": "Yerel YZ Hazır", - "jobs_one": "{{count}} iş", - "jobs_other": "{{count}} iş" + "loading": "本地 AI 加载中", + "active": "本地 AI 运行中", + "error": "本地 AI 错误", + "ready": "本地 AI 就绪", + "jobs_one": "{{count}} 个任务", + "jobs_other": "{{count}} 个任务", + "jobs": "{{count}} 个任务" }, "clearKeyframesDialog": { - "titleProperty": "{{property}} Anahtar Karelerini Temizle", - "titleAll": "Tüm Anahtar Kareleri Temizle", - "descriptionProperty_one": "{{count}} klipten tüm {{property}} anahtar karelerini temizlemek istediğinizden emin misiniz?", - "descriptionProperty_other": "{{count}} klipten tüm {{property}} anahtar karelerini temizlemek istediğinizden emin misiniz?", - "descriptionAll_one": "{{count}} klipten tüm anahtar kareleri temizlemek istediğinizden emin misiniz?", - "descriptionAll_other": "{{count}} klipten tüm anahtar kareleri temizlemek istediğinizden emin misiniz?", - "undoHint": "Bu işlem Ctrl+Z ile geri alınabilir.", - "confirm": "Anahtar Kareleri Temizle" - }, - "projectUpgradeDialog": { - "title": "Açmadan Önce Projeyi Yükselt", - "versionMismatch": "{{projectName}} proje şeması v{{storedSchemaVersion}} ile kaydedilmiş, ancak bu sürüm v{{currentSchemaVersion}} bekliyor.", - "explanation": "FreeCut, editörü yüklemeden önce projeyi sizin için yükseltebilir. Bir şey ters görünürse eski veriye dönebilmeniz için önce yükseltme öncesi bir yedek oluşturulur.", - "backupCopy": "Yedek kopya: {{backupName}}", - "creatingBackup": "Yedek Oluşturuluyor...", - "confirm": "Yedek Oluştur ve Yükselt" + "titleProperty": "清除 {{property}} 关键帧", + "titleAll": "清除所有关键帧", + "descriptionProperty_one": "确定要从 {{count}} 个片段中清除所有 {{property}} 关键帧吗?", + "descriptionProperty_other": "确定要从 {{count}} 个片段中清除所有 {{property}} 关键帧吗?", + "descriptionAll_one": "确定要从 {{count}} 个片段中清除所有关键帧吗?", + "descriptionAll_other": "确定要从 {{count}} 个片段中清除所有关键帧吗?", + "undoHint": "此操作可通过 Ctrl+Z 撤销。", + "confirm": "清除关键帧", + "descriptionProperty": "从所选 {{count}} 个项目清除 {{property}} 关键帧吗?", + "descriptionAll": "从所选 {{count}} 个项目清除所有关键帧吗?" + }, + "projectUpgradeDialog": { + "title": "打开前升级项目", + "versionMismatch": "{{projectName}} 使用项目架构 v{{storedSchemaVersion}} 保存,但此版本需要 v{{currentSchemaVersion}}。", + "explanation": "FreeCut 可以在加载编辑器之前为你升级它。会先创建升级前的备份,以便在出现问题时恢复旧数据。", + "backupCopy": "备份副本:{{backupName}}", + "creatingBackup": "正在创建备份...", + "confirm": "创建备份并升级" }, "autoSave": { - "failed": "Otomatik kaydetme başarısız" + "failed": "自动保存失败" }, "whatsNew": { - "title": "Yenilikler", - "thisWeek": "Bu Hafta", - "asOf": "{{date}} itibarıyla", - "weekOf": "{{range}} haftası", - "newBadge": "Yeni", - "released": "Yayınlandı: v{{version}}", - "preRelease": "Ön sürüm", - "fullChangelog": "Tam değişiklik günlüğü", - "groupAdded": "Eklendi", - "groupFixed": "Düzeltildi", - "groupImproved": "İyileştirildi" + "title": "新功能", + "thisWeek": "本周", + "asOf": "截至 {{date}}", + "weekOf": "{{range}} 那一周", + "newBadge": "新", + "released": "已发布:v{{version}}", + "preRelease": "预发布", + "fullChangelog": "完整更新日志", + "groupAdded": "新增", + "groupFixed": "修复", + "groupImproved": "改进" }, "transitions": { - "hintClickToApply": "Seçili kesime uygulamak için tıklayın veya geçişi zaman çizelgesindeki geçerli bir kesime sürükleyin.", - "hintUnavailable": "Geçişi geçerli bir kesime sürükleyin. Burada tıklayarak uygulama kullanılamaz: {{reason}}.", - "hintSelectOne": "Geçişi geçerli bir kesime sürükleyin veya klipleri yan yana koyup tıklayarak uygulamak için bir klip seçin.", - "hintSelectSingle": "Geçişi geçerli bir kesime sürükleyin veya tıklayarak uygulamak için tek bir video ya da görsel klip seçin.", - "hintSelectClip": "Geçişi geçerli bir kesime sürükleyin veya komşusuna geçiş eklemek için bir video ya da görsel klip seçin.", + "hintClickToApply": "点击应用到所选的剪切点,或将转场拖到时间线上任何有效的剪切点上。", + "hintUnavailable": "将转场拖到有效的剪切点上。此处无法点击应用:{{reason}}。", + "hintSelectOne": "将转场拖到有效的剪切点上,或将片段并排放置并选择一个以点击应用。", + "hintSelectSingle": "将转场拖到有效的剪切点上,或选择单个视频或图片片段以点击应用。", + "hintSelectClip": "将转场拖到有效的剪切点上,或选择一个视频或图片片段以在其相邻片段之间添加转场。", "category": { - "basic": "Temel", - "dissolve": "Çözülme", - "motion": "Hareket", - "wipe": "Silme", - "slide": "Kaydırma", - "flip": "Çevirme", - "mask": "Maske", - "iris": "İris", - "shape": "Şekil", - "light": "Işık", - "chromatic": "Kromatik", - "custom": "Özel" + "basic": "基础", + "dissolve": "溶解", + "motion": "运动", + "wipe": "擦除", + "slide": "滑动", + "flip": "翻转", + "mask": "蒙版", + "iris": "光圈", + "shape": "形状", + "light": "光", + "chromatic": "色差", + "custom": "自定义" } }, "transitionPanel": { - "sectionTitle": "Geçiş", - "preset": "Ön ayar", - "presetTooltip": "Geçiş stili ön ayarı", - "selectPreset": "Ön ayar seç", - "searchTransitions": "Geçişlerde ara", - "noTransitionsFound": "Geçiş bulunamadı", - "notFound": "Geçiş bulunamadı", - "duration": "Süre", - "durationTooltip": "Geçiş süresi", - "resetToDefault": "1 sn'ye sıfırla", - "placement": "Yerleşim", - "placementTooltip": "Geçişi kesimin önüne, üzerine veya sonrasına konumlandır", - "placementLeft": "Sol", - "placementCenter": "Merkez", - "placementRight": "Sağ", - "placementLeftTooltip": "Geçişi kesimden önce yerleştir", - "placementCenterTooltip": "Geçişi kesimin ortasına yerleştir", - "placementRightTooltip": "Geçişi kesimden sonra yerleştir", - "placementAria": "{{label}} yerleşimi", - "placementDisabled": "{{title}} (yeterli kaynak payı yok)", - "ease": "Yumuşatma", - "easeTooltip": "Geçiş için yumuşatma eğrisi", - "easeLinear": "Doğrusal", - "easeIn": "Giriş", - "easeOut": "Çıkış", - "easeInOut": "Giriş ve Çıkış", - "direction": "Yön", - "directionTooltip": "Geçiş hareketinin yönü", - "directionLeft": "Sol", - "directionRight": "Sağ", - "directionTop": "Üst", - "directionBottom": "Alt", - "resetParam": "{{label}} sıfırla", - "paramColorAria": "{{label}} rengi" + "sectionTitle": "转场", + "preset": "预设", + "presetTooltip": "转场样式预设", + "selectPreset": "选择预设", + "searchTransitions": "搜索转场", + "noTransitionsFound": "未找到转场", + "notFound": "未找到转场", + "duration": "时长", + "durationTooltip": "转场时长", + "resetToDefault": "重置为 1 秒", + "placement": "位置", + "placementTooltip": "将转场放在剪切点之前、之上或之后", + "placementLeft": "左", + "placementCenter": "居中", + "placementRight": "右", + "placementLeftTooltip": "将转场放在剪切点之前", + "placementCenterTooltip": "将转场居中于剪切点", + "placementRightTooltip": "将转场放在剪切点之后", + "placementAria": "{{label}}位置", + "placementDisabled": "{{title}}(源余量不足)", + "ease": "缓动", + "easeTooltip": "转场的缓动曲线", + "easeLinear": "线性", + "easeIn": "缓入", + "easeOut": "缓出", + "easeInOut": "缓入缓出", + "direction": "方向", + "directionTooltip": "转场运动方向", + "directionLeft": "左", + "directionRight": "右", + "directionTop": "上", + "directionBottom": "下", + "resetParam": "重置{{label}}", + "paramColorAria": "{{label}}颜色" }, "propertiesSidebar": { - "title": "Özellikler", - "clipsSelected_one": "{{count}} klip seçildi", - "clipsSelected_other": "{{count}} klip seçildi", - "dockToPreview": "Önizlemeye sabitle", - "expandFullColumn": "Tam sütuna genişlet", - "showPanel": "Özellikler Panelini Göster" + "title": "属性", + "clipsSelected_one": "已选择 {{count}} 个片段", + "clipsSelected_other": "已选择 {{count}} 个片段", + "dockToPreview": "停靠到预览", + "expandFullColumn": "展开为整列", + "showPanel": "显示属性面板", + "clipsSelected": "已选择 {{count}} 个剪辑" }, "markerPanel": { - "notFound": "İşaretçi bulunamadı", - "title": "İşaretçi", - "frame": "Kare", - "time": "Zaman", - "label": "Etiket", - "labelPlaceholder": "Etiket girin...", - "color": "Renk", - "deleteMarker": "İşaretçiyi Sil" + "notFound": "未找到标记", + "title": "标记", + "frame": "帧", + "time": "时间", + "label": "标签", + "labelPlaceholder": "输入标签...", + "color": "颜色", + "deleteMarker": "删除标记" }, "canvasPanel": { - "updateFailed": "Tuval ayarları güncellenemedi", - "tryAgain": "Lütfen tekrar deneyin.", - "noProjectLoaded": "Proje yüklenmedi", - "canvas": "Tuval", - "swap": "Değiştir", - "background": "Arka plan", - "resetToBlack": "Siyaha sıfırla", - "duration": "Süre", - "frameRate": "Kare Hızı", - "totalFrames": "Toplam Kare" + "updateFailed": "更新画布设置失败", + "tryAgain": "请重试。", + "noProjectLoaded": "未加载项目", + "canvas": "画布", + "swap": "交换", + "background": "背景", + "resetToBlack": "重置为黑色", + "duration": "时长", + "frameRate": "帧率", + "totalFrames": "总帧数" }, "clipPanel": { - "tabVideo": "Video", - "tabAudio": "Ses", - "tabEffects": "Efektler", - "adjustmentLayerHint": "Ayar katmanlarındaki efektler, üst izlerdeki tüm öğelere uygulanır." + "tabVideo": "视频", + "tabAudio": "音频", + "tabEffects": "效果", + "adjustmentLayerHint": "调整图层上的效果会应用到上方所有轨道的项目。" }, "gifSection": { - "animation": "Animasyon", - "speed": "Hız", - "resetSpeed": "1x'e sıfırla" + "animation": "动画", + "speed": "速度", + "resetSpeed": "重置为 1x" }, "cornerPinSection": { - "title": "Köşe Sabitleme", - "exitEditor": "Köşe sabitleme düzenleyicisinden çık", - "editOnPreview": "Köşeleri önizlemede düzenle", - "editing": "Düzenleniyor...", - "edit": "Düzenle", - "reset": "Köşe sabitlemeyi sıfırla" + "title": "边角定位", + "exitEditor": "退出边角定位编辑器", + "editOnPreview": "在预览中编辑边角", + "editing": "编辑中...", + "edit": "编辑", + "reset": "重置边角定位" }, "fillSection": { - "composite": "Bileşik", - "opacity": "Opaklık", - "resetOpacity": "%100'e sıfırla", - "blend": "Karışım", - "blendNormal": "Normal", - "blendMixed": "Karışık", - "radius": "Yarıçap", - "resetRadius": "0'a sıfırla" + "composite": "合成", + "opacity": "不透明度", + "resetOpacity": "重置为 100%", + "blend": "混合", + "blendNormal": "正常", + "blendMixed": "混合", + "radius": "圆角", + "resetRadius": "重置为 0" }, "fontPicker": { - "selectFont": "Yazı tipi seç", - "searchFonts": "Yazı tiplerinde ara...", - "livePreview": "Canlı Önizleme", - "allFonts": "Tüm Yazı Tipleri", - "fontOptions": "Yazı tipi seçenekleri", - "noMatch": "Aramanızla eşleşen yazı tipi yok.", - "previewPangram": "Pijamalı hasta yağız şoföre çabucak güvendi" - }, - "layoutSection": { - "transform": "Dönüşüm", - "position": "Konum", - "resetPosition": "Ortaya sıfırla", - "size": "Boyut", - "unlockAspect": "En-boy oranı kilidini aç", - "lockAspect": "En-boy oranını kilitle", - "resetSize": "Özgün boyuta sıfırla", - "rotation": "Döndürme", - "resetRotation": "Döndürmeyi sıfırla", - "anchor": "Bağlantı noktası", - "resetAnchor": "Bağlantı noktasını ortaya sıfırla", - "flip": "Çevir", - "flipHorizontalAria": "Videoyu yatay çevir", - "flipVerticalAria": "Videoyu dikey çevir", - "horizontal": "Yatay", - "horizontalMixed": "Yatay (karışık)", - "vertical": "Dikey", - "verticalMixed": "Dikey (karışık)" + "selectFont": "选择字体", + "searchFonts": "搜索字体...", + "livePreview": "实时预览", + "allFonts": "所有字体", + "fontOptions": "字体选项", + "noMatch": "没有字体与你的搜索匹配。", + "previewPangram": "天地玄黄 宇宙洪荒 日月盈昃" + }, + "layoutSection": { + "transform": "变换", + "position": "位置", + "resetPosition": "重置到中心", + "size": "尺寸", + "unlockAspect": "解锁宽高比", + "lockAspect": "锁定宽高比", + "resetSize": "重置到原始尺寸", + "rotation": "旋转", + "resetRotation": "重置旋转", + "anchor": "锚点", + "resetAnchor": "将锚点重置到中心", + "flip": "翻转", + "flipHorizontalAria": "水平翻转视频", + "flipVerticalAria": "垂直翻转视频", + "horizontal": "水平", + "horizontalMixed": "水平(混合)", + "vertical": "垂直", + "verticalMixed": "垂直(混合)" }, "shapeSection": { - "shape": "Şekil", - "type": "Tür", - "mixed": "Karışık", - "selectShape": "Şekil seç", - "typeRectangle": "Dikdörtgen", - "typeCircle": "Daire", - "typeTriangle": "Üçgen", - "typeEllipse": "Elips", - "typeStar": "Yıldız", - "typePolygon": "Çokgen", - "typeHeart": "Kalp", - "path": "Yol", - "editPath": "Yolu Düzenle", - "editPathHint": "Önizlemede noktaları ve tutamaçları sürükleyin.", - "fill": "Dolgu", - "strokeWidth": "Kontur G.", - "stroke": "Kontur", - "radius": "Yarıçap", - "direction": "Yön", - "directionUp": "Yukarı", - "directionDown": "Aşağı", - "directionLeft": "Sol", - "directionRight": "Sağ", - "points": "Noktalar", - "innerRadius": "İç Y.", - "useAsMask": "Maske Olarak Kullan", - "on": "Açık", - "off": "Kapalı", - "maskType": "Maske Türü", - "selectType": "Tür seç", - "maskTypeClip": "Klip (Sert kenarlar)", - "maskTypeAlpha": "Alfa (Yumuşak kenarlar)", - "feather": "Yumuşatma", - "resetFeather": "10px'e sıfırla", - "invert": "Ters çevir" + "shape": "形状", + "type": "类型", + "mixed": "混合", + "selectShape": "选择形状", + "typeRectangle": "矩形", + "typeCircle": "圆形", + "typeTriangle": "三角形", + "typeEllipse": "椭圆", + "typeStar": "星形", + "typePolygon": "多边形", + "typeHeart": "心形", + "path": "路径", + "editPath": "编辑路径", + "editPathHint": "在预览中拖动点和手柄。", + "fill": "填充", + "strokeWidth": "描边宽度", + "stroke": "描边", + "radius": "圆角", + "direction": "方向", + "directionUp": "上", + "directionDown": "下", + "directionLeft": "左", + "directionRight": "右", + "points": "顶点数", + "innerRadius": "内半径", + "useAsMask": "用作蒙版", + "on": "开", + "off": "关", + "maskType": "蒙版类型", + "selectType": "选择类型", + "maskTypeClip": "裁剪(硬边)", + "maskTypeAlpha": "Alpha(柔边)", + "feather": "羽化", + "resetFeather": "重置为 10px", + "invert": "反转" }, "subtitleSection": { - "title": "Altyazı", - "multiSelectHint": "{{segments}} segment seçildi · toplam {{cues}} ipucu. Stil hepsine uygulanır. Tek tek ipuçlarını düzenlemek için tek bir segment seçin.", - "trackLabel": "İz {{number}}", - "transcript": "Döküm", - "cueCount_one": "{{count}} ipucu", - "cueCount_other": "{{count}} ipucu", - "seekToCue": "Oynatma kafasını bu ipucuna taşı", - "start": "Başlangıç", - "end": "Bitiş", - "italic": "İtalik", - "bold": "Kalın", - "underline": "Altı çizili", - "cuePosition": "İpucu konumu: {{vertical}} {{horizontal}}" + "title": "字幕", + "multiSelectHint": "已选择 {{segments}} 个段 · 共 {{cues}} 条字幕。样式应用于全部。选择单个段以编辑单条字幕。", + "trackLabel": "轨道 {{number}}", + "transcript": "转录", + "cueCount_one": "{{count}} 条字幕", + "cueCount_other": "{{count}} 条字幕", + "seekToCue": "将播放头移动到此字幕", + "start": "开始", + "end": "结束", + "italic": "斜体", + "bold": "粗体", + "underline": "下划线", + "cuePosition": "字幕位置:{{vertical}} {{horizontal}}", + "cueCount": "{{count}} 条字幕" }, "audioSection": { - "audio": "Ses", - "gain": "Kazanç", - "resetGain": "0 dB'ye sıfırla", - "fadeIn": "Açılma", - "fadeOut": "Kapanma", - "resetToZero": "0'a sıfırla", - "pitch": "Perde", - "semitones": "Yarım Sesler", - "resetSemitones": "Yarım ses perdesini sıfırla", - "cents": "Sent", - "resetCents": "Sent perdesini sıfırla", - "equalizer": "Ekolayzer" + "audio": "音频", + "gain": "增益", + "resetGain": "重置为 0 dB", + "fadeIn": "淡入", + "fadeOut": "淡出", + "resetToZero": "重置为 0", + "pitch": "音高", + "semitones": "半音", + "resetSemitones": "重置半音音高", + "cents": "音分", + "resetCents": "重置音分音高", + "equalizer": "均衡器" }, "captionStyleControls": { - "stylePreset": "Stil ön ayarı", - "color": "Renk", - "size": "Boyut", - "vertical": "Dikey", - "background": "Arka plan", - "on": "Açık", - "off": "Kapalı", - "padding": "Dolgu" + "stylePreset": "样式预设", + "color": "颜色", + "size": "尺寸", + "vertical": "垂直位置", + "background": "背景", + "on": "开", + "off": "关", + "padding": "内边距" }, "captionPresets": { - "netflixHint": "Yuvarlatılmış koyu kutuda Inter, alt üçte birlik konum — yayın kalitesinde nötr.", - "youtubeHint": "Yumuşak gölgeli Roboto, kutusuz — otomatik altyazı havası.", - "boldYellowHint": "Siyah gölgeli sinema sarısı Roboto Slab — DVD dönemi klasiği.", - "outlinedHint": "İnce konturlu Manrope — temiz, modern, gölgesiz.", - "tiktokHint": "Anton display, büyük ve ortalanmış — dikey video viral görünümü." + "netflixHint": "圆角深色框上的 Inter,画面下三分之一——广播级中性。", + "youtubeHint": "带柔和投影的 Roboto,无背景框——自动字幕的感觉。", + "boldYellowHint": "影院黄的 Roboto Slab 配黑色投影——DVD 时代的经典。", + "outlinedHint": "带细描边的 Manrope——简洁、现代、无阴影。", + "tiktokHint": "Anton 展示字体,超大居中——竖屏视频的爆款外观。" }, "alignment": { - "left": "Sola Hizala", - "centerHorizontally": "Yatay Ortala", - "right": "Sağa Hizala", - "top": "Üste Hizala", - "centerVertically": "Dikey Ortala", - "bottom": "Alta Hizala", - "distributeHorizontally": "Yatay Dağıt", - "distributeVertically": "Dikey Dağıt" + "left": "左对齐", + "centerHorizontally": "水平居中", + "right": "右对齐", + "top": "顶对齐", + "centerVertically": "垂直居中", + "bottom": "底对齐", + "distributeHorizontally": "水平分布", + "distributeVertically": "垂直分布" }, "projectMediaMatch": { - "updateFailed": "Proje ayarları güncellenemedi", - "tryAgain": "Lütfen tekrar deneyin.", - "title": "Proje İlk Videoya Uydurulsun mu?", - "descriptionWithFile": "\"{{fileName}}\" bu projeye eklenen ilk video.", - "description": "İlk içe aktarılan video proje boyutunu ve kare hızını belirleyebilir.", - "current": "Geçerli", - "clip": "Klip", - "size": "Boyut", - "frameRate": "Kare hızı", - "fpsRoundedHint": "FreeCut içe aktarılan videoyu en yakın desteklenen proje kare hızına eşler. Bu klip {{fps}} fps kullanır.", - "keepCurrent": "Geçerli Olanı Koru", - "fpsOnly": "Yalnızca FPS", - "sizeOnly": "Yalnızca Boyut", - "matchBoth": "İkisini de Eşle", - "matchFps": "FPS'yi Eşle", - "matchSize": "Boyutu Eşle" + "updateFailed": "更新项目设置失败", + "tryAgain": "请重试。", + "title": "将项目匹配到第一个视频?", + "descriptionWithFile": "\"{{fileName}}\" 是添加到此项目的第一个视频。", + "description": "第一个导入的视频可以定义项目的尺寸和帧率。", + "current": "当前", + "clip": "片段", + "size": "尺寸", + "frameRate": "帧率", + "fpsRoundedHint": "FreeCut 会将导入的视频匹配到最接近的受支持项目帧率。此片段将使用 {{fps}} fps。", + "keepCurrent": "保持当前", + "fpsOnly": "仅帧率", + "sizeOnly": "仅尺寸", + "matchBoth": "都匹配", + "matchFps": "匹配帧率", + "matchSize": "匹配尺寸" }, "editor": { - "appLabel": "FreeCut Video Editörü", - "backupCreated": "Yükseltmeden önce yedek oluşturuldu", - "backupFailed": "Yükseltmeden önce yedek oluşturulamadı", - "tryAgain": "Lütfen tekrar deneyin.", - "projectSaved": "Proje kaydedildi", - "projectSaveFailed": "Proje kaydedilemedi", - "projectBundle": "FreeCut Proje Paketi" + "appLabel": "FreeCut 视频编辑器", + "backupCreated": "升级前已创建备份", + "backupFailed": "升级前创建备份失败", + "tryAgain": "请重试。", + "projectSaved": "项目已保存", + "projectSaveFailed": "保存项目失败", + "projectBundle": "FreeCut 项目包" }, "audioMeters": { - "meters": "Sayaçlar", - "mixer": "Mikser", - "floatMixer": "Mikseri Ayır", - "panelMode": "Panel modu", - "audioMeter": "Ses sayacı", - "audioMixer": "Ses mikseri" + "meters": "电平表", + "mixer": "混音器", + "floatMixer": "浮动混音器", + "panelMode": "面板模式", + "audioMeter": "音频电平表", + "audioMixer": "音频混音器" + }, + "videoSection": { + "cropBottom": "裁剪底部", + "cropLeft": "裁剪左侧", + "cropRight": "裁剪右侧", + "cropTop": "裁剪顶部", + "cropping": "裁剪", + "fadeIn": "淡入", + "fadeOut": "淡出", + "playback": "播放", + "resetCropBottom": "重置底部裁剪", + "resetCropLeft": "重置左侧裁剪", + "resetCropRight": "重置右侧裁剪", + "resetCropTop": "重置顶部裁剪", + "resetSoftness": "重置柔和度", + "resetSpeed": "重置速度", + "resetToZero": "重置为零", + "softness": "柔和度", + "speed": "速度" + }, + "mediaSidebar": { + "media": "媒体", + "text": "文本", + "shapes": "形状", + "effects": "效果", + "transitions": "转场", + "ai": "AI", + "collapsePanel": "收起面板", + "expandPanel": "展开面板", + "keyframeEditor": "关键帧编辑器", + "hideKeyframeEditor": "隐藏关键帧编辑器", + "showKeyframeEditor": "显示关键帧编辑器", + "templates": "模板", + "textGroupSingle": "单段", + "textGroupTwoSpans": "2 段", + "textGroupThreeSpans": "3 段", + "addText": "添加文本", + "pen": "钢笔", + "penToolHint": "使用钢笔工具绘制自定义路径形状", + "adjustmentLayer": "调整图层", + "blankAdjustmentLayer": "空白调整图层", + "presets": "预设" + }, + "textSection": { + "sectionTitle": "文本", + "content": "内容", + "single": "单段", + "twoSpans": "2 段", + "threeSpans": "3 段", + "mixedNone": "混合 / 无", + "selectPreset": "选择预设", + "span": "段 {{count}}", + "spanText": "段 {{count}} 文本", + "eyebrow": "眉题", + "eyebrowText": "眉题文本", + "title": "标题", + "titleText": "标题文本", + "subtitle": "副标题", + "subtitleText": "副标题文本", + "text": "文本", + "enterText": "输入文本...", + "defaultText": "在此输入文本", + "selectFont": "选择字体", + "size": "大小", + "spacing": "间距", + "scale": "缩放", + "font": "字体", + "weight": "字重", + "style": "样式", + "align": "对齐", + "color": "颜色", + "background": "背景", + "clear": "清除", + "clearBackground": "清除背景", + "lineHeightShort": "行高", + "padding": "内边距", + "radius": "圆角", + "mixed": "混合", + "selectWeight": "选择字重", + "bold": "加粗", + "boldUnavailable": "此字体不支持加粗", + "italic": "斜体", + "underline": "下划线", + "italicSpan": "{{label}} 斜体", + "underlineSpan": "{{label}} 下划线", + "alignLeft": "左对齐", + "alignCenter": "居中对齐", + "alignRight": "右对齐", + "alignTop": "顶部对齐", + "alignMiddle": "垂直居中", + "alignBottom": "底部对齐", + "effects": "效果", + "presets": "预设", + "fontWeights": { + "regular": "常规", + "medium": "中等", + "semibold": "半粗", + "bold": "加粗" + }, + "effectPresets": { + "none": "无", + "shadow": "阴影", + "outline": "描边", + "glow": "发光" + }, + "shadow": "阴影", + "shadowX": "阴影 X", + "shadowY": "阴影 Y", + "shadowBlur": "阴影模糊", + "strokeWidth": "描边宽度", + "stroke": "描边", + "animation": "动画", + "intro": "入场", + "outro": "出场", + "animationFooter": "在所选每个剪辑的开头或结尾应用短暂的缓出文字动作。", + "animationPresets": { + "none": "无", + "fade": "淡入淡出", + "rise": "上升", + "drop": "下落", + "left": "向左", + "right": "向右", + "tilt": "倾斜", + "pop": "弹出", + "swing": "摇摆" + } + }, + "tts": { + "seek": "定位", + "dialogTitle": "从文本生成音频", + "dialogDescription": "生成语音并插入到文本剪辑的位置。", + "kokoroUnsupported": "此浏览器不支持 WebGPU. Kokoro TTS 需要 Chrome 113+, Edge 113+ 或 Safari 26+.", + "mossUnsupported": "此浏览器不支持浏览器管理的存储。MOSS 多语言 TTS 在较新的 Chromium 浏览器中效果最佳。", + "engine": "引擎", + "engineSupportDetails": "TTS 引擎支持详情", + "kokoroDescription": "基于 WebGPU 的英语语音。", + "supportedLanguages": "支持的语言:{{languages}}。", + "kokoroOption": "Kokoro (英语, WebGPU)", + "mossOption": "MOSS Nano (20 种语言, CPU)", + "voice": "语音", + "text": "文本", + "textPlaceholder": "输入你想听到朗读的文本...", + "speed": "速度", + "progressPreparing": "正在准备本地 TTS...", + "insertedAndLinked": "已插入并链接", + "generate": "生成", + "generating": "正在生成...", + "regenerate": "重新生成", + "insertAndLink": "插入并链接", + "inserting": "正在插入...", + "errors": { + "openProject": "生成音频前请先打开项目。", + "enterText": "请输入要合成的文本。", + "kokoroUnsupported": "Kokoro TTS 需要 WebGPU. 请尝试 Chrome 113+, Edge 113+ 或 Safari 26+.", + "mossUnsupported": "MOSS 多语言 TTS 需要浏览器管理的存储。请尝试较新的 Chromium 浏览器。", + "generateFailed": "无法生成语音。", + "insertFailed": "无法保存并插入音频。", + "supertonicUnsupported": "此浏览器无法运行本地 Supertonic TTS 运行时。请使用最新版 Chrome 或 Edge。" + }, + "notifications": { + "addedAndLinked": "已将\"{{fileName}}\"添加到时间线并与文本链接。", + "savedNoTrack": "已保存\"{{fileName}}\",但没有可用的音频轨道。" + }, + "supertonicUnsupported": "此浏览器无法运行本地 Supertonic TTS 运行时。请使用最新版 Chrome 或 Edge。", + "supertonicDescription": "在 WebGPU 或 WASM 上运行的 31 种语言本地 ONNX 语音。", + "supertonicOption": "Supertonic 3(31 种语言,本地 ONNX)", + "language": "语言", + "autoDetectLanguage": "自动检测", + "expressiveTags": "表现标签" + }, + "aiPanel": { + "textToSpeech": "文本转语音", + "collapseTextToSpeech": "折叠文本转语音", + "expandTextToSpeech": "展开文本转语音", + "runsLocally": "{{runtime}} 会在浏览器中通过 {{backend}} 本地运行。", + "history": "历史记录 ({{count}}) - {{size}}", + "clearAll": "全部清除", + "musicGeneration": "音乐生成", + "collapseMusicGeneration": "折叠音乐生成", + "expandMusicGeneration": "展开音乐生成", + "musicGenerationInfo": "音乐生成信息", + "musicgenDescription": "通过 Transformers.js 使用 Xenova 的浏览器版 MusicGen 模型。首次下载较大,之后会缓存在本地。", + "musicgenPromptHint": "请描述流派、情绪、速度和乐器。较短的片段会完成得更快。", + "musicgenUnsupported": "此浏览器不支持 WebGPU. MusicGen 需要 Chrome 113+, Edge 113+ 或 Safari 26+.", + "prompt": "提示词", + "presets": "预设", + "musicPromptPlaceholder": "描述你想生成的音乐类型...", + "length": "长度", + "generateMusic": "生成音乐", + "musicHistory": "音乐历史记录 ({{count}}) - {{size}}", + "remove": "移除", + "saved": "已保存", + "saving": "正在保存...", + "saveAndInsert": "保存并插入", + "saveToLibrary": "保存到库", + "progressPreparingMusic": "正在准备本地音乐生成...", + "errors": { + "describeMusic": "请描述你想生成的音乐。", + "musicgenUnsupported": "MusicGen 需要 WebGPU. 请尝试 Chrome 113+, Edge 113+ 或 Safari 26+.", + "generateMusicFailed": "无法生成音乐。", + "saveAudioFailed": "无法将音频保存到媒体库。" + }, + "notifications": { + "savedToLibrary": "已将\"{{fileName}}\"保存到媒体库。", + "savedAndAdded": "已保存\"{{fileName}}\"并添加到时间线。" + }, + "defaultTtsPrompt": "Welcome to freecut. This voice was generated locally in the browser.", + "musicPresets": { + "lofiChillLabel": "Lo-fi 放松", + "lofiChillPrompt": "温暖的 lo-fi 节拍,配有复古鼓点、柔和低音和梦幻合成器主旋律", + "pop80sLabel": "80 年代流行", + "pop80sPrompt": "80 年代流行曲,配有厚重鼓点和合成器", + "rock90sLabel": "90 年代摇滚", + "rock90sPrompt": "90 年代摇滚歌曲,配有响亮的吉他和厚重鼓点", + "upbeatEdmLabel": "欢快 EDM", + "upbeatEdmPrompt": "轻快愉快的 EDM 曲目,配有切分鼓点、空灵铺底和强烈情绪 bpm: 130", + "countryLabel": "乡村", + "countryPrompt": "一首欢快的乡村歌,配有原声吉他", + "lofiElectroLabel": "Lo-fi 电子", + "lofiElectroPrompt": "低速 lo-fi 电子\f,配有有机采样" + } } } } diff --git a/src/i18n/locales/partials/effects.json b/src/i18n/locales/partials/effects.json index 299847589..782abc157 100644 --- a/src/i18n/locales/partials/effects.json +++ b/src/i18n/locales/partials/effects.json @@ -1439,6 +1439,293 @@ "flat": "Flat" } }, + "tr": { + "categories": { + "color": "Renk", + "blur": "Bulanıklık", + "distort": "Bozma", + "stylize": "Stilize", + "keying": "Anahtarlama" + }, + "panel": { + "disableEffect": "Efekti Devre Dışı Bırak", + "enableEffect": "Efekti Etkinleştir", + "off": "Kapalı", + "on": "Açık", + "removeEffect": "Efekti Kaldır", + "resetToDefaults": "Varsayılanlara Sıfırla" + }, + "section": { + "addEffect": "Efekt Ekle", + "disableAll": "Tümünü Devre Dışı Bırak", + "emptyState": "Henüz efekt yok", + "enableAll": "Tümünü Etkinleştir", + "noEffectsFound": "Efekt bulunamadı", + "presets": "Ön Ayarlar", + "searchEffects": "Efektlerde ara", + "title": "Efektler" + }, + "curves": { + "channel": "Kanal", + "dragHint": "{{channel}} eğrisini ayarlamak için noktaları sürükleyin", + "resetChannel": "Kanalı Sıfırla", + "channelMaster": "Ana", + "channelRed": "Kırmızı", + "channelGreen": "Yeşil", + "channelBlue": "Mavi" + }, + "wheels": { + "resetWheel": "{{name}} sıfırla", + "shadows": "Gölgeler", + "midtones": "Orta tonlar", + "highlights": "Parlak alanlar" + }, + "definitions": { + "gpu-brightness": { + "name": "Parlaklık" + }, + "gpu-color-wheels": { + "name": "Renk Tekerlekleri" + }, + "gpu-contrast": { + "name": "Kontrast" + }, + "gpu-curves": { + "name": "Eğriler" + }, + "gpu-exposure": { + "name": "Pozlama" + }, + "gpu-grayscale": { + "name": "Gri Tonlama" + }, + "gpu-hue-shift": { + "name": "Ton Kaydırma" + }, + "gpu-invert": { + "name": "Ters Çevir" + }, + "gpu-levels": { + "name": "Seviyeler" + }, + "gpu-saturation": { + "name": "Doygunluk" + }, + "gpu-sepia": { + "name": "Sepya" + }, + "gpu-temperature": { + "name": "Sıcaklık" + }, + "gpu-vibrance": { + "name": "Canlılık" + }, + "gpu-box-blur": { + "name": "Kutu Bulanıklığı" + }, + "gpu-gaussian-blur": { + "name": "Gauss Bulanıklığı" + }, + "gpu-motion-blur": { + "name": "Hareket Bulanıklığı" + }, + "gpu-radial-blur": { + "name": "Radyal Bulanıklık" + }, + "gpu-zoom-blur": { + "name": "Yakınlaştırma Bulanıklığı" + }, + "gpu-bulge": { + "name": "Şişir/Sıkıştır" + }, + "gpu-fluted-glass": { + "name": "Oluklu Cam" + }, + "gpu-kaleidoscope": { + "name": "Kaleydoskop" + }, + "gpu-mirror": { + "name": "Ayna" + }, + "gpu-pixelate": { + "name": "Pikselleştir" + }, + "gpu-rgb-split": { + "name": "RGB Ayırma" + }, + "gpu-twirl": { + "name": "Girdap" + }, + "gpu-wave": { + "name": "Dalga" + }, + "gpu-ascii": { + "name": "ASCII" + }, + "gpu-color-glitch": { + "name": "Renk Bozulması" + }, + "gpu-dither": { + "name": "Titreşimli Tarama" + }, + "gpu-edge-detect": { + "name": "Kenar Algılama" + }, + "gpu-glow": { + "name": "Parlama" + }, + "gpu-grain": { + "name": "Film Greni" + }, + "gpu-halftone": { + "name": "Yarı Ton" + }, + "gpu-posterize": { + "name": "Posterize" + }, + "gpu-scanlines": { + "name": "Tarama Çizgileri" + }, + "gpu-sharpen": { + "name": "Keskinleştir" + }, + "gpu-threshold": { + "name": "Eşik" + }, + "gpu-vignette": { + "name": "Vinyet" + }, + "gpu-chroma-key": { + "name": "Chroma Key" + } + }, + "params": { + "amount": "Miktar", + "angle": "Açı", + "radius": "Yarıçap", + "samples": "Örnekler", + "centerX": "Merkez X", + "centerY": "Merkez Y", + "exposure": "Pozlama (EV)", + "offset": "Ofset", + "gamma": "Gama", + "inputBlack": "Giriş Siyahı", + "inputWhite": "Giriş Beyazı", + "outputBlack": "Çıkış Siyahı", + "outputWhite": "Çıkış Beyazı", + "temperature": "Sıcaklık", + "tint": "Ton", + "saturation": "Doygunluk", + "shift": "Kaydırma", + "horizontal": "Yatay", + "vertical": "Dikey", + "size": "Boyut", + "pattern": "Desen", + "mode": "Mod", + "style": "Stil", + "shape": "Şekil", + "palette": "Palet", + "cellSize": "Hücre Boyutu", + "scale": "Ölçek", + "offsetX": "Ofset X", + "offsetY": "Ofset Y", + "strength": "Güç", + "invert": "Ters Çevir", + "softness": "Yumuşaklık", + "rings": "Halkalar", + "samplesPerRing": "Örnek/Halka", + "density": "Yoğunluk", + "opacity": "Opaklık", + "speed": "Hız", + "level": "Seviye", + "roundness": "Yuvarlaklık", + "keyColor": "Anahtar Renk", + "tolerance": "Tolerans", + "spillSuppression": "Taşma Bastırma", + "charSet": "Karakter Kümesi", + "fontSize": "Yazı Tipi Boyutu", + "letterSpacing": "Harf Aralığı", + "lineHeight": "Satır Yüksekliği", + "matchSourceColor": "Kaynak Rengiyle Eşle", + "textColor": "Metin Rengi", + "bgColor": "Arka Plan", + "colorSaturation": "Doygunluk", + "asciiOpacity": "ASCII Opaklığı", + "originalOpacity": "Özgün Opaklık", + "contrast": "Kontrast", + "colorBack": "Arka Renk", + "colorShadow": "Gölge Rengi", + "colorHighlight": "Vurgu Rengi", + "shadows": "Gölgeler", + "highlights": "Parlak alanlar", + "distortionShape": "Bozulma Şekli", + "distortion": "Bozulma", + "stretch": "Esnetme", + "blur": "Bulanıklık", + "edges": "Kenarlar", + "margin": "Kenar Boşluğu", + "marginLeft": "Sol Boşluk", + "marginRight": "Sağ Boşluk", + "marginTop": "Üst Boşluk", + "marginBottom": "Alt Boşluk", + "grainMixer": "Gren Mikseri", + "grainOverlay": "Gren Kaplaması", + "segments": "Segmentler", + "rotation": "Döndürme", + "amplitudeX": "Yatay Genlik", + "amplitudeY": "Dikey Genlik", + "frequencyX": "Yatay Frekans", + "frequencyY": "Dikey Frekans", + "colorFront": "Ön Renk", + "originalColors": "Özgün Renkler", + "inverted": "Ters Çevrilmiş", + "grid": "Izgara", + "type": "Tür", + "grainSize": "Gren Boyutu" + }, + "options": { + "standard": "Standart", + "simple": "Basit", + "blocks": "Bloklar", + "dots": "Noktalar", + "minimal": "Minimal", + "bayer2": "Bayer 2x2", + "bayer4": "Bayer 4x4", + "bayer8": "Bayer 8x8", + "halftone": "Yarı Ton", + "lines": "Çizgiler", + "crosses": "Çaprazlar", + "grid": "Izgara", + "scales": "Ölçekler", + "image": "Görüntü", + "linear": "Doğrusal", + "radial": "Radyal", + "threshold": "Eşik", + "scaled": "Ölçekli", + "circle": "Daire", + "square": "Kare", + "diamond": "Elmas", + "bw": "Siyah-Beyaz", + "gameboy": "Game Boy", + "cga": "CGA", + "sepia": "Sepya", + "hex": "Altıgen", + "classic": "Klasik", + "gooey": "Yapışkan", + "holes": "Delikler", + "soft": "Yumuşak", + "green": "Yeşil Perde", + "blue": "Mavi Perde", + "linesIrregular": "Düzensiz Çizgiler", + "wave": "Dalga", + "zigzag": "Zikzak", + "prism": "Prizma", + "lens": "Lens", + "contour": "Kontur", + "cascade": "Kademeli", + "flat": "Düz" + } + }, "ja": { "categories": { "color": "色", @@ -2308,214 +2595,5 @@ "cascade": "Cascade", "flat": "Flat" } - }, - "tr": { - "categories": { - "color": "Renk", - "blur": "Bulanıklık", - "distort": "Bozma", - "stylize": "Stilize", - "keying": "Anahtarlama" - }, - "panel": { - "disableEffect": "Efekti Devre Dışı Bırak", - "enableEffect": "Efekti Etkinleştir", - "off": "Kapalı", - "on": "Açık", - "removeEffect": "Efekti Kaldır", - "resetToDefaults": "Varsayılanlara Sıfırla" - }, - "section": { - "addEffect": "Efekt Ekle", - "disableAll": "Tümünü Devre Dışı Bırak", - "emptyState": "Henüz efekt yok", - "enableAll": "Tümünü Etkinleştir", - "noEffectsFound": "Efekt bulunamadı", - "presets": "Ön Ayarlar", - "searchEffects": "Efektlerde ara", - "title": "Efektler" - }, - "curves": { - "channel": "Kanal", - "dragHint": "{{channel}} eğrisini ayarlamak için noktaları sürükleyin", - "resetChannel": "Kanalı Sıfırla", - "channelMaster": "Ana", - "channelRed": "Kırmızı", - "channelGreen": "Yeşil", - "channelBlue": "Mavi" - }, - "wheels": { - "resetWheel": "{{name}} sıfırla", - "shadows": "Gölgeler", - "midtones": "Orta tonlar", - "highlights": "Parlak alanlar" - }, - "definitions": { - "gpu-brightness": { "name": "Parlaklık" }, - "gpu-color-wheels": { "name": "Renk Tekerlekleri" }, - "gpu-contrast": { "name": "Kontrast" }, - "gpu-curves": { "name": "Eğriler" }, - "gpu-exposure": { "name": "Pozlama" }, - "gpu-grayscale": { "name": "Gri Tonlama" }, - "gpu-hue-shift": { "name": "Ton Kaydırma" }, - "gpu-invert": { "name": "Ters Çevir" }, - "gpu-levels": { "name": "Seviyeler" }, - "gpu-saturation": { "name": "Doygunluk" }, - "gpu-sepia": { "name": "Sepya" }, - "gpu-temperature": { "name": "Sıcaklık" }, - "gpu-vibrance": { "name": "Canlılık" }, - "gpu-box-blur": { "name": "Kutu Bulanıklığı" }, - "gpu-gaussian-blur": { "name": "Gauss Bulanıklığı" }, - "gpu-motion-blur": { "name": "Hareket Bulanıklığı" }, - "gpu-radial-blur": { "name": "Radyal Bulanıklık" }, - "gpu-zoom-blur": { "name": "Yakınlaştırma Bulanıklığı" }, - "gpu-bulge": { "name": "Şişir/Sıkıştır" }, - "gpu-fluted-glass": { "name": "Oluklu Cam" }, - "gpu-kaleidoscope": { "name": "Kaleydoskop" }, - "gpu-mirror": { "name": "Ayna" }, - "gpu-pixelate": { "name": "Pikselleştir" }, - "gpu-rgb-split": { "name": "RGB Ayırma" }, - "gpu-twirl": { "name": "Girdap" }, - "gpu-wave": { "name": "Dalga" }, - "gpu-ascii": { "name": "ASCII" }, - "gpu-color-glitch": { "name": "Renk Bozulması" }, - "gpu-dither": { "name": "Titreşimli Tarama" }, - "gpu-edge-detect": { "name": "Kenar Algılama" }, - "gpu-glow": { "name": "Parlama" }, - "gpu-grain": { "name": "Film Greni" }, - "gpu-halftone": { "name": "Yarı Ton" }, - "gpu-posterize": { "name": "Posterize" }, - "gpu-scanlines": { "name": "Tarama Çizgileri" }, - "gpu-sharpen": { "name": "Keskinleştir" }, - "gpu-threshold": { "name": "Eşik" }, - "gpu-vignette": { "name": "Vinyet" }, - "gpu-chroma-key": { "name": "Chroma Key" } - }, - "params": { - "amount": "Miktar", - "angle": "Açı", - "radius": "Yarıçap", - "samples": "Örnekler", - "centerX": "Merkez X", - "centerY": "Merkez Y", - "exposure": "Pozlama (EV)", - "offset": "Ofset", - "gamma": "Gama", - "inputBlack": "Giriş Siyahı", - "inputWhite": "Giriş Beyazı", - "outputBlack": "Çıkış Siyahı", - "outputWhite": "Çıkış Beyazı", - "temperature": "Sıcaklık", - "tint": "Ton", - "saturation": "Doygunluk", - "shift": "Kaydırma", - "horizontal": "Yatay", - "vertical": "Dikey", - "size": "Boyut", - "pattern": "Desen", - "mode": "Mod", - "style": "Stil", - "shape": "Şekil", - "palette": "Palet", - "cellSize": "Hücre Boyutu", - "scale": "Ölçek", - "offsetX": "Ofset X", - "offsetY": "Ofset Y", - "strength": "Güç", - "invert": "Ters Çevir", - "softness": "Yumuşaklık", - "rings": "Halkalar", - "samplesPerRing": "Örnek/Halka", - "density": "Yoğunluk", - "opacity": "Opaklık", - "speed": "Hız", - "level": "Seviye", - "roundness": "Yuvarlaklık", - "keyColor": "Anahtar Renk", - "tolerance": "Tolerans", - "spillSuppression": "Taşma Bastırma", - "charSet": "Karakter Kümesi", - "fontSize": "Yazı Tipi Boyutu", - "letterSpacing": "Harf Aralığı", - "lineHeight": "Satır Yüksekliği", - "matchSourceColor": "Kaynak Rengiyle Eşle", - "textColor": "Metin Rengi", - "bgColor": "Arka Plan", - "colorSaturation": "Doygunluk", - "asciiOpacity": "ASCII Opaklığı", - "originalOpacity": "Özgün Opaklık", - "contrast": "Kontrast", - "colorBack": "Arka Renk", - "colorShadow": "Gölge Rengi", - "colorHighlight": "Vurgu Rengi", - "shadows": "Gölgeler", - "highlights": "Parlak alanlar", - "distortionShape": "Bozulma Şekli", - "distortion": "Bozulma", - "stretch": "Esnetme", - "blur": "Bulanıklık", - "edges": "Kenarlar", - "margin": "Kenar Boşluğu", - "marginLeft": "Sol Boşluk", - "marginRight": "Sağ Boşluk", - "marginTop": "Üst Boşluk", - "marginBottom": "Alt Boşluk", - "grainMixer": "Gren Mikseri", - "grainOverlay": "Gren Kaplaması", - "segments": "Segmentler", - "rotation": "Döndürme", - "amplitudeX": "Yatay Genlik", - "amplitudeY": "Dikey Genlik", - "frequencyX": "Yatay Frekans", - "frequencyY": "Dikey Frekans", - "colorFront": "Ön Renk", - "originalColors": "Özgün Renkler", - "inverted": "Ters Çevrilmiş", - "grid": "Izgara", - "type": "Tür", - "grainSize": "Gren Boyutu" - }, - "options": { - "standard": "Standart", - "simple": "Basit", - "blocks": "Bloklar", - "dots": "Noktalar", - "minimal": "Minimal", - "bayer2": "Bayer 2x2", - "bayer4": "Bayer 4x4", - "bayer8": "Bayer 8x8", - "halftone": "Yarı Ton", - "lines": "Çizgiler", - "crosses": "Çaprazlar", - "grid": "Izgara", - "scales": "Ölçekler", - "image": "Görüntü", - "linear": "Doğrusal", - "radial": "Radyal", - "threshold": "Eşik", - "scaled": "Ölçekli", - "circle": "Daire", - "square": "Kare", - "diamond": "Elmas", - "bw": "Siyah-Beyaz", - "gameboy": "Game Boy", - "cga": "CGA", - "sepia": "Sepya", - "hex": "Altıgen", - "classic": "Klasik", - "gooey": "Yapışkan", - "holes": "Delikler", - "soft": "Yumuşak", - "green": "Yeşil Perde", - "blue": "Mavi Perde", - "linesIrregular": "Düzensiz Çizgiler", - "wave": "Dalga", - "zigzag": "Zikzak", - "prism": "Prizma", - "lens": "Lens", - "contour": "Kontur", - "cascade": "Kademeli", - "flat": "Düz" - } } } diff --git a/src/i18n/locales/partials/export.json b/src/i18n/locales/partials/export.json new file mode 100644 index 000000000..2c3837fbe --- /dev/null +++ b/src/i18n/locales/partials/export.json @@ -0,0 +1,866 @@ +{ + "en": { + "export": { + "audioContainer": { + "aac": "AAC", + "mp3": "MP3", + "wav": "WAV" + }, + "cancelled": { + "message": "The export was cancelled. No file was saved." + }, + "complete": { + "audioSuccess": "Your audio is ready to download.", + "download": "Download", + "fileSizeLabel": "Size", + "timeTakenLabel": "Time", + "videoSuccess": "Your video is ready to download." + }, + "dialog": { + "descCancelled": "The export was cancelled before it finished.", + "descComplete": "Your file is ready.", + "descError": "Something went wrong while exporting.", + "descProgress": "Rendering your video. This can take a few minutes.", + "descSettings": "Configure your export options.", + "titleCancelled": "Export cancelled", + "titleComplete": "Export complete", + "titleError": "Export failed", + "titleProgress": "Exporting video", + "titleSettings": "Export" + }, + "errors": { + "verifyCodec": "Couldn't verify codec support for the current settings." + }, + "progress": { + "cancelExport": "Cancel", + "elapsedLabel": "Elapsed", + "encoding": "Encoding…", + "finalizing": "Finalizing…", + "framesLabel": "Frames", + "keepTabOpen": "Keep this tab open until the export finishes.", + "preparing": "Preparing…", + "rendering": "Rendering…" + }, + "settings": { + "audio": "Audio", + "audioOnlyNote": "Audio-only export — no video will be included.", + "audioQualityHigh": "High", + "audioQualityLow": "Low", + "audioQualityMedium": "Medium", + "audioQualityUltra": "Ultra", + "cannotEncode": "No supported encoder for {{width}}×{{height}} at the selected quality.", + "codec": "Codec", + "codecSupportUnverified": "Couldn't verify codec support. Exporting may still work, but compatibility isn't guaranteed.", + "duration": "Duration", + "embedSubtitles": "Embed subtitles", + "embedSubtitlesDescription": "Include transcript captions as a subtitle track in the exported file.", + "embedSubtitlesMp4Note": "MP4 stores subtitles as a separate track. Some players may not show them by default.", + "embedSubtitlesUnsupported": "{{container}} doesn't support embedded subtitles. Choose MP4, MKV, or WebM.", + "exportAudio": "Export Audio", + "exportRange": "Export range", + "exportType": "Export type", + "exportVideo": "Export Video", + "format": "Format", + "in": "In", + "inOutRangeHint": "Only the selected in/out range will be exported.", + "noTranscriptSegments": "No transcript captions available to embed.", + "out": "Out", + "presetBalanced": "Balanced", + "presetCustom": "Custom", + "presetLabel": "Preset", + "presetMax": "Maximum", + "presetRecommended": "Recommended", + "presetSmall": "Small file", + "quality": "Quality", + "qualityHigh": "High", + "qualityLow": "Low", + "qualityMedium": "Medium", + "qualityUltra": "Ultra", + "quicktimeMov": "QuickTime MOV", + "renderWholeProject": "Render whole project", + "resolution": "Resolution", + "resolutionSameAsProject": "Same as project ({{width}}×{{height}})", + "resolutionScaled": "{{p}}p ({{width}}×{{height}})", + "selectCodec": "Select codec", + "selectFormat": "Select format", + "selectQuality": "Select quality", + "selectResolution": "Select resolution", + "video": "Video" + }, + "videoContainer": { + "mp4": "Widely supported, great for sharing", + "mov": "Apple ecosystem, best in Final Cut and QuickTime", + "webm": "Open, modern container for web playback", + "mkv": "Flexible container with strong subtitle support" + } + } + }, + "es": { + "export": { + "audioContainer": { + "aac": "AAC", + "mp3": "MP3", + "wav": "WAV" + }, + "cancelled": { + "message": "La exportación se canceló. No se guardó ningún archivo." + }, + "complete": { + "audioSuccess": "Tu audio está listo para descargar.", + "download": "Descargar", + "fileSizeLabel": "Tamaño", + "timeTakenLabel": "Tiempo", + "videoSuccess": "Tu vídeo está listo para descargar." + }, + "dialog": { + "descCancelled": "La exportación se canceló antes de terminar.", + "descComplete": "Tu archivo está listo.", + "descError": "Algo salió mal durante la exportación.", + "descProgress": "Estamos renderizando tu vídeo. Esto puede tardar unos minutos.", + "descSettings": "Configura las opciones de exportación.", + "titleCancelled": "Exportación cancelada", + "titleComplete": "Exportación completada", + "titleError": "Error al exportar", + "titleProgress": "Exportando vídeo", + "titleSettings": "Exportar" + }, + "errors": { + "verifyCodec": "No se pudo verificar la compatibilidad del códec con la configuración actual." + }, + "progress": { + "cancelExport": "Cancelar", + "elapsedLabel": "Transcurrido", + "encoding": "Codificando…", + "finalizing": "Finalizando…", + "framesLabel": "Fotogramas", + "keepTabOpen": "Mantén esta pestaña abierta hasta que finalice la exportación.", + "preparing": "Preparando…", + "rendering": "Renderizando…" + }, + "settings": { + "audio": "Audio", + "audioOnlyNote": "Exportación solo de audio: no se incluirá vídeo.", + "audioQualityHigh": "Alta", + "audioQualityLow": "Baja", + "audioQualityMedium": "Media", + "audioQualityUltra": "Ultra", + "cannotEncode": "No hay un codificador compatible para {{width}}×{{height}} con la calidad seleccionada.", + "codec": "Códec", + "codecSupportUnverified": "No se pudo verificar la compatibilidad del códec. La exportación puede funcionar, pero no se garantiza.", + "duration": "Duración", + "embedSubtitles": "Incrustar subtítulos", + "embedSubtitlesDescription": "Incluye los subtítulos de la transcripción como una pista en el archivo exportado.", + "embedSubtitlesMp4Note": "MP4 guarda los subtítulos como una pista aparte. Algunos reproductores pueden no mostrarlos por defecto.", + "embedSubtitlesUnsupported": "{{container}} no admite subtítulos incrustados. Elige MP4, MKV o WebM.", + "exportAudio": "Exportar audio", + "exportRange": "Rango de exportación", + "exportType": "Tipo de exportación", + "exportVideo": "Exportar vídeo", + "format": "Formato", + "in": "Entrada", + "inOutRangeHint": "Solo se exportará el rango de entrada/salida seleccionado.", + "noTranscriptSegments": "No hay subtítulos de transcripción disponibles para incrustar.", + "out": "Salida", + "presetBalanced": "Equilibrado", + "presetCustom": "Personalizado", + "presetLabel": "Preajuste", + "presetMax": "Máxima", + "presetRecommended": "Recomendado", + "presetSmall": "Archivo pequeño", + "quality": "Calidad", + "qualityHigh": "Alta", + "qualityLow": "Baja", + "qualityMedium": "Media", + "qualityUltra": "Ultra", + "quicktimeMov": "QuickTime MOV", + "renderWholeProject": "Renderizar todo el proyecto", + "resolution": "Resolución", + "resolutionSameAsProject": "Igual que el proyecto ({{width}}×{{height}})", + "resolutionScaled": "{{p}}p ({{width}}×{{height}})", + "selectCodec": "Selecciona un códec", + "selectFormat": "Selecciona un formato", + "selectQuality": "Selecciona una calidad", + "selectResolution": "Selecciona una resolución", + "video": "Vídeo" + }, + "videoContainer": { + "mp4": "Compatible con casi cualquier reproductor", + "mov": "Ecosistema Apple, ideal para Final Cut y QuickTime", + "webm": "Contenedor abierto y moderno para web", + "mkv": "Contenedor flexible con buen soporte de subtítulos" + } + } + }, + "fr": { + "export": { + "audioContainer": { + "aac": "AAC", + "mp3": "MP3", + "wav": "WAV" + }, + "cancelled": { + "message": "L'exportation a été annulée. Aucun fichier n'a été enregistré." + }, + "complete": { + "audioSuccess": "Votre audio est prêt à être téléchargé.", + "download": "Télécharger", + "fileSizeLabel": "Taille", + "timeTakenLabel": "Durée", + "videoSuccess": "Votre vidéo est prête à être téléchargée." + }, + "dialog": { + "descCancelled": "L'exportation a été annulée avant d'être terminée.", + "descComplete": "Votre fichier est prêt.", + "descError": "Une erreur est survenue lors de l'exportation.", + "descProgress": "Rendu de votre vidéo en cours. Cela peut prendre quelques minutes.", + "descSettings": "Configurez les options d'exportation.", + "titleCancelled": "Exportation annulée", + "titleComplete": "Exportation terminée", + "titleError": "Échec de l'exportation", + "titleProgress": "Exportation de la vidéo", + "titleSettings": "Exporter" + }, + "errors": { + "verifyCodec": "Impossible de vérifier la prise en charge du codec pour ces paramètres." + }, + "progress": { + "cancelExport": "Annuler", + "elapsedLabel": "Écoulé", + "encoding": "Encodage…", + "finalizing": "Finalisation…", + "framesLabel": "Images", + "keepTabOpen": "Gardez cet onglet ouvert jusqu'à la fin de l'exportation.", + "preparing": "Préparation…", + "rendering": "Rendu en cours…" + }, + "settings": { + "audio": "Audio", + "audioOnlyNote": "Exportation audio uniquement — aucune vidéo ne sera incluse.", + "audioQualityHigh": "Haute", + "audioQualityLow": "Basse", + "audioQualityMedium": "Moyenne", + "audioQualityUltra": "Ultra", + "cannotEncode": "Aucun encodeur compatible pour {{width}}×{{height}} à la qualité choisie.", + "codec": "Codec", + "codecSupportUnverified": "Impossible de vérifier la prise en charge du codec. L'exportation peut fonctionner, mais sans garantie.", + "duration": "Durée", + "embedSubtitles": "Intégrer les sous-titres", + "embedSubtitlesDescription": "Incluez les sous-titres de la transcription en tant que piste dans le fichier exporté.", + "embedSubtitlesMp4Note": "MP4 enregistre les sous-titres dans une piste distincte. Certains lecteurs ne les affichent pas par défaut.", + "embedSubtitlesUnsupported": "{{container}} ne prend pas en charge les sous-titres intégrés. Choisissez MP4, MKV ou WebM.", + "exportAudio": "Exporter l'audio", + "exportRange": "Plage d'exportation", + "exportType": "Type d'exportation", + "exportVideo": "Exporter la vidéo", + "format": "Format", + "in": "Entrée", + "inOutRangeHint": "Seule la plage entrée/sortie sélectionnée sera exportée.", + "noTranscriptSegments": "Aucun sous-titre de transcription à intégrer.", + "out": "Sortie", + "presetBalanced": "Équilibré", + "presetCustom": "Personnalisé", + "presetLabel": "Préréglage", + "presetMax": "Maximale", + "presetRecommended": "Recommandé", + "presetSmall": "Fichier léger", + "quality": "Qualité", + "qualityHigh": "Haute", + "qualityLow": "Basse", + "qualityMedium": "Moyenne", + "qualityUltra": "Ultra", + "quicktimeMov": "QuickTime MOV", + "renderWholeProject": "Rendre le projet entier", + "resolution": "Résolution", + "resolutionSameAsProject": "Identique au projet ({{width}}×{{height}})", + "resolutionScaled": "{{p}}p ({{width}}×{{height}})", + "selectCodec": "Sélectionner un codec", + "selectFormat": "Sélectionner un format", + "selectQuality": "Sélectionner une qualité", + "selectResolution": "Sélectionner une résolution", + "video": "Vidéo" + }, + "videoContainer": { + "mp4": "Très large compatibilité, idéal pour le partage", + "mov": "Écosystème Apple, idéal pour Final Cut et QuickTime", + "webm": "Conteneur ouvert et moderne pour le web", + "mkv": "Conteneur flexible avec prise en charge avancée des sous-titres" + } + } + }, + "de": { + "export": { + "audioContainer": { + "aac": "AAC", + "mp3": "MP3", + "wav": "WAV" + }, + "cancelled": { + "message": "Der Export wurde abgebrochen. Es wurde keine Datei gespeichert." + }, + "complete": { + "audioSuccess": "Dein Audio kann heruntergeladen werden.", + "download": "Herunterladen", + "fileSizeLabel": "Größe", + "timeTakenLabel": "Dauer", + "videoSuccess": "Dein Video kann heruntergeladen werden." + }, + "dialog": { + "descCancelled": "Der Export wurde vor dem Abschluss abgebrochen.", + "descComplete": "Deine Datei ist bereit.", + "descError": "Beim Export ist etwas schiefgegangen.", + "descProgress": "Dein Video wird gerendert. Das kann einige Minuten dauern.", + "descSettings": "Konfiguriere die Exportoptionen.", + "titleCancelled": "Export abgebrochen", + "titleComplete": "Export abgeschlossen", + "titleError": "Export fehlgeschlagen", + "titleProgress": "Video wird exportiert", + "titleSettings": "Exportieren" + }, + "errors": { + "verifyCodec": "Codec-Unterstützung konnte für diese Einstellungen nicht überprüft werden." + }, + "progress": { + "cancelExport": "Abbrechen", + "elapsedLabel": "Verstrichen", + "encoding": "Codieren…", + "finalizing": "Wird abgeschlossen…", + "framesLabel": "Bilder", + "keepTabOpen": "Lass diesen Tab geöffnet, bis der Export abgeschlossen ist.", + "preparing": "Vorbereiten…", + "rendering": "Rendern…" + }, + "settings": { + "audio": "Audio", + "audioOnlyNote": "Reiner Audio-Export — kein Video wird einbezogen.", + "audioQualityHigh": "Hoch", + "audioQualityLow": "Niedrig", + "audioQualityMedium": "Mittel", + "audioQualityUltra": "Ultra", + "cannotEncode": "Kein unterstützter Encoder für {{width}}×{{height}} in der gewählten Qualität.", + "codec": "Codec", + "codecSupportUnverified": "Codec-Unterstützung konnte nicht überprüft werden. Der Export kann trotzdem funktionieren, ist aber nicht garantiert.", + "duration": "Dauer", + "embedSubtitles": "Untertitel einbetten", + "embedSubtitlesDescription": "Untertitel aus dem Transkript als Spur in die exportierte Datei einfügen.", + "embedSubtitlesMp4Note": "MP4 speichert Untertitel als separate Spur. Manche Player zeigen sie nicht standardmäßig an.", + "embedSubtitlesUnsupported": "{{container}} unterstützt keine eingebetteten Untertitel. Wähle MP4, MKV oder WebM.", + "exportAudio": "Audio exportieren", + "exportRange": "Exportbereich", + "exportType": "Exporttyp", + "exportVideo": "Video exportieren", + "format": "Format", + "in": "Anfang", + "inOutRangeHint": "Nur der ausgewählte In/Out-Bereich wird exportiert.", + "noTranscriptSegments": "Keine Transkript-Untertitel zum Einbetten vorhanden.", + "out": "Ende", + "presetBalanced": "Ausgewogen", + "presetCustom": "Benutzerdefiniert", + "presetLabel": "Voreinstellung", + "presetMax": "Maximal", + "presetRecommended": "Empfohlen", + "presetSmall": "Kleine Datei", + "quality": "Qualität", + "qualityHigh": "Hoch", + "qualityLow": "Niedrig", + "qualityMedium": "Mittel", + "qualityUltra": "Ultra", + "quicktimeMov": "QuickTime MOV", + "renderWholeProject": "Gesamtes Projekt rendern", + "resolution": "Auflösung", + "resolutionSameAsProject": "Wie Projekt ({{width}}×{{height}})", + "resolutionScaled": "{{p}}p ({{width}}×{{height}})", + "selectCodec": "Codec auswählen", + "selectFormat": "Format auswählen", + "selectQuality": "Qualität auswählen", + "selectResolution": "Auflösung auswählen", + "video": "Video" + }, + "videoContainer": { + "mp4": "Breite Unterstützung, ideal zum Teilen", + "mov": "Apple-Ökosystem, ideal für Final Cut und QuickTime", + "webm": "Offener, moderner Container für die Webwiedergabe", + "mkv": "Flexibler Container mit starker Untertitelunterstützung" + } + } + }, + "pt-BR": { + "export": { + "audioContainer": { + "aac": "AAC", + "mp3": "MP3", + "wav": "WAV" + }, + "cancelled": { + "message": "A exportação foi cancelada. Nenhum arquivo foi salvo." + }, + "complete": { + "audioSuccess": "Seu áudio está pronto para download.", + "download": "Baixar", + "fileSizeLabel": "Tamanho", + "timeTakenLabel": "Tempo", + "videoSuccess": "Seu vídeo está pronto para download." + }, + "dialog": { + "descCancelled": "A exportação foi cancelada antes de terminar.", + "descComplete": "Seu arquivo está pronto.", + "descError": "Algo deu errado durante a exportação.", + "descProgress": "Exportacao em andamento.", + "descSettings": "Configure as opções de exportação.", + "titleCancelled": "Exportação cancelada", + "titleComplete": "Exportação concluída", + "titleError": "Falha na exportação", + "titleProgress": "Exportando", + "titleSettings": "Exportar" + }, + "errors": { + "verifyCodec": "Não foi possível verificar a compatibilidade do codec para estas configurações." + }, + "progress": { + "cancelExport": "Cancelar exportação", + "elapsedLabel": "Decorrido", + "encoding": "Codificando…", + "finalizing": "Finalizando…", + "framesLabel": "Quadros", + "keepTabOpen": "Mantenha esta aba aberta até o término da exportação.", + "preparing": "Preparando…", + "rendering": "Renderizando…" + }, + "settings": { + "audio": "Áudio", + "audioOnlyNote": "Exportação somente de áudio — nenhum vídeo será incluído.", + "audioQualityHigh": "Alta", + "audioQualityLow": "Baixa", + "audioQualityMedium": "Média", + "audioQualityUltra": "Ultra", + "cannotEncode": "Nenhum codificador compatível para {{width}}×{{height}} com a qualidade selecionada.", + "codec": "Codec", + "codecSupportUnverified": "Não foi possível verificar o suporte ao codec. A exportação pode funcionar, mas sem garantia.", + "duration": "Duração", + "embedSubtitles": "Incorporar legendas", + "embedSubtitlesDescription": "Inclui legendas da transcrição como uma faixa no arquivo exportado.", + "embedSubtitlesMp4Note": "MP4 armazena legendas em uma faixa separada. Alguns reprodutores podem não mostrá-las por padrão.", + "embedSubtitlesUnsupported": "{{container}} não suporta legendas incorporadas. Escolha MP4, MKV ou WebM.", + "exportAudio": "Exportar áudio", + "exportRange": "Intervalo de exportação", + "exportType": "Tipo de exportação", + "exportVideo": "Exportar vídeo", + "format": "Formato", + "in": "Entrada", + "inOutRangeHint": "Apenas o intervalo de entrada/saída selecionado será exportado.", + "noTranscriptSegments": "Nenhuma legenda de transcrição disponível para incorporar.", + "out": "Saída", + "presetBalanced": "Equilibrado", + "presetCustom": "Personalizado", + "presetLabel": "Predefinição", + "presetMax": "Máxima", + "presetRecommended": "Recomendado", + "presetSmall": "Arquivo pequeno", + "quality": "Qualidade", + "qualityHigh": "Alta", + "qualityLow": "Baixa", + "qualityMedium": "Média", + "qualityUltra": "Ultra", + "quicktimeMov": "QuickTime MOV", + "renderWholeProject": "Renderizar o projeto inteiro", + "resolution": "Resolução", + "resolutionSameAsProject": "Igual ao projeto ({{width}}×{{height}})", + "resolutionScaled": "{{p}}p ({{width}}×{{height}})", + "selectCodec": "Selecione um codec", + "selectFormat": "Selecione um formato", + "selectQuality": "Selecione uma qualidade", + "selectResolution": "Selecione uma resolução", + "video": "Vídeo" + }, + "videoContainer": { + "mp4": "Amplo suporte, ideal para compartilhar", + "mov": "Ecossistema Apple, ideal para Final Cut e QuickTime", + "webm": "Contêiner aberto e moderno para reprodução na web", + "mkv": "Contêiner flexível com ótimo suporte a legendas" + } + } + }, + "tr": { + "export": { + "audioContainer": { + "aac": "AAC", + "mp3": "MP3", + "wav": "WAV" + }, + "cancelled": { + "message": "Dışa aktarma iptal edildi. Dosya kaydedilmedi." + }, + "complete": { + "audioSuccess": "Sesiniz indirilmeye hazır.", + "download": "İndir", + "fileSizeLabel": "Boyut", + "timeTakenLabel": "Süre", + "videoSuccess": "Videonuz indirilmeye hazır." + }, + "dialog": { + "descCancelled": "Dışa aktarma tamamlanmadan iptal edildi.", + "descComplete": "Dosyanız hazır.", + "descError": "Dışa aktarma sırasında bir sorun oluştu.", + "descProgress": "Videonuz işleniyor.", + "descSettings": "Dışa aktarma seçeneklerini yapılandırın.", + "titleCancelled": "Dışa aktarma iptal edildi", + "titleComplete": "Dışa aktarma tamamlandı", + "titleError": "Dışa aktarma başarısız", + "titleProgress": "Dışa aktarılıyor", + "titleSettings": "Dışa aktar" + }, + "errors": { + "verifyCodec": "Mevcut ayarlar için codec desteği doğrulanamadı." + }, + "progress": { + "cancelExport": "Dışa aktarmayı iptal et", + "elapsedLabel": "Geçen süre", + "encoding": "Kodlanıyor…", + "finalizing": "Tamamlanıyor…", + "framesLabel": "Kareler", + "keepTabOpen": "Lütfen bu sekmeyi açık tutun.", + "preparing": "Hazırlanıyor…", + "rendering": "İşleniyor…" + }, + "settings": { + "audio": "Ses", + "audioOnlyNote": "Yalnızca ses dışa aktarılacak — video dahil edilmeyecek.", + "audioQualityHigh": "Yüksek", + "audioQualityLow": "Düşük", + "audioQualityMedium": "Orta", + "audioQualityUltra": "Ultra", + "cannotEncode": "Seçilen kalitede {{width}}×{{height}} için desteklenen bir kodlayıcı yok.", + "codec": "Codec", + "codecSupportUnverified": "Codec desteği doğrulanamadı. Dışa aktarma çalışabilir ancak uyumluluk garanti edilmez.", + "duration": "Süre", + "embedSubtitles": "Altyazıları göm", + "embedSubtitlesDescription": "Transkripsiyon altyazılarını dışa aktarılan dosyaya bir altyazı izi olarak ekle.", + "embedSubtitlesMp4Note": "MP4 altyazıları ayrı bir iz olarak saklar. Bazı oynatıcılar varsayılan olarak göstermeyebilir.", + "embedSubtitlesUnsupported": "{{container}} gömülü altyazıları desteklemiyor. MP4, MKV veya WebM seçin.", + "exportAudio": "Sesi dışa aktar", + "exportRange": "Dışa aktarma aralığı", + "exportType": "Dışa aktarma türü", + "exportVideo": "Videoyu dışa aktar", + "format": "Biçim", + "in": "Giriş", + "inOutRangeHint": "Yalnızca seçili giriş/çıkış aralığı dışa aktarılacak.", + "noTranscriptSegments": "Gömülecek transkripsiyon altyazısı bulunamadı.", + "out": "Çıkış", + "presetBalanced": "Dengeli", + "presetCustom": "Özel", + "presetLabel": "Hazır ayar", + "presetMax": "En yüksek", + "presetRecommended": "Önerilen", + "presetSmall": "Küçük dosya", + "quality": "Kalite", + "qualityHigh": "Yüksek", + "qualityLow": "Düşük", + "qualityMedium": "Orta", + "qualityUltra": "Ultra", + "quicktimeMov": "QuickTime MOV", + "renderWholeProject": "Projenin tamamını işle", + "resolution": "Çözünürlük", + "resolutionSameAsProject": "Proje ile aynı ({{width}}×{{height}})", + "resolutionScaled": "{{p}}p ({{width}}×{{height}})", + "selectCodec": "Codec seçin", + "selectFormat": "Biçim seçin", + "selectQuality": "Kalite seçin", + "selectResolution": "Çözünürlük seçin", + "video": "Video" + }, + "videoContainer": { + "mp4": "Geniş uyumluluk, paylaşmak için ideal", + "mov": "Apple ekosistemi, Final Cut ve QuickTime için ideal", + "webm": "Web oynatma için açık ve modern bir kapsayıcı", + "mkv": "Esnek bir kapsayıcı, güçlü altyazı desteği" + } + } + }, + "ja": { + "export": { + "audioContainer": { + "aac": "AAC", + "mp3": "MP3", + "wav": "WAV" + }, + "cancelled": { + "message": "書き出しはキャンセルされました。ファイルは保存されていません。" + }, + "complete": { + "audioSuccess": "オーディオのダウンロード準備が整いました。", + "download": "ダウンロード", + "fileSizeLabel": "サイズ", + "timeTakenLabel": "経過時間", + "videoSuccess": "動画のダウンロード準備が整いました。" + }, + "dialog": { + "descCancelled": "書き出しが完了する前にキャンセルされました。", + "descComplete": "ファイルの準備ができました。", + "descError": "書き出し中に問題が発生しました。", + "descProgress": "動画をレンダリングしています。数分かかる場合があります。", + "descSettings": "書き出しオプションを設定します。", + "titleCancelled": "書き出しをキャンセルしました", + "titleComplete": "書き出しが完了しました", + "titleError": "書き出しに失敗しました", + "titleProgress": "動画を書き出し中", + "titleSettings": "書き出し" + }, + "errors": { + "verifyCodec": "現在の設定でコーデックのサポート状況を確認できませんでした。" + }, + "progress": { + "cancelExport": "キャンセル", + "elapsedLabel": "経過時間", + "encoding": "エンコード中…", + "finalizing": "仕上げ中…", + "framesLabel": "フレーム", + "keepTabOpen": "書き出しが完了するまでこのタブを開いたままにしてください。", + "preparing": "準備中…", + "rendering": "レンダリング中…" + }, + "settings": { + "audio": "オーディオ", + "audioOnlyNote": "オーディオのみの書き出しです。動画は含まれません。", + "audioQualityHigh": "高", + "audioQualityLow": "低", + "audioQualityMedium": "中", + "audioQualityUltra": "ウルトラ", + "cannotEncode": "選択した品質で {{width}}×{{height}} に対応するエンコーダーがありません。", + "codec": "コーデック", + "codecSupportUnverified": "コーデックのサポート状況を確認できませんでした。書き出しは可能かもしれませんが、互換性は保証されません。", + "duration": "長さ", + "embedSubtitles": "字幕を埋め込む", + "embedSubtitlesDescription": "文字起こしの字幕を書き出しファイルの字幕トラックとして埋め込みます。", + "embedSubtitlesMp4Note": "MP4 は字幕を別トラックとして保存します。プレーヤーによっては既定で表示されない場合があります。", + "embedSubtitlesUnsupported": "{{container}} は字幕の埋め込みに対応していません。MP4、MKV、または WebM を選択してください。", + "exportAudio": "オーディオを書き出す", + "exportRange": "書き出し範囲", + "exportType": "書き出しタイプ", + "exportVideo": "動画を書き出す", + "format": "形式", + "in": "イン", + "inOutRangeHint": "選択したイン/アウト範囲のみが書き出されます。", + "noTranscriptSegments": "埋め込み可能な文字起こし字幕がありません。", + "out": "アウト", + "presetBalanced": "バランス", + "presetCustom": "カスタム", + "presetLabel": "プリセット", + "presetMax": "最高品質", + "presetRecommended": "推奨", + "presetSmall": "小サイズ", + "quality": "品質", + "qualityHigh": "高", + "qualityLow": "低", + "qualityMedium": "中", + "qualityUltra": "ウルトラ", + "quicktimeMov": "QuickTime MOV", + "renderWholeProject": "プロジェクト全体を書き出す", + "resolution": "解像度", + "resolutionSameAsProject": "プロジェクトと同じ ({{width}}×{{height}})", + "resolutionScaled": "{{p}}p ({{width}}×{{height}})", + "selectCodec": "コーデックを選択", + "selectFormat": "形式を選択", + "selectQuality": "品質を選択", + "selectResolution": "解像度を選択", + "video": "動画" + }, + "videoContainer": { + "mp4": "対応プレーヤーが多く、共有に最適", + "mov": "Apple 環境向け、Final Cut や QuickTime に最適", + "webm": "ウェブ再生向けのオープンで先進的なコンテナ", + "mkv": "字幕サポートが優れた柔軟なコンテナ" + } + } + }, + "ko": { + "export": { + "audioContainer": { + "aac": "AAC", + "mp3": "MP3", + "wav": "WAV" + }, + "cancelled": { + "message": "내보내기가 취소되었습니다. 파일이 저장되지 않았습니다." + }, + "complete": { + "audioSuccess": "오디오를 다운로드할 준비가 되었습니다.", + "download": "다운로드", + "fileSizeLabel": "크기", + "timeTakenLabel": "소요 시간", + "videoSuccess": "동영상을 다운로드할 준비가 되었습니다." + }, + "dialog": { + "descCancelled": "내보내기가 완료되기 전에 취소되었습니다.", + "descComplete": "파일이 준비되었습니다.", + "descError": "내보내는 중 문제가 발생했습니다.", + "descProgress": "동영상을 렌더링하는 중입니다. 몇 분 정도 걸릴 수 있습니다.", + "descSettings": "내보내기 옵션을 구성합니다.", + "titleCancelled": "내보내기가 취소됨", + "titleComplete": "내보내기 완료", + "titleError": "내보내기 실패", + "titleProgress": "동영상 내보내는 중", + "titleSettings": "내보내기" + }, + "errors": { + "verifyCodec": "현재 설정에 대한 코덱 지원 여부를 확인할 수 없습니다." + }, + "progress": { + "cancelExport": "취소", + "elapsedLabel": "경과 시간", + "encoding": "인코딩 중…", + "finalizing": "마무리하는 중…", + "framesLabel": "프레임", + "keepTabOpen": "내보내기가 완료될 때까지 이 탭을 열어 두세요.", + "preparing": "준비 중…", + "rendering": "렌더링 중…" + }, + "settings": { + "audio": "오디오", + "audioOnlyNote": "오디오만 내보내며 동영상은 포함되지 않습니다.", + "audioQualityHigh": "높음", + "audioQualityLow": "낮음", + "audioQualityMedium": "보통", + "audioQualityUltra": "최고", + "cannotEncode": "선택한 품질의 {{width}}×{{height}} 인코더를 사용할 수 없습니다.", + "codec": "코덱", + "codecSupportUnverified": "코덱 지원 여부를 확인할 수 없습니다. 내보내기는 가능할 수 있지만 호환성은 보장되지 않습니다.", + "duration": "길이", + "embedSubtitles": "자막 포함", + "embedSubtitlesDescription": "전사 자막을 내보낸 파일에 자막 트랙으로 포함합니다.", + "embedSubtitlesMp4Note": "MP4는 자막을 별도 트랙으로 저장합니다. 일부 플레이어에서는 기본적으로 표시되지 않을 수 있습니다.", + "embedSubtitlesUnsupported": "{{container}}는 자막 포함을 지원하지 않습니다. MP4, MKV 또는 WebM을 선택하세요.", + "exportAudio": "오디오 내보내기", + "exportRange": "내보내기 범위", + "exportType": "내보내기 형식", + "exportVideo": "동영상 내보내기", + "format": "형식", + "in": "시작", + "inOutRangeHint": "선택한 시작/종료 구간만 내보내집니다.", + "noTranscriptSegments": "포함할 전사 자막이 없습니다.", + "out": "끝", + "presetBalanced": "균형", + "presetCustom": "사용자 지정", + "presetLabel": "사전 설정", + "presetMax": "최고 품질", + "presetRecommended": "권장", + "presetSmall": "작은 파일", + "quality": "품질", + "qualityHigh": "높음", + "qualityLow": "낮음", + "qualityMedium": "보통", + "qualityUltra": "최고", + "quicktimeMov": "QuickTime MOV", + "renderWholeProject": "프로젝트 전체 렌더링", + "resolution": "해상도", + "resolutionSameAsProject": "프로젝트와 동일 ({{width}}×{{height}})", + "resolutionScaled": "{{p}}p ({{width}}×{{height}})", + "selectCodec": "코덱 선택", + "selectFormat": "형식 선택", + "selectQuality": "품질 선택", + "selectResolution": "해상도 선택", + "video": "동영상" + }, + "videoContainer": { + "mp4": "광범위한 지원, 공유에 적합", + "mov": "Apple 생태계, Final Cut 및 QuickTime에 적합", + "webm": "웹 재생을 위한 개방형 최신 컨테이너", + "mkv": "자막 지원이 강력한 유연한 컨테이너" + } + } + }, + "zh": { + "export": { + "audioContainer": { + "aac": "AAC", + "mp3": "MP3", + "wav": "WAV" + }, + "cancelled": { + "message": "导出已取消,未保存任何文件。" + }, + "complete": { + "audioSuccess": "音频已准备好下载。", + "download": "下载", + "fileSizeLabel": "大小", + "timeTakenLabel": "用时", + "videoSuccess": "视频已准备好下载。" + }, + "dialog": { + "descCancelled": "导出在完成前已被取消。", + "descComplete": "您的文件已就绪。", + "descError": "导出过程中发生错误。", + "descProgress": "正在渲染您的视频,这可能需要几分钟。", + "descSettings": "配置导出选项。", + "titleCancelled": "导出已取消", + "titleComplete": "导出完成", + "titleError": "导出失败", + "titleProgress": "正在导出视频", + "titleSettings": "导出" + }, + "errors": { + "verifyCodec": "无法验证当前设置的编解码器支持情况。" + }, + "progress": { + "cancelExport": "取消", + "elapsedLabel": "已用时间", + "encoding": "正在编码…", + "finalizing": "正在完成…", + "framesLabel": "帧", + "keepTabOpen": "请保持此标签页打开,直至导出完成。", + "preparing": "正在准备…", + "rendering": "正在渲染…" + }, + "settings": { + "audio": "音频", + "audioOnlyNote": "仅导出音频 — 不会包含视频。", + "audioQualityHigh": "高", + "audioQualityLow": "低", + "audioQualityMedium": "中", + "audioQualityUltra": "超高", + "cannotEncode": "在所选画质下,没有可用于 {{width}}×{{height}} 的编码器。", + "codec": "编解码器", + "codecSupportUnverified": "无法验证编解码器支持情况。仍可尝试导出,但兼容性无法保证。", + "duration": "时长", + "embedSubtitles": "嵌入字幕", + "embedSubtitlesDescription": "将转录字幕作为字幕轨道嵌入导出的文件。", + "embedSubtitlesMp4Note": "MP4 将字幕保存为独立轨道。某些播放器默认可能不会显示。", + "embedSubtitlesUnsupported": "{{container}} 不支持嵌入字幕。请选择 MP4、MKV 或 WebM。", + "exportAudio": "导出音频", + "exportRange": "导出范围", + "exportType": "导出类型", + "exportVideo": "导出视频", + "format": "格式", + "in": "入点", + "inOutRangeHint": "仅会导出所选的入/出点范围。", + "noTranscriptSegments": "没有可嵌入的转录字幕。", + "out": "出点", + "presetBalanced": "平衡", + "presetCustom": "自定义", + "presetLabel": "预设", + "presetMax": "最高质量", + "presetRecommended": "推荐", + "presetSmall": "小文件", + "quality": "画质", + "qualityHigh": "高", + "qualityLow": "低", + "qualityMedium": "中", + "qualityUltra": "超高", + "quicktimeMov": "QuickTime MOV", + "renderWholeProject": "渲染整个项目", + "resolution": "分辨率", + "resolutionSameAsProject": "与项目相同 ({{width}}×{{height}})", + "resolutionScaled": "{{p}}p ({{width}}×{{height}})", + "selectCodec": "选择编解码器", + "selectFormat": "选择格式", + "selectQuality": "选择画质", + "selectResolution": "选择分辨率", + "video": "视频" + }, + "videoContainer": { + "mp4": "兼容性广,适合分享", + "mov": "Apple 生态系统,最适合 Final Cut 与 QuickTime", + "webm": "面向网页播放的开放式现代容器", + "mkv": "灵活容器,字幕支持出色" + } + } + } +} diff --git a/src/i18n/locales/partials/media.json b/src/i18n/locales/partials/media.json new file mode 100644 index 000000000..6d0a2d3a2 --- /dev/null +++ b/src/i18n/locales/partials/media.json @@ -0,0 +1,2306 @@ +{ + "en": { + "media": { + "card": { + "aiCaptionsCount": "AI Captions Count", + "analyzeWithAI": "Analyze with AI", + "analyzingWithAI": "Analyzing with AI", + "chooseMkvOrWebm": "Choose MKV Or WebM", + "deleteProxy": "Delete Proxy", + "deleteTranscript": "Delete Transcript", + "extractEmbeddedSubtitles": "Extract Embedded Subtitles", + "generateProxy": "Generate Proxy", + "generateTranscript": "Generate Transcript", + "importing": "Importing", + "menuAi": "Menu AI", + "menuCaptions": "Embedded captions", + "menuFile": "File", + "menuProxy": "Proxy", + "menuTranscript": "Transcript", + "playAudio": "Play Audio", + "refreshTranscript": "Refresh Transcript", + "relinkFile": "Relink File...", + "stopAudio": "Stop Audio", + "subtitlesCachedAll": "Subtitles Cached All", + "subtitlesCachedPartial": "Subtitles Cached Partial", + "subtitlesCannotRead": "FreeCut could not read \"{{name}}\" right now. Close any app using it and try again.", + "subtitlesExtractFailed": "Subtitles Extract Failed", + "subtitlesFileMissing": "FreeCut can't find \"{{name}}\" anymore.", + "subtitlesNeedPermission": "FreeCut needs permission to read \"{{name}}\" before extracting subtitles.", + "subtitlesScanFailed": "Subtitles Scan Failed", + "transcribeFailed": "Transcribe Failed", + "transcribing": "Transcribing", + "transcriptDeleteFailed": "Transcript Delete Failed", + "transcriptDeleteFailedFor": "Couldn't delete transcript for \"{{name}}\"", + "transcriptDeletedFor": "Transcript deleted for \"{{name}}\"", + "transcriptProgressAria": "Transcript progress", + "transcriptReadyFor": "Transcript ready for \"{{name}}\"", + "transcriptionFailedFor": "Transcription failed for \"{{name}}\"", + "transcriptsDeleted": "Transcripts Deleted", + "transcriptsReady": "Transcripts Ready" + }, + "compositions": { + "deleteBody": "Delete compound clip \"{{name}}\"? This cannot be undone.", + "deleteInstancesDetail": "{{count}} timeline instances that use this compound clip will also be removed.", + "deleteInstancesTitle": "Linked instances will be removed", + "deleteTitle": "Delete Compound Clip", + "enter": "Enter", + "itemCount": "{{count}} items", + "rename": "Rename", + "sectionTitle": "Compound Clips" + }, + "deleteDialog": { + "bodyMultiple": "This will permanently remove {{count}} media items from this project. This action cannot be undone.", + "bodySingle": "This will permanently remove \"{{name}}\" from this project. This action cannot be undone.", + "confirmWithClips": "Delete With Clips", + "timelineClipsDetail": "{{count}} timeline clips that use this media will also be removed.", + "timelineClipsDetail_one": "{{count}} timeline clip that uses this media will also be removed.", + "timelineClipsDetail_other": "{{count}} timeline clips that use this media will also be removed.", + "timelineClipsRemoved": "Timeline clips will be removed", + "titleMultiple": "Delete {{count}} Media Items?", + "titleSingle": "Delete Media Item?" + }, + "embeddedSubtitles": { + "badgeAutoPick": "Auto", + "badgeDefault": "Default", + "badgeForced": "Forced", + "cuesCount": "{{count}} cues", + "desc": "Select a subtitle track to insert it into the timeline.", + "descForFile": "Subtitles available in \"{{name}}\".", + "empty": "No subtitle tracks found in this file.", + "insert": "Insert", + "insertWithCues": "Insert ({{count}} cues)", + "loadedFromCache": "Subtitles loaded from cache.", + "scanning": "Scanning…", + "title": "Embedded subtitles", + "trackInfo": "Track {{n}} · {{lang}} · {{codec}}" + }, + "grid": { + "emptyHint": "Drop files here or click Import to add media.", + "emptyTitle": "No media yet", + "loadingSubtitle": "Loading Subtitle", + "loadingTitle": "Loading media…" + }, + "info": { + "codec": "Codec", + "dimensions": "Dimensions", + "duration": "Duration", + "fpsValue": "FPS Value", + "frameRate": "Frame Rate", + "loadingTranscript": "Loading Transcript", + "mediaInfo": "Media info", + "openInSourceMonitor": "Open In Source Monitor", + "size": "Size", + "transcript": "Transcript", + "transcriptWithCount": "Transcript ({{count}})", + "type": "Type" + }, + "library": { + "aiAnalysisProgress": "AI Analysis Progress", + "allTypes": "All Types", + "analyzingMultiple": "Analyzing Multiple", + "analyzingSingle": "Analyzing Single", + "andJoiner": " and ", + "assetsCount": "{{count}} assets", + "assetsCount_one": "{{count}} asset", + "assetsCount_other": "{{count}} assets", + "back": "Back", + "cancelAll": "Cancel All", + "cancelling": "Cancelling", + "clearSelection": "Clear Selection", + "compoundClipsCount": "{{count}} compound clips", + "compoundClipsCount_one": "{{count}} compound clip", + "compoundClipsCount_other": "{{count}} compound clips", + "copyToClipboard": "Copy To Clipboard", + "deleteAssetsBody": "This will permanently remove {{summary}} from this project. This action cannot be undone.", + "deleteAssetsTitle": "Delete Selected Assets?", + "deleteSelectedAssets": "Delete Selected Assets", + "deleteSummary": "Delete {{summary}}", + "deleteWithClips": "Delete With Clips", + "dismiss": "Dismiss", + "dragDropUnsupported": "Drag Drop Unsupported", + "dropFilesHere": "Drop Files Here", + "filesRejected": "Files Rejected", + "generateProxiesForSelected": "Generate proxies for {{count}} selected items", + "generatingProxies": "Generating Proxies", + "generatingTranscripts": "Generating Transcripts", + "gridItemSize": "Grid Item Size", + "groupAudio": "Group Audio", + "groupGifs": "Group GIFs", + "groupImages": "Group Images", + "groupVideos": "Group Videos", + "import": "Import", + "importFromUrlDescription": "Import a media file from a direct URL.", + "importFromUrlHint": "Paste a direct link to a video, audio, image, or GIF file.", + "importFromUrlTitle": "Import From URL", + "importMediaFiles": "Import Media Files", + "importMediaFromUrl": "Import Media From URL", + "libraryView": "Library View", + "linkedInstancesDetail": "{{count}} timeline instances that use these assets will also be removed.", + "linkedInstancesDetail_one": "{{count}} timeline instance that uses these assets will also be removed.", + "linkedInstancesDetail_other": "{{count}} timeline instances that use these assets will also be removed.", + "linkedInstancesTitle": "Linked timeline instances will be removed", + "mediaItemsCount": "{{count}} media items", + "mediaItemsCount_one": "{{count}} media item", + "mediaItemsCount_other": "{{count}} media items", + "mediaTab": "Media", + "missingCount": "{{count}} Missing", + "proxyCount": "{{count}} proxies", + "proxyGenerationProgress": "Proxy Generation Progress", + "scenesTab": "Scenes", + "searchScenes": "Search Scenes", + "selected": "Selected", + "selectedAssetsCount": "{{count}} selected assets", + "selectedAssetsCount_one": "{{count}} selected asset", + "selectedAssetsCount_other": "{{count}} selected assets", + "showMediaLibrary": "Show Media Library", + "sortDate": "Sort Date", + "sortName": "Sort Name", + "sortSize": "Sort Size", + "transcriptGenerationProgress": "Transcript Generation Progress", + "url": "URL", + "viewMissingMedia": "View Missing Media", + "allShort": "ALL", + "typeShort": { + "video": "VIDEO", + "audio": "AUDIO", + "image": "IMAGE" + }, + "sortShort": { + "name": "NAME", + "date": "DATE", + "size": "SIZE" + }, + "missingCount_one": "{{count}} Missing", + "missingCount_other": "{{count}} Missing", + "selectedCount": "{{count}} Selected", + "selectedCount_one": "{{count}} Selected", + "selectedCount_other": "{{count}} Selected", + "generateProxiesForSelected_one": "Generate proxy for {{count}} selected item", + "generateProxiesForSelected_other": "Generate proxies for {{count}} selected items", + "proxyCount_one": "{{count}} proxy", + "proxyCount_other": "{{count}} proxies", + "perItemProgress": "Show per-item progress" + }, + "missingMedia": { + "title": "Missing Media", + "description": "FreeCut cannot access {{count}} media files. Locate the files or keep working offline.", + "description_one": "FreeCut cannot access {{count}} media file. Locate the file or keep working offline.", + "description_other": "FreeCut cannot access {{count}} media files. Locate the files or keep working offline.", + "needPermission": "{{count}} need permission", + "needPermission_one": "{{count}} needs permission", + "needPermission_other": "{{count}} need permission", + "notFound": "{{count}} not found", + "notFound_one": "{{count}} not found", + "notFound_other": "{{count}} not found", + "browseAnotherFolder": "Browse Another Folder", + "fileMovedOrDeleted": "File moved or deleted", + "grantAccess": "Grant Access", + "locate": "Locate", + "locateFolder": "Locate Folder", + "permissionExpired": "Permission expired", + "scanProjectFolder": "Scan {{name}}", + "workOffline": "Work Offline" + }, + "orphanedClips": { + "autoMatch": "Auto Match", + "description": "{{count}} clip(s) reference media that can no longer be found. Replace them or remove them to continue.", + "keepAsBroken": "Keep As Broken", + "removeAll": "Remove All", + "select": "Select", + "selectReplacement": "Select Replacement", + "title": "Missing media" + }, + "picker": { + "descAll": "Choose a file from the project library.", + "noFiles": "No media in this project yet.", + "noSearchResults": "No results found.", + "title": "Choose media" + }, + "searchMedia": "Search media", + "subtitleScan": { + "cached": "Cached", + "cancelBatch": "Cancel all", + "descComplete": "Subtitle scan completed.", + "descScanning": "Scanning your media for subtitles…", + "failed": "Failed", + "titleComplete": "Subtitle scan complete", + "titleScanning": "Scanning for subtitles…" + }, + "transcribe": { + "autoDetect": "Auto detect", + "generateTitle": "Generate transcript", + "language": "Language", + "model": "Model", + "noLanguages": "No languages available.", + "progressAria": "Transcription progress", + "quantization": "Quantization", + "refreshTitle": "Refresh transcript", + "searchLanguages": "Search Languages", + "start": "Start", + "stop": "Stop" + }, + "type": { + "audio": "Audio", + "image": "Image", + "video": "Video" + }, + "unsupportedCodec": { + "bodyMultiple": "Body Multiple", + "bodySingle": "Body Single", + "cancelImport": "Cancel Import", + "importAnyway": "Import Anyway", + "note": "Note", + "title": "Unsupported codec" + } + } + }, + "es": { + "media": { + "card": { + "aiCaptionsCount": "{{count}} subtítulos con IA", + "analyzeWithAI": "Analizar con IA", + "analyzingWithAI": "Analizando con IA", + "chooseMkvOrWebm": "Elige MKV o WebM", + "deleteProxy": "Eliminar proxy", + "deleteTranscript": "Eliminar transcripción", + "extractEmbeddedSubtitles": "Extraer subtítulos incrustados", + "generateProxy": "Generar proxy", + "generateTranscript": "Generar transcripción", + "importing": "Importando", + "menuAi": "IA", + "menuCaptions": "Subtítulos incrustados", + "menuFile": "Archivo", + "menuProxy": "Proxy", + "menuTranscript": "Transcripción", + "playAudio": "Reproducir audio", + "refreshTranscript": "Actualizar transcripción", + "relinkFile": "Volver a vincular archivo…", + "stopAudio": "Detener audio", + "subtitlesCachedAll": "Subtítulos en caché", + "subtitlesCachedPartial": "Algunos subtítulos en caché", + "subtitlesCannotRead": "FreeCut no puede leer \"{{name}}\" en este momento. Cierra la app que lo esté usando e inténtalo de nuevo.", + "subtitlesExtractFailed": "No se pudieron extraer los subtítulos", + "subtitlesFileMissing": "FreeCut ya no encuentra \"{{name}}\".", + "subtitlesNeedPermission": "FreeCut necesita permiso para leer \"{{name}}\" antes de extraer los subtítulos.", + "subtitlesScanFailed": "Falló la búsqueda de subtítulos", + "transcribeFailed": "No se pudo transcribir", + "transcribing": "Transcribiendo", + "transcriptDeleteFailed": "No se pudo eliminar la transcripción", + "transcriptDeleteFailedFor": "No se pudo eliminar la transcripción de \"{{name}}\"", + "transcriptDeletedFor": "Transcripción de \"{{name}}\" eliminada", + "transcriptProgressAria": "Progreso de la transcripción", + "transcriptReadyFor": "Transcripción lista para \"{{name}}\"", + "transcriptionFailedFor": "Falló la transcripción de \"{{name}}\"", + "transcriptsDeleted": "Transcripciones eliminadas", + "transcriptsReady": "Transcripciones listas" + }, + "compositions": { + "deleteBody": "¿Eliminar el clip compuesto \"{{name}}\"? Esta acción no se puede deshacer.", + "deleteInstancesDetail": "También se eliminarán {{count}} instancias de la línea de tiempo que usan este clip compuesto.", + "deleteInstancesTitle": "Se eliminarán las instancias vinculadas", + "deleteTitle": "Eliminar clip compuesto", + "enter": "Entrar", + "itemCount": "{{count}} elementos", + "rename": "Renombrar", + "sectionTitle": "Clips compuestos" + }, + "deleteDialog": { + "bodyMultiple": "Esto eliminará permanentemente {{count}} elementos de medios de este proyecto. Esta acción no se puede deshacer.", + "bodySingle": "Esto eliminará permanentemente \"{{name}}\" de este proyecto. Esta acción no se puede deshacer.", + "confirmWithClips": "Eliminar con clips", + "timelineClipsDetail": "También se eliminarán {{count}} clips de la línea de tiempo que usan este medio.", + "timelineClipsDetail_one": "También se eliminará {{count}} clip de la línea de tiempo que usa este medio.", + "timelineClipsDetail_other": "También se eliminarán {{count}} clips de la línea de tiempo que usan este medio.", + "timelineClipsRemoved": "Se eliminarán clips de la línea de tiempo", + "titleMultiple": "¿Eliminar {{count}} elementos de medios?", + "titleSingle": "¿Eliminar elemento de medios?" + }, + "embeddedSubtitles": { + "badgeAutoPick": "Auto", + "badgeDefault": "Predeterminado", + "badgeForced": "Forzado", + "cuesCount": "{{count}} líneas", + "desc": "Selecciona una pista de subtítulos para insertarla en la línea de tiempo.", + "descForFile": "Subtítulos disponibles en \"{{name}}\".", + "empty": "No se encontraron pistas de subtítulos en este archivo.", + "insert": "Insertar", + "insertWithCues": "Insertar ({{count}} líneas)", + "loadedFromCache": "Subtítulos cargados desde la caché.", + "scanning": "Buscando…", + "title": "Subtítulos incrustados", + "trackInfo": "Pista {{n}} · {{lang}} · {{codec}}" + }, + "grid": { + "emptyHint": "Suelta archivos aquí o haz clic en Importar para añadir medios.", + "emptyTitle": "Aún no hay medios", + "loadingSubtitle": "Preparando tus medios…", + "loadingTitle": "Cargando medios…" + }, + "info": { + "codec": "Códec", + "dimensions": "Dimensiones", + "duration": "Duración", + "fpsValue": "{{value}} fps", + "frameRate": "Velocidad de fotogramas", + "loadingTranscript": "Cargando transcripción…", + "mediaInfo": "Información del medio", + "openInSourceMonitor": "Abrir en el monitor de origen", + "size": "Tamaño", + "transcript": "Transcripción", + "transcriptWithCount": "Transcripción ({{count}})", + "type": "Tipo" + }, + "library": { + "aiAnalysisProgress": "Análisis con IA", + "allTypes": "Todos los tipos", + "analyzingMultiple": "Analizando {{count}} clips…", + "analyzingSingle": "Analizando…", + "andJoiner": " y ", + "assetsCount": "{{count}} recursos", + "assetsCount_one": "{{count}} recurso", + "assetsCount_other": "{{count}} recursos", + "back": "Atrás", + "cancelAll": "Cancelar todo", + "cancelling": "Cancelando…", + "clearSelection": "Borrar selección", + "compoundClipsCount": "{{count}} clips compuestos", + "compoundClipsCount_one": "{{count}} clip compuesto", + "compoundClipsCount_other": "{{count}} clips compuestos", + "copyToClipboard": "Copiar al portapapeles", + "deleteAssetsBody": "Esto eliminará permanentemente {{summary}} de este proyecto. Esta acción no se puede deshacer.", + "deleteAssetsTitle": "¿Eliminar recursos seleccionados?", + "deleteSelectedAssets": "Eliminar recursos seleccionados", + "deleteSummary": "Eliminar {{summary}}", + "deleteWithClips": "Eliminar con clips", + "dismiss": "Descartar", + "dragDropUnsupported": "Arrastrar y soltar no es compatible aquí.", + "dropFilesHere": "Suelta archivos aquí", + "filesRejected": "Algunos archivos no se pudieron añadir.", + "generateProxiesForSelected": "Generar proxies para {{count}} elementos seleccionados", + "generatingProxies": "Generando proxies…", + "generatingTranscripts": "Generando transcripciones…", + "gridItemSize": "Tamaño de miniatura", + "groupAudio": "Grupo audio", + "groupGifs": "Grupo GIF", + "groupImages": "Grupo imagenes", + "groupVideos": "Grupo videos", + "import": "Importar", + "importFromUrlDescription": "Importa un archivo multimedia desde una URL directa.", + "importFromUrlHint": "Pega un enlace directo a un archivo de video, audio, imagen o GIF.", + "importFromUrlTitle": "Importar desde URL", + "importMediaFiles": "Importar archivos multimedia", + "importMediaFromUrl": "Importar multimedia desde URL", + "libraryView": "Vista de la biblioteca", + "linkedInstancesDetail": "También se eliminarán {{count}} instancias de la línea de tiempo que usan estos recursos.", + "linkedInstancesDetail_one": "También se eliminará {{count}} instancia de la línea de tiempo que usa estos recursos.", + "linkedInstancesDetail_other": "También se eliminarán {{count}} instancias de la línea de tiempo que usan estos recursos.", + "linkedInstancesTitle": "Se eliminarán instancias vinculadas de la línea de tiempo", + "mediaItemsCount": "{{count}} elementos de medios", + "mediaItemsCount_one": "{{count}} elemento de medios", + "mediaItemsCount_other": "{{count}} elementos de medios", + "mediaTab": "Medios", + "missingCount": "{{count}} faltantes", + "proxyCount": "{{count}} proxies", + "proxyGenerationProgress": "Progreso de los proxies", + "scenesTab": "Escenas", + "searchScenes": "Buscar escenas", + "selected": "Seleccionado", + "selectedAssetsCount": "{{count}} recursos seleccionados", + "selectedAssetsCount_one": "{{count}} recurso seleccionado", + "selectedAssetsCount_other": "{{count}} recursos seleccionados", + "showMediaLibrary": "Mostrar biblioteca de medios", + "sortDate": "Ordenar por fecha", + "sortName": "Ordenar por nombre", + "sortSize": "Ordenar por tamano", + "transcriptGenerationProgress": "Progreso de la transcripción", + "url": "URL", + "viewMissingMedia": "Ver medios faltantes", + "allShort": "TODO", + "typeShort": { + "video": "VIDEO", + "audio": "AUDIO", + "image": "IMAGEN" + }, + "sortShort": { + "name": "NOMBRE", + "date": "FECHA", + "size": "TAMANO" + }, + "missingCount_one": "{{count}} faltante", + "missingCount_other": "{{count}} faltantes", + "selectedCount": "{{count}} seleccionados", + "selectedCount_one": "{{count}} seleccionado", + "selectedCount_other": "{{count}} seleccionados", + "generateProxiesForSelected_one": "Generar proxy para {{count}} elemento seleccionado", + "generateProxiesForSelected_other": "Generar proxies para {{count}} elementos seleccionados", + "proxyCount_one": "{{count}} proxy", + "proxyCount_other": "{{count}} proxies", + "perItemProgress": "Mostrar progreso por elemento" + }, + "missingMedia": { + "title": "Medios faltantes", + "description": "FreeCut no puede acceder a {{count}} archivos multimedia. Localiza los archivos o sigue trabajando sin conexión.", + "description_one": "FreeCut no puede acceder a {{count}} archivo multimedia. Localiza el archivo o sigue trabajando sin conexión.", + "description_other": "FreeCut no puede acceder a {{count}} archivos multimedia. Localiza los archivos o sigue trabajando sin conexión.", + "needPermission": "{{count}} necesitan permiso", + "needPermission_one": "{{count}} necesita permiso", + "needPermission_other": "{{count}} necesitan permiso", + "notFound": "{{count}} no encontrados", + "notFound_one": "{{count}} no encontrado", + "notFound_other": "{{count}} no encontrados", + "browseAnotherFolder": "Buscar en otra carpeta", + "fileMovedOrDeleted": "Archivo movido o eliminado", + "grantAccess": "Conceder acceso", + "locate": "Localizar", + "locateFolder": "Localizar carpeta", + "permissionExpired": "Permiso vencido", + "scanProjectFolder": "Escanear {{name}}", + "workOffline": "Trabajar sin conexión" + }, + "orphanedClips": { + "autoMatch": "Coincidencia automática", + "description": "{{count}} clip(s) hacen referencia a medios que no se encuentran. Reemplázalos o elimínalos para continuar.", + "keepAsBroken": "Mantener como rotos", + "removeAll": "Eliminar todo", + "select": "Seleccionar", + "selectReplacement": "Selecciona un reemplazo para \"{{name}}\"", + "title": "Medios faltantes" + }, + "picker": { + "descAll": "Elige un archivo de la biblioteca del proyecto.", + "noFiles": "Aún no hay medios en este proyecto.", + "noSearchResults": "No se encontraron resultados.", + "title": "Elige un medio" + }, + "searchMedia": "Buscar medios", + "subtitleScan": { + "cached": "En caché", + "cancelBatch": "Cancelar todo", + "descComplete": "Búsqueda de subtítulos completada.", + "descScanning": "Buscando subtítulos en tus medios…", + "failed": "Falló", + "titleComplete": "Búsqueda de subtítulos completada", + "titleScanning": "Buscando subtítulos…" + }, + "transcribe": { + "autoDetect": "Detección automática", + "generateTitle": "Generar transcripción", + "language": "Idioma", + "model": "Modelo", + "noLanguages": "No hay idiomas disponibles.", + "progressAria": "Progreso de la transcripción", + "quantization": "Cuantización", + "refreshTitle": "Actualizar transcripción", + "searchLanguages": "Buscar idiomas", + "start": "Iniciar", + "stop": "Detener" + }, + "type": { + "audio": "Audio", + "image": "Imagen", + "video": "Video" + }, + "unsupportedCodec": { + "bodyMultiple": "{{count}} archivos usan códecs que tu navegador no puede decodificar. Es posible que no se reproduzcan correctamente.", + "bodySingle": "Este archivo usa un códec que tu navegador no puede decodificar. Es posible que no se reproduzca correctamente.", + "cancelImport": "Cancelar importación", + "importAnyway": "Importar de todos modos", + "note": "Conviértelos a MP4 (H.264/AAC) o WebM para mayor compatibilidad.", + "title": "Códec no compatible" + } + } + }, + "fr": { + "media": { + "card": { + "aiCaptionsCount": "{{count}} sous-titres IA", + "analyzeWithAI": "Analyser avec l'IA", + "analyzingWithAI": "Analyse avec l'IA", + "chooseMkvOrWebm": "Choisir MKV ou WebM", + "deleteProxy": "Supprimer le proxy", + "deleteTranscript": "Supprimer la transcription", + "extractEmbeddedSubtitles": "Extraire les sous-titres intégrés", + "generateProxy": "Générer un proxy", + "generateTranscript": "Générer une transcription", + "importing": "Importation", + "menuAi": "IA", + "menuCaptions": "Sous-titres intégrés", + "menuFile": "Fichier", + "menuProxy": "Proxy", + "menuTranscript": "Transcription", + "playAudio": "Lire l'audio", + "refreshTranscript": "Actualiser la transcription", + "relinkFile": "Reconnecter le fichier…", + "stopAudio": "Arrêter l'audio", + "subtitlesCachedAll": "Sous-titres mis en cache", + "subtitlesCachedPartial": "Sous-titres partiellement mis en cache", + "subtitlesCannotRead": "FreeCut ne peut pas lire « {{name}} » pour le moment. Fermez toute appli qui l'utilise et réessayez.", + "subtitlesExtractFailed": "Échec de l'extraction des sous-titres", + "subtitlesFileMissing": "FreeCut ne trouve plus « {{name}} ».", + "subtitlesNeedPermission": "FreeCut a besoin d'autorisation pour lire « {{name}} » avant d'extraire les sous-titres.", + "subtitlesScanFailed": "Échec de l'analyse des sous-titres", + "transcribeFailed": "Échec de la transcription", + "transcribing": "Transcription en cours", + "transcriptDeleteFailed": "Échec de la suppression de la transcription", + "transcriptDeleteFailedFor": "Impossible de supprimer la transcription de « {{name}} »", + "transcriptDeletedFor": "Transcription de « {{name}} » supprimée", + "transcriptProgressAria": "Progression de la transcription", + "transcriptReadyFor": "Transcription prête pour « {{name}} »", + "transcriptionFailedFor": "Échec de la transcription de « {{name}} »", + "transcriptsDeleted": "Transcriptions supprimées", + "transcriptsReady": "Transcriptions prêtes" + }, + "compositions": { + "deleteBody": "Supprimer le clip composé \"{{name}}\" ? Cette action est irréversible.", + "deleteInstancesDetail": "{{count}} instances de la timeline qui utilisent ce clip composé seront également supprimées.", + "deleteInstancesTitle": "Les instances liées seront supprimées", + "deleteTitle": "Supprimer le clip composé", + "enter": "Ouvrir", + "itemCount": "{{count}} éléments", + "rename": "Renommer", + "sectionTitle": "Clips composés" + }, + "deleteDialog": { + "bodyMultiple": "Cela supprimera définitivement {{count}} éléments multimédias de ce projet. Cette action est irréversible.", + "bodySingle": "Cela supprimera définitivement \"{{name}}\" de ce projet. Cette action est irréversible.", + "confirmWithClips": "Supprimer avec les clips", + "timelineClipsDetail": "{{count}} clips de la timeline utilisant ce média seront aussi supprimés.", + "timelineClipsDetail_one": "{{count}} clip de la timeline utilisant ce média sera aussi supprimé.", + "timelineClipsDetail_other": "{{count}} clips de la timeline utilisant ce média seront aussi supprimés.", + "timelineClipsRemoved": "Des clips de la timeline seront supprimés", + "titleMultiple": "Supprimer {{count}} éléments multimédias ?", + "titleSingle": "Supprimer l'élément multimédia ?" + }, + "embeddedSubtitles": { + "badgeAutoPick": "Auto", + "badgeDefault": "Par défaut", + "badgeForced": "Forcé", + "cuesCount": "{{count}} entrées", + "desc": "Choisissez une piste de sous-titres à insérer dans le montage.", + "descForFile": "Sous-titres disponibles dans « {{name}} ».", + "empty": "Aucune piste de sous-titres dans ce fichier.", + "insert": "Insérer", + "insertWithCues": "Insérer ({{count}} entrées)", + "loadedFromCache": "Sous-titres chargés depuis le cache.", + "scanning": "Analyse…", + "title": "Sous-titres intégrés", + "trackInfo": "Piste {{n}} · {{lang}} · {{codec}}" + }, + "grid": { + "emptyHint": "Déposez des fichiers ici ou cliquez sur Importer pour ajouter des médias.", + "emptyTitle": "Aucun média pour l'instant", + "loadingSubtitle": "Préparation de vos médias…", + "loadingTitle": "Chargement des médias…" + }, + "info": { + "codec": "Codec", + "dimensions": "Dimensions", + "duration": "Durée", + "fpsValue": "{{value}} ips", + "frameRate": "Fréquence d'images", + "loadingTranscript": "Chargement de la transcription…", + "mediaInfo": "Infos du média", + "openInSourceMonitor": "Ouvrir dans le moniteur source", + "size": "Taille", + "transcript": "Transcription", + "transcriptWithCount": "Transcription ({{count}})", + "type": "Type" + }, + "library": { + "aiAnalysisProgress": "Analyse IA", + "allTypes": "Tous les types", + "analyzingMultiple": "Analyse de {{count}} clips…", + "analyzingSingle": "Analyse…", + "andJoiner": " et ", + "assetsCount": "{{count}} ressources", + "assetsCount_one": "{{count}} ressource", + "assetsCount_other": "{{count}} ressources", + "back": "Retour", + "cancelAll": "Tout annuler", + "cancelling": "Annulation…", + "clearSelection": "Effacer la sélection", + "compoundClipsCount": "{{count}} clips composés", + "compoundClipsCount_one": "{{count}} clip composé", + "compoundClipsCount_other": "{{count}} clips composés", + "copyToClipboard": "Copier dans le presse-papiers", + "deleteAssetsBody": "Cela supprimera définitivement {{summary}} de ce projet. Cette action est irréversible.", + "deleteAssetsTitle": "Supprimer les ressources sélectionnées ?", + "deleteSelectedAssets": "Supprimer les ressources sélectionnées", + "deleteSummary": "Supprimer {{summary}}", + "deleteWithClips": "Supprimer avec les clips", + "dismiss": "Ignorer", + "dragDropUnsupported": "Le glisser-déposer n'est pas pris en charge ici.", + "dropFilesHere": "Déposez les fichiers ici", + "filesRejected": "Certains fichiers n'ont pas pu être ajoutés.", + "generateProxiesForSelected": "Générer des proxies pour {{count}} éléments sélectionnés", + "generatingProxies": "Génération des proxys…", + "generatingTranscripts": "Génération des transcriptions…", + "gridItemSize": "Taille des vignettes", + "groupAudio": "Groupe audio", + "groupGifs": "Groupe GIF", + "groupImages": "Groupe images", + "groupVideos": "Groupe videos", + "import": "Importer", + "importFromUrlDescription": "Importez un fichier multimédia depuis une URL directe.", + "importFromUrlHint": "Collez un lien direct vers une vidéo, un audio, une image ou un GIF.", + "importFromUrlTitle": "Importer depuis une URL", + "importMediaFiles": "Importer des fichiers media", + "importMediaFromUrl": "Importer un media depuis une URL", + "libraryView": "Vue de la médiathèque", + "linkedInstancesDetail": "{{count}} instances de la timeline utilisant ces ressources seront aussi supprimées.", + "linkedInstancesDetail_one": "{{count}} instance de la timeline utilisant ces ressources sera aussi supprimée.", + "linkedInstancesDetail_other": "{{count}} instances de la timeline utilisant ces ressources seront aussi supprimées.", + "linkedInstancesTitle": "Des instances liées de la timeline seront supprimées", + "mediaItemsCount": "{{count}} éléments multimédias", + "mediaItemsCount_one": "{{count}} élément multimédia", + "mediaItemsCount_other": "{{count}} éléments multimédias", + "mediaTab": "Medias", + "missingCount": "{{count}} manquants", + "proxyCount": "{{count}} proxys", + "proxyGenerationProgress": "Progression des proxys", + "scenesTab": "Scenes", + "searchScenes": "Rechercher des scènes", + "selected": "Sélectionné", + "selectedAssetsCount": "{{count}} ressources sélectionnées", + "selectedAssetsCount_one": "{{count}} ressource sélectionnée", + "selectedAssetsCount_other": "{{count}} ressources sélectionnées", + "showMediaLibrary": "Afficher la médiathèque", + "sortDate": "Trier par date", + "sortName": "Trier par nom", + "sortSize": "Trier par taille", + "transcriptGenerationProgress": "Progression de la transcription", + "url": "URL", + "viewMissingMedia": "Voir les médias manquants", + "allShort": "TOUT", + "typeShort": { + "video": "VIDEO", + "audio": "AUDIO", + "image": "IMAGE" + }, + "sortShort": { + "name": "NOM", + "date": "DATE", + "size": "TAILLE" + }, + "missingCount_one": "{{count}} manquant", + "missingCount_other": "{{count}} manquants", + "selectedCount": "{{count}} sélectionnés", + "selectedCount_one": "{{count}} sélectionné", + "selectedCount_other": "{{count}} sélectionnés", + "generateProxiesForSelected_one": "Générer un proxy pour {{count}} élément sélectionné", + "generateProxiesForSelected_other": "Générer des proxies pour {{count}} éléments sélectionnés", + "proxyCount_one": "{{count}} proxy", + "proxyCount_other": "{{count}} proxys", + "perItemProgress": "Afficher la progression par élément" + }, + "missingMedia": { + "title": "Médias manquants", + "description": "FreeCut ne peut pas accéder à {{count}} fichiers multimédias. Localisez les fichiers ou continuez hors ligne.", + "description_one": "FreeCut ne peut pas accéder à {{count}} fichier multimédia. Localisez le fichier ou continuez hors ligne.", + "description_other": "FreeCut ne peut pas accéder à {{count}} fichiers multimédias. Localisez les fichiers ou continuez hors ligne.", + "needPermission": "{{count}} nécessitent une autorisation", + "needPermission_one": "{{count}} nécessite une autorisation", + "needPermission_other": "{{count}} nécessitent une autorisation", + "notFound": "{{count}} introuvables", + "notFound_one": "{{count}} introuvable", + "notFound_other": "{{count}} introuvables", + "browseAnotherFolder": "Parcourir un autre dossier", + "fileMovedOrDeleted": "Fichier déplacé ou supprimé", + "grantAccess": "Autoriser l'accès", + "locate": "Localiser", + "locateFolder": "Localiser le dossier", + "permissionExpired": "Autorisation expirée", + "scanProjectFolder": "Analyser {{name}}", + "workOffline": "Travailler hors ligne" + }, + "orphanedClips": { + "autoMatch": "Correspondance automatique", + "description": "{{count}} clip(s) référencent des médias introuvables. Remplacez-les ou supprimez-les pour continuer.", + "keepAsBroken": "Conserver comme cassés", + "removeAll": "Tout supprimer", + "select": "Sélectionner", + "selectReplacement": "Choisir un remplacement pour « {{name}} »", + "title": "Médias manquants" + }, + "picker": { + "descAll": "Choisissez un fichier dans la médiathèque du projet.", + "noFiles": "Aucun média dans ce projet pour l'instant.", + "noSearchResults": "Aucun résultat.", + "title": "Choisir un média" + }, + "searchMedia": "Rechercher des medias", + "subtitleScan": { + "cached": "En cache", + "cancelBatch": "Tout annuler", + "descComplete": "Recherche de sous-titres terminée.", + "descScanning": "Recherche de sous-titres dans vos médias…", + "failed": "Échec", + "titleComplete": "Recherche de sous-titres terminée", + "titleScanning": "Recherche de sous-titres…" + }, + "transcribe": { + "autoDetect": "Détection automatique", + "generateTitle": "Générer une transcription", + "language": "Langue", + "model": "Modèle", + "noLanguages": "Aucune langue disponible.", + "progressAria": "Progression de la transcription", + "quantization": "Quantification", + "refreshTitle": "Actualiser la transcription", + "searchLanguages": "Rechercher une langue", + "start": "Démarrer", + "stop": "Arrêter" + }, + "type": { + "audio": "Audio", + "image": "Image", + "video": "Video" + }, + "unsupportedCodec": { + "bodyMultiple": "{{count}} fichiers utilisent des codecs que votre navigateur ne peut pas décoder. Ils risquent de ne pas se lire correctement.", + "bodySingle": "Ce fichier utilise un codec que votre navigateur ne peut pas décoder. Il risque de ne pas se lire correctement.", + "cancelImport": "Annuler l'importation", + "importAnyway": "Importer quand même", + "note": "Convertissez-les en MP4 (H.264/AAC) ou WebM pour une meilleure compatibilité.", + "title": "Codec non pris en charge" + } + } + }, + "de": { + "media": { + "card": { + "aiCaptionsCount": "{{count}} KI-Untertitel", + "analyzeWithAI": "Mit KI analysieren", + "analyzingWithAI": "Mit KI wird analysiert", + "chooseMkvOrWebm": "MKV oder WebM wählen", + "deleteProxy": "Proxy löschen", + "deleteTranscript": "Transkript löschen", + "extractEmbeddedSubtitles": "Eingebettete Untertitel extrahieren", + "generateProxy": "Proxy erstellen", + "generateTranscript": "Transkript erstellen", + "importing": "Wird importiert", + "menuAi": "KI", + "menuCaptions": "Eingebettete Untertitel", + "menuFile": "Datei", + "menuProxy": "Proxy", + "menuTranscript": "Transkript", + "playAudio": "Audio abspielen", + "refreshTranscript": "Transkript aktualisieren", + "relinkFile": "Datei neu verknüpfen…", + "stopAudio": "Audio stoppen", + "subtitlesCachedAll": "Untertitel zwischengespeichert", + "subtitlesCachedPartial": "Untertitel teilweise zwischengespeichert", + "subtitlesCannotRead": "FreeCut kann „{{name}}\" gerade nicht lesen. Schließe jede App, die sie verwendet, und versuche es erneut.", + "subtitlesExtractFailed": "Untertitel-Extraktion fehlgeschlagen", + "subtitlesFileMissing": "FreeCut findet „{{name}}\" nicht mehr.", + "subtitlesNeedPermission": "FreeCut benötigt Zugriff auf „{{name}}\", bevor Untertitel extrahiert werden können.", + "subtitlesScanFailed": "Untertitelsuche fehlgeschlagen", + "transcribeFailed": "Transkription fehlgeschlagen", + "transcribing": "Wird transkribiert", + "transcriptDeleteFailed": "Löschen des Transkripts fehlgeschlagen", + "transcriptDeleteFailedFor": "Transkript für „{{name}}\" konnte nicht gelöscht werden", + "transcriptDeletedFor": "Transkript für „{{name}}\" gelöscht", + "transcriptProgressAria": "Transkript-Fortschritt", + "transcriptReadyFor": "Transkript für „{{name}}\" ist bereit", + "transcriptionFailedFor": "Transkription für „{{name}}\" fehlgeschlagen", + "transcriptsDeleted": "Transkripte gelöscht", + "transcriptsReady": "Transkripte bereit" + }, + "compositions": { + "deleteBody": "Zusammengesetzten Clip \"{{name}}\" löschen? Dies kann nicht rückgängig gemacht werden.", + "deleteInstancesDetail": "{{count}} Timeline-Instanzen, die diesen zusammengesetzten Clip verwenden, werden ebenfalls entfernt.", + "deleteInstancesTitle": "Verknüpfte Instanzen werden entfernt", + "deleteTitle": "Zusammengesetzten Clip löschen", + "enter": "Öffnen", + "itemCount": "{{count}} Elemente", + "rename": "Umbenennen", + "sectionTitle": "Zusammengesetzte Clips" + }, + "deleteDialog": { + "bodyMultiple": "Dadurch werden {{count}} Medienelemente dauerhaft aus diesem Projekt entfernt. Diese Aktion kann nicht rückgängig gemacht werden.", + "bodySingle": "Dadurch wird \"{{name}}\" dauerhaft aus diesem Projekt entfernt. Diese Aktion kann nicht rückgängig gemacht werden.", + "confirmWithClips": "Mit Clips löschen", + "timelineClipsDetail": "{{count}} Timeline-Clips, die dieses Medium verwenden, werden ebenfalls entfernt.", + "timelineClipsDetail_one": "{{count}} Timeline-Clip, der dieses Medium verwendet, wird ebenfalls entfernt.", + "timelineClipsDetail_other": "{{count}} Timeline-Clips, die dieses Medium verwenden, werden ebenfalls entfernt.", + "timelineClipsRemoved": "Timeline-Clips werden entfernt", + "titleMultiple": "{{count}} Medienelemente löschen?", + "titleSingle": "Medienelement löschen?" + }, + "embeddedSubtitles": { + "badgeAutoPick": "Auto", + "badgeDefault": "Standard", + "badgeForced": "Erzwungen", + "cuesCount": "{{count}} Einträge", + "desc": "Wähle eine Untertitelspur, die in die Timeline eingefügt werden soll.", + "descForFile": "Verfügbare Untertitel in „{{name}}\".", + "empty": "In dieser Datei wurden keine Untertitelspuren gefunden.", + "insert": "Einfügen", + "insertWithCues": "Einfügen ({{count}} Einträge)", + "loadedFromCache": "Untertitel aus dem Zwischenspeicher geladen.", + "scanning": "Wird gesucht…", + "title": "Eingebettete Untertitel", + "trackInfo": "Spur {{n}} · {{lang}} · {{codec}}" + }, + "grid": { + "emptyHint": "Dateien hier ablegen oder auf Importieren klicken, um Medien hinzuzufügen.", + "emptyTitle": "Noch keine Medien", + "loadingSubtitle": "Medien werden vorbereitet…", + "loadingTitle": "Medien werden geladen…" + }, + "info": { + "codec": "Codec", + "dimensions": "Abmessungen", + "duration": "Dauer", + "fpsValue": "{{value}} fps", + "frameRate": "Bildrate", + "loadingTranscript": "Transkript wird geladen…", + "mediaInfo": "Medieninformationen", + "openInSourceMonitor": "Im Quellmonitor öffnen", + "size": "Größe", + "transcript": "Transkript", + "transcriptWithCount": "Transkript ({{count}})", + "type": "Typ" + }, + "library": { + "aiAnalysisProgress": "KI-Analyse", + "allTypes": "Alle Typen", + "analyzingMultiple": "{{count}} Clips werden analysiert…", + "analyzingSingle": "Wird analysiert…", + "andJoiner": " und ", + "assetsCount": "{{count}} Assets", + "assetsCount_one": "{{count}} Asset", + "assetsCount_other": "{{count}} Assets", + "back": "Zurück", + "cancelAll": "Alle abbrechen", + "cancelling": "Wird abgebrochen…", + "clearSelection": "Auswahl löschen", + "compoundClipsCount": "{{count}} zusammengesetzte Clips", + "compoundClipsCount_one": "{{count}} zusammengesetzter Clip", + "compoundClipsCount_other": "{{count}} zusammengesetzte Clips", + "copyToClipboard": "In Zwischenablage kopieren", + "deleteAssetsBody": "Dadurch werden {{summary}} dauerhaft aus diesem Projekt entfernt. Diese Aktion kann nicht rückgängig gemacht werden.", + "deleteAssetsTitle": "Ausgewählte Assets löschen?", + "deleteSelectedAssets": "Ausgewählte Assets löschen", + "deleteSummary": "{{summary}} löschen", + "deleteWithClips": "Mit Clips löschen", + "dismiss": "Verwerfen", + "dragDropUnsupported": "Drag-and-drop wird hier nicht unterstützt.", + "dropFilesHere": "Dateien hier ablegen", + "filesRejected": "Einige Dateien konnten nicht hinzugefügt werden.", + "generateProxiesForSelected": "Proxys für {{count}} ausgewählte Elemente erzeugen", + "generatingProxies": "Proxys werden erstellt…", + "generatingTranscripts": "Transkripte werden erstellt…", + "gridItemSize": "Vorschaugröße", + "groupAudio": "Audio gruppieren", + "groupGifs": "GIFs gruppieren", + "groupImages": "Bilder gruppieren", + "groupVideos": "Videos gruppieren", + "import": "Importieren", + "importFromUrlDescription": "Importiere eine Mediendatei über eine direkte URL.", + "importFromUrlHint": "Füge einen direkten Link zu einer Video-, Audio-, Bild- oder GIF-Datei ein.", + "importFromUrlTitle": "Von URL importieren", + "importMediaFiles": "Mediendateien importieren", + "importMediaFromUrl": "Medien von URL importieren", + "libraryView": "Bibliotheksansicht", + "linkedInstancesDetail": "{{count}} Timeline-Instanzen, die diese Assets verwenden, werden ebenfalls entfernt.", + "linkedInstancesDetail_one": "{{count}} Timeline-Instanz, die diese Assets verwendet, wird ebenfalls entfernt.", + "linkedInstancesDetail_other": "{{count}} Timeline-Instanzen, die diese Assets verwenden, werden ebenfalls entfernt.", + "linkedInstancesTitle": "Verknüpfte Timeline-Instanzen werden entfernt", + "mediaItemsCount": "{{count}} Medienelemente", + "mediaItemsCount_one": "{{count}} Medienelement", + "mediaItemsCount_other": "{{count}} Medienelemente", + "mediaTab": "Medien", + "missingCount": "{{count}} fehlen", + "proxyCount": "{{count}} Proxys", + "proxyGenerationProgress": "Proxy-Fortschritt", + "scenesTab": "Szenen", + "searchScenes": "Szenen suchen", + "selected": "Ausgewählt", + "selectedAssetsCount": "{{count}} ausgewählte Assets", + "selectedAssetsCount_one": "{{count}} ausgewähltes Asset", + "selectedAssetsCount_other": "{{count}} ausgewählte Assets", + "showMediaLibrary": "Medienbibliothek anzeigen", + "sortDate": "Nach Datum sortieren", + "sortName": "Nach Name sortieren", + "sortSize": "Nach Groesse sortieren", + "transcriptGenerationProgress": "Transkript-Fortschritt", + "url": "URL", + "viewMissingMedia": "Fehlende Medien anzeigen", + "allShort": "ALLE", + "typeShort": { + "video": "VIDEO", + "audio": "AUDIO", + "image": "BILD" + }, + "sortShort": { + "name": "NAME", + "date": "DATUM", + "size": "GROESSE" + }, + "missingCount_one": "{{count}} fehlt", + "missingCount_other": "{{count}} fehlen", + "selectedCount": "{{count}} ausgewählt", + "selectedCount_one": "{{count}} ausgewählt", + "selectedCount_other": "{{count}} ausgewählt", + "generateProxiesForSelected_one": "Proxy für {{count}} ausgewähltes Element erzeugen", + "generateProxiesForSelected_other": "Proxys für {{count}} ausgewählte Elemente erzeugen", + "proxyCount_one": "{{count}} Proxy", + "proxyCount_other": "{{count}} Proxys", + "perItemProgress": "Fortschritt pro Element anzeigen" + }, + "missingMedia": { + "title": "Fehlende Medien", + "description": "FreeCut kann auf {{count}} Mediendateien nicht zugreifen. Suche die Dateien oder arbeite offline weiter.", + "description_one": "FreeCut kann auf {{count}} Mediendatei nicht zugreifen. Suche die Datei oder arbeite offline weiter.", + "description_other": "FreeCut kann auf {{count}} Mediendateien nicht zugreifen. Suche die Dateien oder arbeite offline weiter.", + "needPermission": "{{count}} benötigen Berechtigung", + "needPermission_one": "{{count}} benötigt Berechtigung", + "needPermission_other": "{{count}} benötigen Berechtigung", + "notFound": "{{count}} nicht gefunden", + "notFound_one": "{{count}} nicht gefunden", + "notFound_other": "{{count}} nicht gefunden", + "browseAnotherFolder": "Anderen Ordner durchsuchen", + "fileMovedOrDeleted": "Datei verschoben oder gelöscht", + "grantAccess": "Zugriff erlauben", + "locate": "Suchen", + "locateFolder": "Ordner suchen", + "permissionExpired": "Berechtigung abgelaufen", + "scanProjectFolder": "{{name}} scannen", + "workOffline": "Offline arbeiten" + }, + "orphanedClips": { + "autoMatch": "Automatisch zuordnen", + "description": "{{count}} Clip(s) verweisen auf Medien, die nicht mehr gefunden werden können. Ersetze oder entferne sie, um fortzufahren.", + "keepAsBroken": "Als defekt behalten", + "removeAll": "Alle entfernen", + "select": "Auswählen", + "selectReplacement": "Ersatz für „{{name}}\" wählen", + "title": "Fehlende Medien" + }, + "picker": { + "descAll": "Wähle eine Datei aus der Projekt-Bibliothek.", + "noFiles": "Noch keine Medien in diesem Projekt.", + "noSearchResults": "Keine Ergebnisse gefunden.", + "title": "Medien auswählen" + }, + "searchMedia": "Medien suchen", + "subtitleScan": { + "cached": "Zwischengespeichert", + "cancelBatch": "Alle abbrechen", + "descComplete": "Untertitelsuche abgeschlossen.", + "descScanning": "Medien werden auf Untertitel durchsucht…", + "failed": "Fehlgeschlagen", + "titleComplete": "Untertitelsuche abgeschlossen", + "titleScanning": "Untertitel werden gesucht…" + }, + "transcribe": { + "autoDetect": "Automatisch erkennen", + "generateTitle": "Transkript erstellen", + "language": "Sprache", + "model": "Modell", + "noLanguages": "Keine Sprachen verfügbar.", + "progressAria": "Transkriptionsfortschritt", + "quantization": "Quantisierung", + "refreshTitle": "Transkript aktualisieren", + "searchLanguages": "Sprachen suchen", + "start": "Starten", + "stop": "Stoppen" + }, + "type": { + "audio": "Audio", + "image": "Bild", + "video": "Video" + }, + "unsupportedCodec": { + "bodyMultiple": "{{count}} Dateien verwenden Codecs, die dein Browser nicht decodieren kann. Sie werden möglicherweise nicht korrekt wiedergegeben.", + "bodySingle": "Diese Datei verwendet einen Codec, den dein Browser nicht decodieren kann. Sie wird möglicherweise nicht korrekt wiedergegeben.", + "cancelImport": "Import abbrechen", + "importAnyway": "Trotzdem importieren", + "note": "Konvertiere sie für bessere Kompatibilität in MP4 (H.264/AAC) oder WebM.", + "title": "Codec nicht unterstützt" + } + } + }, + "pt-BR": { + "media": { + "card": { + "aiCaptionsCount": "Contagem de legendas de IA", + "analyzeWithAI": "Analisar com IA", + "analyzingWithAI": "Analisando com IA", + "chooseMkvOrWebm": "Escolha MKV ou WebM", + "deleteProxy": "Excluir proxy", + "deleteTranscript": "Excluir transcricao", + "extractEmbeddedSubtitles": "Extrair legendas incorporadas", + "generateProxy": "Gerar proxy", + "generateTranscript": "Gerar transcricao", + "importing": "Importando", + "menuAi": "Menu IA", + "menuCaptions": "Legendas incorporadas", + "menuFile": "Arquivo", + "menuProxy": "Proxy", + "menuTranscript": "Transcrição", + "playAudio": "Reproduzir audio", + "refreshTranscript": "Atualizar transcricao", + "relinkFile": "Revincular arquivo...", + "stopAudio": "Parar audio", + "subtitlesCachedAll": "Todas as legendas em cache", + "subtitlesCachedPartial": "Algumas legendas em cache", + "subtitlesCannotRead": "FreeCut nao conseguiu ler \"{{name}}\" agora. Feche qualquer app que esteja usando o arquivo e tente novamente.", + "subtitlesExtractFailed": "Falha ao extrair legendas", + "subtitlesFileMissing": "FreeCut nao consegue mais encontrar \"{{name}}\".", + "subtitlesNeedPermission": "FreeCut precisa de permissao para ler \"{{name}}\" antes de extrair legendas.", + "subtitlesScanFailed": "Falha ao escanear legendas", + "transcribeFailed": "Falha na transcricao", + "transcribing": "Transcrevendo", + "transcriptDeleteFailed": "Falha ao excluir transcricao", + "transcriptDeleteFailedFor": "Nao foi possivel excluir a transcricao de \"{{name}}\"", + "transcriptDeletedFor": "Transcricao excluida de \"{{name}}\"", + "transcriptProgressAria": "Progresso da transcricao", + "transcriptReadyFor": "Transcricao pronta para \"{{name}}\"", + "transcriptionFailedFor": "Falha na transcricao de \"{{name}}\"", + "transcriptsDeleted": "Transcricoes excluidas", + "transcriptsReady": "Transcricoes prontas" + }, + "compositions": { + "deleteBody": "Excluir o clipe composto \"{{name}}\"? Esta ação não pode ser desfeita.", + "deleteInstancesDetail": "{{count}} instâncias da linha do tempo que usam este clipe composto também serão removidas.", + "deleteInstancesTitle": "As instâncias vinculadas serão removidas", + "deleteTitle": "Excluir clipe composto", + "enter": "Entrar", + "itemCount": "{{count}} itens", + "rename": "Renomear", + "sectionTitle": "Clipes compostos" + }, + "deleteDialog": { + "bodyMultiple": "Isso removerá permanentemente {{count}} itens de mídia deste projeto. Esta ação não pode ser desfeita.", + "bodySingle": "Isso removerá permanentemente \"{{name}}\" deste projeto. Esta ação não pode ser desfeita.", + "confirmWithClips": "Excluir com clipes", + "timelineClipsDetail": "{{count}} clipes da linha do tempo que usam esta mídia também serão removidos.", + "timelineClipsDetail_one": "{{count}} clipe da linha do tempo que usa esta mídia também será removido.", + "timelineClipsDetail_other": "{{count}} clipes da linha do tempo que usam esta mídia também serão removidos.", + "timelineClipsRemoved": "Clipes removidos da linha do tempo", + "titleMultiple": "Excluir {{count}} itens de mídia?", + "titleSingle": "Excluir item de mídia?" + }, + "embeddedSubtitles": { + "badgeAutoPick": "Automático", + "badgeDefault": "Padrão", + "badgeForced": "Forçado", + "cuesCount": "Contagem de falas", + "desc": "Descricao", + "descForFile": "Descricao do arquivo", + "empty": "Vazio", + "insert": "Inserir", + "insertWithCues": "Inserir com falas", + "loadedFromCache": "Carregado do cache", + "scanning": "Escaneando", + "title": "Legendas incorporadas", + "trackInfo": "Informacoes da faixa" + }, + "grid": { + "emptyHint": "Solte arquivos aqui ou clique em Importar para adicionar mídia.", + "emptyTitle": "Ainda sem mídia", + "loadingSubtitle": "Carregando subtitulo", + "loadingTitle": "Carregando mídia…" + }, + "info": { + "codec": "Codificador", + "dimensions": "Dimensoes", + "duration": "Duracao", + "fpsValue": "Valor de FPS", + "frameRate": "Taxa de quadros", + "loadingTranscript": "Carregando transcricao", + "mediaInfo": "Informacoes da midia", + "openInSourceMonitor": "Abrir no monitor de origem", + "size": "Tamanho", + "transcript": "Transcricao", + "transcriptWithCount": "Transcricao ({{count}})", + "type": "Tipo" + }, + "library": { + "aiAnalysisProgress": "Progresso da analise de IA", + "allTypes": "Todos os tipos", + "analyzingMultiple": "Analisando varios", + "analyzingSingle": "Analisando um item", + "andJoiner": "e", + "assetsCount": "{{count}} assets", + "assetsCount_one": "{{count}} asset", + "assetsCount_other": "{{count}} assets", + "back": "Voltar", + "cancelAll": "Cancelar tudo", + "cancelling": "Cancelando", + "clearSelection": "Limpar seleção", + "compoundClipsCount": "{{count}} clipes compostos", + "compoundClipsCount_one": "{{count}} clipe composto", + "compoundClipsCount_other": "{{count}} clipes compostos", + "copyToClipboard": "Copiar para a area de transferencia", + "deleteAssetsBody": "Isso removerá permanentemente {{summary}} deste projeto. Esta ação não pode ser desfeita.", + "deleteAssetsTitle": "Excluir assets selecionados?", + "deleteSelectedAssets": "Excluir itens selecionados", + "deleteSummary": "Excluir {{summary}}", + "deleteWithClips": "Excluir com clipes", + "dismiss": "Dispensar", + "dragDropUnsupported": "Arrastar e soltar nao suportado", + "dropFilesHere": "Solte os arquivos aqui", + "filesRejected": "Arquivos rejeitados", + "generateProxiesForSelected": "Gerar proxies para {{count}} itens selecionados", + "generatingProxies": "Gerando proxies", + "generatingTranscripts": "Gerando transcricoes", + "gridItemSize": "Tamanho dos itens da grade", + "groupAudio": "Agrupar audio", + "groupGifs": "Agrupar GIFs", + "groupImages": "Agrupar imagens", + "groupVideos": "Agrupar videos", + "import": "Importar", + "importFromUrlDescription": "Importe um arquivo de mídia de uma URL direta.", + "importFromUrlHint": "Cole um link direto para um arquivo de vídeo, áudio, imagem ou GIF.", + "importFromUrlTitle": "Importar de URL", + "importMediaFiles": "Importar arquivos de midia", + "importMediaFromUrl": "Importar midia da URL", + "libraryView": "Visualizacao da biblioteca", + "linkedInstancesDetail": "{{count}} instâncias da linha do tempo que usam estes assets também serão removidas.", + "linkedInstancesDetail_one": "{{count}} instância da linha do tempo que usa estes assets também será removida.", + "linkedInstancesDetail_other": "{{count}} instâncias da linha do tempo que usam estes assets também serão removidas.", + "linkedInstancesTitle": "Instâncias vinculadas da linha do tempo serão removidas", + "mediaItemsCount": "{{count}} itens de mídia", + "mediaItemsCount_one": "{{count}} item de mídia", + "mediaItemsCount_other": "{{count}} itens de mídia", + "mediaTab": "Midia", + "missingCount": "{{count}} ausentes", + "proxyCount": "{{count}} proxies", + "proxyGenerationProgress": "Progresso da geracao de proxies", + "scenesTab": "Cenas", + "searchScenes": "Pesquisar cenas", + "selected": "Selecionado", + "selectedAssetsCount": "{{count}} assets selecionados", + "selectedAssetsCount_one": "{{count}} asset selecionado", + "selectedAssetsCount_other": "{{count}} assets selecionados", + "showMediaLibrary": "Mostrar biblioteca de midia", + "sortDate": "Ordenar por data", + "sortName": "Ordenar por nome", + "sortSize": "Ordenar por tamanho", + "transcriptGenerationProgress": "Progresso da geracao de transcricoes", + "url": "URL", + "viewMissingMedia": "Ver mídia ausente", + "allShort": "TODOS", + "typeShort": { + "video": "VIDEO", + "audio": "AUDIO", + "image": "IMAGEM" + }, + "sortShort": { + "name": "NOME", + "date": "DATA", + "size": "TAMANHO" + }, + "missingCount_one": "{{count}} ausente", + "missingCount_other": "{{count}} ausentes", + "selectedCount": "{{count}} selecionado", + "selectedCount_one": "{{count}} selecionado", + "selectedCount_other": "{{count}} selecionados", + "generateProxiesForSelected_one": "Gerar proxy para {{count}} item selecionado", + "generateProxiesForSelected_other": "Gerar proxies para {{count}} itens selecionados", + "proxyCount_one": "{{count}} proxy", + "proxyCount_other": "{{count}} proxies", + "perItemProgress": "Mostrar progresso por item" + }, + "missingMedia": { + "title": "Mídia ausente", + "description": "O FreeCut não consegue acessar {{count}} arquivos de mídia. Localize os arquivos ou continue trabalhando offline.", + "description_one": "O FreeCut não consegue acessar {{count}} arquivo de mídia. Localize o arquivo ou continue trabalhando offline.", + "description_other": "O FreeCut não consegue acessar {{count}} arquivos de mídia. Localize os arquivos ou continue trabalhando offline.", + "needPermission": "{{count}} precisam de permissão", + "needPermission_one": "{{count}} precisa de permissão", + "needPermission_other": "{{count}} precisam de permissão", + "notFound": "{{count}} não encontrados", + "notFound_one": "{{count}} não encontrado", + "notFound_other": "{{count}} não encontrados", + "browseAnotherFolder": "Procurar outra pasta", + "fileMovedOrDeleted": "Arquivo movido ou excluído", + "grantAccess": "Conceder acesso", + "locate": "Localizar", + "locateFolder": "Localizar pasta", + "permissionExpired": "Permissão expirada", + "scanProjectFolder": "Escanear {{name}}", + "workOffline": "Trabalhar offline" + }, + "orphanedClips": { + "autoMatch": "Correspondencia automatica", + "description": "{{count}} clipe(s) referenciam mídia que não pode mais ser encontrada. Substitua ou remova para continuar.", + "keepAsBroken": "Manter como quebrado", + "removeAll": "Remover tudo", + "select": "Selecionar", + "selectReplacement": "Selecionar substituto", + "title": "Mídia ausente" + }, + "picker": { + "descAll": "Descricao de todos", + "noFiles": "Ainda não há mídia neste projeto.", + "noSearchResults": "Nenhum resultado", + "title": "Escolha uma mídia" + }, + "searchMedia": "Pesquisar midia", + "subtitleScan": { + "cached": "Em cache", + "cancelBatch": "Cancelar tudo", + "descComplete": "Concluido.", + "descScanning": "Escaneando.", + "failed": "Falhou", + "titleComplete": "Busca de legendas concluída", + "titleScanning": "Buscando legendas…" + }, + "transcribe": { + "autoDetect": "Detectar automaticamente", + "generateTitle": "Gerar transcrição", + "language": "Idioma", + "model": "Modelo", + "noLanguages": "Nenhum idioma", + "progressAria": "Progresso", + "quantization": "Quantizacao", + "refreshTitle": "Atualizar transcrição", + "searchLanguages": "Pesquisar idiomas", + "start": "Iniciar", + "stop": "Parar" + }, + "type": { + "audio": "Audio", + "image": "Imagem", + "video": "Video" + }, + "unsupportedCodec": { + "bodyMultiple": "Corpo para multiplos itens", + "bodySingle": "Corpo para item unico", + "cancelImport": "Cancelar importacao", + "importAnyway": "Importar mesmo assim", + "note": "Observacao", + "title": "Codec não suportado" + } + } + }, + "tr": { + "media": { + "card": { + "aiCaptionsCount": "YZ altyazı sayısı", + "analyzeWithAI": "YZ ile analiz et", + "analyzingWithAI": "YZ ile analiz ediliyor", + "chooseMkvOrWebm": "MKV veya WebM seç", + "deleteProxy": "Proxy'yi sil", + "deleteTranscript": "Transkripti sil", + "extractEmbeddedSubtitles": "Gömülü altyazıları çıkar", + "generateProxy": "Proxy oluştur", + "generateTranscript": "Transkript oluştur", + "importing": "İçe aktarılıyor", + "menuAi": "YZ", + "menuCaptions": "Gömülü altyazılar", + "menuFile": "Dosya", + "menuProxy": "Proxy", + "menuTranscript": "Transkripsiyon", + "playAudio": "Sesi oynat", + "refreshTranscript": "Transkripti yenile", + "relinkFile": "Dosyayı yeniden bağla...", + "stopAudio": "Sesi durdur", + "subtitlesCachedAll": "Tüm altyazılar önbellekte", + "subtitlesCachedPartial": "Altyazıların bir kısmı önbellekte", + "subtitlesCannotRead": "FreeCut şu anda \"{{name}}\" dosyasını okuyamadı. Onu kullanan uygulamaları kapatıp tekrar deneyin.", + "subtitlesExtractFailed": "Altyazılar çıkarılamadı", + "subtitlesFileMissing": "FreeCut artık \"{{name}}\" dosyasını bulamıyor.", + "subtitlesNeedPermission": "Altyazıları çıkarmadan önce FreeCut'ın \"{{name}}\" dosyasını okuma iznine ihtiyacı var.", + "subtitlesScanFailed": "Altyazı taraması başarısız", + "transcribeFailed": "Transkripsiyon başarısız", + "transcribing": "Transkripsiyon yapılıyor", + "transcriptDeleteFailed": "Transkript silinemedi", + "transcriptDeleteFailedFor": "\"{{name}}\" için transkript silinemedi", + "transcriptDeletedFor": "\"{{name}}\" için transkript silindi", + "transcriptProgressAria": "Transkript ilerlemesi", + "transcriptReadyFor": "\"{{name}}\" için transkript hazır", + "transcriptionFailedFor": "\"{{name}}\" için transkripsiyon başarısız", + "transcriptsDeleted": "Transkriptler silindi", + "transcriptsReady": "Transkriptler hazır" + }, + "compositions": { + "deleteBody": "Bu bileşik klip silinsin mi?", + "deleteInstancesDetail": "Bu bileşik klibi kullanan {{count}} zaman çizelgesi örneği de kaldırılacak.", + "deleteInstancesTitle": "Bağlı örnekler kaldırılacak", + "deleteTitle": "Bileşik klibi sil", + "enter": "Gir", + "itemCount": "{{count}} öğe", + "rename": "Yeniden adlandır", + "sectionTitle": "Bileşik Klipler" + }, + "deleteDialog": { + "bodyMultiple": "Bu projeden {{count}} medya öğesi kalıcı olarak kaldırılacak. Bu işlem geri alınamaz.", + "bodySingle": "\"{{name}}\" bu projeden kalıcı olarak kaldırılacak. Bu işlem geri alınamaz.", + "confirmWithClips": "Kliplerle Birlikte Sil", + "timelineClipsDetail": "Bu medyayı kullanan {{count}} zaman çizelgesi klibi de kaldırılacak.", + "timelineClipsDetail_one": "Bu medyayı kullanan {{count}} zaman çizelgesi klibi de kaldırılacak.", + "timelineClipsDetail_other": "Bu medyayı kullanan {{count}} zaman çizelgesi klibi de kaldırılacak.", + "timelineClipsRemoved": "Zaman çizelgesi klipleri kaldırılacak", + "titleMultiple": "{{count}} medya öğesi silinsin mi?", + "titleSingle": "Medya öğesi silinsin mi?" + }, + "embeddedSubtitles": { + "badgeAutoPick": "Otomatik", + "badgeDefault": "Varsayılan", + "badgeForced": "Zorunlu", + "cuesCount": "İpucu sayısı", + "desc": "Gömülü altyazı parçalarını seçin.", + "descForFile": "\"{{name}}\" içindeki altyazıları seçin.", + "empty": "Altyazı bulunamadı", + "insert": "Ekle", + "insertWithCues": "{{count}} ipucuyla ekle", + "loadedFromCache": "Önbellekten yüklendi", + "scanning": "Taranıyor", + "title": "Gömülü altyazılar", + "trackInfo": "Parça bilgisi" + }, + "grid": { + "emptyHint": "Dosyaları buraya bırakın veya İçe Aktar'a tıklayın.", + "emptyTitle": "Henüz medya yok", + "loadingSubtitle": "Medya kitaplığı hazırlanıyor.", + "loadingTitle": "Medya yükleniyor…" + }, + "info": { + "codec": "Kodek", + "dimensions": "Boyutlar", + "duration": "Süre", + "fpsValue": "FPS değeri", + "frameRate": "Kare hızı", + "loadingTranscript": "Transkript yükleniyor", + "mediaInfo": "Medya bilgisi", + "openInSourceMonitor": "Kaynak monitöründe aç", + "size": "Boyut", + "transcript": "Transkript", + "transcriptWithCount": "Transkript ({{count}})", + "type": "Tür" + }, + "library": { + "aiAnalysisProgress": "YZ analiz ilerlemesi", + "allTypes": "Tüm türler", + "analyzingMultiple": "Seçili öğeler analiz ediliyor", + "analyzingSingle": "Öğe analiz ediliyor", + "andJoiner": " ve ", + "assetsCount": "{{count}} varlık", + "assetsCount_one": "{{count}} varlık", + "assetsCount_other": "{{count}} varlık", + "back": "Geri", + "cancelAll": "Tümünü iptal et", + "cancelling": "İptal ediliyor", + "clearSelection": "Seçimi temizle", + "compoundClipsCount": "{{count}} bileşik klip", + "compoundClipsCount_one": "{{count}} bileşik klip", + "compoundClipsCount_other": "{{count}} bileşik klip", + "copyToClipboard": "Panoya kopyala", + "deleteAssetsBody": "{{summary}} bu projeden kalıcı olarak kaldırılacak. Bu işlem geri alınamaz.", + "deleteAssetsTitle": "Seçili varlıklar silinsin mi?", + "deleteSelectedAssets": "Seçili öğeleri sil", + "deleteSummary": "{{summary}} sil", + "deleteWithClips": "Kliplerle birlikte sil", + "dismiss": "Kapat", + "dragDropUnsupported": "Sürükle bırak desteklenmiyor", + "dropFilesHere": "Dosyaları buraya bırakın", + "filesRejected": "Dosyalar reddedildi", + "generateProxiesForSelected": "{{count}} seçili öğe için proxy oluştur", + "generatingProxies": "Proxy'ler oluşturuluyor", + "generatingTranscripts": "Transkriptler oluşturuluyor", + "gridItemSize": "Izgara öğe boyutu", + "groupAudio": "Ses", + "groupGifs": "GIF'ler", + "groupImages": "Görseller", + "groupVideos": "Videolar", + "import": "İçe aktar", + "importFromUrlDescription": "Doğrudan URL'den bir medya dosyası içe aktarın.", + "importFromUrlHint": "Video, ses, görüntü veya GIF dosyasına doğrudan bağlantı yapıştırın.", + "importFromUrlTitle": "URL'den içe aktar", + "importMediaFiles": "Medya dosyalarını içe aktar", + "importMediaFromUrl": "URL'den medya içe aktar", + "libraryView": "Kitaplık görünümü", + "linkedInstancesDetail": "Bu varlıkları kullanan {{count}} zaman çizelgesi örneği de kaldırılacak.", + "linkedInstancesDetail_one": "Bu varlıkları kullanan {{count}} zaman çizelgesi örneği de kaldırılacak.", + "linkedInstancesDetail_other": "Bu varlıkları kullanan {{count}} zaman çizelgesi örneği de kaldırılacak.", + "linkedInstancesTitle": "Bağlı zaman çizelgesi örnekleri kaldırılacak", + "mediaItemsCount": "{{count}} medya öğesi", + "mediaItemsCount_one": "{{count}} medya öğesi", + "mediaItemsCount_other": "{{count}} medya öğesi", + "mediaTab": "Medya", + "missingCount": "{{count}} eksik", + "proxyCount": "{{count}} proxy", + "proxyGenerationProgress": "Proxy oluşturma ilerlemesi", + "scenesTab": "Sahneler", + "searchScenes": "Sahnelerde ara", + "selected": "Seçili", + "selectedAssetsCount": "{{count}} seçili varlık", + "selectedAssetsCount_one": "{{count}} seçili varlık", + "selectedAssetsCount_other": "{{count}} seçili varlık", + "showMediaLibrary": "Medya kitaplığını göster", + "sortDate": "Tarihe göre sırala", + "sortName": "Ada göre sırala", + "sortSize": "Boyuta göre sırala", + "transcriptGenerationProgress": "Transkript oluşturma ilerlemesi", + "url": "URL", + "viewMissingMedia": "Eksik medyayı görüntüle", + "allShort": "TÜMÜ", + "typeShort": { + "video": "VİDEO", + "audio": "SES", + "image": "GÖRSEL" + }, + "sortShort": { + "name": "AD", + "date": "TARİH", + "size": "BOYUT" + }, + "missingCount_one": "{{count}} eksik", + "missingCount_other": "{{count}} eksik", + "selectedCount": "{{count}} seçili", + "selectedCount_one": "{{count}} seçili", + "selectedCount_other": "{{count}} seçili", + "generateProxiesForSelected_one": "{{count}} seçili öğe için proxy oluştur", + "generateProxiesForSelected_other": "{{count}} seçili öğe için proxy oluştur", + "proxyCount_one": "{{count}} proxy", + "proxyCount_other": "{{count}} proxy", + "perItemProgress": "Öğe başına ilerlemeyi göster" + }, + "missingMedia": { + "title": "Eksik medya", + "description": "FreeCut {{count}} medya dosyasına erişemiyor. Dosyaları bulun veya çevrimdışı çalışmaya devam edin.", + "description_one": "FreeCut {{count}} medya dosyasına erişemiyor. Dosyayı bulun veya çevrimdışı çalışmaya devam edin.", + "description_other": "FreeCut {{count}} medya dosyasına erişemiyor. Dosyaları bulun veya çevrimdışı çalışmaya devam edin.", + "needPermission": "{{count}} izin gerektiriyor", + "needPermission_one": "{{count}} izin gerektiriyor", + "needPermission_other": "{{count}} izin gerektiriyor", + "notFound": "{{count}} bulunamadı", + "notFound_one": "{{count}} bulunamadı", + "notFound_other": "{{count}} bulunamadı", + "browseAnotherFolder": "Başka klasöre göz at", + "fileMovedOrDeleted": "Dosya taşınmış veya silinmiş", + "grantAccess": "Erişim ver", + "locate": "Bul", + "locateFolder": "Klasörü bul", + "permissionExpired": "İzin süresi doldu", + "scanProjectFolder": "{{name}} klasörünü tara", + "workOffline": "Çevrimdışı çalış" + }, + "orphanedClips": { + "autoMatch": "Otomatik eşleştir", + "description": "{{count}} klip artık bulunamayan medyaya başvuruyor. Devam etmek için değiştirin veya kaldırın.", + "keepAsBroken": "Bozuk olarak tut", + "removeAll": "Tümünü kaldır", + "select": "Seç", + "selectReplacement": "Yedek seç", + "title": "Eksik medya" + }, + "picker": { + "descAll": "Projeden medya seçin.", + "noFiles": "Bu projede henüz medya yok.", + "noSearchResults": "Arama sonucu yok", + "title": "Medya seçin" + }, + "searchMedia": "Medyada ara", + "subtitleScan": { + "cached": "Önbellekte", + "cancelBatch": "Tümünü iptal et", + "descComplete": "Altyazı taraması tamamlandı.", + "descScanning": "Altyazılar taranıyor.", + "failed": "Başarısız", + "titleComplete": "Altyazı taraması tamamlandı", + "titleScanning": "Altyazılar taranıyor…" + }, + "transcribe": { + "autoDetect": "Otomatik algıla", + "generateTitle": "Transkripsiyon oluştur", + "language": "Dil", + "model": "Model", + "noLanguages": "Dil yok", + "progressAria": "İlerleme", + "quantization": "Niceleme", + "refreshTitle": "Transkripsiyonu yenile", + "searchLanguages": "Dillerde ara", + "start": "Başlat", + "stop": "Durdur" + }, + "type": { + "audio": "Ses", + "image": "Görsel", + "video": "Video" + }, + "unsupportedCodec": { + "bodyMultiple": "Bazı dosyalar desteklenmeyen kodekler kullanıyor olabilir.", + "bodySingle": "Bu dosya desteklenmeyen bir kodek kullanıyor olabilir.", + "cancelImport": "İçe aktarmayı iptal et", + "importAnyway": "Yine de içe aktar", + "note": "Not", + "title": "Desteklenmeyen codec" + } + } + }, + "ja": { + "media": { + "card": { + "aiCaptionsCount": "{{count}} 件のAI字幕", + "analyzeWithAI": "AIで解析", + "analyzingWithAI": "AIで解析中", + "chooseMkvOrWebm": "MKV または WebM を選択", + "deleteProxy": "プロキシを削除", + "deleteTranscript": "文字起こしを削除", + "extractEmbeddedSubtitles": "埋め込み字幕を抽出", + "generateProxy": "プロキシを生成", + "generateTranscript": "文字起こしを生成", + "importing": "インポート中", + "menuAi": "AI", + "menuCaptions": "埋め込み字幕", + "menuFile": "ファイル", + "menuProxy": "プロキシ", + "menuTranscript": "文字起こし", + "playAudio": "オーディオを再生", + "refreshTranscript": "文字起こしを更新", + "relinkFile": "ファイルを再リンク…", + "stopAudio": "オーディオを停止", + "subtitlesCachedAll": "字幕をキャッシュ済み", + "subtitlesCachedPartial": "一部の字幕をキャッシュ済み", + "subtitlesCannotRead": "FreeCut は今「{{name}}」を読み込めません。使用中のアプリを閉じて再試行してください。", + "subtitlesExtractFailed": "字幕の抽出に失敗しました", + "subtitlesFileMissing": "FreeCut は「{{name}}」を見つけられなくなりました。", + "subtitlesNeedPermission": "字幕を抽出する前に「{{name}}」へのアクセス許可が必要です。", + "subtitlesScanFailed": "字幕スキャンに失敗しました", + "transcribeFailed": "文字起こしに失敗しました", + "transcribing": "文字起こし中", + "transcriptDeleteFailed": "文字起こしを削除できませんでした", + "transcriptDeleteFailedFor": "「{{name}}」の文字起こしを削除できませんでした", + "transcriptDeletedFor": "「{{name}}」の文字起こしを削除しました", + "transcriptProgressAria": "文字起こしの進行状況", + "transcriptReadyFor": "「{{name}}」の文字起こしが完了しました", + "transcriptionFailedFor": "「{{name}}」の文字起こしに失敗しました", + "transcriptsDeleted": "文字起こしを削除しました", + "transcriptsReady": "文字起こしが完了しました" + }, + "compositions": { + "deleteBody": "複合クリップ「{{name}}」を削除しますか?この操作は元に戻せません。", + "deleteInstancesDetail": "この複合クリップを使用しているタイムライン上の {{count}} 個のインスタンスも削除されます。", + "deleteInstancesTitle": "リンクされたインスタンスが削除されます", + "deleteTitle": "複合クリップを削除", + "enter": "開く", + "itemCount": "{{count}} 個の項目", + "rename": "名前を変更", + "sectionTitle": "複合クリップ" + }, + "deleteDialog": { + "bodyMultiple": "{{count}} 個のメディア項目をこのプロジェクトから完全に削除します。この操作は元に戻せません。", + "bodySingle": "\"{{name}}\" をこのプロジェクトから完全に削除します。この操作は元に戻せません。", + "confirmWithClips": "クリップごと削除", + "timelineClipsDetail": "このメディアを使用しているタイムライン上の {{count}} 個のクリップも削除されます。", + "timelineClipsDetail_one": "このメディアを使用しているタイムライン上の {{count}} 個のクリップも削除されます。", + "timelineClipsDetail_other": "このメディアを使用しているタイムライン上の {{count}} 個のクリップも削除されます。", + "timelineClipsRemoved": "タイムラインのクリップも削除されます", + "titleMultiple": "{{count}} 個のメディア項目を削除しますか?", + "titleSingle": "メディア項目を削除しますか?" + }, + "embeddedSubtitles": { + "badgeAutoPick": "自動", + "badgeDefault": "既定", + "badgeForced": "強制", + "cuesCount": "{{count}} 件", + "desc": "タイムラインに挿入する字幕トラックを選択します。", + "descForFile": "「{{name}}」に含まれる字幕。", + "empty": "このファイルに字幕トラックは見つかりませんでした。", + "insert": "挿入", + "insertWithCues": "挿入 ({{count}} 件)", + "loadedFromCache": "キャッシュから字幕を読み込みました。", + "scanning": "スキャン中…", + "title": "埋め込み字幕", + "trackInfo": "トラック {{n}} · {{lang}} · {{codec}}" + }, + "grid": { + "emptyHint": "ここにファイルをドロップするか、インポートをクリックします。", + "emptyTitle": "メディアはまだありません", + "loadingSubtitle": "メディアを準備中…", + "loadingTitle": "メディアを読み込み中…" + }, + "info": { + "codec": "コーデック", + "dimensions": "サイズ", + "duration": "長さ", + "fpsValue": "{{value}} fps", + "frameRate": "フレームレート", + "loadingTranscript": "文字起こしを読み込み中…", + "mediaInfo": "メディア情報", + "openInSourceMonitor": "ソースモニターで開く", + "size": "サイズ", + "transcript": "文字起こし", + "transcriptWithCount": "文字起こし ({{count}})", + "type": "種類" + }, + "library": { + "aiAnalysisProgress": "AI解析", + "allTypes": "すべての種類", + "analyzingMultiple": "{{count}} 件のクリップを解析中…", + "analyzingSingle": "解析中…", + "andJoiner": " と ", + "assetsCount": "{{count}} 個のアセット", + "assetsCount_one": "{{count}} 個のアセット", + "assetsCount_other": "{{count}} 個のアセット", + "back": "戻る", + "cancelAll": "すべてキャンセル", + "cancelling": "キャンセル中…", + "clearSelection": "選択を解除", + "compoundClipsCount": "{{count}} 個の複合クリップ", + "compoundClipsCount_one": "{{count}} 個の複合クリップ", + "compoundClipsCount_other": "{{count}} 個の複合クリップ", + "copyToClipboard": "クリップボードにコピー", + "deleteAssetsBody": "{{summary}} をこのプロジェクトから完全に削除します。この操作は元に戻せません。", + "deleteAssetsTitle": "選択したアセットを削除しますか?", + "deleteSelectedAssets": "選択した項目を削除", + "deleteSummary": "{{summary}} を削除", + "deleteWithClips": "クリップごと削除", + "dismiss": "閉じる", + "dragDropUnsupported": "ここではドラッグ&ドロップに対応していません。", + "dropFilesHere": "ここにファイルをドロップ", + "filesRejected": "一部のファイルを追加できませんでした。", + "generateProxiesForSelected": "選択した {{count}} 個の項目のプロキシを生成", + "generatingProxies": "プロキシを生成中…", + "generatingTranscripts": "文字起こしを生成中…", + "gridItemSize": "サムネイルサイズ", + "groupAudio": "オーディオグループ", + "groupGifs": "GIFグループ", + "groupImages": "画像グループ", + "groupVideos": "ビデオグループ", + "import": "インポート", + "importFromUrlDescription": "直接URLからメディアファイルを読み込みます。", + "importFromUrlHint": "動画、音声、画像、または GIF ファイルへの直接リンクを貼り付けてください。", + "importFromUrlTitle": "URLから読み込む", + "importMediaFiles": "メディアファイルをインポート", + "importMediaFromUrl": "URLからメディアをインポート", + "libraryView": "ライブラリ表示", + "linkedInstancesDetail": "これらのアセットを使用しているタイムライン上の {{count}} 個のインスタンスも削除されます。", + "linkedInstancesDetail_one": "これらのアセットを使用しているタイムライン上の {{count}} 個のインスタンスも削除されます。", + "linkedInstancesDetail_other": "これらのアセットを使用しているタイムライン上の {{count}} 個のインスタンスも削除されます。", + "linkedInstancesTitle": "リンクされたタイムラインインスタンスも削除されます", + "mediaItemsCount": "{{count}} 個のメディア項目", + "mediaItemsCount_one": "{{count}} 個のメディア項目", + "mediaItemsCount_other": "{{count}} 個のメディア項目", + "mediaTab": "メディア", + "missingCount": "{{count}} 個が見つかりません", + "proxyCount": "{{count}} 個のプロキシ", + "proxyGenerationProgress": "プロキシの進行状況", + "scenesTab": "シーン", + "searchScenes": "シーンを検索", + "selected": "選択済み", + "selectedAssetsCount": "{{count}} 個の選択済みアセット", + "selectedAssetsCount_one": "{{count}} 個の選択済みアセット", + "selectedAssetsCount_other": "{{count}} 個の選択済みアセット", + "showMediaLibrary": "メディアライブラリを表示", + "sortDate": "日付で並べ替え", + "sortName": "名前で並べ替え", + "sortSize": "サイズで並べ替え", + "transcriptGenerationProgress": "文字起こしの進行状況", + "url": "URL", + "viewMissingMedia": "見つからないメディアを表示", + "allShort": "すべて", + "typeShort": { + "video": "ビデオ", + "audio": "音声", + "image": "画像" + }, + "sortShort": { + "name": "名前", + "date": "日付", + "size": "サイズ" + }, + "missingCount_one": "{{count}} 個が見つかりません", + "missingCount_other": "{{count}} 個が見つかりません", + "selectedCount": "{{count}} 個選択済み", + "selectedCount_one": "{{count}} 個選択済み", + "selectedCount_other": "{{count}} 個選択済み", + "generateProxiesForSelected_one": "選択した {{count}} 個の項目のプロキシを生成", + "generateProxiesForSelected_other": "選択した {{count}} 個の項目のプロキシを生成", + "proxyCount_one": "{{count}} 個のプロキシ", + "proxyCount_other": "{{count}} 個のプロキシ", + "perItemProgress": "項目ごとの進捗を表示" + }, + "missingMedia": { + "title": "メディアが見つかりません", + "description": "FreeCut は {{count}} 件のメディアファイルにアクセスできません。ファイルを指定するか、オフラインで作業を続けてください。", + "description_one": "FreeCut は {{count}} 件のメディアファイルにアクセスできません。ファイルを指定するか、オフラインで作業を続けてください。", + "description_other": "FreeCut は {{count}} 件のメディアファイルにアクセスできません。ファイルを指定するか、オフラインで作業を続けてください。", + "needPermission": "{{count}} 件に権限が必要です", + "needPermission_one": "{{count}} 件に権限が必要です", + "needPermission_other": "{{count}} 件に権限が必要です", + "notFound": "{{count}} 件が見つかりません", + "notFound_one": "{{count}} 件が見つかりません", + "notFound_other": "{{count}} 件が見つかりません", + "browseAnotherFolder": "別のフォルダーを参照", + "fileMovedOrDeleted": "ファイルが移動または削除されました", + "grantAccess": "アクセスを許可", + "locate": "指定", + "locateFolder": "フォルダーを指定", + "permissionExpired": "権限の有効期限切れ", + "scanProjectFolder": "{{name}} をスキャン", + "workOffline": "オフラインで作業" + }, + "orphanedClips": { + "autoMatch": "自動マッチング", + "description": "{{count}} 件のクリップが見つからないメディアを参照しています。続行するには置換または削除してください。", + "keepAsBroken": "壊れたまま保持", + "removeAll": "すべて削除", + "select": "選択", + "selectReplacement": "「{{name}}」の代替を選択", + "title": "メディアが見つかりません" + }, + "picker": { + "descAll": "プロジェクトのライブラリからファイルを選びます。", + "noFiles": "このプロジェクトにはまだメディアがありません。", + "noSearchResults": "結果が見つかりません。", + "title": "メディアを選択" + }, + "searchMedia": "メディアを検索", + "subtitleScan": { + "cached": "キャッシュ済み", + "cancelBatch": "すべてキャンセル", + "descComplete": "字幕スキャンが完了しました。", + "descScanning": "メディアの字幕をスキャン中…", + "failed": "失敗", + "titleComplete": "字幕スキャンが完了しました", + "titleScanning": "字幕をスキャン中…" + }, + "transcribe": { + "autoDetect": "自動検出", + "generateTitle": "文字起こしを生成", + "language": "言語", + "model": "モデル", + "noLanguages": "利用できる言語がありません。", + "progressAria": "文字起こしの進行状況", + "quantization": "量子化", + "refreshTitle": "文字起こしを更新", + "searchLanguages": "言語を検索", + "start": "開始", + "stop": "停止" + }, + "type": { + "audio": "音声", + "image": "画像", + "video": "ビデオ" + }, + "unsupportedCodec": { + "bodyMultiple": "{{count}} 件のファイルはブラウザがデコードできないコーデックを使用しています。正しく再生されない可能性があります。", + "bodySingle": "このファイルはブラウザがデコードできないコーデックを使用しています。正しく再生されない可能性があります。", + "cancelImport": "インポートをキャンセル", + "importAnyway": "それでもインポート", + "note": "互換性を高めるには MP4 (H.264/AAC) または WebM に変換してください。", + "title": "サポートされていないコーデック" + } + } + }, + "ko": { + "media": { + "card": { + "aiCaptionsCount": "AI 자막 {{count}}개", + "analyzeWithAI": "AI로 분석", + "analyzingWithAI": "AI로 분석 중", + "chooseMkvOrWebm": "MKV 또는 WebM 선택", + "deleteProxy": "프록시 삭제", + "deleteTranscript": "전사 삭제", + "extractEmbeddedSubtitles": "포함된 자막 추출", + "generateProxy": "프록시 생성", + "generateTranscript": "전사 생성", + "importing": "가져오는 중", + "menuAi": "AI", + "menuCaptions": "포함된 자막", + "menuFile": "파일", + "menuProxy": "프록시", + "menuTranscript": "전사", + "playAudio": "오디오 재생", + "refreshTranscript": "전사 새로 고침", + "relinkFile": "파일 다시 연결…", + "stopAudio": "오디오 정지", + "subtitlesCachedAll": "자막 캐시 완료", + "subtitlesCachedPartial": "자막이 일부만 캐시됨", + "subtitlesCannotRead": "FreeCut가 지금 \"{{name}}\"을(를) 읽을 수 없습니다. 사용 중인 앱을 닫고 다시 시도하세요.", + "subtitlesExtractFailed": "자막 추출에 실패했습니다", + "subtitlesFileMissing": "FreeCut가 더 이상 \"{{name}}\"을(를) 찾을 수 없습니다.", + "subtitlesNeedPermission": "자막을 추출하기 전에 \"{{name}}\"에 대한 권한이 필요합니다.", + "subtitlesScanFailed": "자막 검색 실패", + "transcribeFailed": "전사 실패", + "transcribing": "전사 중", + "transcriptDeleteFailed": "전사 삭제 실패", + "transcriptDeleteFailedFor": "\"{{name}}\"의 전사를 삭제할 수 없습니다", + "transcriptDeletedFor": "\"{{name}}\"의 전사를 삭제했습니다", + "transcriptProgressAria": "전사 진행률", + "transcriptReadyFor": "\"{{name}}\"의 전사가 준비되었습니다", + "transcriptionFailedFor": "\"{{name}}\"의 전사에 실패했습니다", + "transcriptsDeleted": "전사가 삭제되었습니다", + "transcriptsReady": "전사가 준비되었습니다" + }, + "compositions": { + "deleteBody": "복합 클립 \"{{name}}\"을(를) 삭제할까요? 이 작업은 되돌릴 수 없습니다.", + "deleteInstancesDetail": "이 복합 클립을 사용하는 타임라인 인스턴스 {{count}}개도 함께 제거됩니다.", + "deleteInstancesTitle": "연결된 인스턴스가 제거됩니다", + "deleteTitle": "복합 클립 삭제", + "enter": "열기", + "itemCount": "{{count}}개 항목", + "rename": "이름 변경", + "sectionTitle": "복합 클립" + }, + "deleteDialog": { + "bodyMultiple": "이 프로젝트에서 미디어 항목 {{count}}개를 영구적으로 제거합니다. 이 작업은 되돌릴 수 없습니다.", + "bodySingle": "이 프로젝트에서 \"{{name}}\"을(를) 영구적으로 제거합니다. 이 작업은 되돌릴 수 없습니다.", + "confirmWithClips": "클립과 함께 삭제", + "timelineClipsDetail": "이 미디어를 사용하는 타임라인 클립 {{count}}개도 함께 제거됩니다.", + "timelineClipsDetail_one": "이 미디어를 사용하는 타임라인 클립 {{count}}개도 함께 제거됩니다.", + "timelineClipsDetail_other": "이 미디어를 사용하는 타임라인 클립 {{count}}개도 함께 제거됩니다.", + "timelineClipsRemoved": "타임라인 클립도 제거됩니다", + "titleMultiple": "미디어 항목 {{count}}개를 삭제할까요?", + "titleSingle": "미디어 항목을 삭제할까요?" + }, + "embeddedSubtitles": { + "badgeAutoPick": "자동", + "badgeDefault": "기본", + "badgeForced": "강제", + "cuesCount": "{{count}}개 항목", + "desc": "타임라인에 삽입할 자막 트랙을 선택하세요.", + "descForFile": "\"{{name}}\"에 포함된 자막입니다.", + "empty": "이 파일에서 자막 트랙을 찾을 수 없습니다.", + "insert": "삽입", + "insertWithCues": "삽입 ({{count}}개)", + "loadedFromCache": "캐시에서 자막을 불러왔습니다.", + "scanning": "검색 중…", + "title": "포함된 자막", + "trackInfo": "트랙 {{n}} · {{lang}} · {{codec}}" + }, + "grid": { + "emptyHint": "파일을 끌어다 놓거나 가져오기를 클릭하세요.", + "emptyTitle": "아직 미디어가 없습니다", + "loadingSubtitle": "미디어를 준비하는 중…", + "loadingTitle": "미디어를 불러오는 중…" + }, + "info": { + "codec": "코덱", + "dimensions": "크기", + "duration": "길이", + "fpsValue": "{{value}} fps", + "frameRate": "프레임 속도", + "loadingTranscript": "전사를 불러오는 중…", + "mediaInfo": "미디어 정보", + "openInSourceMonitor": "소스 모니터에서 열기", + "size": "크기", + "transcript": "전사", + "transcriptWithCount": "전사 ({{count}})", + "type": "유형" + }, + "library": { + "aiAnalysisProgress": "AI 분석", + "allTypes": "모든 유형", + "analyzingMultiple": "클립 {{count}}개 분석 중…", + "analyzingSingle": "분석 중…", + "andJoiner": " 및 ", + "assetsCount": "에셋 {{count}}개", + "assetsCount_one": "에셋 {{count}}개", + "assetsCount_other": "에셋 {{count}}개", + "back": "뒤로", + "cancelAll": "모두 취소", + "cancelling": "취소 중…", + "clearSelection": "선택 해제", + "compoundClipsCount": "복합 클립 {{count}}개", + "compoundClipsCount_one": "복합 클립 {{count}}개", + "compoundClipsCount_other": "복합 클립 {{count}}개", + "copyToClipboard": "클립보드에 복사", + "deleteAssetsBody": "이 프로젝트에서 {{summary}}을(를) 영구적으로 제거합니다. 이 작업은 되돌릴 수 없습니다.", + "deleteAssetsTitle": "선택한 에셋을 삭제할까요?", + "deleteSelectedAssets": "선택한 항목 삭제", + "deleteSummary": "{{summary}} 삭제", + "deleteWithClips": "클립과 함께 삭제", + "dismiss": "닫기", + "dragDropUnsupported": "여기에서는 끌어다 놓기를 지원하지 않습니다.", + "dropFilesHere": "파일을 여기에 놓기", + "filesRejected": "일부 파일을 추가할 수 없습니다.", + "generateProxiesForSelected": "선택한 항목 {{count}}개의 프록시 생성", + "generatingProxies": "프록시 생성 중…", + "generatingTranscripts": "전사 생성 중…", + "gridItemSize": "썸네일 크기", + "groupAudio": "오디오 그룹", + "groupGifs": "GIF 그룹", + "groupImages": "이미지 그룹", + "groupVideos": "비디오 그룹", + "import": "가져오기", + "importFromUrlDescription": "직접 URL에서 미디어 파일을 가져옵니다.", + "importFromUrlHint": "비디오, 오디오, 이미지 또는 GIF 파일의 직접 링크를 붙여넣으세요.", + "importFromUrlTitle": "URL에서 가져오기", + "importMediaFiles": "미디어 파일 가져오기", + "importMediaFromUrl": "URL에서 미디어 가져오기", + "libraryView": "라이브러리 보기", + "linkedInstancesDetail": "이 에셋을 사용하는 타임라인 인스턴스 {{count}}개도 함께 제거됩니다.", + "linkedInstancesDetail_one": "이 에셋을 사용하는 타임라인 인스턴스 {{count}}개도 함께 제거됩니다.", + "linkedInstancesDetail_other": "이 에셋을 사용하는 타임라인 인스턴스 {{count}}개도 함께 제거됩니다.", + "linkedInstancesTitle": "연결된 타임라인 인스턴스도 제거됩니다", + "mediaItemsCount": "미디어 항목 {{count}}개", + "mediaItemsCount_one": "미디어 항목 {{count}}개", + "mediaItemsCount_other": "미디어 항목 {{count}}개", + "mediaTab": "미디어", + "missingCount": "{{count}}개 누락", + "proxyCount": "프록시 {{count}}개", + "proxyGenerationProgress": "프록시 진행률", + "scenesTab": "장면", + "searchScenes": "장면 검색", + "selected": "선택됨", + "selectedAssetsCount": "선택한 에셋 {{count}}개", + "selectedAssetsCount_one": "선택한 에셋 {{count}}개", + "selectedAssetsCount_other": "선택한 에셋 {{count}}개", + "showMediaLibrary": "미디어 라이브러리 표시", + "sortDate": "날짜순 정렬", + "sortName": "이름순 정렬", + "sortSize": "크기순 정렬", + "transcriptGenerationProgress": "전사 진행률", + "url": "URL", + "viewMissingMedia": "누락된 미디어 보기", + "allShort": "전체", + "typeShort": { + "video": "비디오", + "audio": "오디오", + "image": "이미지" + }, + "sortShort": { + "name": "이름", + "date": "날짜", + "size": "크기" + }, + "missingCount_one": "{{count}}개 누락", + "missingCount_other": "{{count}}개 누락", + "selectedCount": "{{count}}개 선택됨", + "selectedCount_one": "{{count}}개 선택됨", + "selectedCount_other": "{{count}}개 선택됨", + "generateProxiesForSelected_one": "선택한 항목 {{count}}개의 프록시 생성", + "generateProxiesForSelected_other": "선택한 항목 {{count}}개의 프록시 생성", + "proxyCount_one": "프록시 {{count}}개", + "proxyCount_other": "프록시 {{count}}개", + "perItemProgress": "항목별 진행률 표시" + }, + "missingMedia": { + "title": "누락된 미디어", + "description": "FreeCut가 미디어 파일 {{count}}개에 접근할 수 없습니다. 파일을 찾거나 오프라인으로 계속 작업하세요.", + "description_one": "FreeCut가 미디어 파일 {{count}}개에 접근할 수 없습니다. 파일을 찾거나 오프라인으로 계속 작업하세요.", + "description_other": "FreeCut가 미디어 파일 {{count}}개에 접근할 수 없습니다. 파일을 찾거나 오프라인으로 계속 작업하세요.", + "needPermission": "{{count}}개 권한 필요", + "needPermission_one": "{{count}}개 권한 필요", + "needPermission_other": "{{count}}개 권한 필요", + "notFound": "{{count}}개 찾을 수 없음", + "notFound_one": "{{count}}개 찾을 수 없음", + "notFound_other": "{{count}}개 찾을 수 없음", + "browseAnotherFolder": "다른 폴더 찾아보기", + "fileMovedOrDeleted": "파일이 이동되었거나 삭제됨", + "grantAccess": "접근 허용", + "locate": "찾기", + "locateFolder": "폴더 찾기", + "permissionExpired": "권한 만료됨", + "scanProjectFolder": "{{name}} 검사", + "workOffline": "오프라인으로 작업" + }, + "orphanedClips": { + "autoMatch": "자동 매칭", + "description": "{{count}}개의 클립이 더 이상 찾을 수 없는 미디어를 참조합니다. 계속하려면 교체하거나 제거하세요.", + "keepAsBroken": "끊긴 상태로 유지", + "removeAll": "모두 제거", + "select": "선택", + "selectReplacement": "\"{{name}}\"의 대체 파일 선택", + "title": "누락된 미디어" + }, + "picker": { + "descAll": "프로젝트 라이브러리에서 파일을 선택하세요.", + "noFiles": "이 프로젝트에는 아직 미디어가 없습니다.", + "noSearchResults": "결과가 없습니다.", + "title": "미디어 선택" + }, + "searchMedia": "미디어 검색", + "subtitleScan": { + "cached": "캐시됨", + "cancelBatch": "모두 취소", + "descComplete": "자막 검색을 완료했습니다.", + "descScanning": "미디어에서 자막을 검색하는 중…", + "failed": "실패", + "titleComplete": "자막 검색을 완료했습니다", + "titleScanning": "자막을 검색하는 중…" + }, + "transcribe": { + "autoDetect": "자동 감지", + "generateTitle": "전사 생성", + "language": "언어", + "model": "모델", + "noLanguages": "사용 가능한 언어가 없습니다.", + "progressAria": "전사 진행률", + "quantization": "양자화", + "refreshTitle": "전사 새로 고침", + "searchLanguages": "언어 검색", + "start": "시작", + "stop": "정지" + }, + "type": { + "audio": "오디오", + "image": "이미지", + "video": "비디오" + }, + "unsupportedCodec": { + "bodyMultiple": "{{count}}개 파일이 브라우저가 디코딩할 수 없는 코덱을 사용합니다. 제대로 재생되지 않을 수 있습니다.", + "bodySingle": "이 파일은 브라우저가 디코딩할 수 없는 코덱을 사용합니다. 제대로 재생되지 않을 수 있습니다.", + "cancelImport": "가져오기 취소", + "importAnyway": "그래도 가져오기", + "note": "호환성을 위해 MP4 (H.264/AAC) 또는 WebM으로 변환하세요.", + "title": "지원되지 않는 코덱" + } + } + }, + "zh": { + "media": { + "card": { + "aiCaptionsCount": "AI 字幕数", + "analyzeWithAI": "用 AI 分析", + "analyzingWithAI": "正在用 AI 分析", + "chooseMkvOrWebm": "选择 MKV 或 WebM", + "deleteProxy": "删除代理", + "deleteTranscript": "删除转录", + "extractEmbeddedSubtitles": "提取内嵌字幕", + "generateProxy": "生成代理", + "generateTranscript": "生成转录", + "importing": "正在导入", + "menuAi": "AI", + "menuCaptions": "嵌入字幕", + "menuFile": "文件", + "menuProxy": "代理", + "menuTranscript": "字幕", + "playAudio": "播放音频", + "refreshTranscript": "刷新转录", + "relinkFile": "重新链接文件...", + "stopAudio": "停止音频", + "subtitlesCachedAll": "字幕已全部缓存", + "subtitlesCachedPartial": "字幕已部分缓存", + "subtitlesCannotRead": "FreeCut 当前无法读取 \"{{name}}\"。请关闭正在使用它的应用后重试。", + "subtitlesExtractFailed": "字幕提取失败", + "subtitlesFileMissing": "FreeCut 找不到 \"{{name}}\"。", + "subtitlesNeedPermission": "提取字幕前,FreeCut 需要读取 \"{{name}}\" 的权限。", + "subtitlesScanFailed": "字幕扫描失败", + "transcribeFailed": "转录失败", + "transcribing": "正在转录", + "transcriptDeleteFailed": "转录删除失败", + "transcriptDeleteFailedFor": "无法删除 \"{{name}}\" 的字幕", + "transcriptDeletedFor": "已删除 \"{{name}}\" 的字幕", + "transcriptProgressAria": "转录进度", + "transcriptReadyFor": "\"{{name}}\" 的字幕已准备好", + "transcriptionFailedFor": "\"{{name}}\" 的字幕生成失败", + "transcriptsDeleted": "转录已删除", + "transcriptsReady": "转录已准备就绪" + }, + "compositions": { + "deleteBody": "要删除复合剪辑“{{name}}”吗?此操作无法撤消。", + "deleteInstancesDetail": "使用此复合剪辑的 {{count}} 个时间线实例也将被移除。", + "deleteInstancesTitle": "已链接的实例将被移除", + "deleteTitle": "删除复合剪辑", + "enter": "进入", + "itemCount": "{{count}} 个项目", + "rename": "重命名", + "sectionTitle": "复合剪辑" + }, + "deleteDialog": { + "bodyMultiple": "这会从此项目中永久移除 {{count}} 个媒体项。此操作无法撤消。", + "bodySingle": "这会从此项目中永久移除“{{name}}”。此操作无法撤消。", + "confirmWithClips": "连同片段删除", + "timelineClipsDetail": "使用此媒体的 {{count}} 个时间线片段也会被移除。", + "timelineClipsDetail_one": "使用此媒体的 {{count}} 个时间线片段也会被移除。", + "timelineClipsDetail_other": "使用此媒体的 {{count}} 个时间线片段也会被移除。", + "timelineClipsRemoved": "时间线片段也会被移除", + "titleMultiple": "删除 {{count}} 个媒体项?", + "titleSingle": "删除媒体项?" + }, + "embeddedSubtitles": { + "badgeAutoPick": "自动", + "badgeDefault": "默认", + "badgeForced": "强制", + "cuesCount": "{{count}} 条", + "desc": "选择要插入到时间轴的字幕轨道。", + "descForFile": "\"{{name}}\" 中可用的字幕。", + "empty": "在此文件中未找到字幕轨道。", + "insert": "插入", + "insertWithCues": "插入({{count}} 条)", + "loadedFromCache": "已从缓存加载字幕。", + "scanning": "正在扫描…", + "title": "嵌入字幕", + "trackInfo": "轨道 {{n}} · {{lang}} · {{codec}}" + }, + "grid": { + "emptyHint": "将文件拖到此处,或点击导入添加媒体。", + "emptyTitle": "暂无媒体", + "loadingSubtitle": "正在准备媒体…", + "loadingTitle": "正在加载媒体…" + }, + "info": { + "codec": "编解码器", + "dimensions": "尺寸", + "duration": "时长", + "fpsValue": "{{value}} fps", + "frameRate": "帧率", + "loadingTranscript": "正在加载字幕…", + "mediaInfo": "媒体信息", + "openInSourceMonitor": "在源监视器中打开", + "size": "大小", + "transcript": "字幕", + "transcriptWithCount": "字幕({{count}})", + "type": "类型" + }, + "library": { + "aiAnalysisProgress": "AI 分析", + "allTypes": "所有类型", + "analyzingMultiple": "正在分析 {{count}} 个片段…", + "analyzingSingle": "正在分析…", + "andJoiner": "和", + "assetsCount": "{{count}} 个素材", + "assetsCount_one": "{{count}} 个素材", + "assetsCount_other": "{{count}} 个素材", + "back": "返回", + "cancelAll": "全部取消", + "cancelling": "正在取消…", + "clearSelection": "清除选择", + "compoundClipsCount": "{{count}} 个复合片段", + "compoundClipsCount_one": "{{count}} 个复合片段", + "compoundClipsCount_other": "{{count}} 个复合片段", + "copyToClipboard": "复制到剪贴板", + "deleteAssetsBody": "这会从此项目中永久移除 {{summary}}。此操作无法撤消。", + "deleteAssetsTitle": "删除所选素材?", + "deleteSelectedAssets": "删除所选项", + "deleteSummary": "删除 {{summary}}", + "deleteWithClips": "连同片段删除", + "dismiss": "关闭", + "dragDropUnsupported": "此处不支持拖放操作。", + "dropFilesHere": "将文件拖到此处", + "filesRejected": "部分文件无法添加。", + "generateProxiesForSelected": "为 {{count}} 个所选项生成代理", + "generatingProxies": "正在生成代理…", + "generatingTranscripts": "正在生成字幕…", + "gridItemSize": "缩略图大小", + "groupAudio": "音频分组", + "groupGifs": "GIF 分组", + "groupImages": "图像分组", + "groupVideos": "视频分组", + "import": "导入", + "importFromUrlDescription": "从直接 URL 导入媒体文件。", + "importFromUrlHint": "粘贴指向视频、音频、图片或 GIF 文件的直接链接。", + "importFromUrlTitle": "从 URL 导入", + "importMediaFiles": "导入媒体文件", + "importMediaFromUrl": "从 URL 导入媒体", + "libraryView": "媒体库视图", + "linkedInstancesDetail": "使用这些素材的 {{count}} 个时间线实例也会被移除。", + "linkedInstancesDetail_one": "使用这些素材的 {{count}} 个时间线实例也会被移除。", + "linkedInstancesDetail_other": "使用这些素材的 {{count}} 个时间线实例也会被移除。", + "linkedInstancesTitle": "关联的时间线实例也会被移除", + "mediaItemsCount": "{{count}} 个媒体项", + "mediaItemsCount_one": "{{count}} 个媒体项", + "mediaItemsCount_other": "{{count}} 个媒体项", + "mediaTab": "媒体", + "missingCount": "{{count}} 个缺失", + "proxyCount": "{{count}} 个代理", + "proxyGenerationProgress": "代理生成进度", + "scenesTab": "场景", + "searchScenes": "搜索场景", + "selected": "已选", + "selectedAssetsCount": "{{count}} 个所选素材", + "selectedAssetsCount_one": "{{count}} 个所选素材", + "selectedAssetsCount_other": "{{count}} 个所选素材", + "showMediaLibrary": "显示媒体库", + "sortDate": "按日期排序", + "sortName": "按名称排序", + "sortSize": "按大小排序", + "transcriptGenerationProgress": "字幕生成进度", + "url": "URL", + "viewMissingMedia": "查看缺失媒体", + "allShort": "全部", + "typeShort": { + "video": "视频", + "audio": "音频", + "image": "图像" + }, + "sortShort": { + "name": "名称", + "date": "日期", + "size": "大小" + }, + "missingCount_one": "{{count}} 个缺失", + "missingCount_other": "{{count}} 个缺失", + "selectedCount": "{{count}} 个已选", + "selectedCount_one": "{{count}} 个已选", + "selectedCount_other": "{{count}} 个已选", + "generateProxiesForSelected_one": "为 {{count}} 个所选项生成代理", + "generateProxiesForSelected_other": "为 {{count}} 个所选项生成代理", + "proxyCount_one": "{{count}} 个代理", + "proxyCount_other": "{{count}} 个代理", + "perItemProgress": "显示各项进度" + }, + "missingMedia": { + "title": "缺失媒体", + "description": "FreeCut 无法访问 {{count}} 个媒体文件。请定位文件,或继续离线工作。", + "description_one": "FreeCut 无法访问 {{count}} 个媒体文件。请定位文件,或继续离线工作。", + "description_other": "FreeCut 无法访问 {{count}} 个媒体文件。请定位文件,或继续离线工作。", + "needPermission": "{{count}} 个需要权限", + "needPermission_one": "{{count}} 个需要权限", + "needPermission_other": "{{count}} 个需要权限", + "notFound": "{{count}} 个未找到", + "notFound_one": "{{count}} 个未找到", + "notFound_other": "{{count}} 个未找到", + "browseAnotherFolder": "浏览其他文件夹", + "fileMovedOrDeleted": "文件已移动或删除", + "grantAccess": "授予访问权限", + "locate": "定位", + "locateFolder": "定位文件夹", + "permissionExpired": "权限已过期", + "scanProjectFolder": "扫描 {{name}}", + "workOffline": "离线工作" + }, + "orphanedClips": { + "autoMatch": "自动匹配", + "description": "{{count}} 个片段引用的媒体已找不到。请替换或删除以继续。", + "keepAsBroken": "保留为损坏", + "removeAll": "全部删除", + "select": "选择", + "selectReplacement": "为 \"{{name}}\" 选择替代文件", + "title": "缺失的媒体" + }, + "picker": { + "descAll": "从项目媒体库中选择文件。", + "noFiles": "此项目中暂无媒体。", + "noSearchResults": "未找到结果。", + "title": "选择媒体" + }, + "searchMedia": "搜索媒体", + "subtitleScan": { + "cached": "已缓存", + "cancelBatch": "全部取消", + "descComplete": "字幕扫描已完成。", + "descScanning": "正在扫描媒体中的字幕…", + "failed": "失败", + "titleComplete": "字幕扫描完成", + "titleScanning": "正在扫描字幕…" + }, + "transcribe": { + "autoDetect": "自动检测", + "generateTitle": "生成字幕", + "language": "语言", + "model": "模型", + "noLanguages": "暂无可用语言。", + "progressAria": "字幕进度", + "quantization": "量化", + "refreshTitle": "刷新字幕", + "searchLanguages": "搜索语言", + "start": "开始", + "stop": "停止" + }, + "type": { + "audio": "音频", + "image": "图像", + "video": "视频" + }, + "unsupportedCodec": { + "bodyMultiple": "{{count}} 个文件使用了浏览器无法解码的编解码器,可能无法正常播放。", + "bodySingle": "此文件使用了浏览器无法解码的编解码器,可能无法正常播放。", + "cancelImport": "取消导入", + "importAnyway": "仍然导入", + "note": "为获得更好的兼容性,请转换为 MP4 (H.264/AAC) 或 WebM。", + "title": "不支持的编解码器" + } + } + } +} diff --git a/src/i18n/locales/partials/missing.json b/src/i18n/locales/partials/missing.json deleted file mode 100644 index 4bf02acd7..000000000 --- a/src/i18n/locales/partials/missing.json +++ /dev/null @@ -1,6482 +0,0 @@ -{ - "en": { - "editor": { - "videoSection": { - "cropBottom": "Crop Bottom", - "cropLeft": "Crop Left", - "cropRight": "Crop Right", - "cropTop": "Crop Top", - "cropping": "Cropping", - "fadeIn": "Fade In", - "fadeOut": "Fade Out", - "playback": "Playback", - "resetCropBottom": "Reset Crop Bottom", - "resetCropLeft": "Reset Crop Left", - "resetCropRight": "Reset Crop Right", - "resetCropTop": "Reset Crop Top", - "resetSoftness": "Reset Softness", - "resetSpeed": "Reset Speed", - "resetToZero": "Reset to zero", - "softness": "Softness", - "speed": "Speed" - } - }, - "effects": { - "curves": { - "channel": "Channel", - "dragHint": "Drag points on the {{channel}} curve to adjust tones.", - "resetChannel": "Reset Channel" - }, - "panel": { - "disableEffect": "Disable Effect", - "enableEffect": "Enable Effect", - "off": "Off", - "on": "On", - "removeEffect": "Remove Effect", - "resetToDefaults": "Reset To Defaults" - }, - "section": { - "addEffect": "Add Effect", - "disableAll": "Disable All", - "emptyState": "No effects applied. Add one to get started.", - "enableAll": "Enable All", - "noEffectsFound": "No Effects Found", - "presets": "Presets", - "searchEffects": "Search Effects", - "title": "Effects" - }, - "wheels": { - "resetWheel": "Reset Wheel" - } - }, - "export": { - "audioContainer": { - "aac": "AAC", - "mp3": "MP3", - "wav": "WAV" - }, - "cancelled": { - "message": "The export was cancelled. No file was saved." - }, - "complete": { - "audioSuccess": "Your audio is ready to download.", - "download": "Download", - "fileSizeLabel": "Size", - "timeTakenLabel": "Time", - "videoSuccess": "Your video is ready to download." - }, - "dialog": { - "descCancelled": "The export was cancelled before it finished.", - "descComplete": "Your file is ready.", - "descError": "Something went wrong while exporting.", - "descProgress": "Rendering your video. This can take a few minutes.", - "descSettings": "Configure your export options.", - "titleCancelled": "Export cancelled", - "titleComplete": "Export complete", - "titleError": "Export failed", - "titleProgress": "Exporting video", - "titleSettings": "Export" - }, - "errors": { - "verifyCodec": "Couldn't verify codec support for the current settings." - }, - "progress": { - "cancelExport": "Cancel", - "elapsedLabel": "Elapsed", - "encoding": "Encoding…", - "finalizing": "Finalizing…", - "framesLabel": "Frames", - "keepTabOpen": "Keep this tab open until the export finishes.", - "preparing": "Preparing…", - "rendering": "Rendering…" - }, - "settings": { - "audio": "Audio", - "audioOnlyNote": "Audio-only export — no video will be included.", - "audioQualityHigh": "High", - "audioQualityLow": "Low", - "audioQualityMedium": "Medium", - "audioQualityUltra": "Ultra", - "cannotEncode": "No supported encoder for {{width}}×{{height}} at the selected quality.", - "codec": "Codec", - "codecSupportUnverified": "Couldn't verify codec support. Exporting may still work, but compatibility isn't guaranteed.", - "duration": "Duration", - "embedSubtitles": "Embed subtitles", - "embedSubtitlesDescription": "Include transcript captions as a subtitle track in the exported file.", - "embedSubtitlesMp4Note": "MP4 stores subtitles as a separate track. Some players may not show them by default.", - "embedSubtitlesUnsupported": "{{container}} doesn't support embedded subtitles. Choose MP4, MKV, or WebM.", - "exportAudio": "Export Audio", - "exportRange": "Export range", - "exportType": "Export type", - "exportVideo": "Export Video", - "format": "Format", - "in": "In", - "inOutRangeHint": "Only the selected in/out range will be exported.", - "noTranscriptSegments": "No transcript captions available to embed.", - "out": "Out", - "quality": "Quality", - "qualityHigh": "High", - "qualityLow": "Low", - "qualityMedium": "Medium", - "qualityUltra": "Ultra", - "quicktimeMov": "QuickTime MOV", - "renderWholeProject": "Render whole project", - "resolution": "Resolution", - "resolutionSameAsProject": "Same as project ({{width}}×{{height}})", - "resolutionScaled": "{{p}}p ({{width}}×{{height}})", - "selectCodec": "Select codec", - "selectFormat": "Select format", - "selectQuality": "Select quality", - "selectResolution": "Select resolution", - "video": "Video" - }, - "videoContainer": { - "mp4": "Widely supported, great for sharing", - "mov": "Apple ecosystem, best in Final Cut and QuickTime", - "webm": "Open, modern container for web playback", - "mkv": "Flexible container with strong subtitle support" - } - }, - "media": { - "card": { - "aiCaptionsCount": "AI Captions Count", - "analyzeWithAI": "Analyze with AI", - "analyzingWithAI": "Analyzing with AI", - "chooseMkvOrWebm": "Choose MKV Or WebM", - "deleteProxy": "Delete Proxy", - "deleteTranscript": "Delete Transcript", - "extractEmbeddedSubtitles": "Extract Embedded Subtitles", - "generateProxy": "Generate Proxy", - "generateTranscript": "Generate Transcript", - "importing": "Importing", - "menuAi": "Menu AI", - "menuCaptions": "Embedded captions", - "menuFile": "File", - "menuProxy": "Proxy", - "menuTranscript": "Transcript", - "playAudio": "Play Audio", - "refreshTranscript": "Refresh Transcript", - "relinkFile": "Relink File...", - "stopAudio": "Stop Audio", - "subtitlesCachedAll": "Subtitles Cached All", - "subtitlesCachedPartial": "Subtitles Cached Partial", - "subtitlesCannotRead": "FreeCut could not read \"{{name}}\" right now. Close any app using it and try again.", - "subtitlesExtractFailed": "Subtitles Extract Failed", - "subtitlesFileMissing": "FreeCut can't find \"{{name}}\" anymore.", - "subtitlesNeedPermission": "FreeCut needs permission to read \"{{name}}\" before extracting subtitles.", - "subtitlesScanFailed": "Subtitles Scan Failed", - "transcribeFailed": "Transcribe Failed", - "transcribing": "Transcribing", - "transcriptDeleteFailed": "Transcript Delete Failed", - "transcriptDeleteFailedFor": "Couldn't delete transcript for \"{{name}}\"", - "transcriptDeletedFor": "Transcript deleted for \"{{name}}\"", - "transcriptProgressAria": "Transcript progress", - "transcriptReadyFor": "Transcript ready for \"{{name}}\"", - "transcriptionFailedFor": "Transcription failed for \"{{name}}\"", - "transcriptsDeleted": "Transcripts Deleted", - "transcriptsReady": "Transcripts Ready" - }, - "compositions": { - "deleteBody": "Delete compound clip \"{{name}}\"? This cannot be undone.", - "deleteInstancesDetail": "{{count}} timeline instances that use this compound clip will also be removed.", - "deleteInstancesTitle": "Linked instances will be removed", - "deleteTitle": "Delete Compound Clip", - "enter": "Enter", - "itemCount": "{{count}} items", - "rename": "Rename", - "sectionTitle": "Compound Clips" - }, - "deleteDialog": { - "bodyMultiple": "This will permanently remove {{count}} media items from this project. This action cannot be undone.", - "bodySingle": "This will permanently remove \"{{name}}\" from this project. This action cannot be undone.", - "confirmWithClips": "Delete With Clips", - "timelineClipsDetail": "{{count}} timeline clips that use this media will also be removed.", - "timelineClipsDetail_one": "{{count}} timeline clip that uses this media will also be removed.", - "timelineClipsDetail_other": "{{count}} timeline clips that use this media will also be removed.", - "timelineClipsRemoved": "Timeline clips will be removed", - "titleMultiple": "Delete {{count}} Media Items?", - "titleSingle": "Delete Media Item?" - }, - "embeddedSubtitles": { - "badgeAutoPick": "Auto", - "badgeDefault": "Default", - "badgeForced": "Forced", - "cuesCount": "Cues Count", - "desc": "Description", - "descForFile": "Description For File", - "empty": "Empty", - "insert": "Insert", - "insertWithCues": "Insert With Cues", - "loadedFromCache": "Loaded From Cache", - "scanning": "Scanning", - "title": "Embedded subtitles", - "trackInfo": "Track Info" - }, - "grid": { - "emptyHint": "Drop files here or click Import to add media.", - "emptyTitle": "No media yet", - "loadingSubtitle": "Loading Subtitle", - "loadingTitle": "Loading media…" - }, - "info": { - "codec": "Codec", - "dimensions": "Dimensions", - "duration": "Duration", - "fpsValue": "FPS Value", - "frameRate": "Frame Rate", - "loadingTranscript": "Loading Transcript", - "mediaInfo": "Media info", - "openInSourceMonitor": "Open In Source Monitor", - "size": "Size", - "transcript": "Transcript", - "transcriptWithCount": "Transcript ({{count}})", - "type": "Type" - }, - "library": { - "aiAnalysisProgress": "AI Analysis Progress", - "allTypes": "All Types", - "analyzingMultiple": "Analyzing Multiple", - "analyzingSingle": "Analyzing Single", - "andJoiner": " and ", - "assetsCount": "{{count}} assets", - "assetsCount_one": "{{count}} asset", - "assetsCount_other": "{{count}} assets", - "back": "Back", - "cancelAll": "Cancel All", - "cancelling": "Cancelling", - "clearSelection": "Clear Selection", - "compoundClipsCount": "{{count}} compound clips", - "compoundClipsCount_one": "{{count}} compound clip", - "compoundClipsCount_other": "{{count}} compound clips", - "copyToClipboard": "Copy To Clipboard", - "deleteAssetsBody": "This will permanently remove {{summary}} from this project. This action cannot be undone.", - "deleteAssetsTitle": "Delete Selected Assets?", - "deleteSelectedAssets": "Delete Selected Assets", - "deleteSummary": "Delete {{summary}}", - "deleteWithClips": "Delete With Clips", - "dismiss": "Dismiss", - "dragDropUnsupported": "Drag Drop Unsupported", - "dropFilesHere": "Drop Files Here", - "filesRejected": "Files Rejected", - "generateProxiesForSelected": "Generate proxies for {{count}} selected items", - "generatingProxies": "Generating Proxies", - "generatingTranscripts": "Generating Transcripts", - "gridItemSize": "Grid Item Size", - "groupAudio": "Group Audio", - "groupGifs": "Group GIFs", - "groupImages": "Group Images", - "groupVideos": "Group Videos", - "import": "Import", - "importFromUrlDescription": "Import a media file from a direct URL.", - "importFromUrlHint": "Paste a direct link to a video, audio, image, or GIF file.", - "importFromUrlTitle": "Import From URL", - "importMediaFiles": "Import Media Files", - "importMediaFromUrl": "Import Media From URL", - "libraryView": "Library View", - "linkedInstancesDetail": "{{count}} timeline instances that use these assets will also be removed.", - "linkedInstancesDetail_one": "{{count}} timeline instance that uses these assets will also be removed.", - "linkedInstancesDetail_other": "{{count}} timeline instances that use these assets will also be removed.", - "linkedInstancesTitle": "Linked timeline instances will be removed", - "mediaItemsCount": "{{count}} media items", - "mediaItemsCount_one": "{{count}} media item", - "mediaItemsCount_other": "{{count}} media items", - "mediaTab": "Media", - "missingCount": "{{count}} Missing", - "proxyCount": "{{count}} proxies", - "proxyGenerationProgress": "Proxy Generation Progress", - "scenesTab": "Scenes", - "searchScenes": "Search Scenes", - "selected": "Selected", - "selectedAssetsCount": "{{count}} selected assets", - "selectedAssetsCount_one": "{{count}} selected asset", - "selectedAssetsCount_other": "{{count}} selected assets", - "showMediaLibrary": "Show Media Library", - "sortDate": "Sort Date", - "sortName": "Sort Name", - "sortSize": "Sort Size", - "transcriptGenerationProgress": "Transcript Generation Progress", - "url": "URL", - "viewMissingMedia": "View Missing Media", - "allShort": "ALL", - "typeShort": { - "video": "VIDEO", - "audio": "AUDIO", - "image": "IMAGE" - }, - "sortShort": { - "name": "NAME", - "date": "DATE", - "size": "SIZE" - }, - "missingCount_one": "{{count}} Missing", - "missingCount_other": "{{count}} Missing", - "selectedCount": "{{count}} Selected", - "selectedCount_one": "{{count}} Selected", - "selectedCount_other": "{{count}} Selected", - "generateProxiesForSelected_one": "Generate proxy for {{count}} selected item", - "generateProxiesForSelected_other": "Generate proxies for {{count}} selected items", - "proxyCount_one": "{{count}} proxy", - "proxyCount_other": "{{count}} proxies" - }, - "missingMedia": { - "title": "Missing Media", - "description": "FreeCut cannot access {{count}} media files. Locate the files or keep working offline.", - "description_one": "FreeCut cannot access {{count}} media file. Locate the file or keep working offline.", - "description_other": "FreeCut cannot access {{count}} media files. Locate the files or keep working offline.", - "needPermission": "{{count}} need permission", - "needPermission_one": "{{count}} needs permission", - "needPermission_other": "{{count}} need permission", - "notFound": "{{count}} not found", - "notFound_one": "{{count}} not found", - "notFound_other": "{{count}} not found", - "browseAnotherFolder": "Browse Another Folder", - "fileMovedOrDeleted": "File moved or deleted", - "grantAccess": "Grant Access", - "locate": "Locate", - "locateFolder": "Locate Folder", - "permissionExpired": "Permission expired", - "scanProjectFolder": "Scan {{name}}", - "workOffline": "Work Offline" - }, - "orphanedClips": { - "autoMatch": "Auto Match", - "description": "{{count}} clip(s) reference media that can no longer be found. Replace them or remove them to continue.", - "keepAsBroken": "Keep As Broken", - "removeAll": "Remove All", - "select": "Select", - "selectReplacement": "Select Replacement", - "title": "Missing media" - }, - "picker": { - "descAll": "Description All", - "noFiles": "No media in this project yet.", - "noSearchResults": "No Search Results", - "title": "Choose media" - }, - "searchMedia": "Search Media", - "subtitleScan": { - "cached": "Cached", - "cancelBatch": "Cancel all", - "descComplete": "Description Complete", - "descScanning": "Description Scanning", - "failed": "Failed", - "titleComplete": "Subtitle scan complete", - "titleScanning": "Scanning for subtitles…" - }, - "transcribe": { - "autoDetect": "Auto Detect", - "generateTitle": "Generate transcript", - "language": "Language", - "model": "Model", - "noLanguages": "No Languages", - "progressAria": "Progress ARIA", - "quantization": "Quantization", - "refreshTitle": "Refresh transcript", - "searchLanguages": "Search Languages", - "start": "Start", - "stop": "Stop" - }, - "type": { - "audio": "Audio", - "image": "Image", - "video": "Video" - }, - "unsupportedCodec": { - "bodyMultiple": "Body Multiple", - "bodySingle": "Body Single", - "cancelImport": "Cancel Import", - "importAnyway": "Import Anyway", - "note": "Note", - "title": "Unsupported codec" - } - }, - "preview": { - "align": { - "disableCanvasSnap": "Disable Canvas Snap", - "enableCanvasSnap": "Enable Canvas Snap", - "toggleCanvasSnap": "Toggle Canvas Snap" - }, - "controls": { - "captureFailed": "Couldn't capture the current frame.", - "disableProxyPlayback": "Disable Proxy Playback", - "enableProxyPlayback": "Enable Proxy Playback", - "frameDownloadedNoProject": "Frame downloaded — open a project to save it to the media library.", - "frameDownloadedNotSaved": "Frame downloaded, but couldn't save to the media library: {{message}}", - "frameSaved": "Saved \"{{name}}\" to the media library.", - "goToEnd": "Go To End", - "goToEndTooltip": "Go To End", - "goToStart": "Go To Start", - "goToStartTooltip": "Go To Start", - "nextFrame": "Next Frame", - "nextFrameTooltip": "Next Frame", - "pauseTooltip": "Pause", - "playTooltip": "Play", - "prevFrame": "Prev Frame", - "prevFrameTooltip": "Prev Frame", - "proxyPlaybackOff": "Proxy Playback Off", - "proxyPlaybackOn": "Proxy Playback On", - "saveFrame": "Save frame", - "saveFrameFailed": "Couldn't save the frame.", - "saveFrameTooltip": "Save frame", - "savingFrame": "Saving frame", - "savingFrameTooltip": "Saving frame" - }, - "monitor": { - "mute": "Mute", - "muteShort": "Mute Short", - "muted": "Muted", - "percent": "Percent", - "previewOnlyNote": "Only affects local playback — exports use the project mix.", - "thisDeviceOnly": "This Device Only", - "title": "Preview audio", - "unmute": "Unmute", - "volume": "Volume" - }, - "player": { - "exitFullscreen": "Exit Fullscreen", - "fullscreen": "Fullscreen", - "mute": "Mute", - "pause": "Pause", - "play": "Play", - "seek": "Seek", - "unmute": "Unmute", - "volume": "Volume" - }, - "stage": { - "loadingMedia": "Loading Media", - "videoPreview": "Video Preview" - }, - "zoom": { - "ariaLabel": "ARIA Label", - "auto": "Auto", - "tooltip": "Zoom: {{label}}" - } - }, - "timeline": { - "addAudioTrackHint": "Add Audio Track Hint", - "addVideoTrackHint": "Add Video Track Hint", - "bento": { - "apply": "Apply", - "description": "Arrange {{count}} selected clips into a grid.", - "gap": "Gap", - "noItemsToArrange": "No Items To Arrange", - "padding": "Padding", - "presetNamePlaceholder": "Preset Name Placeholder", - "saveAsPreset": "Save As Preset", - "title": "Bento layout" - }, - "captions": { - "addedFromTranscript": "Added From Transcript", - "addedWithModel": "Added With Model", - "failedGenerateSegment": "Failed Generate Segment", - "failedUpdateSegment": "Failed Update Segment", - "refreshedWithModel": "Refreshed With Model", - "removedFromSegment": "Removed From Segment", - "updatedFromTranscript": "Updated From Transcript", - "updatedWithModel": "Updated With Model" - }, - "clipIndicators": { - "hasKeyframes": "Has Keyframes", - "mask": "Mask", - "mediaMissing": "Media Missing", - "preparingReversed": "Preparing Reversed", - "reversePrepFailedShort": "Reverse Prep Failed Short", - "reversedPlayback": "Reversed Playback", - "reversedPrepFailed": "Reversed Prep Failed", - "reversedPrepared": "Reversed Prepared", - "speed": "Speed: {{speed}}x" - }, - "contextMenu": { - "bentoLayout": "Bento Layout", - "captions": "Captions", - "clearAll": "Clear All", - "clearKeyframes": "Clear Keyframes", - "consolidateCaptionsToSegment": "Consolidate Captions To Segment", - "createCompoundClip": "Create Compound Clip", - "detectScenesAi": "AI ({{model}})", - "detectScenesAndSplit": "Detect Scenes & Split", - "detectScenesFast": "Fast (Histogram)", - "detectingFillers": "Detecting Fillers", - "detectingScenes": "Detecting Scenes", - "detectingSilence": "Detecting Silence", - "dissolveCompoundClip": "Dissolve Compound Clip", - "extractEmbeddedSubtitles": "Extract Embedded Subtitles", - "generateAudioFromText": "Generate Audio From Text", - "generateCaptions": "Generate Captions", - "insertExistingCaptions": "Insert Existing Captions", - "insertFreezeFrame": "Insert Freeze Frame", - "joinSelected": "Join Selected", - "joinWithNext": "Join With Next", - "joinWithPrevious": "Join With Previous", - "linkClips": "Link Clips", - "openCompoundClip": "Open Compound Clip", - "regenerateCaptions": "Regenerate Captions", - "removeFillerWords": "Remove Filler Words", - "removeSilence": "Remove Silence", - "reverse": "Reverse", - "rippleDelete": "Ripple Delete", - "unlinkClips": "Unlink Clips", - "unreverse": "Unreverse", - "updatingCaptions": "Updating Captions" - }, - "fadeHandles": { - "adjustAudioFadeIn": "Adjust audio fade in", - "adjustAudioFadeInCurve": "Adjust Audio Fade In Curve", - "adjustAudioFadeOut": "Adjust audio fade out", - "adjustAudioFadeOutCurve": "Adjust Audio Fade Out Curve", - "adjustVideoFadeIn": "Adjust video fade in", - "adjustVideoFadeOut": "Adjust video fade out" - }, - "fillerRemoval": { - "aboutWillBeRemoved": "About {{duration}} will be removed.", - "add": "Add", - "addPhrase": "Add Phrase", - "addWord": "Add Word", - "cutPadding": "Cut Padding", - "filler": "Filler", - "fillerRange": "filler word", - "found": "Found", - "includeRange": "Include Range", - "maxPhrase": "Max Phrase", - "maxWord": "Max Word", - "noEntriesFound": "No Entries Found", - "noRemovableDetectedShort": "No filler words found.", - "none": "None", - "phrases": "Phrases", - "playThisRange": "Play This Range", - "rangesSelected": "Ranges Selected", - "redo": "Redo", - "redoEditTitle": "Redo last edit", - "remove": "Remove", - "removed": "Removed", - "scoreAudio": "Score Audio", - "scoring": "Scoring", - "title": "Remove filler words", - "toastAudioScored": "Audio confidence scoring done.", - "toastNoRemovable": "No filler words matched the current settings.", - "toastNoneInClips": "No filler words to remove in the selected clips.", - "toastPreviewFailed": "Couldn't preview filler removal.", - "toastRemoveFailed": "Couldn't remove filler words.", - "toastRemoved": "Removed {{count}} filler ranges.", - "toastScoreFailed": "Couldn't analyze audio confidence.", - "undo": "Undo", - "undoEditTitle": "Undo last edit", - "updatePreview": "Update Preview", - "updating": "Updating", - "words": "Words" - }, - "header": { - "addMarker": "Add Marker", - "addMarkerTooltip": "Add Marker", - "clearAllMarkers": "Clear All Markers", - "clearAllMarkersTooltip": "Clear All Markers", - "clearInOutPoints": "Clear In Out Points", - "clearInOutPointsTooltip": "Clear In Out Points", - "controls": "Controls", - "disableLinkedSelection": "Disable Linked Selection", - "disableSnapping": "Disable Snapping", - "enableLinkedSelection": "Enable Linked Selection", - "enableSnapping": "Enable Snapping", - "hideColorScopes": "Hide Color Scopes", - "hideColorScopesTooltip": "Hide Color Scopes", - "linkedSelectionOff": "Linked Selection Off", - "linkedSelectionOn": "Linked Selection On", - "linkedSelectionTooltip": "Linked selection: {{state}} ({{shortcut}})", - "rateStretchTool": "Rate Stretch Tool", - "rateStretchToolTooltip": "Rate Stretch Tool", - "razorTool": "Razor Tool", - "razorToolTooltip": "Razor Tool", - "redo": "Redo", - "redoTooltip": "Redo", - "redoWithLabel": "Redo {{label}}", - "redoWithLabelTooltip": "Redo {{label}}", - "removeSelectedMarker": "Remove Selected Marker", - "removeSelectedMarkerTooltip": "Remove Selected Marker", - "selectTool": "Select Tool", - "selectToolTooltip": "Select Tool", - "setInPoint": "Set In Point", - "setInPointTooltip": "Set In Point", - "setOutPoint": "Set Out Point", - "setOutPointTooltip": "Set Out Point", - "showColorScopes": "Show Color Scopes", - "showColorScopesTooltip": "Show Color Scopes", - "slideTool": "Slide Tool", - "slipSlideTools": "Slip Slide Tools", - "slipSlideToolsTooltip": "Slip Slide Tools", - "slipTool": "Slip Tool", - "snapDisabled": "Snap Disabled", - "snapEnabled": "Snap Enabled", - "title": "Timeline", - "trimEditTool": "Trim Edit Tool", - "trimEditToolTooltip": "Trim Edit Tool", - "undo": "Undo", - "undoTooltip": "Undo", - "undoWithLabel": "Undo {{label}}", - "undoWithLabelTooltip": "Undo {{label}}", - "zoomIn": "Zoom In", - "zoomInTooltip": "Zoom In", - "zoomOut": "Zoom Out", - "zoomOutTooltip": "Zoom Out", - "zoomSlider": "Zoom Slider", - "zoomToFit": "Zoom To Fit", - "zoomToFitTooltip": "Zoom To Fit" - }, - "itemActions": { - "appliedEffectToClips": "Applied Effect To Clips", - "selectAvClipFirst": "Select Av Clip First" - }, - "joinIndicators": { - "canJoinNext": "Can Join Next", - "canJoinPrevious": "Can Join Previous" - }, - "keyframeEditor": { - "bezier": "Bezier", - "custom": "Custom", - "dragHandlesHint": "Drag the handles to shape the curve.", - "graph": "Graph", - "mixedCurves": "Mixed Curves", - "mixedSpring": "Mixed Spring", - "movedKeyframes": "Moved Keyframes", - "noKeyframesPasted": "No Keyframes Pasted", - "pastedKeyframes": "Pasted Keyframes", - "preset": "Preset", - "reasonBlocked": "{{count}} blocked by another keyframe", - "reasonUnsupported": "{{count}} unsupported by the target property", - "selectItem": "Select Item", - "sheet": "Sheet", - "skippedDescription": "Skipped {{count}}: {{reasons}}", - "spring": "Spring", - "springHint": "Spring physics — feels natural and bouncy.", - "title": "Keyframes", - "unableToPasteCut": "Unable To Paste Cut", - "unableToPasteCutDescription": "Cut keyframes can't be pasted here. {{reasons}}" - }, - "noTracksToRemove": "No Tracks To Remove", - "region": "Region", - "removeActiveTrack": "Remove Active Track", - "removeSelectedTracks": "Remove Selected Tracks", - "reverseConform": { - "cancellingDescription": "Cancelling reverse preparation…", - "couldNotPrepare": "Could not prepare clip for reverse playback.", - "progressDescription": "Preparing clip {{current}} of {{total}}…", - "titleCancelling": "Cancelling", - "titleFailed": "Reverse failed", - "titlePreparing": "Preparing reverse playback" - }, - "sceneDetection": { - "detectingScenes": "Detecting Scenes", - "failed": "Failed", - "noScenesDetected": "No Scenes Detected", - "noScenesWithinBounds": "No Scenes Within Bounds", - "noValidSplitPoints": "No Valid Split Points", - "requiresWebGpu": "Requires Web GPU", - "splitAtScenes": "Split At Scenes" - }, - "selectTrackToRemove": "Select Track To Remove", - "silenceRemoval": { - "aboutWillBeRemoved": "About {{duration}} of silence will be removed.", - "keepPadding": "Keep Padding", - "minimumSilence": "Minimum Silence", - "noRemovableDetectedShort": "No silence found.", - "rangesSelected": "Ranges Selected", - "remove": "Remove", - "threshold": "Threshold", - "title": "Remove silence", - "toastNoRemovable": "No silence matched the current settings.", - "toastNoneInClips": "No silence to remove in the selected clips.", - "toastPreviewFailed": "Couldn't preview silence removal.", - "toastRemoved": "Removed {{count}} silent ranges.", - "toastSettingsChanged": "Settings changed — preview before applying.", - "updatePreview": "Update Preview", - "updating": "Updating" - }, - "track": { - "locked": "Locked" - }, - "trackHeader": { - "addAudioTrack": "Add Audio Track", - "addVideoTrack": "Add Video Track", - "clipCount": "Clip Count", - "closeAllGaps": "Close All Gaps", - "deleteEmptyTracks": "Delete Empty Tracks", - "deleteTrack": "Delete Track", - "disableSyncLock": "Disable sync lock", - "disableTrack": "Disable track", - "enableSyncLock": "Enable sync lock", - "enableTrack": "Enable track", - "lockTrack": "Lock Track", - "soloTrack": "Solo Track", - "unlockTrack": "Unlock Track", - "unsoloTrack": "Unsolo Track" - }, - "trackRow": { - "resizeSections": "Resize video and audio track sections", - "resizeTrackHeight": "Resize track height" - }, - "tracks": "Tracks", - "volumeControl": { - "adjustClipVolume": "Adjust clip volume" - } - } - }, - "es": { - "editor": { - "videoSection": { - "cropBottom": "Recorte inferior", - "cropLeft": "Recorte izquierdo", - "cropRight": "Recorte derecho", - "cropTop": "Recorte superior", - "cropping": "Recorte", - "fadeIn": "Fundido de entrada", - "fadeOut": "Fundido de salida", - "playback": "Reproduccion", - "resetCropBottom": "Restablecer recorte inferior", - "resetCropLeft": "Restablecer recorte izquierdo", - "resetCropRight": "Restablecer recorte derecho", - "resetCropTop": "Restablecer recorte superior", - "resetSoftness": "Restablecer suavidad", - "resetSpeed": "Restablecer velocidad", - "resetToZero": "Restablecer a cero", - "softness": "Suavidad", - "speed": "Velocidad" - } - }, - "effects": { - "curves": { - "channel": "Canal", - "dragHint": "Arrastra los puntos de la curva {{channel}} para ajustar los tonos.", - "resetChannel": "Restablecer canal" - }, - "panel": { - "disableEffect": "Desactivar efecto", - "enableEffect": "Activar efecto", - "off": "Desactivado", - "on": "Activado", - "removeEffect": "Eliminar efecto", - "resetToDefaults": "Restablecer valores" - }, - "section": { - "addEffect": "Añadir efecto", - "disableAll": "Desactivar todo", - "emptyState": "No hay efectos aplicados. Añade uno para empezar.", - "enableAll": "Activar todo", - "noEffectsFound": "No se encontraron efectos", - "presets": "Ajustes preestablecidos", - "searchEffects": "Buscar efectos", - "title": "Efectos" - }, - "wheels": { - "resetWheel": "Restablecer rueda" - } - }, - "export": { - "audioContainer": { - "aac": "AAC", - "mp3": "MP3", - "wav": "WAV" - }, - "cancelled": { - "message": "La exportación se canceló. No se guardó ningún archivo." - }, - "complete": { - "audioSuccess": "Tu audio está listo para descargar.", - "download": "Descargar", - "fileSizeLabel": "Tamaño", - "timeTakenLabel": "Tiempo", - "videoSuccess": "Tu vídeo está listo para descargar." - }, - "dialog": { - "descCancelled": "La exportación se canceló antes de terminar.", - "descComplete": "Tu archivo está listo.", - "descError": "Algo salió mal durante la exportación.", - "descProgress": "Estamos renderizando tu vídeo. Esto puede tardar unos minutos.", - "descSettings": "Configura las opciones de exportación.", - "titleCancelled": "Exportación cancelada", - "titleComplete": "Exportación completada", - "titleError": "Error al exportar", - "titleProgress": "Exportando vídeo", - "titleSettings": "Exportar" - }, - "errors": { - "verifyCodec": "No se pudo verificar la compatibilidad del códec con la configuración actual." - }, - "progress": { - "cancelExport": "Cancelar", - "elapsedLabel": "Transcurrido", - "encoding": "Codificando…", - "finalizing": "Finalizando…", - "framesLabel": "Fotogramas", - "keepTabOpen": "Mantén esta pestaña abierta hasta que finalice la exportación.", - "preparing": "Preparando…", - "rendering": "Renderizando…" - }, - "settings": { - "audio": "Audio", - "audioOnlyNote": "Exportación solo de audio: no se incluirá vídeo.", - "audioQualityHigh": "Alta", - "audioQualityLow": "Baja", - "audioQualityMedium": "Media", - "audioQualityUltra": "Ultra", - "cannotEncode": "No hay un codificador compatible para {{width}}×{{height}} con la calidad seleccionada.", - "codec": "Códec", - "codecSupportUnverified": "No se pudo verificar la compatibilidad del códec. La exportación puede funcionar, pero no se garantiza.", - "duration": "Duración", - "embedSubtitles": "Incrustar subtítulos", - "embedSubtitlesDescription": "Incluye los subtítulos de la transcripción como una pista en el archivo exportado.", - "embedSubtitlesMp4Note": "MP4 guarda los subtítulos como una pista aparte. Algunos reproductores pueden no mostrarlos por defecto.", - "embedSubtitlesUnsupported": "{{container}} no admite subtítulos incrustados. Elige MP4, MKV o WebM.", - "exportAudio": "Exportar audio", - "exportRange": "Rango de exportación", - "exportType": "Tipo de exportación", - "exportVideo": "Exportar vídeo", - "format": "Formato", - "in": "Entrada", - "inOutRangeHint": "Solo se exportará el rango de entrada/salida seleccionado.", - "noTranscriptSegments": "No hay subtítulos de transcripción disponibles para incrustar.", - "out": "Salida", - "quality": "Calidad", - "qualityHigh": "Alta", - "qualityLow": "Baja", - "qualityMedium": "Media", - "qualityUltra": "Ultra", - "quicktimeMov": "QuickTime MOV", - "renderWholeProject": "Renderizar todo el proyecto", - "resolution": "Resolución", - "resolutionSameAsProject": "Igual que el proyecto ({{width}}×{{height}})", - "resolutionScaled": "{{p}}p ({{width}}×{{height}})", - "selectCodec": "Selecciona un códec", - "selectFormat": "Selecciona un formato", - "selectQuality": "Selecciona una calidad", - "selectResolution": "Selecciona una resolución", - "video": "Vídeo" - }, - "videoContainer": { - "mp4": "Compatible con casi cualquier reproductor", - "mov": "Ecosistema Apple, ideal para Final Cut y QuickTime", - "webm": "Contenedor abierto y moderno para web", - "mkv": "Contenedor flexible con buen soporte de subtítulos" - } - }, - "media": { - "card": { - "aiCaptionsCount": "{{count}} subtítulos con IA", - "analyzeWithAI": "Analizar con IA", - "analyzingWithAI": "Analizando con IA", - "chooseMkvOrWebm": "Elige MKV o WebM", - "deleteProxy": "Eliminar proxy", - "deleteTranscript": "Eliminar transcripción", - "extractEmbeddedSubtitles": "Extraer subtítulos incrustados", - "generateProxy": "Generar proxy", - "generateTranscript": "Generar transcripción", - "importing": "Importando", - "menuAi": "IA", - "menuCaptions": "Subtítulos incrustados", - "menuFile": "Archivo", - "menuProxy": "Proxy", - "menuTranscript": "Transcripción", - "playAudio": "Reproducir audio", - "refreshTranscript": "Actualizar transcripción", - "relinkFile": "Volver a vincular archivo…", - "stopAudio": "Detener audio", - "subtitlesCachedAll": "Subtítulos en caché", - "subtitlesCachedPartial": "Algunos subtítulos en caché", - "subtitlesCannotRead": "FreeCut no puede leer \"{{name}}\" en este momento. Cierra la app que lo esté usando e inténtalo de nuevo.", - "subtitlesExtractFailed": "No se pudieron extraer los subtítulos", - "subtitlesFileMissing": "FreeCut ya no encuentra \"{{name}}\".", - "subtitlesNeedPermission": "FreeCut necesita permiso para leer \"{{name}}\" antes de extraer los subtítulos.", - "subtitlesScanFailed": "Falló la búsqueda de subtítulos", - "transcribeFailed": "No se pudo transcribir", - "transcribing": "Transcribiendo", - "transcriptDeleteFailed": "No se pudo eliminar la transcripción", - "transcriptDeleteFailedFor": "No se pudo eliminar la transcripción de \"{{name}}\"", - "transcriptDeletedFor": "Transcripción de \"{{name}}\" eliminada", - "transcriptProgressAria": "Progreso de la transcripción", - "transcriptReadyFor": "Transcripción lista para \"{{name}}\"", - "transcriptionFailedFor": "Falló la transcripción de \"{{name}}\"", - "transcriptsDeleted": "Transcripciones eliminadas", - "transcriptsReady": "Transcripciones listas" - }, - "compositions": { - "deleteBody": "¿Eliminar el clip compuesto \"{{name}}\"? Esta acción no se puede deshacer.", - "deleteInstancesDetail": "También se eliminarán {{count}} instancias de la línea de tiempo que usan este clip compuesto.", - "deleteInstancesTitle": "Se eliminarán las instancias vinculadas", - "deleteTitle": "Eliminar clip compuesto", - "enter": "Entrar", - "itemCount": "{{count}} elementos", - "rename": "Renombrar", - "sectionTitle": "Clips compuestos" - }, - "deleteDialog": { - "bodyMultiple": "Esto eliminará permanentemente {{count}} elementos de medios de este proyecto. Esta acción no se puede deshacer.", - "bodySingle": "Esto eliminará permanentemente \"{{name}}\" de este proyecto. Esta acción no se puede deshacer.", - "confirmWithClips": "Eliminar con clips", - "timelineClipsDetail": "También se eliminarán {{count}} clips de la línea de tiempo que usan este medio.", - "timelineClipsDetail_one": "También se eliminará {{count}} clip de la línea de tiempo que usa este medio.", - "timelineClipsDetail_other": "También se eliminarán {{count}} clips de la línea de tiempo que usan este medio.", - "timelineClipsRemoved": "Se eliminarán clips de la línea de tiempo", - "titleMultiple": "¿Eliminar {{count}} elementos de medios?", - "titleSingle": "¿Eliminar elemento de medios?" - }, - "embeddedSubtitles": { - "badgeAutoPick": "Auto", - "badgeDefault": "Predeterminado", - "badgeForced": "Forzado", - "cuesCount": "{{count}} líneas", - "desc": "Selecciona una pista de subtítulos para insertarla en la línea de tiempo.", - "descForFile": "Subtítulos disponibles en \"{{name}}\".", - "empty": "No se encontraron pistas de subtítulos en este archivo.", - "insert": "Insertar", - "insertWithCues": "Insertar ({{count}} líneas)", - "loadedFromCache": "Subtítulos cargados desde la caché.", - "scanning": "Buscando…", - "title": "Subtítulos incrustados", - "trackInfo": "Pista {{n}} · {{lang}} · {{codec}}" - }, - "grid": { - "emptyHint": "Suelta archivos aquí o haz clic en Importar para añadir medios.", - "emptyTitle": "Aún no hay medios", - "loadingSubtitle": "Preparando tus medios…", - "loadingTitle": "Cargando medios…" - }, - "info": { - "codec": "Códec", - "dimensions": "Dimensiones", - "duration": "Duración", - "fpsValue": "{{value}} fps", - "frameRate": "Velocidad de fotogramas", - "loadingTranscript": "Cargando transcripción…", - "mediaInfo": "Información del medio", - "openInSourceMonitor": "Abrir en el monitor de origen", - "size": "Tamaño", - "transcript": "Transcripción", - "transcriptWithCount": "Transcripción ({{count}})", - "type": "Tipo" - }, - "library": { - "aiAnalysisProgress": "Análisis con IA", - "allTypes": "Todos los tipos", - "analyzingMultiple": "Analizando {{count}} clips…", - "analyzingSingle": "Analizando…", - "andJoiner": " y ", - "assetsCount": "{{count}} recursos", - "assetsCount_one": "{{count}} recurso", - "assetsCount_other": "{{count}} recursos", - "back": "Atrás", - "cancelAll": "Cancelar todo", - "cancelling": "Cancelando…", - "clearSelection": "Borrar selección", - "compoundClipsCount": "{{count}} clips compuestos", - "compoundClipsCount_one": "{{count}} clip compuesto", - "compoundClipsCount_other": "{{count}} clips compuestos", - "copyToClipboard": "Copiar al portapapeles", - "deleteAssetsBody": "Esto eliminará permanentemente {{summary}} de este proyecto. Esta acción no se puede deshacer.", - "deleteAssetsTitle": "¿Eliminar recursos seleccionados?", - "deleteSelectedAssets": "Eliminar recursos seleccionados", - "deleteSummary": "Eliminar {{summary}}", - "deleteWithClips": "Eliminar con clips", - "dismiss": "Descartar", - "dragDropUnsupported": "Arrastrar y soltar no es compatible aquí.", - "dropFilesHere": "Suelta archivos aquí", - "filesRejected": "Algunos archivos no se pudieron añadir.", - "generateProxiesForSelected": "Generar proxies para {{count}} elementos seleccionados", - "generatingProxies": "Generando proxies…", - "generatingTranscripts": "Generando transcripciones…", - "gridItemSize": "Tamaño de miniatura", - "groupAudio": "Grupo audio", - "groupGifs": "Grupo GIF", - "groupImages": "Grupo imagenes", - "groupVideos": "Grupo videos", - "import": "Importar", - "importFromUrlDescription": "Importa un archivo multimedia desde una URL directa.", - "importFromUrlHint": "Pega un enlace directo a un archivo de video, audio, imagen o GIF.", - "importFromUrlTitle": "Importar desde URL", - "importMediaFiles": "Importar archivos multimedia", - "importMediaFromUrl": "Importar multimedia desde URL", - "libraryView": "Vista de la biblioteca", - "linkedInstancesDetail": "También se eliminarán {{count}} instancias de la línea de tiempo que usan estos recursos.", - "linkedInstancesDetail_one": "También se eliminará {{count}} instancia de la línea de tiempo que usa estos recursos.", - "linkedInstancesDetail_other": "También se eliminarán {{count}} instancias de la línea de tiempo que usan estos recursos.", - "linkedInstancesTitle": "Se eliminarán instancias vinculadas de la línea de tiempo", - "mediaItemsCount": "{{count}} elementos de medios", - "mediaItemsCount_one": "{{count}} elemento de medios", - "mediaItemsCount_other": "{{count}} elementos de medios", - "mediaTab": "Medios", - "missingCount": "{{count}} faltantes", - "proxyCount": "{{count}} proxies", - "proxyGenerationProgress": "Progreso de los proxies", - "scenesTab": "Escenas", - "searchScenes": "Buscar escenas", - "selected": "Seleccionado", - "selectedAssetsCount": "{{count}} recursos seleccionados", - "selectedAssetsCount_one": "{{count}} recurso seleccionado", - "selectedAssetsCount_other": "{{count}} recursos seleccionados", - "showMediaLibrary": "Mostrar biblioteca de medios", - "sortDate": "Ordenar por fecha", - "sortName": "Ordenar por nombre", - "sortSize": "Ordenar por tamano", - "transcriptGenerationProgress": "Progreso de la transcripción", - "url": "URL", - "viewMissingMedia": "Ver medios faltantes", - "allShort": "TODO", - "typeShort": { - "video": "VIDEO", - "audio": "AUDIO", - "image": "IMAGEN" - }, - "sortShort": { - "name": "NOMBRE", - "date": "FECHA", - "size": "TAMANO" - }, - "missingCount_one": "{{count}} faltante", - "missingCount_other": "{{count}} faltantes", - "selectedCount": "{{count}} seleccionados", - "selectedCount_one": "{{count}} seleccionado", - "selectedCount_other": "{{count}} seleccionados", - "generateProxiesForSelected_one": "Generar proxy para {{count}} elemento seleccionado", - "generateProxiesForSelected_other": "Generar proxies para {{count}} elementos seleccionados", - "proxyCount_one": "{{count}} proxy", - "proxyCount_other": "{{count}} proxies" - }, - "missingMedia": { - "title": "Medios faltantes", - "description": "FreeCut no puede acceder a {{count}} archivos multimedia. Localiza los archivos o sigue trabajando sin conexión.", - "description_one": "FreeCut no puede acceder a {{count}} archivo multimedia. Localiza el archivo o sigue trabajando sin conexión.", - "description_other": "FreeCut no puede acceder a {{count}} archivos multimedia. Localiza los archivos o sigue trabajando sin conexión.", - "needPermission": "{{count}} necesitan permiso", - "needPermission_one": "{{count}} necesita permiso", - "needPermission_other": "{{count}} necesitan permiso", - "notFound": "{{count}} no encontrados", - "notFound_one": "{{count}} no encontrado", - "notFound_other": "{{count}} no encontrados", - "browseAnotherFolder": "Buscar en otra carpeta", - "fileMovedOrDeleted": "Archivo movido o eliminado", - "grantAccess": "Conceder acceso", - "locate": "Localizar", - "locateFolder": "Localizar carpeta", - "permissionExpired": "Permiso vencido", - "scanProjectFolder": "Escanear {{name}}", - "workOffline": "Trabajar sin conexión" - }, - "orphanedClips": { - "autoMatch": "Coincidencia automática", - "description": "{{count}} clip(s) hacen referencia a medios que no se encuentran. Reemplázalos o elimínalos para continuar.", - "keepAsBroken": "Mantener como rotos", - "removeAll": "Eliminar todo", - "select": "Seleccionar", - "selectReplacement": "Selecciona un reemplazo para \"{{name}}\"", - "title": "Medios faltantes" - }, - "picker": { - "descAll": "Elige un archivo de la biblioteca del proyecto.", - "noFiles": "Aún no hay medios en este proyecto.", - "noSearchResults": "No se encontraron resultados.", - "title": "Elige un medio" - }, - "searchMedia": "Buscar medios", - "subtitleScan": { - "cached": "En caché", - "cancelBatch": "Cancelar todo", - "descComplete": "Búsqueda de subtítulos completada.", - "descScanning": "Buscando subtítulos en tus medios…", - "failed": "Falló", - "titleComplete": "Búsqueda de subtítulos completada", - "titleScanning": "Buscando subtítulos…" - }, - "transcribe": { - "autoDetect": "Detección automática", - "generateTitle": "Generar transcripción", - "language": "Idioma", - "model": "Modelo", - "noLanguages": "No hay idiomas disponibles.", - "progressAria": "Progreso de la transcripción", - "quantization": "Cuantización", - "refreshTitle": "Actualizar transcripción", - "searchLanguages": "Buscar idiomas", - "start": "Iniciar", - "stop": "Detener" - }, - "type": { - "audio": "Audio", - "image": "Imagen", - "video": "Video" - }, - "unsupportedCodec": { - "bodyMultiple": "{{count}} archivos usan códecs que tu navegador no puede decodificar. Es posible que no se reproduzcan correctamente.", - "bodySingle": "Este archivo usa un códec que tu navegador no puede decodificar. Es posible que no se reproduzca correctamente.", - "cancelImport": "Cancelar importación", - "importAnyway": "Importar de todos modos", - "note": "Conviértelos a MP4 (H.264/AAC) o WebM para mayor compatibilidad.", - "title": "Códec no compatible" - } - }, - "preview": { - "align": { - "disableCanvasSnap": "Desactivar ajuste al lienzo", - "enableCanvasSnap": "Activar ajuste al lienzo", - "toggleCanvasSnap": "Alternar ajuste al lienzo" - }, - "controls": { - "captureFailed": "No se pudo capturar el fotograma actual.", - "disableProxyPlayback": "Desactivar reproducción con proxy", - "enableProxyPlayback": "Activar reproducción con proxy", - "frameDownloadedNoProject": "Fotograma descargado. Abre un proyecto para guardarlo en la biblioteca.", - "frameDownloadedNotSaved": "Fotograma descargado, pero no se pudo guardar en la biblioteca: {{message}}", - "frameSaved": "Se guardó \"{{name}}\" en la biblioteca.", - "goToEnd": "Ir al final", - "goToEndTooltip": "Ir al final", - "goToStart": "Ir al inicio", - "goToStartTooltip": "Ir al inicio", - "nextFrame": "Fotograma siguiente", - "nextFrameTooltip": "Fotograma siguiente", - "pauseTooltip": "Pausar", - "playTooltip": "Reproducir", - "prevFrame": "Fotograma anterior", - "prevFrameTooltip": "Fotograma anterior", - "proxyPlaybackOff": "Reproducción con proxy desactivada", - "proxyPlaybackOn": "Reproducción con proxy activada", - "saveFrame": "Guardar fotograma", - "saveFrameFailed": "No se pudo guardar el fotograma.", - "saveFrameTooltip": "Guardar fotograma", - "savingFrame": "Guardando fotograma…", - "savingFrameTooltip": "Guardando fotograma…" - }, - "monitor": { - "mute": "Silenciar", - "muteShort": "Mudo", - "muted": "Silenciado", - "percent": "{{value}}%", - "previewOnlyNote": "Solo afecta a la reproducción local; la exportación usa la mezcla del proyecto.", - "thisDeviceOnly": "Solo en este dispositivo", - "title": "Audio de previsualización", - "unmute": "Activar sonido", - "volume": "Volumen" - }, - "player": { - "exitFullscreen": "Salir de pantalla completa", - "fullscreen": "Pantalla completa", - "mute": "Silenciar", - "pause": "Pausar", - "play": "Reproducir", - "seek": "Buscar", - "unmute": "Activar sonido", - "volume": "Volumen" - }, - "stage": { - "loadingMedia": "Cargando medios…", - "videoPreview": "Previsualización de vídeo" - }, - "zoom": { - "ariaLabel": "Nivel de zoom: {{label}}", - "auto": "Automático", - "tooltip": "Zoom: {{label}}" - } - }, - "timeline": { - "addAudioTrackHint": "Agregar Audio Pista Ayuda", - "addVideoTrackHint": "Agregar Video Pista Ayuda", - "bento": { - "apply": "Aplicar", - "description": "Organiza {{count}} clips seleccionados en una cuadrícula.", - "gap": "Espacio", - "noItemsToArrange": "Sin elementos a ordenar", - "padding": "Margen", - "presetNamePlaceholder": "Preajuste Nombre marcador", - "saveAsPreset": "Guardar como Preajuste", - "title": "Diseño bento" - }, - "captions": { - "addedFromTranscript": "Agregado desde transcripcion", - "addedWithModel": "Agregado con modelo", - "failedGenerateSegment": "Error Generar segmento", - "failedUpdateSegment": "Error actualizar segmento", - "refreshedWithModel": "Actualizado con modelo", - "removedFromSegment": "Eliminado desde segmento", - "updatedFromTranscript": "Updated desde transcripcion", - "updatedWithModel": "Updated con modelo" - }, - "clipIndicators": { - "hasKeyframes": "Has fotogramas clave", - "mask": "Mascara", - "mediaMissing": "Medios faltante", - "preparingReversed": "Preparando invertido", - "reversePrepFailedShort": "Invertir preparacion Error Short", - "reversedPlayback": "invertido Reproduccion", - "reversedPrepFailed": "invertido preparacion Error", - "reversedPrepared": "invertido Prepared", - "speed": "Velocidad: {{speed}}x" - }, - "contextMenu": { - "bentoLayout": "Bento Diseno", - "captions": "Subtitulos", - "clearAll": "Limpiar todo", - "clearKeyframes": "Limpiar fotogramas clave", - "consolidateCaptionsToSegment": "Consolidar Subtitulos a segmento", - "createCompoundClip": "Crear compuesto clip", - "detectScenesAi": "IA ({{model}})", - "detectScenesAndSplit": "Detectar escenas & dividir", - "detectScenesFast": "Rapido (Histograma)", - "detectingFillers": "Detectando muletillas", - "detectingScenes": "Detectando escenas", - "detectingSilence": "Detectando silencio", - "dissolveCompoundClip": "Disolver compuesto clip", - "extractEmbeddedSubtitles": "Extraer incrustados subtitulos", - "generateAudioFromText": "Generar Audio desde Texto", - "generateCaptions": "Generar Subtitulos", - "insertExistingCaptions": "Insertar existentes Subtitulos", - "insertFreezeFrame": "Insertar congelado fotograma", - "joinSelected": "Unir seleccionados", - "joinWithNext": "Unir con siguiente", - "joinWithPrevious": "Unir con anterior", - "linkClips": "Vincular clips", - "openCompoundClip": "Abrir compuesto clip", - "regenerateCaptions": "Regenerar Subtitulos", - "removeFillerWords": "Eliminar muletilla palabras", - "removeSilence": "Eliminar silencio", - "reverse": "Invertir", - "rippleDelete": "Ripple Eliminar", - "unlinkClips": "Desvincular clips", - "unreverse": "Quitar inversion", - "updatingCaptions": "Actualizando Subtitulos" - }, - "fadeHandles": { - "adjustAudioFadeIn": "Ajustar Audio fundido entrada", - "adjustAudioFadeInCurve": "Ajustar Audio fundido entrada curva", - "adjustAudioFadeOut": "Ajustar Audio fundido salida", - "adjustAudioFadeOutCurve": "Ajustar Audio fundido salida curva", - "adjustVideoFadeIn": "Ajustar Video fundido entrada", - "adjustVideoFadeOut": "Ajustar Video fundido salida" - }, - "fillerRemoval": { - "aboutWillBeRemoved": "Se eliminarán unos {{duration}}.", - "add": "Agregar", - "addPhrase": "Agregar frase", - "addWord": "Agregar palabra", - "cutPadding": "corte Margen", - "filler": "muletilla", - "fillerRange": "muletilla", - "found": "Encontrado", - "includeRange": "Incluir rango", - "maxPhrase": "Maximo frase", - "maxWord": "Maximo palabra", - "noEntriesFound": "Sin entradas Encontrado", - "noRemovableDetectedShort": "No se encontraron muletillas.", - "none": "Ninguno", - "phrases": "frases", - "playThisRange": "Reproducir este rango", - "rangesSelected": "rangos seleccionados", - "redo": "Rehacer", - "redoEditTitle": "Rehacer la última edición", - "remove": "Eliminar", - "removed": "Eliminado", - "scoreAudio": "Puntuar Audio", - "scoring": "Puntuando", - "title": "Eliminar muletillas", - "toastAudioScored": "Análisis de confianza del audio completado.", - "toastNoRemovable": "Ninguna muletilla coincide con la configuración actual.", - "toastNoneInClips": "No hay muletillas que eliminar en los clips seleccionados.", - "toastPreviewFailed": "No se pudo previsualizar la eliminación.", - "toastRemoveFailed": "No se pudieron eliminar las muletillas.", - "toastRemoved": "Se eliminaron {{count}} rangos de muletillas.", - "toastScoreFailed": "No se pudo analizar la confianza del audio.", - "undo": "Deshacer", - "undoEditTitle": "Deshacer la última edición", - "updatePreview": "actualizar vista previa", - "updating": "Actualizando", - "words": "palabras" - }, - "header": { - "addMarker": "Agregar marcador", - "addMarkerTooltip": "Agregar marcador", - "clearAllMarkers": "Limpiar todo marcadores", - "clearAllMarkersTooltip": "Limpiar todo marcadores", - "clearInOutPoints": "Limpiar entrada salida puntos", - "clearInOutPointsTooltip": "Limpiar entrada salida puntos", - "controls": "Controles", - "disableLinkedSelection": "Desactivar vinculada seleccion", - "disableSnapping": "Desactivar ajuste", - "enableLinkedSelection": "Activar vinculada seleccion", - "enableSnapping": "Activar ajuste", - "hideColorScopes": "Ocultar color scopes", - "hideColorScopesTooltip": "Ocultar color scopes", - "linkedSelectionOff": "vinculada seleccion apagado", - "linkedSelectionOn": "vinculada seleccion encendido", - "linkedSelectionTooltip": "Selección vinculada: {{state}} ({{shortcut}})", - "rateStretchTool": "tasa estirar herramienta", - "rateStretchToolTooltip": "tasa estirar herramienta", - "razorTool": "cuchilla herramienta", - "razorToolTooltip": "cuchilla herramienta", - "redo": "Rehacer", - "redoTooltip": "Rehacer", - "redoWithLabel": "Rehacer {{label}}", - "redoWithLabelTooltip": "Rehacer {{label}}", - "removeSelectedMarker": "Eliminar seleccionados marcador", - "removeSelectedMarkerTooltip": "Eliminar seleccionados marcador", - "selectTool": "Seleccionar herramienta", - "selectToolTooltip": "Seleccionar herramienta", - "setInPoint": "Definir entrada punto", - "setInPointTooltip": "Definir entrada punto", - "setOutPoint": "Definir salida punto", - "setOutPointTooltip": "Definir salida punto", - "showColorScopes": "Mostrar color scopes", - "showColorScopesTooltip": "Mostrar color scopes", - "slideTool": "slide herramienta", - "slipSlideTools": "slip slide Tools", - "slipSlideToolsTooltip": "slip slide Tools", - "slipTool": "slip herramienta", - "snapDisabled": "Ajuste desactivado", - "snapEnabled": "Ajuste activado", - "title": "Línea de tiempo", - "trimEditTool": "recorte edicion herramienta", - "trimEditToolTooltip": "recorte edicion herramienta", - "undo": "Deshacer", - "undoTooltip": "Deshacer", - "undoWithLabel": "Deshacer {{label}}", - "undoWithLabelTooltip": "Deshacer {{label}}", - "zoomIn": "zoom entrada", - "zoomInTooltip": "zoom entrada", - "zoomOut": "zoom salida", - "zoomOutTooltip": "zoom salida", - "zoomSlider": "zoom control", - "zoomToFit": "zoom a ajustar", - "zoomToFitTooltip": "zoom a ajustar" - }, - "itemActions": { - "appliedEffectToClips": "Applied efecto a clips", - "selectAvClipFirst": "Seleccionar AV clip primero" - }, - "joinIndicators": { - "canJoinNext": "Puede Unir siguiente", - "canJoinPrevious": "Puede Unir anterior" - }, - "keyframeEditor": { - "bezier": "Curva Bezier", - "custom": "Personalizado", - "dragHandlesHint": "Arrastra los puntos para dar forma a la curva.", - "graph": "grafico", - "mixedCurves": "mixto curvas", - "mixedSpring": "mixto resorte", - "movedKeyframes": "movidos fotogramas clave", - "noKeyframesPasted": "Sin fotogramas clave pegados", - "pastedKeyframes": "pegados fotogramas clave", - "preset": "Preajuste", - "reasonBlocked": "{{count}} bloqueados por otro fotograma clave", - "reasonUnsupported": "{{count}} no admitidos por la propiedad de destino", - "selectItem": "Seleccionar elemento", - "sheet": "hoja", - "skippedDescription": "Se omitieron {{count}}: {{reasons}}", - "spring": "resorte", - "springHint": "Física de resorte: movimiento natural y elástico.", - "title": "Fotogramas clave", - "unableToPasteCut": "No se pudo a pegar corte", - "unableToPasteCutDescription": "Los fotogramas clave cortados no se pueden pegar aquí. {{reasons}}" - }, - "noTracksToRemove": "Sin pistas a Eliminar", - "region": "region", - "removeActiveTrack": "Eliminar activa Pista", - "removeSelectedTracks": "Eliminar seleccionados pistas", - "reverseConform": { - "cancellingDescription": "Cancelando la preparación de la inversión…", - "couldNotPrepare": "No se pudo preparar el clip para reproducción invertida.", - "progressDescription": "Preparando clip {{current}} de {{total}}…", - "titleCancelling": "Cancelando", - "titleFailed": "Error al invertir", - "titlePreparing": "Preparando reproducción invertida" - }, - "sceneDetection": { - "detectingScenes": "Detectando escenas", - "failed": "Error", - "noScenesDetected": "Sin escenas Detected", - "noScenesWithinBounds": "Sin escenas dentro limites", - "noValidSplitPoints": "Sin valido dividir puntos", - "requiresWebGpu": "Requiere Web GPU", - "splitAtScenes": "dividir At escenas" - }, - "selectTrackToRemove": "Seleccionar Pista a Eliminar", - "silenceRemoval": { - "aboutWillBeRemoved": "Se eliminarán unos {{duration}} de silencio.", - "keepPadding": "Mantener Margen", - "minimumSilence": "Minimo silencio", - "noRemovableDetectedShort": "No se encontraron silencios.", - "rangesSelected": "rangos seleccionados", - "remove": "Eliminar", - "threshold": "Umbral", - "title": "Eliminar silencios", - "toastNoRemovable": "Ningún silencio coincide con la configuración actual.", - "toastNoneInClips": "No hay silencios que eliminar en los clips seleccionados.", - "toastPreviewFailed": "No se pudo previsualizar la eliminación de silencios.", - "toastRemoved": "Se eliminaron {{count}} rangos de silencio.", - "toastSettingsChanged": "Ajustes modificados: previsualiza antes de aplicar.", - "updatePreview": "actualizar vista previa", - "updating": "Actualizando" - }, - "track": { - "locked": "bloqueado" - }, - "trackHeader": { - "addAudioTrack": "Agregar Audio Pista", - "addVideoTrack": "Agregar Video Pista", - "clipCount": "clip Count", - "closeAllGaps": "Cerrar todo espacios", - "deleteEmptyTracks": "Eliminar vacias pistas", - "deleteTrack": "Eliminar Pista", - "disableSyncLock": "Desactivar sincronizacion bloqueo", - "disableTrack": "Desactivar Pista", - "enableSyncLock": "Activar sincronizacion bloqueo", - "enableTrack": "Activar Pista", - "lockTrack": "bloqueo Pista", - "soloTrack": "solo Pista", - "unlockTrack": "desbloquear Pista", - "unsoloTrack": "quitar solo Pista" - }, - "trackRow": { - "resizeSections": "redimensionar Video and Audio Pista secciones", - "resizeTrackHeight": "redimensionar Pista altura" - }, - "tracks": "pistas", - "volumeControl": { - "adjustClipVolume": "Ajustar clip volumen" - } - } - }, - "fr": { - "editor": { - "videoSection": { - "cropBottom": "Recadrage bas", - "cropLeft": "Recadrage gauche", - "cropRight": "Recadrage droit", - "cropTop": "Recadrage haut", - "cropping": "Recadrage", - "fadeIn": "Fondu entrant", - "fadeOut": "Fondu sortant", - "playback": "Lecture", - "resetCropBottom": "Reinitialiser le recadrage bas", - "resetCropLeft": "Reinitialiser le recadrage gauche", - "resetCropRight": "Reinitialiser le recadrage droit", - "resetCropTop": "Reinitialiser le recadrage haut", - "resetSoftness": "Reinitialiser la douceur", - "resetSpeed": "Reinitialiser la vitesse", - "resetToZero": "Remettre a zero", - "softness": "Douceur", - "speed": "Vitesse" - } - }, - "effects": { - "curves": { - "channel": "Canal", - "dragHint": "Faites glisser les points de la courbe {{channel}} pour ajuster les tons.", - "resetChannel": "Réinitialiser le canal" - }, - "panel": { - "disableEffect": "Désactiver l'effet", - "enableEffect": "Activer l'effet", - "off": "Désactivé", - "on": "Activé", - "removeEffect": "Supprimer l'effet", - "resetToDefaults": "Réinitialiser" - }, - "section": { - "addEffect": "Ajouter un effet", - "disableAll": "Tout désactiver", - "emptyState": "Aucun effet appliqué. Ajoutez-en un pour commencer.", - "enableAll": "Tout activer", - "noEffectsFound": "Aucun effet trouvé", - "presets": "Préréglages", - "searchEffects": "Rechercher des effets", - "title": "Effets" - }, - "wheels": { - "resetWheel": "Réinitialiser la roue" - } - }, - "export": { - "audioContainer": { - "aac": "AAC", - "mp3": "MP3", - "wav": "WAV" - }, - "cancelled": { - "message": "L'exportation a été annulée. Aucun fichier n'a été enregistré." - }, - "complete": { - "audioSuccess": "Votre audio est prêt à être téléchargé.", - "download": "Télécharger", - "fileSizeLabel": "Taille", - "timeTakenLabel": "Durée", - "videoSuccess": "Votre vidéo est prête à être téléchargée." - }, - "dialog": { - "descCancelled": "L'exportation a été annulée avant d'être terminée.", - "descComplete": "Votre fichier est prêt.", - "descError": "Une erreur est survenue lors de l'exportation.", - "descProgress": "Rendu de votre vidéo en cours. Cela peut prendre quelques minutes.", - "descSettings": "Configurez les options d'exportation.", - "titleCancelled": "Exportation annulée", - "titleComplete": "Exportation terminée", - "titleError": "Échec de l'exportation", - "titleProgress": "Exportation de la vidéo", - "titleSettings": "Exporter" - }, - "errors": { - "verifyCodec": "Impossible de vérifier la prise en charge du codec pour ces paramètres." - }, - "progress": { - "cancelExport": "Annuler", - "elapsedLabel": "Écoulé", - "encoding": "Encodage…", - "finalizing": "Finalisation…", - "framesLabel": "Images", - "keepTabOpen": "Gardez cet onglet ouvert jusqu'à la fin de l'exportation.", - "preparing": "Préparation…", - "rendering": "Rendu en cours…" - }, - "settings": { - "audio": "Audio", - "audioOnlyNote": "Exportation audio uniquement — aucune vidéo ne sera incluse.", - "audioQualityHigh": "Haute", - "audioQualityLow": "Basse", - "audioQualityMedium": "Moyenne", - "audioQualityUltra": "Ultra", - "cannotEncode": "Aucun encodeur compatible pour {{width}}×{{height}} à la qualité choisie.", - "codec": "Codec", - "codecSupportUnverified": "Impossible de vérifier la prise en charge du codec. L'exportation peut fonctionner, mais sans garantie.", - "duration": "Durée", - "embedSubtitles": "Intégrer les sous-titres", - "embedSubtitlesDescription": "Incluez les sous-titres de la transcription en tant que piste dans le fichier exporté.", - "embedSubtitlesMp4Note": "MP4 enregistre les sous-titres dans une piste distincte. Certains lecteurs ne les affichent pas par défaut.", - "embedSubtitlesUnsupported": "{{container}} ne prend pas en charge les sous-titres intégrés. Choisissez MP4, MKV ou WebM.", - "exportAudio": "Exporter l'audio", - "exportRange": "Plage d'exportation", - "exportType": "Type d'exportation", - "exportVideo": "Exporter la vidéo", - "format": "Format", - "in": "Entrée", - "inOutRangeHint": "Seule la plage entrée/sortie sélectionnée sera exportée.", - "noTranscriptSegments": "Aucun sous-titre de transcription à intégrer.", - "out": "Sortie", - "quality": "Qualité", - "qualityHigh": "Haute", - "qualityLow": "Basse", - "qualityMedium": "Moyenne", - "qualityUltra": "Ultra", - "quicktimeMov": "QuickTime MOV", - "renderWholeProject": "Rendre le projet entier", - "resolution": "Résolution", - "resolutionSameAsProject": "Identique au projet ({{width}}×{{height}})", - "resolutionScaled": "{{p}}p ({{width}}×{{height}})", - "selectCodec": "Sélectionner un codec", - "selectFormat": "Sélectionner un format", - "selectQuality": "Sélectionner une qualité", - "selectResolution": "Sélectionner une résolution", - "video": "Vidéo" - }, - "videoContainer": { - "mp4": "Très large compatibilité, idéal pour le partage", - "mov": "Écosystème Apple, idéal pour Final Cut et QuickTime", - "webm": "Conteneur ouvert et moderne pour le web", - "mkv": "Conteneur flexible avec prise en charge avancée des sous-titres" - } - }, - "media": { - "card": { - "aiCaptionsCount": "{{count}} sous-titres IA", - "analyzeWithAI": "Analyser avec l'IA", - "analyzingWithAI": "Analyse avec l'IA", - "chooseMkvOrWebm": "Choisir MKV ou WebM", - "deleteProxy": "Supprimer le proxy", - "deleteTranscript": "Supprimer la transcription", - "extractEmbeddedSubtitles": "Extraire les sous-titres intégrés", - "generateProxy": "Générer un proxy", - "generateTranscript": "Générer une transcription", - "importing": "Importation", - "menuAi": "IA", - "menuCaptions": "Sous-titres intégrés", - "menuFile": "Fichier", - "menuProxy": "Proxy", - "menuTranscript": "Transcription", - "playAudio": "Lire l'audio", - "refreshTranscript": "Actualiser la transcription", - "relinkFile": "Reconnecter le fichier…", - "stopAudio": "Arrêter l'audio", - "subtitlesCachedAll": "Sous-titres mis en cache", - "subtitlesCachedPartial": "Sous-titres partiellement mis en cache", - "subtitlesCannotRead": "FreeCut ne peut pas lire « {{name}} » pour le moment. Fermez toute appli qui l'utilise et réessayez.", - "subtitlesExtractFailed": "Échec de l'extraction des sous-titres", - "subtitlesFileMissing": "FreeCut ne trouve plus « {{name}} ».", - "subtitlesNeedPermission": "FreeCut a besoin d'autorisation pour lire « {{name}} » avant d'extraire les sous-titres.", - "subtitlesScanFailed": "Échec de l'analyse des sous-titres", - "transcribeFailed": "Échec de la transcription", - "transcribing": "Transcription en cours", - "transcriptDeleteFailed": "Échec de la suppression de la transcription", - "transcriptDeleteFailedFor": "Impossible de supprimer la transcription de « {{name}} »", - "transcriptDeletedFor": "Transcription de « {{name}} » supprimée", - "transcriptProgressAria": "Progression de la transcription", - "transcriptReadyFor": "Transcription prête pour « {{name}} »", - "transcriptionFailedFor": "Échec de la transcription de « {{name}} »", - "transcriptsDeleted": "Transcriptions supprimées", - "transcriptsReady": "Transcriptions prêtes" - }, - "compositions": { - "deleteBody": "Supprimer le clip composé \"{{name}}\" ? Cette action est irréversible.", - "deleteInstancesDetail": "{{count}} instances de la timeline qui utilisent ce clip composé seront également supprimées.", - "deleteInstancesTitle": "Les instances liées seront supprimées", - "deleteTitle": "Supprimer le clip composé", - "enter": "Ouvrir", - "itemCount": "{{count}} éléments", - "rename": "Renommer", - "sectionTitle": "Clips composés" - }, - "deleteDialog": { - "bodyMultiple": "Cela supprimera définitivement {{count}} éléments multimédias de ce projet. Cette action est irréversible.", - "bodySingle": "Cela supprimera définitivement \"{{name}}\" de ce projet. Cette action est irréversible.", - "confirmWithClips": "Supprimer avec les clips", - "timelineClipsDetail": "{{count}} clips de la timeline utilisant ce média seront aussi supprimés.", - "timelineClipsDetail_one": "{{count}} clip de la timeline utilisant ce média sera aussi supprimé.", - "timelineClipsDetail_other": "{{count}} clips de la timeline utilisant ce média seront aussi supprimés.", - "timelineClipsRemoved": "Des clips de la timeline seront supprimés", - "titleMultiple": "Supprimer {{count}} éléments multimédias ?", - "titleSingle": "Supprimer l'élément multimédia ?" - }, - "embeddedSubtitles": { - "badgeAutoPick": "Auto", - "badgeDefault": "Par défaut", - "badgeForced": "Forcé", - "cuesCount": "{{count}} entrées", - "desc": "Choisissez une piste de sous-titres à insérer dans le montage.", - "descForFile": "Sous-titres disponibles dans « {{name}} ».", - "empty": "Aucune piste de sous-titres dans ce fichier.", - "insert": "Insérer", - "insertWithCues": "Insérer ({{count}} entrées)", - "loadedFromCache": "Sous-titres chargés depuis le cache.", - "scanning": "Analyse…", - "title": "Sous-titres intégrés", - "trackInfo": "Piste {{n}} · {{lang}} · {{codec}}" - }, - "grid": { - "emptyHint": "Déposez des fichiers ici ou cliquez sur Importer pour ajouter des médias.", - "emptyTitle": "Aucun média pour l'instant", - "loadingSubtitle": "Préparation de vos médias…", - "loadingTitle": "Chargement des médias…" - }, - "info": { - "codec": "Codec", - "dimensions": "Dimensions", - "duration": "Durée", - "fpsValue": "{{value}} ips", - "frameRate": "Fréquence d'images", - "loadingTranscript": "Chargement de la transcription…", - "mediaInfo": "Infos du média", - "openInSourceMonitor": "Ouvrir dans le moniteur source", - "size": "Taille", - "transcript": "Transcription", - "transcriptWithCount": "Transcription ({{count}})", - "type": "Type" - }, - "library": { - "aiAnalysisProgress": "Analyse IA", - "allTypes": "Tous les types", - "analyzingMultiple": "Analyse de {{count}} clips…", - "analyzingSingle": "Analyse…", - "andJoiner": " et ", - "assetsCount": "{{count}} ressources", - "assetsCount_one": "{{count}} ressource", - "assetsCount_other": "{{count}} ressources", - "back": "Retour", - "cancelAll": "Tout annuler", - "cancelling": "Annulation…", - "clearSelection": "Effacer la sélection", - "compoundClipsCount": "{{count}} clips composés", - "compoundClipsCount_one": "{{count}} clip composé", - "compoundClipsCount_other": "{{count}} clips composés", - "copyToClipboard": "Copier dans le presse-papiers", - "deleteAssetsBody": "Cela supprimera définitivement {{summary}} de ce projet. Cette action est irréversible.", - "deleteAssetsTitle": "Supprimer les ressources sélectionnées ?", - "deleteSelectedAssets": "Supprimer les ressources sélectionnées", - "deleteSummary": "Supprimer {{summary}}", - "deleteWithClips": "Supprimer avec les clips", - "dismiss": "Ignorer", - "dragDropUnsupported": "Le glisser-déposer n'est pas pris en charge ici.", - "dropFilesHere": "Déposez les fichiers ici", - "filesRejected": "Certains fichiers n'ont pas pu être ajoutés.", - "generateProxiesForSelected": "Générer des proxies pour {{count}} éléments sélectionnés", - "generatingProxies": "Génération des proxys…", - "generatingTranscripts": "Génération des transcriptions…", - "gridItemSize": "Taille des vignettes", - "groupAudio": "Groupe audio", - "groupGifs": "Groupe GIF", - "groupImages": "Groupe images", - "groupVideos": "Groupe videos", - "import": "Importer", - "importFromUrlDescription": "Importez un fichier multimédia depuis une URL directe.", - "importFromUrlHint": "Collez un lien direct vers une vidéo, un audio, une image ou un GIF.", - "importFromUrlTitle": "Importer depuis une URL", - "importMediaFiles": "Importer des fichiers media", - "importMediaFromUrl": "Importer un media depuis une URL", - "libraryView": "Vue de la médiathèque", - "linkedInstancesDetail": "{{count}} instances de la timeline utilisant ces ressources seront aussi supprimées.", - "linkedInstancesDetail_one": "{{count}} instance de la timeline utilisant ces ressources sera aussi supprimée.", - "linkedInstancesDetail_other": "{{count}} instances de la timeline utilisant ces ressources seront aussi supprimées.", - "linkedInstancesTitle": "Des instances liées de la timeline seront supprimées", - "mediaItemsCount": "{{count}} éléments multimédias", - "mediaItemsCount_one": "{{count}} élément multimédia", - "mediaItemsCount_other": "{{count}} éléments multimédias", - "mediaTab": "Medias", - "missingCount": "{{count}} manquants", - "proxyCount": "{{count}} proxys", - "proxyGenerationProgress": "Progression des proxys", - "scenesTab": "Scenes", - "searchScenes": "Rechercher des scènes", - "selected": "Sélectionné", - "selectedAssetsCount": "{{count}} ressources sélectionnées", - "selectedAssetsCount_one": "{{count}} ressource sélectionnée", - "selectedAssetsCount_other": "{{count}} ressources sélectionnées", - "showMediaLibrary": "Afficher la médiathèque", - "sortDate": "Trier par date", - "sortName": "Trier par nom", - "sortSize": "Trier par taille", - "transcriptGenerationProgress": "Progression de la transcription", - "url": "URL", - "viewMissingMedia": "Voir les médias manquants", - "allShort": "TOUT", - "typeShort": { - "video": "VIDEO", - "audio": "AUDIO", - "image": "IMAGE" - }, - "sortShort": { - "name": "NOM", - "date": "DATE", - "size": "TAILLE" - }, - "missingCount_one": "{{count}} manquant", - "missingCount_other": "{{count}} manquants", - "selectedCount": "{{count}} sélectionnés", - "selectedCount_one": "{{count}} sélectionné", - "selectedCount_other": "{{count}} sélectionnés", - "generateProxiesForSelected_one": "Générer un proxy pour {{count}} élément sélectionné", - "generateProxiesForSelected_other": "Générer des proxies pour {{count}} éléments sélectionnés", - "proxyCount_one": "{{count}} proxy", - "proxyCount_other": "{{count}} proxys" - }, - "missingMedia": { - "title": "Médias manquants", - "description": "FreeCut ne peut pas accéder à {{count}} fichiers multimédias. Localisez les fichiers ou continuez hors ligne.", - "description_one": "FreeCut ne peut pas accéder à {{count}} fichier multimédia. Localisez le fichier ou continuez hors ligne.", - "description_other": "FreeCut ne peut pas accéder à {{count}} fichiers multimédias. Localisez les fichiers ou continuez hors ligne.", - "needPermission": "{{count}} nécessitent une autorisation", - "needPermission_one": "{{count}} nécessite une autorisation", - "needPermission_other": "{{count}} nécessitent une autorisation", - "notFound": "{{count}} introuvables", - "notFound_one": "{{count}} introuvable", - "notFound_other": "{{count}} introuvables", - "browseAnotherFolder": "Parcourir un autre dossier", - "fileMovedOrDeleted": "Fichier déplacé ou supprimé", - "grantAccess": "Autoriser l'accès", - "locate": "Localiser", - "locateFolder": "Localiser le dossier", - "permissionExpired": "Autorisation expirée", - "scanProjectFolder": "Analyser {{name}}", - "workOffline": "Travailler hors ligne" - }, - "orphanedClips": { - "autoMatch": "Correspondance automatique", - "description": "{{count}} clip(s) référencent des médias introuvables. Remplacez-les ou supprimez-les pour continuer.", - "keepAsBroken": "Conserver comme cassés", - "removeAll": "Tout supprimer", - "select": "Sélectionner", - "selectReplacement": "Choisir un remplacement pour « {{name}} »", - "title": "Médias manquants" - }, - "picker": { - "descAll": "Choisissez un fichier dans la médiathèque du projet.", - "noFiles": "Aucun média dans ce projet pour l'instant.", - "noSearchResults": "Aucun résultat.", - "title": "Choisir un média" - }, - "searchMedia": "Rechercher des medias", - "subtitleScan": { - "cached": "En cache", - "cancelBatch": "Tout annuler", - "descComplete": "Recherche de sous-titres terminée.", - "descScanning": "Recherche de sous-titres dans vos médias…", - "failed": "Échec", - "titleComplete": "Recherche de sous-titres terminée", - "titleScanning": "Recherche de sous-titres…" - }, - "transcribe": { - "autoDetect": "Détection automatique", - "generateTitle": "Générer une transcription", - "language": "Langue", - "model": "Modèle", - "noLanguages": "Aucune langue disponible.", - "progressAria": "Progression de la transcription", - "quantization": "Quantification", - "refreshTitle": "Actualiser la transcription", - "searchLanguages": "Rechercher une langue", - "start": "Démarrer", - "stop": "Arrêter" - }, - "type": { - "audio": "Audio", - "image": "Image", - "video": "Video" - }, - "unsupportedCodec": { - "bodyMultiple": "{{count}} fichiers utilisent des codecs que votre navigateur ne peut pas décoder. Ils risquent de ne pas se lire correctement.", - "bodySingle": "Ce fichier utilise un codec que votre navigateur ne peut pas décoder. Il risque de ne pas se lire correctement.", - "cancelImport": "Annuler l'importation", - "importAnyway": "Importer quand même", - "note": "Convertissez-les en MP4 (H.264/AAC) ou WebM pour une meilleure compatibilité.", - "title": "Codec non pris en charge" - } - }, - "preview": { - "align": { - "disableCanvasSnap": "Désactiver l'aimantation", - "enableCanvasSnap": "Activer l'aimantation", - "toggleCanvasSnap": "Basculer l'aimantation" - }, - "controls": { - "captureFailed": "Impossible de capturer l'image actuelle.", - "disableProxyPlayback": "Désactiver la lecture proxy", - "enableProxyPlayback": "Activer la lecture proxy", - "frameDownloadedNoProject": "Image téléchargée — ouvrez un projet pour l'ajouter à la médiathèque.", - "frameDownloadedNotSaved": "Image téléchargée, mais impossible de l'enregistrer dans la médiathèque : {{message}}", - "frameSaved": "« {{name}} » enregistré dans la médiathèque.", - "goToEnd": "Aller à la fin", - "goToEndTooltip": "Aller à la fin", - "goToStart": "Aller au début", - "goToStartTooltip": "Aller au début", - "nextFrame": "Image suivante", - "nextFrameTooltip": "Image suivante", - "pauseTooltip": "Pause", - "playTooltip": "Lecture", - "prevFrame": "Image précédente", - "prevFrameTooltip": "Image précédente", - "proxyPlaybackOff": "Lecture proxy désactivée", - "proxyPlaybackOn": "Lecture proxy activée", - "saveFrame": "Enregistrer l'image", - "saveFrameFailed": "Impossible d'enregistrer l'image.", - "saveFrameTooltip": "Enregistrer l'image", - "savingFrame": "Enregistrement de l'image…", - "savingFrameTooltip": "Enregistrement de l'image…" - }, - "monitor": { - "mute": "Couper le son", - "muteShort": "Muet", - "muted": "Muet", - "percent": "{{value}} %", - "previewOnlyNote": "Concerne uniquement la lecture locale — l'exportation utilise le mixage du projet.", - "thisDeviceOnly": "Cet appareil uniquement", - "title": "Audio de prévisualisation", - "unmute": "Réactiver le son", - "volume": "Volume" - }, - "player": { - "exitFullscreen": "Quitter le plein écran", - "fullscreen": "Plein écran", - "mute": "Couper le son", - "pause": "Pause", - "play": "Lecture", - "seek": "Rechercher", - "unmute": "Réactiver le son", - "volume": "Volume" - }, - "stage": { - "loadingMedia": "Chargement des médias…", - "videoPreview": "Prévisualisation vidéo" - }, - "zoom": { - "ariaLabel": "Niveau de zoom : {{label}}", - "auto": "Auto", - "tooltip": "Zoom : {{label}}" - } - }, - "timeline": { - "addAudioTrackHint": "Ajouter Audio Piste Aide", - "addVideoTrackHint": "Ajouter Video Piste Aide", - "bento": { - "apply": "Appliquer", - "description": "Disposer les {{count}} clips sélectionnés en grille.", - "gap": "Ecart", - "noItemsToArrange": "Aucun elements a organiser", - "padding": "Marge", - "presetNamePlaceholder": "Prereglage Nom indication", - "saveAsPreset": "Enregistrer comme Prereglage", - "title": "Mise en page bento" - }, - "captions": { - "addedFromTranscript": "Ajoute depuis transcription", - "addedWithModel": "Ajoute avec modele", - "failedGenerateSegment": "Echec Generer segment", - "failedUpdateSegment": "Echec mettre a jour segment", - "refreshedWithModel": "Actualise avec modele", - "removedFromSegment": "Supprime depuis segment", - "updatedFromTranscript": "Updated depuis transcription", - "updatedWithModel": "Updated avec modele" - }, - "clipIndicators": { - "hasKeyframes": "Has images cles", - "mask": "Masque", - "mediaMissing": "Media manquant", - "preparingReversed": "Preparation inverse", - "reversePrepFailedShort": "Inverser preparation Echec Short", - "reversedPlayback": "inverse Lecture", - "reversedPrepFailed": "inverse preparation Echec", - "reversedPrepared": "inverse Prepared", - "speed": "Vitesse: {{speed}}x" - }, - "contextMenu": { - "bentoLayout": "Bento Mise en page", - "captions": "Sous-titres", - "clearAll": "Effacer tout", - "clearKeyframes": "Effacer images cles", - "consolidateCaptionsToSegment": "Consolider Sous-titres a segment", - "createCompoundClip": "Creer compose clip", - "detectScenesAi": "IA ({{model}})", - "detectScenesAndSplit": "Detecter scenes & diviser", - "detectScenesFast": "Rapide (Histogramme)", - "detectingFillers": "Detection tics", - "detectingScenes": "Detection scenes", - "detectingSilence": "Detection silence", - "dissolveCompoundClip": "Dissoudre compose clip", - "extractEmbeddedSubtitles": "Extraire integres sous-titres", - "generateAudioFromText": "Generer Audio depuis Texte", - "generateCaptions": "Generer Sous-titres", - "insertExistingCaptions": "Inserer existants Sous-titres", - "insertFreezeFrame": "Inserer fige image", - "joinSelected": "Joindre selectionnes", - "joinWithNext": "Joindre avec suivant", - "joinWithPrevious": "Joindre avec precedent", - "linkClips": "Lier clips", - "openCompoundClip": "Ouvrir compose clip", - "regenerateCaptions": "Regenerer Sous-titres", - "removeFillerWords": "Supprimer tic mots", - "removeSilence": "Supprimer silence", - "reverse": "Inverser", - "rippleDelete": "Ripple Supprimer", - "unlinkClips": "Delier clips", - "unreverse": "Annuler inversion", - "updatingCaptions": "Mise a jour Sous-titres" - }, - "fadeHandles": { - "adjustAudioFadeIn": "Ajuster Audio fondu entree", - "adjustAudioFadeInCurve": "Ajuster Audio fondu entree courbe", - "adjustAudioFadeOut": "Ajuster Audio fondu sortie", - "adjustAudioFadeOutCurve": "Ajuster Audio fondu sortie courbe", - "adjustVideoFadeIn": "Ajuster Video fondu entree", - "adjustVideoFadeOut": "Ajuster Video fondu sortie" - }, - "fillerRemoval": { - "aboutWillBeRemoved": "Environ {{duration}} seront supprimés.", - "add": "Ajouter", - "addPhrase": "Ajouter phrase", - "addWord": "Ajouter mot", - "cutPadding": "coupe Marge", - "filler": "tic", - "fillerRange": "hésitation", - "found": "Trouve", - "includeRange": "Inclure plage", - "maxPhrase": "Max phrase", - "maxWord": "Max mot", - "noEntriesFound": "Aucun entrees Trouve", - "noRemovableDetectedShort": "Aucune hésitation détectée.", - "none": "Aucun", - "phrases": "phrases", - "playThisRange": "Lire cette plage", - "rangesSelected": "plages selectionnes", - "redo": "Retablir", - "redoEditTitle": "Rétablir la dernière modification", - "remove": "Supprimer", - "removed": "Supprime", - "scoreAudio": "Evaluer Audio", - "scoring": "Evaluation", - "title": "Supprimer les hésitations", - "toastAudioScored": "Analyse de confiance audio terminée.", - "toastNoRemovable": "Aucune hésitation ne correspond aux réglages actuels.", - "toastNoneInClips": "Aucune hésitation à supprimer dans les clips sélectionnés.", - "toastPreviewFailed": "Impossible de prévisualiser la suppression.", - "toastRemoveFailed": "Impossible de supprimer les hésitations.", - "toastRemoved": "{{count}} plages d'hésitations supprimées.", - "toastScoreFailed": "Impossible d'analyser la confiance audio.", - "undo": "Annuler", - "undoEditTitle": "Annuler la dernière modification", - "updatePreview": "mettre a jour apercu", - "updating": "Mise a jour", - "words": "mots" - }, - "header": { - "addMarker": "Ajouter marqueur", - "addMarkerTooltip": "Ajouter marqueur", - "clearAllMarkers": "Effacer tout marqueurs", - "clearAllMarkersTooltip": "Effacer tout marqueurs", - "clearInOutPoints": "Effacer entree sortie points", - "clearInOutPointsTooltip": "Effacer entree sortie points", - "controls": "Controles", - "disableLinkedSelection": "Desactiver liee selection", - "disableSnapping": "Desactiver aimantation", - "enableLinkedSelection": "Activer liee selection", - "enableSnapping": "Activer aimantation", - "hideColorScopes": "Masquer couleur scopes", - "hideColorScopesTooltip": "Masquer couleur scopes", - "linkedSelectionOff": "liee selection desactive", - "linkedSelectionOn": "liee selection active", - "linkedSelectionTooltip": "Sélection liée : {{state}} ({{shortcut}})", - "rateStretchTool": "vitesse etirer outil", - "rateStretchToolTooltip": "vitesse etirer outil", - "razorTool": "rasoir outil", - "razorToolTooltip": "rasoir outil", - "redo": "Retablir", - "redoTooltip": "Retablir", - "redoWithLabel": "Retablir {{label}}", - "redoWithLabelTooltip": "Retablir {{label}}", - "removeSelectedMarker": "Supprimer selectionnes marqueur", - "removeSelectedMarkerTooltip": "Supprimer selectionnes marqueur", - "selectTool": "Selectionner outil", - "selectToolTooltip": "Selectionner outil", - "setInPoint": "Definir entree point", - "setInPointTooltip": "Definir entree point", - "setOutPoint": "Definir sortie point", - "setOutPointTooltip": "Definir sortie point", - "showColorScopes": "Afficher couleur scopes", - "showColorScopesTooltip": "Afficher couleur scopes", - "slideTool": "slide outil", - "slipSlideTools": "slip slide Tools", - "slipSlideToolsTooltip": "slip slide Tools", - "slipTool": "slip outil", - "snapDisabled": "Aimantation desactivee", - "snapEnabled": "Aimantation activee", - "title": "Montage", - "trimEditTool": "ajustement edition outil", - "trimEditToolTooltip": "ajustement edition outil", - "undo": "Annuler", - "undoTooltip": "Annuler", - "undoWithLabel": "Annuler {{label}}", - "undoWithLabelTooltip": "Annuler {{label}}", - "zoomIn": "zoom entree", - "zoomInTooltip": "zoom entree", - "zoomOut": "zoom sortie", - "zoomOutTooltip": "zoom sortie", - "zoomSlider": "zoom curseur", - "zoomToFit": "zoom a adapter", - "zoomToFitTooltip": "zoom a adapter" - }, - "itemActions": { - "appliedEffectToClips": "Applied effet a clips", - "selectAvClipFirst": "Selectionner AV clip d abord" - }, - "joinIndicators": { - "canJoinNext": "Peut Joindre suivant", - "canJoinPrevious": "Peut Joindre precedent" - }, - "keyframeEditor": { - "bezier": "Courbe Bezier", - "custom": "Personnalise", - "dragHandlesHint": "Faites glisser les poignées pour modeler la courbe.", - "graph": "graphe", - "mixedCurves": "mixte courbes", - "mixedSpring": "mixte ressort", - "movedKeyframes": "deplacees images cles", - "noKeyframesPasted": "Aucun images cles collees", - "pastedKeyframes": "collees images cles", - "preset": "Prereglage", - "reasonBlocked": "{{count}} bloqué(s) par une autre image clé", - "reasonUnsupported": "{{count}} non pris en charge par la propriété cible", - "selectItem": "Selectionner element", - "sheet": "feuille", - "skippedDescription": "{{count}} ignoré(s) : {{reasons}}", - "spring": "ressort", - "springHint": "Physique de ressort — naturel et rebondissant.", - "title": "Images clés", - "unableToPasteCut": "Impossible a coller coupe", - "unableToPasteCutDescription": "Impossible de coller ici les images clés coupées. {{reasons}}" - }, - "noTracksToRemove": "Aucun pistes a Supprimer", - "region": "region", - "removeActiveTrack": "Supprimer active Piste", - "removeSelectedTracks": "Supprimer selectionnes pistes", - "reverseConform": { - "cancellingDescription": "Annulation de la préparation de l'inversion…", - "couldNotPrepare": "Impossible de préparer le clip pour la lecture inversée.", - "progressDescription": "Préparation du clip {{current}} sur {{total}}…", - "titleCancelling": "Annulation", - "titleFailed": "Échec de l'inversion", - "titlePreparing": "Préparation de la lecture inversée" - }, - "sceneDetection": { - "detectingScenes": "Detection scenes", - "failed": "Echec", - "noScenesDetected": "Aucun scenes Detected", - "noScenesWithinBounds": "Aucun scenes dans limites", - "noValidSplitPoints": "Aucun valide diviser points", - "requiresWebGpu": "Requiert Web GPU", - "splitAtScenes": "diviser At scenes" - }, - "selectTrackToRemove": "Selectionner Piste a Supprimer", - "silenceRemoval": { - "aboutWillBeRemoved": "Environ {{duration}} de silence seront supprimés.", - "keepPadding": "Garder Marge", - "minimumSilence": "Minimum silence", - "noRemovableDetectedShort": "Aucun silence détecté.", - "rangesSelected": "plages selectionnes", - "remove": "Supprimer", - "threshold": "Seuil", - "title": "Supprimer les silences", - "toastNoRemovable": "Aucun silence ne correspond aux réglages actuels.", - "toastNoneInClips": "Aucun silence à supprimer dans les clips sélectionnés.", - "toastPreviewFailed": "Impossible de prévisualiser la suppression du silence.", - "toastRemoved": "{{count}} plages de silence supprimées.", - "toastSettingsChanged": "Réglages modifiés — prévisualisez avant d'appliquer.", - "updatePreview": "mettre a jour apercu", - "updating": "Mise a jour" - }, - "track": { - "locked": "verrouille" - }, - "trackHeader": { - "addAudioTrack": "Ajouter Audio Piste", - "addVideoTrack": "Ajouter Video Piste", - "clipCount": "clip Count", - "closeAllGaps": "Fermer tout ecarts", - "deleteEmptyTracks": "Supprimer vides pistes", - "deleteTrack": "Supprimer Piste", - "disableSyncLock": "Desactiver synchro verrou", - "disableTrack": "Desactiver Piste", - "enableSyncLock": "Activer synchro verrou", - "enableTrack": "Activer Piste", - "lockTrack": "verrou Piste", - "soloTrack": "solo Piste", - "unlockTrack": "deverrouiller Piste", - "unsoloTrack": "retirer solo Piste" - }, - "trackRow": { - "resizeSections": "redimensionner Video and Audio Piste sections", - "resizeTrackHeight": "redimensionner Piste hauteur" - }, - "tracks": "pistes", - "volumeControl": { - "adjustClipVolume": "Ajuster clip volume" - } - } - }, - "de": { - "editor": { - "videoSection": { - "cropBottom": "Unten zuschneiden", - "cropLeft": "Links zuschneiden", - "cropRight": "Rechts zuschneiden", - "cropTop": "Oben zuschneiden", - "cropping": "Zuschneiden", - "fadeIn": "Einblenden", - "fadeOut": "Ausblenden", - "playback": "Wiedergabe", - "resetCropBottom": "Unteren Zuschnitt zuruecksetzen", - "resetCropLeft": "Linken Zuschnitt zuruecksetzen", - "resetCropRight": "Rechten Zuschnitt zuruecksetzen", - "resetCropTop": "Oberen Zuschnitt zuruecksetzen", - "resetSoftness": "Weichheit zuruecksetzen", - "resetSpeed": "Geschwindigkeit zuruecksetzen", - "resetToZero": "Auf null zuruecksetzen", - "softness": "Weichheit", - "speed": "Geschwindigkeit" - } - }, - "effects": { - "curves": { - "channel": "Kanal", - "dragHint": "Ziehe Punkte der {{channel}}-Kurve, um Tonwerte anzupassen.", - "resetChannel": "Kanal zurücksetzen" - }, - "panel": { - "disableEffect": "Effekt deaktivieren", - "enableEffect": "Effekt aktivieren", - "off": "Aus", - "on": "Ein", - "removeEffect": "Effekt entfernen", - "resetToDefaults": "Auf Standard zurücksetzen" - }, - "section": { - "addEffect": "Effekt hinzufügen", - "disableAll": "Alle deaktivieren", - "emptyState": "Keine Effekte angewendet. Füge einen hinzu, um zu beginnen.", - "enableAll": "Alle aktivieren", - "noEffectsFound": "Keine Effekte gefunden", - "presets": "Voreinstellungen", - "searchEffects": "Effekte suchen", - "title": "Effekte" - }, - "wheels": { - "resetWheel": "Farbrad zurücksetzen" - } - }, - "export": { - "audioContainer": { - "aac": "AAC", - "mp3": "MP3", - "wav": "WAV" - }, - "cancelled": { - "message": "Der Export wurde abgebrochen. Es wurde keine Datei gespeichert." - }, - "complete": { - "audioSuccess": "Dein Audio kann heruntergeladen werden.", - "download": "Herunterladen", - "fileSizeLabel": "Größe", - "timeTakenLabel": "Dauer", - "videoSuccess": "Dein Video kann heruntergeladen werden." - }, - "dialog": { - "descCancelled": "Der Export wurde vor dem Abschluss abgebrochen.", - "descComplete": "Deine Datei ist bereit.", - "descError": "Beim Export ist etwas schiefgegangen.", - "descProgress": "Dein Video wird gerendert. Das kann einige Minuten dauern.", - "descSettings": "Konfiguriere die Exportoptionen.", - "titleCancelled": "Export abgebrochen", - "titleComplete": "Export abgeschlossen", - "titleError": "Export fehlgeschlagen", - "titleProgress": "Video wird exportiert", - "titleSettings": "Exportieren" - }, - "errors": { - "verifyCodec": "Codec-Unterstützung konnte für diese Einstellungen nicht überprüft werden." - }, - "progress": { - "cancelExport": "Abbrechen", - "elapsedLabel": "Verstrichen", - "encoding": "Codieren…", - "finalizing": "Wird abgeschlossen…", - "framesLabel": "Bilder", - "keepTabOpen": "Lass diesen Tab geöffnet, bis der Export abgeschlossen ist.", - "preparing": "Vorbereiten…", - "rendering": "Rendern…" - }, - "settings": { - "audio": "Audio", - "audioOnlyNote": "Reiner Audio-Export — kein Video wird einbezogen.", - "audioQualityHigh": "Hoch", - "audioQualityLow": "Niedrig", - "audioQualityMedium": "Mittel", - "audioQualityUltra": "Ultra", - "cannotEncode": "Kein unterstützter Encoder für {{width}}×{{height}} in der gewählten Qualität.", - "codec": "Codec", - "codecSupportUnverified": "Codec-Unterstützung konnte nicht überprüft werden. Der Export kann trotzdem funktionieren, ist aber nicht garantiert.", - "duration": "Dauer", - "embedSubtitles": "Untertitel einbetten", - "embedSubtitlesDescription": "Untertitel aus dem Transkript als Spur in die exportierte Datei einfügen.", - "embedSubtitlesMp4Note": "MP4 speichert Untertitel als separate Spur. Manche Player zeigen sie nicht standardmäßig an.", - "embedSubtitlesUnsupported": "{{container}} unterstützt keine eingebetteten Untertitel. Wähle MP4, MKV oder WebM.", - "exportAudio": "Audio exportieren", - "exportRange": "Exportbereich", - "exportType": "Exporttyp", - "exportVideo": "Video exportieren", - "format": "Format", - "in": "Anfang", - "inOutRangeHint": "Nur der ausgewählte In/Out-Bereich wird exportiert.", - "noTranscriptSegments": "Keine Transkript-Untertitel zum Einbetten vorhanden.", - "out": "Ende", - "quality": "Qualität", - "qualityHigh": "Hoch", - "qualityLow": "Niedrig", - "qualityMedium": "Mittel", - "qualityUltra": "Ultra", - "quicktimeMov": "QuickTime MOV", - "renderWholeProject": "Gesamtes Projekt rendern", - "resolution": "Auflösung", - "resolutionSameAsProject": "Wie Projekt ({{width}}×{{height}})", - "resolutionScaled": "{{p}}p ({{width}}×{{height}})", - "selectCodec": "Codec auswählen", - "selectFormat": "Format auswählen", - "selectQuality": "Qualität auswählen", - "selectResolution": "Auflösung auswählen", - "video": "Video" - }, - "videoContainer": { - "mp4": "Breite Unterstützung, ideal zum Teilen", - "mov": "Apple-Ökosystem, ideal für Final Cut und QuickTime", - "webm": "Offener, moderner Container für die Webwiedergabe", - "mkv": "Flexibler Container mit starker Untertitelunterstützung" - } - }, - "media": { - "card": { - "aiCaptionsCount": "{{count}} KI-Untertitel", - "analyzeWithAI": "Mit KI analysieren", - "analyzingWithAI": "Mit KI wird analysiert", - "chooseMkvOrWebm": "MKV oder WebM wählen", - "deleteProxy": "Proxy löschen", - "deleteTranscript": "Transkript löschen", - "extractEmbeddedSubtitles": "Eingebettete Untertitel extrahieren", - "generateProxy": "Proxy erstellen", - "generateTranscript": "Transkript erstellen", - "importing": "Wird importiert", - "menuAi": "KI", - "menuCaptions": "Eingebettete Untertitel", - "menuFile": "Datei", - "menuProxy": "Proxy", - "menuTranscript": "Transkript", - "playAudio": "Audio abspielen", - "refreshTranscript": "Transkript aktualisieren", - "relinkFile": "Datei neu verknüpfen…", - "stopAudio": "Audio stoppen", - "subtitlesCachedAll": "Untertitel zwischengespeichert", - "subtitlesCachedPartial": "Untertitel teilweise zwischengespeichert", - "subtitlesCannotRead": "FreeCut kann „{{name}}\" gerade nicht lesen. Schließe jede App, die sie verwendet, und versuche es erneut.", - "subtitlesExtractFailed": "Untertitel-Extraktion fehlgeschlagen", - "subtitlesFileMissing": "FreeCut findet „{{name}}\" nicht mehr.", - "subtitlesNeedPermission": "FreeCut benötigt Zugriff auf „{{name}}\", bevor Untertitel extrahiert werden können.", - "subtitlesScanFailed": "Untertitelsuche fehlgeschlagen", - "transcribeFailed": "Transkription fehlgeschlagen", - "transcribing": "Wird transkribiert", - "transcriptDeleteFailed": "Löschen des Transkripts fehlgeschlagen", - "transcriptDeleteFailedFor": "Transkript für „{{name}}\" konnte nicht gelöscht werden", - "transcriptDeletedFor": "Transkript für „{{name}}\" gelöscht", - "transcriptProgressAria": "Transkript-Fortschritt", - "transcriptReadyFor": "Transkript für „{{name}}\" ist bereit", - "transcriptionFailedFor": "Transkription für „{{name}}\" fehlgeschlagen", - "transcriptsDeleted": "Transkripte gelöscht", - "transcriptsReady": "Transkripte bereit" - }, - "compositions": { - "deleteBody": "Zusammengesetzten Clip \"{{name}}\" löschen? Dies kann nicht rückgängig gemacht werden.", - "deleteInstancesDetail": "{{count}} Timeline-Instanzen, die diesen zusammengesetzten Clip verwenden, werden ebenfalls entfernt.", - "deleteInstancesTitle": "Verknüpfte Instanzen werden entfernt", - "deleteTitle": "Zusammengesetzten Clip löschen", - "enter": "Öffnen", - "itemCount": "{{count}} Elemente", - "rename": "Umbenennen", - "sectionTitle": "Zusammengesetzte Clips" - }, - "deleteDialog": { - "bodyMultiple": "Dadurch werden {{count}} Medienelemente dauerhaft aus diesem Projekt entfernt. Diese Aktion kann nicht rückgängig gemacht werden.", - "bodySingle": "Dadurch wird \"{{name}}\" dauerhaft aus diesem Projekt entfernt. Diese Aktion kann nicht rückgängig gemacht werden.", - "confirmWithClips": "Mit Clips löschen", - "timelineClipsDetail": "{{count}} Timeline-Clips, die dieses Medium verwenden, werden ebenfalls entfernt.", - "timelineClipsDetail_one": "{{count}} Timeline-Clip, der dieses Medium verwendet, wird ebenfalls entfernt.", - "timelineClipsDetail_other": "{{count}} Timeline-Clips, die dieses Medium verwenden, werden ebenfalls entfernt.", - "timelineClipsRemoved": "Timeline-Clips werden entfernt", - "titleMultiple": "{{count}} Medienelemente löschen?", - "titleSingle": "Medienelement löschen?" - }, - "embeddedSubtitles": { - "badgeAutoPick": "Auto", - "badgeDefault": "Standard", - "badgeForced": "Erzwungen", - "cuesCount": "{{count}} Einträge", - "desc": "Wähle eine Untertitelspur, die in die Timeline eingefügt werden soll.", - "descForFile": "Verfügbare Untertitel in „{{name}}\".", - "empty": "In dieser Datei wurden keine Untertitelspuren gefunden.", - "insert": "Einfügen", - "insertWithCues": "Einfügen ({{count}} Einträge)", - "loadedFromCache": "Untertitel aus dem Zwischenspeicher geladen.", - "scanning": "Wird gesucht…", - "title": "Eingebettete Untertitel", - "trackInfo": "Spur {{n}} · {{lang}} · {{codec}}" - }, - "grid": { - "emptyHint": "Dateien hier ablegen oder auf Importieren klicken, um Medien hinzuzufügen.", - "emptyTitle": "Noch keine Medien", - "loadingSubtitle": "Medien werden vorbereitet…", - "loadingTitle": "Medien werden geladen…" - }, - "info": { - "codec": "Codec", - "dimensions": "Abmessungen", - "duration": "Dauer", - "fpsValue": "{{value}} fps", - "frameRate": "Bildrate", - "loadingTranscript": "Transkript wird geladen…", - "mediaInfo": "Medieninformationen", - "openInSourceMonitor": "Im Quellmonitor öffnen", - "size": "Größe", - "transcript": "Transkript", - "transcriptWithCount": "Transkript ({{count}})", - "type": "Typ" - }, - "library": { - "aiAnalysisProgress": "KI-Analyse", - "allTypes": "Alle Typen", - "analyzingMultiple": "{{count}} Clips werden analysiert…", - "analyzingSingle": "Wird analysiert…", - "andJoiner": " und ", - "assetsCount": "{{count}} Assets", - "assetsCount_one": "{{count}} Asset", - "assetsCount_other": "{{count}} Assets", - "back": "Zurück", - "cancelAll": "Alle abbrechen", - "cancelling": "Wird abgebrochen…", - "clearSelection": "Auswahl löschen", - "compoundClipsCount": "{{count}} zusammengesetzte Clips", - "compoundClipsCount_one": "{{count}} zusammengesetzter Clip", - "compoundClipsCount_other": "{{count}} zusammengesetzte Clips", - "copyToClipboard": "In Zwischenablage kopieren", - "deleteAssetsBody": "Dadurch werden {{summary}} dauerhaft aus diesem Projekt entfernt. Diese Aktion kann nicht rückgängig gemacht werden.", - "deleteAssetsTitle": "Ausgewählte Assets löschen?", - "deleteSelectedAssets": "Ausgewählte Assets löschen", - "deleteSummary": "{{summary}} löschen", - "deleteWithClips": "Mit Clips löschen", - "dismiss": "Verwerfen", - "dragDropUnsupported": "Drag-and-drop wird hier nicht unterstützt.", - "dropFilesHere": "Dateien hier ablegen", - "filesRejected": "Einige Dateien konnten nicht hinzugefügt werden.", - "generateProxiesForSelected": "Proxys für {{count}} ausgewählte Elemente erzeugen", - "generatingProxies": "Proxys werden erstellt…", - "generatingTranscripts": "Transkripte werden erstellt…", - "gridItemSize": "Vorschaugröße", - "groupAudio": "Audio gruppieren", - "groupGifs": "GIFs gruppieren", - "groupImages": "Bilder gruppieren", - "groupVideos": "Videos gruppieren", - "import": "Importieren", - "importFromUrlDescription": "Importiere eine Mediendatei über eine direkte URL.", - "importFromUrlHint": "Füge einen direkten Link zu einer Video-, Audio-, Bild- oder GIF-Datei ein.", - "importFromUrlTitle": "Von URL importieren", - "importMediaFiles": "Mediendateien importieren", - "importMediaFromUrl": "Medien von URL importieren", - "libraryView": "Bibliotheksansicht", - "linkedInstancesDetail": "{{count}} Timeline-Instanzen, die diese Assets verwenden, werden ebenfalls entfernt.", - "linkedInstancesDetail_one": "{{count}} Timeline-Instanz, die diese Assets verwendet, wird ebenfalls entfernt.", - "linkedInstancesDetail_other": "{{count}} Timeline-Instanzen, die diese Assets verwenden, werden ebenfalls entfernt.", - "linkedInstancesTitle": "Verknüpfte Timeline-Instanzen werden entfernt", - "mediaItemsCount": "{{count}} Medienelemente", - "mediaItemsCount_one": "{{count}} Medienelement", - "mediaItemsCount_other": "{{count}} Medienelemente", - "mediaTab": "Medien", - "missingCount": "{{count}} fehlen", - "proxyCount": "{{count}} Proxys", - "proxyGenerationProgress": "Proxy-Fortschritt", - "scenesTab": "Szenen", - "searchScenes": "Szenen suchen", - "selected": "Ausgewählt", - "selectedAssetsCount": "{{count}} ausgewählte Assets", - "selectedAssetsCount_one": "{{count}} ausgewähltes Asset", - "selectedAssetsCount_other": "{{count}} ausgewählte Assets", - "showMediaLibrary": "Medienbibliothek anzeigen", - "sortDate": "Nach Datum sortieren", - "sortName": "Nach Name sortieren", - "sortSize": "Nach Groesse sortieren", - "transcriptGenerationProgress": "Transkript-Fortschritt", - "url": "URL", - "viewMissingMedia": "Fehlende Medien anzeigen", - "allShort": "ALLE", - "typeShort": { - "video": "VIDEO", - "audio": "AUDIO", - "image": "BILD" - }, - "sortShort": { - "name": "NAME", - "date": "DATUM", - "size": "GROESSE" - }, - "missingCount_one": "{{count}} fehlt", - "missingCount_other": "{{count}} fehlen", - "selectedCount": "{{count}} ausgewählt", - "selectedCount_one": "{{count}} ausgewählt", - "selectedCount_other": "{{count}} ausgewählt", - "generateProxiesForSelected_one": "Proxy für {{count}} ausgewähltes Element erzeugen", - "generateProxiesForSelected_other": "Proxys für {{count}} ausgewählte Elemente erzeugen", - "proxyCount_one": "{{count}} Proxy", - "proxyCount_other": "{{count}} Proxys" - }, - "missingMedia": { - "title": "Fehlende Medien", - "description": "FreeCut kann auf {{count}} Mediendateien nicht zugreifen. Suche die Dateien oder arbeite offline weiter.", - "description_one": "FreeCut kann auf {{count}} Mediendatei nicht zugreifen. Suche die Datei oder arbeite offline weiter.", - "description_other": "FreeCut kann auf {{count}} Mediendateien nicht zugreifen. Suche die Dateien oder arbeite offline weiter.", - "needPermission": "{{count}} benötigen Berechtigung", - "needPermission_one": "{{count}} benötigt Berechtigung", - "needPermission_other": "{{count}} benötigen Berechtigung", - "notFound": "{{count}} nicht gefunden", - "notFound_one": "{{count}} nicht gefunden", - "notFound_other": "{{count}} nicht gefunden", - "browseAnotherFolder": "Anderen Ordner durchsuchen", - "fileMovedOrDeleted": "Datei verschoben oder gelöscht", - "grantAccess": "Zugriff erlauben", - "locate": "Suchen", - "locateFolder": "Ordner suchen", - "permissionExpired": "Berechtigung abgelaufen", - "scanProjectFolder": "{{name}} scannen", - "workOffline": "Offline arbeiten" - }, - "orphanedClips": { - "autoMatch": "Automatisch zuordnen", - "description": "{{count}} Clip(s) verweisen auf Medien, die nicht mehr gefunden werden können. Ersetze oder entferne sie, um fortzufahren.", - "keepAsBroken": "Als defekt behalten", - "removeAll": "Alle entfernen", - "select": "Auswählen", - "selectReplacement": "Ersatz für „{{name}}\" wählen", - "title": "Fehlende Medien" - }, - "picker": { - "descAll": "Wähle eine Datei aus der Projekt-Bibliothek.", - "noFiles": "Noch keine Medien in diesem Projekt.", - "noSearchResults": "Keine Ergebnisse gefunden.", - "title": "Medien auswählen" - }, - "searchMedia": "Medien suchen", - "subtitleScan": { - "cached": "Zwischengespeichert", - "cancelBatch": "Alle abbrechen", - "descComplete": "Untertitelsuche abgeschlossen.", - "descScanning": "Medien werden auf Untertitel durchsucht…", - "failed": "Fehlgeschlagen", - "titleComplete": "Untertitelsuche abgeschlossen", - "titleScanning": "Untertitel werden gesucht…" - }, - "transcribe": { - "autoDetect": "Automatisch erkennen", - "generateTitle": "Transkript erstellen", - "language": "Sprache", - "model": "Modell", - "noLanguages": "Keine Sprachen verfügbar.", - "progressAria": "Transkriptionsfortschritt", - "quantization": "Quantisierung", - "refreshTitle": "Transkript aktualisieren", - "searchLanguages": "Sprachen suchen", - "start": "Starten", - "stop": "Stoppen" - }, - "type": { - "audio": "Audio", - "image": "Bild", - "video": "Video" - }, - "unsupportedCodec": { - "bodyMultiple": "{{count}} Dateien verwenden Codecs, die dein Browser nicht decodieren kann. Sie werden möglicherweise nicht korrekt wiedergegeben.", - "bodySingle": "Diese Datei verwendet einen Codec, den dein Browser nicht decodieren kann. Sie wird möglicherweise nicht korrekt wiedergegeben.", - "cancelImport": "Import abbrechen", - "importAnyway": "Trotzdem importieren", - "note": "Konvertiere sie für bessere Kompatibilität in MP4 (H.264/AAC) oder WebM.", - "title": "Codec nicht unterstützt" - } - }, - "preview": { - "align": { - "disableCanvasSnap": "Canvas-Einrasten deaktivieren", - "enableCanvasSnap": "Canvas-Einrasten aktivieren", - "toggleCanvasSnap": "Canvas-Einrasten umschalten" - }, - "controls": { - "captureFailed": "Aktuelles Bild konnte nicht erfasst werden.", - "disableProxyPlayback": "Proxy-Wiedergabe deaktivieren", - "enableProxyPlayback": "Proxy-Wiedergabe aktivieren", - "frameDownloadedNoProject": "Bild heruntergeladen — öffne ein Projekt, um es zur Mediathek hinzuzufügen.", - "frameDownloadedNotSaved": "Bild heruntergeladen, konnte aber nicht zur Mediathek hinzugefügt werden: {{message}}", - "frameSaved": "„{{name}}\" zur Mediathek hinzugefügt.", - "goToEnd": "Zum Ende", - "goToEndTooltip": "Zum Ende", - "goToStart": "Zum Anfang", - "goToStartTooltip": "Zum Anfang", - "nextFrame": "Nächstes Bild", - "nextFrameTooltip": "Nächstes Bild", - "pauseTooltip": "Pause", - "playTooltip": "Wiedergabe", - "prevFrame": "Vorheriges Bild", - "prevFrameTooltip": "Vorheriges Bild", - "proxyPlaybackOff": "Proxy-Wiedergabe aus", - "proxyPlaybackOn": "Proxy-Wiedergabe an", - "saveFrame": "Bild speichern", - "saveFrameFailed": "Bild konnte nicht gespeichert werden.", - "saveFrameTooltip": "Bild speichern", - "savingFrame": "Bild wird gespeichert…", - "savingFrameTooltip": "Bild wird gespeichert…" - }, - "monitor": { - "mute": "Stummschalten", - "muteShort": "Stumm", - "muted": "Stummgeschaltet", - "percent": "{{value}} %", - "previewOnlyNote": "Wirkt nur auf die lokale Wiedergabe — Exporte nutzen den Projektmix.", - "thisDeviceOnly": "Nur dieses Gerät", - "title": "Vorschau-Audio", - "unmute": "Stummschaltung aufheben", - "volume": "Lautstärke" - }, - "player": { - "exitFullscreen": "Vollbild beenden", - "fullscreen": "Vollbild", - "mute": "Stummschalten", - "pause": "Pause", - "play": "Wiedergabe", - "seek": "Suchen", - "unmute": "Stummschaltung aufheben", - "volume": "Lautstärke" - }, - "stage": { - "loadingMedia": "Medien werden geladen…", - "videoPreview": "Videovorschau" - }, - "zoom": { - "ariaLabel": "Zoomstufe: {{label}}", - "auto": "Automatisch", - "tooltip": "Zoom: {{label}}" - } - }, - "timeline": { - "addAudioTrackHint": "Hinzufugen Audio Spur Hinweis", - "addVideoTrackHint": "Hinzufugen Video Spur Hinweis", - "bento": { - "apply": "Anwenden", - "description": "{{count}} ausgewählte Clips in einem Raster anordnen.", - "gap": "Lucke", - "noItemsToArrange": "Keine Elemente zu anordnen", - "padding": "Abstand", - "presetNamePlaceholder": "Vorgabe Name Platzhalter", - "saveAsPreset": "Speichern als Vorgabe", - "title": "Bento-Layout" - }, - "captions": { - "addedFromTranscript": "Hinzugefugt aus Transkript", - "addedWithModel": "Hinzugefugt mit Modell", - "failedGenerateSegment": "Fehlgeschlagen Generieren Segment", - "failedUpdateSegment": "Fehlgeschlagen aktualisieren Segment", - "refreshedWithModel": "Aktualisiert mit Modell", - "removedFromSegment": "Entfernt aus Segment", - "updatedFromTranscript": "Updated aus Transkript", - "updatedWithModel": "Updated mit Modell" - }, - "clipIndicators": { - "hasKeyframes": "Hat Keyframes", - "mask": "Maske", - "mediaMissing": "Medien fehlt", - "preparingReversed": "Vorbereiten ruckwarts", - "reversePrepFailedShort": "Umkehren Vorbereitung Fehlgeschlagen Short", - "reversedPlayback": "ruckwarts Wiedergabe", - "reversedPrepFailed": "ruckwarts Vorbereitung Fehlgeschlagen", - "reversedPrepared": "ruckwarts Prepared", - "speed": "Geschwindigkeit: {{speed}}x" - }, - "contextMenu": { - "bentoLayout": "Bento-Layout", - "captions": "Untertitel", - "clearAll": "Loschen alle", - "clearKeyframes": "Loschen Keyframes", - "consolidateCaptionsToSegment": "Zusammenfassen Untertitel zu Segment", - "createCompoundClip": "Erstellen zusammengesetzt Clip", - "detectScenesAi": "KI ({{model}})", - "detectScenesAndSplit": "Erkennen Szenen & teilen", - "detectScenesFast": "Schnell (Histogramm)", - "detectingFillers": "Erkennen Fuller", - "detectingScenes": "Erkennen Szenen", - "detectingSilence": "Erkennen Stille", - "dissolveCompoundClip": "Auflosen zusammengesetzt Clip", - "extractEmbeddedSubtitles": "Extrahieren eingebettet Untertitel", - "generateAudioFromText": "Generieren Audio aus Text", - "generateCaptions": "Generieren Untertitel", - "insertExistingCaptions": "Einfugen vorhandene Untertitel", - "insertFreezeFrame": "Einfugen Standbild Frame", - "joinSelected": "Verbinden ausgewahlt", - "joinWithNext": "Verbinden mit nachstem", - "joinWithPrevious": "Verbinden mit vorherigem", - "linkClips": "Verknupfen Clips", - "openCompoundClip": "Offnen zusammengesetzt Clip", - "regenerateCaptions": "Neu generieren Untertitel", - "removeFillerWords": "Entfernen Fuller Worter", - "removeSilence": "Entfernen Stille", - "reverse": "Umkehren", - "rippleDelete": "Ripple Loschen", - "unlinkClips": "Trennen Clips", - "unreverse": "Umkehrung aufheben", - "updatingCaptions": "Aktualisieren Untertitel" - }, - "fadeHandles": { - "adjustAudioFadeIn": "Anpassen Audio Fade ein", - "adjustAudioFadeInCurve": "Anpassen Audio Fade ein Kurve", - "adjustAudioFadeOut": "Anpassen Audio Fade aus", - "adjustAudioFadeOutCurve": "Anpassen Audio Fade aus Kurve", - "adjustVideoFadeIn": "Anpassen Video Fade ein", - "adjustVideoFadeOut": "Anpassen Video Fade aus" - }, - "fillerRemoval": { - "aboutWillBeRemoved": "Etwa {{duration}} werden entfernt.", - "add": "Hinzufugen", - "addPhrase": "Hinzufugen Phrase", - "addWord": "Hinzufugen Wort", - "cutPadding": "Schnitt Abstand", - "filler": "Fuller", - "fillerRange": "Füllwort", - "found": "Gefunden", - "includeRange": "Einschliessen Bereich", - "maxPhrase": "Maximale Phrase", - "maxWord": "Max Wort", - "noEntriesFound": "Keine Eintrage Gefunden", - "noRemovableDetectedShort": "Keine Füllwörter gefunden.", - "none": "Keine", - "phrases": "Phrasen", - "playThisRange": "Abspielen diesen Bereich", - "rangesSelected": "Bereiche ausgewahlt", - "redo": "Wiederholen", - "redoEditTitle": "Letzte Änderung wiederherstellen", - "remove": "Entfernen", - "removed": "Entfernt", - "scoreAudio": "Bewerten Audio", - "scoring": "Bewertung", - "title": "Füllwörter entfernen", - "toastAudioScored": "Audio-Vertrauensbewertung abgeschlossen.", - "toastNoRemovable": "Keine Füllwörter passten zu den aktuellen Einstellungen.", - "toastNoneInClips": "Keine zu entfernenden Füllwörter in den ausgewählten Clips.", - "toastPreviewFailed": "Vorschau der Entfernung konnte nicht erstellt werden.", - "toastRemoveFailed": "Füllwörter konnten nicht entfernt werden.", - "toastRemoved": "{{count}} Füllwort-Bereiche entfernt.", - "toastScoreFailed": "Audio-Vertrauen konnte nicht analysiert werden.", - "undo": "Ruckgangig", - "undoEditTitle": "Letzte Änderung rückgängig machen", - "updatePreview": "aktualisieren Vorschau", - "updating": "Aktualisieren", - "words": "Worter" - }, - "header": { - "addMarker": "Hinzufugen Marker", - "addMarkerTooltip": "Hinzufugen Marker", - "clearAllMarkers": "Loschen alle Marker", - "clearAllMarkersTooltip": "Loschen alle Marker", - "clearInOutPoints": "Loschen ein aus Punkte", - "clearInOutPointsTooltip": "Loschen ein aus Punkte", - "controls": "Steuerung", - "disableLinkedSelection": "Deaktivieren verknupfte Auswahl", - "disableSnapping": "Deaktivieren Einrasten", - "enableLinkedSelection": "Aktivieren verknupfte Auswahl", - "enableSnapping": "Aktivieren Einrasten", - "hideColorScopes": "Ausblenden Farbe Scopes", - "hideColorScopesTooltip": "Ausblenden Farbe Scopes", - "linkedSelectionOff": "verknupfte Auswahl aus", - "linkedSelectionOn": "verknupfte Auswahl an", - "linkedSelectionTooltip": "Verknüpfte Auswahl: {{state}} ({{shortcut}})", - "rateStretchTool": "Rate Strecken Werkzeug", - "rateStretchToolTooltip": "Rate Strecken Werkzeug", - "razorTool": "Rasiermesser Werkzeug", - "razorToolTooltip": "Rasiermesser Werkzeug", - "redo": "Wiederholen", - "redoTooltip": "Wiederholen", - "redoWithLabel": "Wiederholen {{label}}", - "redoWithLabelTooltip": "Wiederholen {{label}}", - "removeSelectedMarker": "Entfernen ausgewahlt Marker", - "removeSelectedMarkerTooltip": "Entfernen ausgewahlt Marker", - "selectTool": "Auswahlen Werkzeug", - "selectToolTooltip": "Auswahlen Werkzeug", - "setInPoint": "Setzen ein Punkt", - "setInPointTooltip": "Setzen ein Punkt", - "setOutPoint": "Setzen aus Punkt", - "setOutPointTooltip": "Setzen aus Punkt", - "showColorScopes": "Anzeigen Farbe Scopes", - "showColorScopesTooltip": "Anzeigen Farbe Scopes", - "slideTool": "Slide Werkzeug", - "slipSlideTools": "Slip/Slide-Werkzeuge", - "slipSlideToolsTooltip": "Slip/Slide-Werkzeuge", - "slipTool": "Slip Werkzeug", - "snapDisabled": "Einrasten deaktiviert", - "snapEnabled": "Einrasten aktiviert", - "title": "Timeline", - "trimEditTool": "Trimmen Bearbeitung Werkzeug", - "trimEditToolTooltip": "Trimmen Bearbeitung Werkzeug", - "undo": "Ruckgangig", - "undoTooltip": "Ruckgangig", - "undoWithLabel": "Ruckgangig {{label}}", - "undoWithLabelTooltip": "Ruckgangig {{label}}", - "zoomIn": "Zoom ein", - "zoomInTooltip": "Zoom ein", - "zoomOut": "Zoom aus", - "zoomOutTooltip": "Zoom aus", - "zoomSlider": "Zoom Regler", - "zoomToFit": "Zoom zu anpassen", - "zoomToFitTooltip": "Zoom zu anpassen" - }, - "itemActions": { - "appliedEffectToClips": "Applied Effekt zu Clips", - "selectAvClipFirst": "Auswahlen AV Clip zuerst" - }, - "joinIndicators": { - "canJoinNext": "Kann Verbinden nachstem", - "canJoinPrevious": "Kann Verbinden vorherigem" - }, - "keyframeEditor": { - "bezier": "Bezier-Kurve", - "custom": "Benutzerdefiniert", - "dragHandlesHint": "Ziehe die Griffe, um die Kurve zu formen.", - "graph": "Diagramm", - "mixedCurves": "gemischt Kurven", - "mixedSpring": "gemischt Feder", - "movedKeyframes": "verschoben Keyframes", - "noKeyframesPasted": "Keine Keyframes eingefugt", - "pastedKeyframes": "eingefugt Keyframes", - "preset": "Vorgabe", - "reasonBlocked": "{{count}} von einem anderen Keyframe blockiert", - "reasonUnsupported": "{{count}} von der Zieleigenschaft nicht unterstützt", - "selectItem": "Auswahlen Element", - "sheet": "Tabelle", - "skippedDescription": "{{count}} übersprungen: {{reasons}}", - "spring": "Feder", - "springHint": "Federphysik — natürlich und federnd.", - "title": "Keyframes", - "unableToPasteCut": "Nicht moglich zu einfugen Schnitt", - "unableToPasteCutDescription": "Ausgeschnittene Keyframes können hier nicht eingefügt werden. {{reasons}}" - }, - "noTracksToRemove": "Keine Spuren zu Entfernen", - "region": "Bereich", - "removeActiveTrack": "Entfernen aktive Spur", - "removeSelectedTracks": "Entfernen ausgewahlt Spuren", - "reverseConform": { - "cancellingDescription": "Vorbereitung der Umkehrung wird abgebrochen…", - "couldNotPrepare": "Clip konnte nicht für die Rückwärtswiedergabe vorbereitet werden.", - "progressDescription": "Clip {{current}} von {{total}} wird vorbereitet…", - "titleCancelling": "Wird abgebrochen", - "titleFailed": "Umkehrung fehlgeschlagen", - "titlePreparing": "Rückwärtswiedergabe wird vorbereitet" - }, - "sceneDetection": { - "detectingScenes": "Erkennen Szenen", - "failed": "Fehlgeschlagen", - "noScenesDetected": "Keine Szenen Detected", - "noScenesWithinBounds": "Keine Szenen innerhalb Grenzen", - "noValidSplitPoints": "Keine gultig teilen Punkte", - "requiresWebGpu": "Benotigt Web GPU", - "splitAtScenes": "teilen At Szenen" - }, - "selectTrackToRemove": "Auswahlen Spur zu Entfernen", - "silenceRemoval": { - "aboutWillBeRemoved": "Etwa {{duration}} Stille werden entfernt.", - "keepPadding": "Behalten Abstand", - "minimumSilence": "Minimum Stille", - "noRemovableDetectedShort": "Keine Stille gefunden.", - "rangesSelected": "Bereiche ausgewahlt", - "remove": "Entfernen", - "threshold": "Schwellwert", - "title": "Stille entfernen", - "toastNoRemovable": "Keine Stille passte zu den aktuellen Einstellungen.", - "toastNoneInClips": "Keine zu entfernende Stille in den ausgewählten Clips.", - "toastPreviewFailed": "Vorschau der Stilleentfernung konnte nicht erstellt werden.", - "toastRemoved": "{{count}} Stille-Bereiche entfernt.", - "toastSettingsChanged": "Einstellungen geändert — vor dem Anwenden Vorschau anzeigen.", - "updatePreview": "aktualisieren Vorschau", - "updating": "Aktualisieren" - }, - "track": { - "locked": "gesperrt" - }, - "trackHeader": { - "addAudioTrack": "Hinzufugen Audio Spur", - "addVideoTrack": "Hinzufugen Video Spur", - "clipCount": "Clip-Anzahl", - "closeAllGaps": "Schliessen alle Lucken", - "deleteEmptyTracks": "Loschen leere Spuren", - "deleteTrack": "Loschen Spur", - "disableSyncLock": "Deaktivieren Sync Sperre", - "disableTrack": "Deaktivieren Spur", - "enableSyncLock": "Aktivieren Sync Sperre", - "enableTrack": "Aktivieren Spur", - "lockTrack": "Sperre Spur", - "soloTrack": "Solo Spur", - "unlockTrack": "entsperren Spur", - "unsoloTrack": "Solo aus Spur" - }, - "trackRow": { - "resizeSections": "Grosse andern Video and Audio Spur Abschnitte", - "resizeTrackHeight": "Grosse andern Spur Hohe" - }, - "tracks": "Spuren", - "volumeControl": { - "adjustClipVolume": "Anpassen Clip Lautstarke" - } - } - }, - "pt-BR": { - "editor": { - "videoSection": { - "cropBottom": "Cortar parte inferior", - "cropLeft": "Cortar esquerda", - "cropRight": "Cortar direita", - "cropTop": "Cortar parte superior", - "cropping": "Recorte", - "fadeIn": "Fade de entrada", - "fadeOut": "Fade de saida", - "playback": "Reproducao", - "resetCropBottom": "Redefinir corte inferior", - "resetCropLeft": "Redefinir corte esquerdo", - "resetCropRight": "Redefinir corte direito", - "resetCropTop": "Redefinir corte superior", - "resetSoftness": "Redefinir suavidade", - "resetSpeed": "Redefinir velocidade", - "resetToZero": "Redefinir para zero", - "softness": "Suavidade", - "speed": "Velocidade" - } - }, - "effects": { - "curves": { - "channel": "Canal", - "dragHint": "Arraste pontos na curva {{channel}} para ajustar os tons.", - "resetChannel": "Redefinir canal" - }, - "panel": { - "disableEffect": "Desativar efeito", - "enableEffect": "Ativar efeito", - "off": "Desligado", - "on": "Ligado", - "removeEffect": "Remover efeito", - "resetToDefaults": "Redefinir para padroes" - }, - "section": { - "addEffect": "Adicionar efeito", - "disableAll": "Desativar tudo", - "emptyState": "Nenhum efeito aplicado. Adicione um para começar.", - "enableAll": "Ativar tudo", - "noEffectsFound": "Nenhum efeito encontrado", - "presets": "Predefinicoes", - "searchEffects": "Pesquisar efeitos", - "title": "Efeitos" - }, - "wheels": { - "resetWheel": "Redefinir roda" - } - }, - "export": { - "audioContainer": { - "aac": "AAC", - "mp3": "MP3", - "wav": "WAV" - }, - "cancelled": { - "message": "A exportação foi cancelada. Nenhum arquivo foi salvo." - }, - "complete": { - "audioSuccess": "Seu áudio está pronto para download.", - "download": "Baixar", - "fileSizeLabel": "Tamanho", - "timeTakenLabel": "Tempo", - "videoSuccess": "Seu vídeo está pronto para download." - }, - "dialog": { - "descCancelled": "A exportação foi cancelada antes de terminar.", - "descComplete": "Seu arquivo está pronto.", - "descError": "Algo deu errado durante a exportação.", - "descProgress": "Exportacao em andamento.", - "descSettings": "Configure as opções de exportação.", - "titleCancelled": "Exportação cancelada", - "titleComplete": "Exportação concluída", - "titleError": "Falha na exportação", - "titleProgress": "Exportando", - "titleSettings": "Exportar" - }, - "errors": { - "verifyCodec": "Não foi possível verificar a compatibilidade do codec para estas configurações." - }, - "progress": { - "cancelExport": "Cancelar exportação", - "elapsedLabel": "Decorrido", - "encoding": "Codificando…", - "finalizing": "Finalizando…", - "framesLabel": "Quadros", - "keepTabOpen": "Mantenha esta aba aberta até o término da exportação.", - "preparing": "Preparando…", - "rendering": "Renderizando…" - }, - "settings": { - "audio": "Áudio", - "audioOnlyNote": "Exportação somente de áudio — nenhum vídeo será incluído.", - "audioQualityHigh": "Alta", - "audioQualityLow": "Baixa", - "audioQualityMedium": "Média", - "audioQualityUltra": "Ultra", - "cannotEncode": "Nenhum codificador compatível para {{width}}×{{height}} com a qualidade selecionada.", - "codec": "Codec", - "codecSupportUnverified": "Não foi possível verificar o suporte ao codec. A exportação pode funcionar, mas sem garantia.", - "duration": "Duração", - "embedSubtitles": "Incorporar legendas", - "embedSubtitlesDescription": "Inclui legendas da transcrição como uma faixa no arquivo exportado.", - "embedSubtitlesMp4Note": "MP4 armazena legendas em uma faixa separada. Alguns reprodutores podem não mostrá-las por padrão.", - "embedSubtitlesUnsupported": "{{container}} não suporta legendas incorporadas. Escolha MP4, MKV ou WebM.", - "exportAudio": "Exportar áudio", - "exportRange": "Intervalo de exportação", - "exportType": "Tipo de exportação", - "exportVideo": "Exportar vídeo", - "format": "Formato", - "in": "Entrada", - "inOutRangeHint": "Apenas o intervalo de entrada/saída selecionado será exportado.", - "noTranscriptSegments": "Nenhuma legenda de transcrição disponível para incorporar.", - "out": "Saída", - "quality": "Qualidade", - "qualityHigh": "Alta", - "qualityLow": "Baixa", - "qualityMedium": "Média", - "qualityUltra": "Ultra", - "quicktimeMov": "QuickTime MOV", - "renderWholeProject": "Renderizar o projeto inteiro", - "resolution": "Resolução", - "resolutionSameAsProject": "Igual ao projeto ({{width}}×{{height}})", - "resolutionScaled": "{{p}}p ({{width}}×{{height}})", - "selectCodec": "Selecione um codec", - "selectFormat": "Selecione um formato", - "selectQuality": "Selecione uma qualidade", - "selectResolution": "Selecione uma resolução", - "video": "Vídeo" - }, - "videoContainer": { - "mp4": "Amplo suporte, ideal para compartilhar", - "mov": "Ecossistema Apple, ideal para Final Cut e QuickTime", - "webm": "Contêiner aberto e moderno para reprodução na web", - "mkv": "Contêiner flexível com ótimo suporte a legendas" - } - }, - "media": { - "card": { - "aiCaptionsCount": "Contagem de legendas de IA", - "analyzeWithAI": "Analisar com IA", - "analyzingWithAI": "Analisando com IA", - "chooseMkvOrWebm": "Escolha MKV ou WebM", - "deleteProxy": "Excluir proxy", - "deleteTranscript": "Excluir transcricao", - "extractEmbeddedSubtitles": "Extrair legendas incorporadas", - "generateProxy": "Gerar proxy", - "generateTranscript": "Gerar transcricao", - "importing": "Importando", - "menuAi": "Menu IA", - "menuCaptions": "Legendas incorporadas", - "menuFile": "Arquivo", - "menuProxy": "Proxy", - "menuTranscript": "Transcrição", - "playAudio": "Reproduzir audio", - "refreshTranscript": "Atualizar transcricao", - "relinkFile": "Revincular arquivo...", - "stopAudio": "Parar audio", - "subtitlesCachedAll": "Todas as legendas em cache", - "subtitlesCachedPartial": "Algumas legendas em cache", - "subtitlesCannotRead": "FreeCut nao conseguiu ler \"{{name}}\" agora. Feche qualquer app que esteja usando o arquivo e tente novamente.", - "subtitlesExtractFailed": "Falha ao extrair legendas", - "subtitlesFileMissing": "FreeCut nao consegue mais encontrar \"{{name}}\".", - "subtitlesNeedPermission": "FreeCut precisa de permissao para ler \"{{name}}\" antes de extrair legendas.", - "subtitlesScanFailed": "Falha ao escanear legendas", - "transcribeFailed": "Falha na transcricao", - "transcribing": "Transcrevendo", - "transcriptDeleteFailed": "Falha ao excluir transcricao", - "transcriptDeleteFailedFor": "Nao foi possivel excluir a transcricao de \"{{name}}\"", - "transcriptDeletedFor": "Transcricao excluida de \"{{name}}\"", - "transcriptProgressAria": "Progresso da transcricao", - "transcriptReadyFor": "Transcricao pronta para \"{{name}}\"", - "transcriptionFailedFor": "Falha na transcricao de \"{{name}}\"", - "transcriptsDeleted": "Transcricoes excluidas", - "transcriptsReady": "Transcricoes prontas" - }, - "compositions": { - "deleteBody": "Excluir o clipe composto \"{{name}}\"? Esta ação não pode ser desfeita.", - "deleteInstancesDetail": "{{count}} instâncias da linha do tempo que usam este clipe composto também serão removidas.", - "deleteInstancesTitle": "As instâncias vinculadas serão removidas", - "deleteTitle": "Excluir clipe composto", - "enter": "Entrar", - "itemCount": "{{count}} itens", - "rename": "Renomear", - "sectionTitle": "Clipes compostos" - }, - "deleteDialog": { - "bodyMultiple": "Isso removerá permanentemente {{count}} itens de mídia deste projeto. Esta ação não pode ser desfeita.", - "bodySingle": "Isso removerá permanentemente \"{{name}}\" deste projeto. Esta ação não pode ser desfeita.", - "confirmWithClips": "Excluir com clipes", - "timelineClipsDetail": "{{count}} clipes da linha do tempo que usam esta mídia também serão removidos.", - "timelineClipsDetail_one": "{{count}} clipe da linha do tempo que usa esta mídia também será removido.", - "timelineClipsDetail_other": "{{count}} clipes da linha do tempo que usam esta mídia também serão removidos.", - "timelineClipsRemoved": "Clipes removidos da linha do tempo", - "titleMultiple": "Excluir {{count}} itens de mídia?", - "titleSingle": "Excluir item de mídia?" - }, - "embeddedSubtitles": { - "badgeAutoPick": "Automático", - "badgeDefault": "Padrão", - "badgeForced": "Forçado", - "cuesCount": "Contagem de falas", - "desc": "Descricao", - "descForFile": "Descricao do arquivo", - "empty": "Vazio", - "insert": "Inserir", - "insertWithCues": "Inserir com falas", - "loadedFromCache": "Carregado do cache", - "scanning": "Escaneando", - "title": "Legendas incorporadas", - "trackInfo": "Informacoes da faixa" - }, - "grid": { - "emptyHint": "Solte arquivos aqui ou clique em Importar para adicionar mídia.", - "emptyTitle": "Ainda sem mídia", - "loadingSubtitle": "Carregando subtitulo", - "loadingTitle": "Carregando mídia…" - }, - "info": { - "codec": "Codificador", - "dimensions": "Dimensoes", - "duration": "Duracao", - "fpsValue": "Valor de FPS", - "frameRate": "Taxa de quadros", - "loadingTranscript": "Carregando transcricao", - "mediaInfo": "Informacoes da midia", - "openInSourceMonitor": "Abrir no monitor de origem", - "size": "Tamanho", - "transcript": "Transcricao", - "transcriptWithCount": "Transcricao ({{count}})", - "type": "Tipo" - }, - "library": { - "aiAnalysisProgress": "Progresso da analise de IA", - "allTypes": "Todos os tipos", - "analyzingMultiple": "Analisando varios", - "analyzingSingle": "Analisando um item", - "andJoiner": "e", - "assetsCount": "{{count}} assets", - "assetsCount_one": "{{count}} asset", - "assetsCount_other": "{{count}} assets", - "back": "Voltar", - "cancelAll": "Cancelar tudo", - "cancelling": "Cancelando", - "clearSelection": "Limpar seleção", - "compoundClipsCount": "{{count}} clipes compostos", - "compoundClipsCount_one": "{{count}} clipe composto", - "compoundClipsCount_other": "{{count}} clipes compostos", - "copyToClipboard": "Copiar para a area de transferencia", - "deleteAssetsBody": "Isso removerá permanentemente {{summary}} deste projeto. Esta ação não pode ser desfeita.", - "deleteAssetsTitle": "Excluir assets selecionados?", - "deleteSelectedAssets": "Excluir itens selecionados", - "deleteSummary": "Excluir {{summary}}", - "deleteWithClips": "Excluir com clipes", - "dismiss": "Dispensar", - "dragDropUnsupported": "Arrastar e soltar nao suportado", - "dropFilesHere": "Solte os arquivos aqui", - "filesRejected": "Arquivos rejeitados", - "generateProxiesForSelected": "Gerar proxies para {{count}} itens selecionados", - "generatingProxies": "Gerando proxies", - "generatingTranscripts": "Gerando transcricoes", - "gridItemSize": "Tamanho dos itens da grade", - "groupAudio": "Agrupar audio", - "groupGifs": "Agrupar GIFs", - "groupImages": "Agrupar imagens", - "groupVideos": "Agrupar videos", - "import": "Importar", - "importFromUrlDescription": "Importe um arquivo de mídia de uma URL direta.", - "importFromUrlHint": "Cole um link direto para um arquivo de vídeo, áudio, imagem ou GIF.", - "importFromUrlTitle": "Importar de URL", - "importMediaFiles": "Importar arquivos de midia", - "importMediaFromUrl": "Importar midia da URL", - "libraryView": "Visualizacao da biblioteca", - "linkedInstancesDetail": "{{count}} instâncias da linha do tempo que usam estes assets também serão removidas.", - "linkedInstancesDetail_one": "{{count}} instância da linha do tempo que usa estes assets também será removida.", - "linkedInstancesDetail_other": "{{count}} instâncias da linha do tempo que usam estes assets também serão removidas.", - "linkedInstancesTitle": "Instâncias vinculadas da linha do tempo serão removidas", - "mediaItemsCount": "{{count}} itens de mídia", - "mediaItemsCount_one": "{{count}} item de mídia", - "mediaItemsCount_other": "{{count}} itens de mídia", - "mediaTab": "Midia", - "missingCount": "{{count}} ausentes", - "proxyCount": "{{count}} proxies", - "proxyGenerationProgress": "Progresso da geracao de proxies", - "scenesTab": "Cenas", - "searchScenes": "Pesquisar cenas", - "selected": "Selecionado", - "selectedAssetsCount": "{{count}} assets selecionados", - "selectedAssetsCount_one": "{{count}} asset selecionado", - "selectedAssetsCount_other": "{{count}} assets selecionados", - "showMediaLibrary": "Mostrar biblioteca de midia", - "sortDate": "Ordenar por data", - "sortName": "Ordenar por nome", - "sortSize": "Ordenar por tamanho", - "transcriptGenerationProgress": "Progresso da geracao de transcricoes", - "url": "URL", - "viewMissingMedia": "Ver mídia ausente", - "allShort": "TODOS", - "typeShort": { - "video": "VIDEO", - "audio": "AUDIO", - "image": "IMAGEM" - }, - "sortShort": { - "name": "NOME", - "date": "DATA", - "size": "TAMANHO" - }, - "missingCount_one": "{{count}} ausente", - "missingCount_other": "{{count}} ausentes", - "selectedCount": "{{count}} selecionado", - "selectedCount_one": "{{count}} selecionado", - "selectedCount_other": "{{count}} selecionados", - "generateProxiesForSelected_one": "Gerar proxy para {{count}} item selecionado", - "generateProxiesForSelected_other": "Gerar proxies para {{count}} itens selecionados", - "proxyCount_one": "{{count}} proxy", - "proxyCount_other": "{{count}} proxies" - }, - "missingMedia": { - "title": "Mídia ausente", - "description": "O FreeCut não consegue acessar {{count}} arquivos de mídia. Localize os arquivos ou continue trabalhando offline.", - "description_one": "O FreeCut não consegue acessar {{count}} arquivo de mídia. Localize o arquivo ou continue trabalhando offline.", - "description_other": "O FreeCut não consegue acessar {{count}} arquivos de mídia. Localize os arquivos ou continue trabalhando offline.", - "needPermission": "{{count}} precisam de permissão", - "needPermission_one": "{{count}} precisa de permissão", - "needPermission_other": "{{count}} precisam de permissão", - "notFound": "{{count}} não encontrados", - "notFound_one": "{{count}} não encontrado", - "notFound_other": "{{count}} não encontrados", - "browseAnotherFolder": "Procurar outra pasta", - "fileMovedOrDeleted": "Arquivo movido ou excluído", - "grantAccess": "Conceder acesso", - "locate": "Localizar", - "locateFolder": "Localizar pasta", - "permissionExpired": "Permissão expirada", - "scanProjectFolder": "Escanear {{name}}", - "workOffline": "Trabalhar offline" - }, - "orphanedClips": { - "autoMatch": "Correspondencia automatica", - "description": "{{count}} clipe(s) referenciam mídia que não pode mais ser encontrada. Substitua ou remova para continuar.", - "keepAsBroken": "Manter como quebrado", - "removeAll": "Remover tudo", - "select": "Selecionar", - "selectReplacement": "Selecionar substituto", - "title": "Mídia ausente" - }, - "picker": { - "descAll": "Descricao de todos", - "noFiles": "Ainda não há mídia neste projeto.", - "noSearchResults": "Nenhum resultado", - "title": "Escolha uma mídia" - }, - "searchMedia": "Pesquisar midia", - "subtitleScan": { - "cached": "Em cache", - "cancelBatch": "Cancelar tudo", - "descComplete": "Concluido.", - "descScanning": "Escaneando.", - "failed": "Falhou", - "titleComplete": "Busca de legendas concluída", - "titleScanning": "Buscando legendas…" - }, - "transcribe": { - "autoDetect": "Detectar automaticamente", - "generateTitle": "Gerar transcrição", - "language": "Idioma", - "model": "Modelo", - "noLanguages": "Nenhum idioma", - "progressAria": "Progresso", - "quantization": "Quantizacao", - "refreshTitle": "Atualizar transcrição", - "searchLanguages": "Pesquisar idiomas", - "start": "Iniciar", - "stop": "Parar" - }, - "type": { - "audio": "Audio", - "image": "Imagem", - "video": "Video" - }, - "unsupportedCodec": { - "bodyMultiple": "Corpo para multiplos itens", - "bodySingle": "Corpo para item unico", - "cancelImport": "Cancelar importacao", - "importAnyway": "Importar mesmo assim", - "note": "Observacao", - "title": "Codec não suportado" - } - }, - "preview": { - "align": { - "disableCanvasSnap": "Desativar encaixe no canvas", - "enableCanvasSnap": "Ativar encaixe no canvas", - "toggleCanvasSnap": "Alternar encaixe no canvas" - }, - "controls": { - "captureFailed": "Não foi possível capturar o quadro atual.", - "disableProxyPlayback": "Desativar reproducao por proxy", - "enableProxyPlayback": "Ativar reproducao por proxy", - "frameDownloadedNoProject": "Quadro baixado — abra um projeto para salvar na biblioteca.", - "frameDownloadedNotSaved": "Quadro baixado, mas não foi possível salvar na biblioteca: {{message}}", - "frameSaved": "\"{{name}}\" salvo na biblioteca de mídia.", - "goToEnd": "Ir para o fim", - "goToEndTooltip": "Ir para o fim", - "goToStart": "Ir para o inicio", - "goToStartTooltip": "Ir para o inicio", - "nextFrame": "Proximo quadro", - "nextFrameTooltip": "Proximo quadro", - "pauseTooltip": "Pausar", - "playTooltip": "Reproduzir", - "prevFrame": "Quadro anterior", - "prevFrameTooltip": "Quadro anterior", - "proxyPlaybackOff": "Reproducao por proxy desativada", - "proxyPlaybackOn": "Reproducao por proxy ativada", - "saveFrame": "Salvar quadro", - "saveFrameFailed": "Não foi possível salvar o quadro.", - "saveFrameTooltip": "Salvar quadro", - "savingFrame": "Salvando quadro", - "savingFrameTooltip": "Salvando quadro" - }, - "monitor": { - "mute": "Silenciar", - "muteShort": "Silenciar", - "muted": "Silenciado", - "percent": "Porcentagem", - "previewOnlyNote": "Afeta apenas a reprodução local — a exportação usa a mixagem do projeto.", - "thisDeviceOnly": "Somente este dispositivo", - "title": "Áudio da prévia", - "unmute": "Ativar som", - "volume": "Volume de som" - }, - "player": { - "exitFullscreen": "Sair da tela cheia", - "fullscreen": "Tela cheia", - "mute": "Silenciar", - "pause": "Pausar", - "play": "Reproduzir", - "seek": "Buscar", - "unmute": "Ativar som", - "volume": "Volume de som" - }, - "stage": { - "loadingMedia": "Carregando midia", - "videoPreview": "Pre-visualizacao de video" - }, - "zoom": { - "ariaLabel": "Rotulo ARIA", - "auto": "Automático", - "tooltip": "Zoom: {{label}}" - } - }, - "timeline": { - "addAudioTrackHint": "Adicionar faixa de audio", - "addVideoTrackHint": "Adicionar faixa de video", - "bento": { - "apply": "Aplicar", - "description": "Organize {{count}} clipes selecionados em uma grade.", - "gap": "Espacamento", - "noItemsToArrange": "Nenhum item para organizar", - "padding": "Preenchimento", - "presetNamePlaceholder": "Nome da predefinicao", - "saveAsPreset": "Salvar como predefinicao", - "title": "Layout bento" - }, - "captions": { - "addedFromTranscript": "Adicionado da transcricao", - "addedWithModel": "Adicionado com modelo", - "failedGenerateSegment": "Falha ao gerar segmento", - "failedUpdateSegment": "Falha ao atualizar segmento", - "refreshedWithModel": "Atualizado com modelo", - "removedFromSegment": "Removido do segmento", - "updatedFromTranscript": "Atualizado da transcricao", - "updatedWithModel": "Atualizado com modelo" - }, - "clipIndicators": { - "hasKeyframes": "Tem keyframes", - "mask": "Mascara", - "mediaMissing": "Midia ausente", - "preparingReversed": "Preparando reverso", - "reversePrepFailedShort": "Falha ao preparar reverso", - "reversedPlayback": "Reproducao reversa", - "reversedPrepFailed": "Falha ao preparar reverso", - "reversedPrepared": "Reverso preparado", - "speed": "Velocidade: {{speed}}x" - }, - "contextMenu": { - "bentoLayout": "Layout bento", - "captions": "Legendas", - "clearAll": "Limpar tudo", - "clearKeyframes": "Limpar keyframes", - "consolidateCaptionsToSegment": "Consolidar legendas em segmento", - "createCompoundClip": "Criar clipe composto", - "detectScenesAi": "IA ({{model}})", - "detectScenesAndSplit": "Detectar cenas e dividir", - "detectScenesFast": "Rapido (histograma)", - "detectingFillers": "Detectando vicios de fala", - "detectingScenes": "Detectando cenas", - "detectingSilence": "Detectando silencio", - "dissolveCompoundClip": "Dissolver clipe composto", - "extractEmbeddedSubtitles": "Extrair legendas incorporadas", - "generateAudioFromText": "Gerar audio a partir de texto", - "generateCaptions": "Gerar legendas", - "insertExistingCaptions": "Inserir legendas existentes", - "insertFreezeFrame": "Inserir quadro congelado", - "joinSelected": "Unir selecionados", - "joinWithNext": "Unir com o proximo", - "joinWithPrevious": "Unir com o anterior", - "linkClips": "Vincular clipes", - "openCompoundClip": "Abrir clipe composto", - "regenerateCaptions": "Gerar legendas novamente", - "removeFillerWords": "Remover vicios de fala", - "removeSilence": "Remover silencio", - "reverse": "Reverter", - "rippleDelete": "Exclusao ripple", - "unlinkClips": "Desvincular clipes", - "unreverse": "Desfazer reversao", - "updatingCaptions": "Atualizando legendas" - }, - "fadeHandles": { - "adjustAudioFadeIn": "Ajustar fade de entrada do audio", - "adjustAudioFadeInCurve": "Ajustar curva do fade de entrada do audio", - "adjustAudioFadeOut": "Ajustar fade de saida do audio", - "adjustAudioFadeOutCurve": "Ajustar curva do fade de saida do audio", - "adjustVideoFadeIn": "Ajustar fade de entrada do video", - "adjustVideoFadeOut": "Ajustar fade de saida do video" - }, - "fillerRemoval": { - "aboutWillBeRemoved": "Cerca de {{duration}} serão removidos.", - "add": "Adicionar", - "addPhrase": "Adicionar frase", - "addWord": "Adicionar palavra", - "cutPadding": "Margem de corte", - "filler": "Vicio de fala", - "fillerRange": "bordão", - "found": "Encontrado", - "includeRange": "Incluir intervalo", - "maxPhrase": "Frase maxima", - "maxWord": "Palavra maxima", - "noEntriesFound": "Nenhuma entrada encontrada", - "noRemovableDetectedShort": "Nenhum bordão encontrado.", - "none": "Nenhum", - "phrases": "Frases", - "playThisRange": "Reproduzir este intervalo", - "rangesSelected": "Intervalos selecionados", - "redo": "Refazer", - "redoEditTitle": "Refazer última edição", - "remove": "Remover", - "removed": "Removido", - "scoreAudio": "Pontuar audio", - "scoring": "Pontuando", - "title": "Remover bordões", - "toastAudioScored": "Análise de confiança do áudio concluída.", - "toastNoRemovable": "Nenhum bordão corresponde aos ajustes atuais.", - "toastNoneInClips": "Nenhum bordão a remover nos clipes selecionados.", - "toastPreviewFailed": "Não foi possível visualizar a remoção.", - "toastRemoveFailed": "Não foi possível remover os bordões.", - "toastRemoved": "{{count}} trechos de bordões removidos.", - "toastScoreFailed": "Não foi possível analisar a confiança do áudio.", - "undo": "Desfazer", - "undoEditTitle": "Desfazer última edição", - "updatePreview": "Atualizar pre-visualizacao", - "updating": "Atualizando", - "words": "Palavras" - }, - "header": { - "addMarker": "Adicionar marcador", - "addMarkerTooltip": "Adicionar marcador", - "clearAllMarkers": "Limpar todos os marcadores", - "clearAllMarkersTooltip": "Limpar todos os marcadores", - "clearInOutPoints": "Limpar pontos de entrada e saida", - "clearInOutPointsTooltip": "Limpar pontos de entrada e saida", - "controls": "Controles", - "disableLinkedSelection": "Desativar selecao vinculada", - "disableSnapping": "Desativar encaixe", - "enableLinkedSelection": "Ativar selecao vinculada", - "enableSnapping": "Ativar encaixe", - "hideColorScopes": "Ocultar escopos de cor", - "hideColorScopesTooltip": "Ocultar escopos de cor", - "linkedSelectionOff": "Selecao vinculada desligada", - "linkedSelectionOn": "Selecao vinculada ligada", - "linkedSelectionTooltip": "Seleção vinculada: {{state}} ({{shortcut}})", - "rateStretchTool": "Ferramenta de esticar taxa", - "rateStretchToolTooltip": "Ferramenta de esticar taxa", - "razorTool": "Ferramenta navalha", - "razorToolTooltip": "Ferramenta navalha", - "redo": "Refazer", - "redoTooltip": "Refazer", - "redoWithLabel": "Refazer {{label}}", - "redoWithLabelTooltip": "Refazer {{label}}", - "removeSelectedMarker": "Remover marcador selecionado", - "removeSelectedMarkerTooltip": "Remover marcador selecionado", - "selectTool": "Ferramenta de selecao", - "selectToolTooltip": "Ferramenta de selecao", - "setInPoint": "Definir ponto de entrada", - "setInPointTooltip": "Definir ponto de entrada", - "setOutPoint": "Definir ponto de saida", - "setOutPointTooltip": "Definir ponto de saida", - "showColorScopes": "Mostrar escopos de cor", - "showColorScopesTooltip": "Mostrar escopos de cor", - "slideTool": "Ferramenta slide", - "slipSlideTools": "Ferramentas slip/slide", - "slipSlideToolsTooltip": "Ferramentas slip/slide", - "slipTool": "Ferramenta slip", - "snapDisabled": "Encaixe desativado", - "snapEnabled": "Encaixe ativado", - "title": "Linha do tempo", - "trimEditTool": "Ferramenta de ajuste", - "trimEditToolTooltip": "Ferramenta de ajuste", - "undo": "Desfazer", - "undoTooltip": "Desfazer", - "undoWithLabel": "Desfazer {{label}}", - "undoWithLabelTooltip": "Desfazer {{label}}", - "zoomIn": "Aproximar", - "zoomInTooltip": "Aproximar", - "zoomOut": "Afastar", - "zoomOutTooltip": "Afastar", - "zoomSlider": "Controle de zoom", - "zoomToFit": "Ajustar zoom", - "zoomToFitTooltip": "Ajustar zoom" - }, - "itemActions": { - "appliedEffectToClips": "Efeito aplicado aos clipes", - "selectAvClipFirst": "Selecione primeiro um clipe de audio/video" - }, - "joinIndicators": { - "canJoinNext": "Pode unir ao proximo", - "canJoinPrevious": "Pode unir ao anterior" - }, - "keyframeEditor": { - "bezier": "Bézier", - "custom": "Personalizado", - "dragHandlesHint": "Arraste as alças para moldar a curva.", - "graph": "Grafico", - "mixedCurves": "Curvas mistas", - "mixedSpring": "Mola mista", - "movedKeyframes": "Keyframes movidos", - "noKeyframesPasted": "Nenhum keyframe colado", - "pastedKeyframes": "Keyframes colados", - "preset": "Predefinicao", - "reasonBlocked": "{{count}} bloqueado(s) por outro quadro-chave", - "reasonUnsupported": "{{count}} não suportado(s) pela propriedade de destino", - "selectItem": "Selecionar item", - "sheet": "Planilha", - "skippedDescription": "{{count}} ignorado(s): {{reasons}}", - "spring": "Mola", - "springHint": "Física de mola — natural e elástica.", - "title": "Quadros-chave", - "unableToPasteCut": "Nao foi possivel colar corte", - "unableToPasteCutDescription": "Quadros-chave recortados não podem ser colados aqui. {{reasons}}" - }, - "noTracksToRemove": "Nenhuma faixa para remover", - "region": "Regiao", - "removeActiveTrack": "Remover faixa ativa", - "removeSelectedTracks": "Remover faixas selecionadas", - "reverseConform": { - "cancellingDescription": "Cancelando...", - "couldNotPrepare": "Nao foi possivel preparar", - "progressDescription": "Preparando...", - "titleCancelling": "Cancelando", - "titleFailed": "Falhou", - "titlePreparing": "Preparando" - }, - "sceneDetection": { - "detectingScenes": "Detectando cenas", - "failed": "Falhou", - "noScenesDetected": "Nenhuma cena detectada", - "noScenesWithinBounds": "Nenhuma cena dentro dos limites", - "noValidSplitPoints": "Nenhum ponto de divisao valido", - "requiresWebGpu": "Requer WebGPU", - "splitAtScenes": "Dividir nas cenas" - }, - "selectTrackToRemove": "Selecione a faixa para remover", - "silenceRemoval": { - "aboutWillBeRemoved": "Cerca de {{duration}} de silêncio serão removidos.", - "keepPadding": "Manter margem", - "minimumSilence": "Silencio minimo", - "noRemovableDetectedShort": "Nenhum silêncio encontrado.", - "rangesSelected": "Intervalos selecionados", - "remove": "Remover", - "threshold": "Limiar", - "title": "Remover silêncio", - "toastNoRemovable": "Nenhum silêncio corresponde aos ajustes atuais.", - "toastNoneInClips": "Nenhum silêncio a remover nos clipes selecionados.", - "toastPreviewFailed": "Não foi possível visualizar a remoção do silêncio.", - "toastRemoved": "{{count}} trechos de silêncio removidos.", - "toastSettingsChanged": "Ajustes alterados — visualize antes de aplicar.", - "updatePreview": "Atualizar pre-visualizacao", - "updating": "Atualizando" - }, - "track": { - "locked": "Bloqueada" - }, - "trackHeader": { - "addAudioTrack": "Adicionar faixa de audio", - "addVideoTrack": "Adicionar faixa de video", - "clipCount": "Contagem de clipes", - "closeAllGaps": "Fechar todos os espacos", - "deleteEmptyTracks": "Excluir faixas vazias", - "deleteTrack": "Excluir faixa", - "disableSyncLock": "Desativar bloqueio de sincronia", - "disableTrack": "Desativar faixa", - "enableSyncLock": "Ativar bloqueio de sincronia", - "enableTrack": "Ativar faixa", - "lockTrack": "Bloquear faixa", - "soloTrack": "Faixa solo", - "unlockTrack": "Desbloquear faixa", - "unsoloTrack": "Remover solo da faixa" - }, - "trackRow": { - "resizeSections": "Redimensionar secoes das faixas de video e audio", - "resizeTrackHeight": "Redimensionar altura da faixa" - }, - "tracks": "Faixas", - "volumeControl": { - "adjustClipVolume": "Ajustar volume do clipe" - } - } - }, - "ja": { - "editor": { - "videoSection": { - "cropBottom": "下をクロップ", - "cropLeft": "左をクロップ", - "cropRight": "右をクロップ", - "cropTop": "上をクロップ", - "cropping": "クロップ", - "fadeIn": "フェードイン", - "fadeOut": "フェードアウト", - "playback": "再生", - "resetCropBottom": "下のクロップをリセット", - "resetCropLeft": "左のクロップをリセット", - "resetCropRight": "右のクロップをリセット", - "resetCropTop": "上のクロップをリセット", - "resetSoftness": "ソフトさをリセット", - "resetSpeed": "速度をリセット", - "resetToZero": "ゼロにリセット", - "softness": "ソフトさ", - "speed": "速度" - } - }, - "effects": { - "curves": { - "channel": "チャンネル", - "dragHint": "{{channel}} カーブのポイントをドラッグしてトーンを調整します。", - "resetChannel": "チャンネルをリセット" - }, - "panel": { - "disableEffect": "エフェクトを無効化", - "enableEffect": "エフェクトを有効化", - "off": "オフ", - "on": "オン", - "removeEffect": "エフェクトを削除", - "resetToDefaults": "既定値に戻す" - }, - "section": { - "addEffect": "エフェクトを追加", - "disableAll": "すべて無効", - "emptyState": "エフェクトが適用されていません。追加して始めましょう。", - "enableAll": "すべて有効", - "noEffectsFound": "エフェクトが見つかりません", - "presets": "プリセット", - "searchEffects": "エフェクトを検索", - "title": "エフェクト" - }, - "wheels": { - "resetWheel": "ホイールをリセット" - } - }, - "export": { - "audioContainer": { - "aac": "AAC", - "mp3": "MP3", - "wav": "WAV" - }, - "cancelled": { - "message": "書き出しはキャンセルされました。ファイルは保存されていません。" - }, - "complete": { - "audioSuccess": "オーディオのダウンロード準備が整いました。", - "download": "ダウンロード", - "fileSizeLabel": "サイズ", - "timeTakenLabel": "経過時間", - "videoSuccess": "動画のダウンロード準備が整いました。" - }, - "dialog": { - "descCancelled": "書き出しが完了する前にキャンセルされました。", - "descComplete": "ファイルの準備ができました。", - "descError": "書き出し中に問題が発生しました。", - "descProgress": "動画をレンダリングしています。数分かかる場合があります。", - "descSettings": "書き出しオプションを設定します。", - "titleCancelled": "書き出しをキャンセルしました", - "titleComplete": "書き出しが完了しました", - "titleError": "書き出しに失敗しました", - "titleProgress": "動画を書き出し中", - "titleSettings": "書き出し" - }, - "errors": { - "verifyCodec": "現在の設定でコーデックのサポート状況を確認できませんでした。" - }, - "progress": { - "cancelExport": "キャンセル", - "elapsedLabel": "経過時間", - "encoding": "エンコード中…", - "finalizing": "仕上げ中…", - "framesLabel": "フレーム", - "keepTabOpen": "書き出しが完了するまでこのタブを開いたままにしてください。", - "preparing": "準備中…", - "rendering": "レンダリング中…" - }, - "settings": { - "audio": "オーディオ", - "audioOnlyNote": "オーディオのみの書き出しです。動画は含まれません。", - "audioQualityHigh": "高", - "audioQualityLow": "低", - "audioQualityMedium": "中", - "audioQualityUltra": "ウルトラ", - "cannotEncode": "選択した品質で {{width}}×{{height}} に対応するエンコーダーがありません。", - "codec": "コーデック", - "codecSupportUnverified": "コーデックのサポート状況を確認できませんでした。書き出しは可能かもしれませんが、互換性は保証されません。", - "duration": "長さ", - "embedSubtitles": "字幕を埋め込む", - "embedSubtitlesDescription": "文字起こしの字幕を書き出しファイルの字幕トラックとして埋め込みます。", - "embedSubtitlesMp4Note": "MP4 は字幕を別トラックとして保存します。プレーヤーによっては既定で表示されない場合があります。", - "embedSubtitlesUnsupported": "{{container}} は字幕の埋め込みに対応していません。MP4、MKV、または WebM を選択してください。", - "exportAudio": "オーディオを書き出す", - "exportRange": "書き出し範囲", - "exportType": "書き出しタイプ", - "exportVideo": "動画を書き出す", - "format": "形式", - "in": "イン", - "inOutRangeHint": "選択したイン/アウト範囲のみが書き出されます。", - "noTranscriptSegments": "埋め込み可能な文字起こし字幕がありません。", - "out": "アウト", - "quality": "品質", - "qualityHigh": "高", - "qualityLow": "低", - "qualityMedium": "中", - "qualityUltra": "ウルトラ", - "quicktimeMov": "QuickTime MOV", - "renderWholeProject": "プロジェクト全体を書き出す", - "resolution": "解像度", - "resolutionSameAsProject": "プロジェクトと同じ ({{width}}×{{height}})", - "resolutionScaled": "{{p}}p ({{width}}×{{height}})", - "selectCodec": "コーデックを選択", - "selectFormat": "形式を選択", - "selectQuality": "品質を選択", - "selectResolution": "解像度を選択", - "video": "動画" - }, - "videoContainer": { - "mp4": "対応プレーヤーが多く、共有に最適", - "mov": "Apple 環境向け、Final Cut や QuickTime に最適", - "webm": "ウェブ再生向けのオープンで先進的なコンテナ", - "mkv": "字幕サポートが優れた柔軟なコンテナ" - } - }, - "media": { - "card": { - "aiCaptionsCount": "{{count}} 件のAI字幕", - "analyzeWithAI": "AIで解析", - "analyzingWithAI": "AIで解析中", - "chooseMkvOrWebm": "MKV または WebM を選択", - "deleteProxy": "プロキシを削除", - "deleteTranscript": "文字起こしを削除", - "extractEmbeddedSubtitles": "埋め込み字幕を抽出", - "generateProxy": "プロキシを生成", - "generateTranscript": "文字起こしを生成", - "importing": "インポート中", - "menuAi": "AI", - "menuCaptions": "埋め込み字幕", - "menuFile": "ファイル", - "menuProxy": "プロキシ", - "menuTranscript": "文字起こし", - "playAudio": "オーディオを再生", - "refreshTranscript": "文字起こしを更新", - "relinkFile": "ファイルを再リンク…", - "stopAudio": "オーディオを停止", - "subtitlesCachedAll": "字幕をキャッシュ済み", - "subtitlesCachedPartial": "一部の字幕をキャッシュ済み", - "subtitlesCannotRead": "FreeCut は今「{{name}}」を読み込めません。使用中のアプリを閉じて再試行してください。", - "subtitlesExtractFailed": "字幕の抽出に失敗しました", - "subtitlesFileMissing": "FreeCut は「{{name}}」を見つけられなくなりました。", - "subtitlesNeedPermission": "字幕を抽出する前に「{{name}}」へのアクセス許可が必要です。", - "subtitlesScanFailed": "字幕スキャンに失敗しました", - "transcribeFailed": "文字起こしに失敗しました", - "transcribing": "文字起こし中", - "transcriptDeleteFailed": "文字起こしを削除できませんでした", - "transcriptDeleteFailedFor": "「{{name}}」の文字起こしを削除できませんでした", - "transcriptDeletedFor": "「{{name}}」の文字起こしを削除しました", - "transcriptProgressAria": "文字起こしの進行状況", - "transcriptReadyFor": "「{{name}}」の文字起こしが完了しました", - "transcriptionFailedFor": "「{{name}}」の文字起こしに失敗しました", - "transcriptsDeleted": "文字起こしを削除しました", - "transcriptsReady": "文字起こしが完了しました" - }, - "compositions": { - "deleteBody": "複合クリップ「{{name}}」を削除しますか?この操作は元に戻せません。", - "deleteInstancesDetail": "この複合クリップを使用しているタイムライン上の {{count}} 個のインスタンスも削除されます。", - "deleteInstancesTitle": "リンクされたインスタンスが削除されます", - "deleteTitle": "複合クリップを削除", - "enter": "開く", - "itemCount": "{{count}} 個の項目", - "rename": "名前を変更", - "sectionTitle": "複合クリップ" - }, - "deleteDialog": { - "bodyMultiple": "{{count}} 個のメディア項目をこのプロジェクトから完全に削除します。この操作は元に戻せません。", - "bodySingle": "\"{{name}}\" をこのプロジェクトから完全に削除します。この操作は元に戻せません。", - "confirmWithClips": "クリップごと削除", - "timelineClipsDetail": "このメディアを使用しているタイムライン上の {{count}} 個のクリップも削除されます。", - "timelineClipsDetail_one": "このメディアを使用しているタイムライン上の {{count}} 個のクリップも削除されます。", - "timelineClipsDetail_other": "このメディアを使用しているタイムライン上の {{count}} 個のクリップも削除されます。", - "timelineClipsRemoved": "タイムラインのクリップも削除されます", - "titleMultiple": "{{count}} 個のメディア項目を削除しますか?", - "titleSingle": "メディア項目を削除しますか?" - }, - "embeddedSubtitles": { - "badgeAutoPick": "自動", - "badgeDefault": "既定", - "badgeForced": "強制", - "cuesCount": "{{count}} 件", - "desc": "タイムラインに挿入する字幕トラックを選択します。", - "descForFile": "「{{name}}」に含まれる字幕。", - "empty": "このファイルに字幕トラックは見つかりませんでした。", - "insert": "挿入", - "insertWithCues": "挿入 ({{count}} 件)", - "loadedFromCache": "キャッシュから字幕を読み込みました。", - "scanning": "スキャン中…", - "title": "埋め込み字幕", - "trackInfo": "トラック {{n}} · {{lang}} · {{codec}}" - }, - "grid": { - "emptyHint": "ここにファイルをドロップするか、インポートをクリックします。", - "emptyTitle": "メディアはまだありません", - "loadingSubtitle": "メディアを準備中…", - "loadingTitle": "メディアを読み込み中…" - }, - "info": { - "codec": "コーデック", - "dimensions": "サイズ", - "duration": "長さ", - "fpsValue": "{{value}} fps", - "frameRate": "フレームレート", - "loadingTranscript": "文字起こしを読み込み中…", - "mediaInfo": "メディア情報", - "openInSourceMonitor": "ソースモニターで開く", - "size": "サイズ", - "transcript": "文字起こし", - "transcriptWithCount": "文字起こし ({{count}})", - "type": "種類" - }, - "library": { - "aiAnalysisProgress": "AI解析", - "allTypes": "すべての種類", - "analyzingMultiple": "{{count}} 件のクリップを解析中…", - "analyzingSingle": "解析中…", - "andJoiner": " と ", - "assetsCount": "{{count}} 個のアセット", - "assetsCount_one": "{{count}} 個のアセット", - "assetsCount_other": "{{count}} 個のアセット", - "back": "戻る", - "cancelAll": "すべてキャンセル", - "cancelling": "キャンセル中…", - "clearSelection": "選択を解除", - "compoundClipsCount": "{{count}} 個の複合クリップ", - "compoundClipsCount_one": "{{count}} 個の複合クリップ", - "compoundClipsCount_other": "{{count}} 個の複合クリップ", - "copyToClipboard": "クリップボードにコピー", - "deleteAssetsBody": "{{summary}} をこのプロジェクトから完全に削除します。この操作は元に戻せません。", - "deleteAssetsTitle": "選択したアセットを削除しますか?", - "deleteSelectedAssets": "選択した項目を削除", - "deleteSummary": "{{summary}} を削除", - "deleteWithClips": "クリップごと削除", - "dismiss": "閉じる", - "dragDropUnsupported": "ここではドラッグ&ドロップに対応していません。", - "dropFilesHere": "ここにファイルをドロップ", - "filesRejected": "一部のファイルを追加できませんでした。", - "generateProxiesForSelected": "選択した {{count}} 個の項目のプロキシを生成", - "generatingProxies": "プロキシを生成中…", - "generatingTranscripts": "文字起こしを生成中…", - "gridItemSize": "サムネイルサイズ", - "groupAudio": "オーディオグループ", - "groupGifs": "GIFグループ", - "groupImages": "画像グループ", - "groupVideos": "ビデオグループ", - "import": "インポート", - "importFromUrlDescription": "直接URLからメディアファイルを読み込みます。", - "importFromUrlHint": "動画、音声、画像、または GIF ファイルへの直接リンクを貼り付けてください。", - "importFromUrlTitle": "URLから読み込む", - "importMediaFiles": "メディアファイルをインポート", - "importMediaFromUrl": "URLからメディアをインポート", - "libraryView": "ライブラリ表示", - "linkedInstancesDetail": "これらのアセットを使用しているタイムライン上の {{count}} 個のインスタンスも削除されます。", - "linkedInstancesDetail_one": "これらのアセットを使用しているタイムライン上の {{count}} 個のインスタンスも削除されます。", - "linkedInstancesDetail_other": "これらのアセットを使用しているタイムライン上の {{count}} 個のインスタンスも削除されます。", - "linkedInstancesTitle": "リンクされたタイムラインインスタンスも削除されます", - "mediaItemsCount": "{{count}} 個のメディア項目", - "mediaItemsCount_one": "{{count}} 個のメディア項目", - "mediaItemsCount_other": "{{count}} 個のメディア項目", - "mediaTab": "メディア", - "missingCount": "{{count}} 個が見つかりません", - "proxyCount": "{{count}} 個のプロキシ", - "proxyGenerationProgress": "プロキシの進行状況", - "scenesTab": "シーン", - "searchScenes": "シーンを検索", - "selected": "選択済み", - "selectedAssetsCount": "{{count}} 個の選択済みアセット", - "selectedAssetsCount_one": "{{count}} 個の選択済みアセット", - "selectedAssetsCount_other": "{{count}} 個の選択済みアセット", - "showMediaLibrary": "メディアライブラリを表示", - "sortDate": "日付で並べ替え", - "sortName": "名前で並べ替え", - "sortSize": "サイズで並べ替え", - "transcriptGenerationProgress": "文字起こしの進行状況", - "url": "URL", - "viewMissingMedia": "見つからないメディアを表示", - "allShort": "すべて", - "typeShort": { - "video": "ビデオ", - "audio": "音声", - "image": "画像" - }, - "sortShort": { - "name": "名前", - "date": "日付", - "size": "サイズ" - }, - "missingCount_one": "{{count}} 個が見つかりません", - "missingCount_other": "{{count}} 個が見つかりません", - "selectedCount": "{{count}} 個選択済み", - "selectedCount_one": "{{count}} 個選択済み", - "selectedCount_other": "{{count}} 個選択済み", - "generateProxiesForSelected_one": "選択した {{count}} 個の項目のプロキシを生成", - "generateProxiesForSelected_other": "選択した {{count}} 個の項目のプロキシを生成", - "proxyCount_one": "{{count}} 個のプロキシ", - "proxyCount_other": "{{count}} 個のプロキシ" - }, - "missingMedia": { - "title": "メディアが見つかりません", - "description": "FreeCut は {{count}} 件のメディアファイルにアクセスできません。ファイルを指定するか、オフラインで作業を続けてください。", - "description_one": "FreeCut は {{count}} 件のメディアファイルにアクセスできません。ファイルを指定するか、オフラインで作業を続けてください。", - "description_other": "FreeCut は {{count}} 件のメディアファイルにアクセスできません。ファイルを指定するか、オフラインで作業を続けてください。", - "needPermission": "{{count}} 件に権限が必要です", - "needPermission_one": "{{count}} 件に権限が必要です", - "needPermission_other": "{{count}} 件に権限が必要です", - "notFound": "{{count}} 件が見つかりません", - "notFound_one": "{{count}} 件が見つかりません", - "notFound_other": "{{count}} 件が見つかりません", - "browseAnotherFolder": "別のフォルダーを参照", - "fileMovedOrDeleted": "ファイルが移動または削除されました", - "grantAccess": "アクセスを許可", - "locate": "指定", - "locateFolder": "フォルダーを指定", - "permissionExpired": "権限の有効期限切れ", - "scanProjectFolder": "{{name}} をスキャン", - "workOffline": "オフラインで作業" - }, - "orphanedClips": { - "autoMatch": "自動マッチング", - "description": "{{count}} 件のクリップが見つからないメディアを参照しています。続行するには置換または削除してください。", - "keepAsBroken": "壊れたまま保持", - "removeAll": "すべて削除", - "select": "選択", - "selectReplacement": "「{{name}}」の代替を選択", - "title": "メディアが見つかりません" - }, - "picker": { - "descAll": "プロジェクトのライブラリからファイルを選びます。", - "noFiles": "このプロジェクトにはまだメディアがありません。", - "noSearchResults": "結果が見つかりません。", - "title": "メディアを選択" - }, - "searchMedia": "メディアを検索", - "subtitleScan": { - "cached": "キャッシュ済み", - "cancelBatch": "すべてキャンセル", - "descComplete": "字幕スキャンが完了しました。", - "descScanning": "メディアの字幕をスキャン中…", - "failed": "失敗", - "titleComplete": "字幕スキャンが完了しました", - "titleScanning": "字幕をスキャン中…" - }, - "transcribe": { - "autoDetect": "自動検出", - "generateTitle": "文字起こしを生成", - "language": "言語", - "model": "モデル", - "noLanguages": "利用できる言語がありません。", - "progressAria": "文字起こしの進行状況", - "quantization": "量子化", - "refreshTitle": "文字起こしを更新", - "searchLanguages": "言語を検索", - "start": "開始", - "stop": "停止" - }, - "type": { - "audio": "音声", - "image": "画像", - "video": "ビデオ" - }, - "unsupportedCodec": { - "bodyMultiple": "{{count}} 件のファイルはブラウザがデコードできないコーデックを使用しています。正しく再生されない可能性があります。", - "bodySingle": "このファイルはブラウザがデコードできないコーデックを使用しています。正しく再生されない可能性があります。", - "cancelImport": "インポートをキャンセル", - "importAnyway": "それでもインポート", - "note": "互換性を高めるには MP4 (H.264/AAC) または WebM に変換してください。", - "title": "サポートされていないコーデック" - } - }, - "preview": { - "align": { - "disableCanvasSnap": "キャンバスへのスナップを無効化", - "enableCanvasSnap": "キャンバスへのスナップを有効化", - "toggleCanvasSnap": "キャンバススナップを切り替え" - }, - "controls": { - "captureFailed": "現在のフレームをキャプチャできませんでした。", - "disableProxyPlayback": "プロキシ再生を無効化", - "enableProxyPlayback": "プロキシ再生を有効化", - "frameDownloadedNoProject": "フレームをダウンロードしました — メディアライブラリに保存するにはプロジェクトを開いてください。", - "frameDownloadedNotSaved": "フレームはダウンロードしましたが、メディアライブラリに保存できませんでした: {{message}}", - "frameSaved": "「{{name}}」をメディアライブラリに保存しました。", - "goToEnd": "末尾へ", - "goToEndTooltip": "末尾へ", - "goToStart": "先頭へ", - "goToStartTooltip": "先頭へ", - "nextFrame": "次のフレーム", - "nextFrameTooltip": "次のフレーム", - "pauseTooltip": "一時停止", - "playTooltip": "再生", - "prevFrame": "前のフレーム", - "prevFrameTooltip": "前のフレーム", - "proxyPlaybackOff": "プロキシ再生はオフ", - "proxyPlaybackOn": "プロキシ再生はオン", - "saveFrame": "フレームを保存", - "saveFrameFailed": "フレームを保存できませんでした。", - "saveFrameTooltip": "フレームを保存", - "savingFrame": "フレームを保存中…", - "savingFrameTooltip": "フレームを保存中…" - }, - "monitor": { - "mute": "ミュート", - "muteShort": "ミュート", - "muted": "ミュート中", - "percent": "{{value}}%", - "previewOnlyNote": "ローカル再生にのみ反映されます。書き出しはプロジェクトミックスを使用します。", - "thisDeviceOnly": "このデバイスのみ", - "title": "プレビュー音声", - "unmute": "ミュート解除", - "volume": "音量" - }, - "player": { - "exitFullscreen": "全画面表示を終了", - "fullscreen": "全画面表示", - "mute": "ミュート", - "pause": "一時停止", - "play": "再生", - "seek": "シーク", - "unmute": "ミュート解除", - "volume": "音量" - }, - "stage": { - "loadingMedia": "メディアを読み込み中…", - "videoPreview": "動画プレビュー" - }, - "zoom": { - "ariaLabel": "ズーム倍率: {{label}}", - "auto": "自動", - "tooltip": "ズーム: {{label}}" - } - }, - "timeline": { - "addAudioTrackHint": "オーディオトラックを追加", - "addVideoTrackHint": "ビデオトラックを追加", - "bento": { - "apply": "適用", - "description": "選択した {{count}} 件のクリップをグリッドに配置します。", - "gap": "ギャップ", - "noItemsToArrange": "配置する項目がありません", - "padding": "余白", - "presetNamePlaceholder": "プリセット名", - "saveAsPreset": "プリセットとして保存", - "title": "ベントーレイアウト" - }, - "captions": { - "addedFromTranscript": "文字起こしから追加しました", - "addedWithModel": "モデルで追加しました", - "failedGenerateSegment": "セグメントの生成に失敗しました", - "failedUpdateSegment": "セグメントの更新に失敗しました", - "refreshedWithModel": "モデルで更新しました", - "removedFromSegment": "セグメントから削除しました", - "updatedFromTranscript": "文字起こしから更新しました", - "updatedWithModel": "モデルで更新しました" - }, - "clipIndicators": { - "hasKeyframes": "キーフレームあり", - "mask": "マスク", - "mediaMissing": "メディアが見つかりません", - "preparingReversed": "逆再生を準備中", - "reversePrepFailedShort": "逆再生準備失敗", - "reversedPlayback": "逆再生", - "reversedPrepFailed": "逆再生の準備に失敗しました", - "reversedPrepared": "逆再生の準備が完了しました", - "speed": "速度: {{speed}}x" - }, - "contextMenu": { - "bentoLayout": "ベントレイアウト", - "captions": "キャプション", - "clearAll": "すべてクリア", - "clearKeyframes": "キーフレームをクリア", - "consolidateCaptionsToSegment": "キャプションをセグメントに統合", - "createCompoundClip": "複合クリップを作成", - "detectScenesAi": "AI ({{model}})", - "detectScenesAndSplit": "シーンを検出して分割", - "detectScenesFast": "高速 (ヒストグラム)", - "detectingFillers": "フィラーを検出中", - "detectingScenes": "シーンを検出中", - "detectingSilence": "無音を検出中", - "dissolveCompoundClip": "複合クリップを解除", - "extractEmbeddedSubtitles": "埋め込み字幕を抽出", - "generateAudioFromText": "テキストから音声を生成", - "generateCaptions": "キャプションを生成", - "insertExistingCaptions": "既存のキャプションを挿入", - "insertFreezeFrame": "フリーズフレームを挿入", - "joinSelected": "選択項目を結合", - "joinWithNext": "次と結合", - "joinWithPrevious": "前と結合", - "linkClips": "クリップをリンク", - "openCompoundClip": "複合クリップを開く", - "regenerateCaptions": "キャプションを再生成", - "removeFillerWords": "フィラー語を削除", - "removeSilence": "無音を削除", - "reverse": "反転", - "rippleDelete": "リップル削除", - "unlinkClips": "クリップのリンクを解除", - "unreverse": "反転を解除", - "updatingCaptions": "キャプションを更新中" - }, - "fadeHandles": { - "adjustAudioFadeIn": "オーディオのフェードインを調整", - "adjustAudioFadeInCurve": "オーディオのフェードインカーブを調整", - "adjustAudioFadeOut": "オーディオのフェードアウトを調整", - "adjustAudioFadeOutCurve": "オーディオのフェードアウトカーブを調整", - "adjustVideoFadeIn": "ビデオのフェードインを調整", - "adjustVideoFadeOut": "ビデオのフェードアウトを調整" - }, - "fillerRemoval": { - "aboutWillBeRemoved": "約 {{duration}} が削除されます。", - "add": "追加", - "addPhrase": "フレーズを追加", - "addWord": "単語を追加", - "cutPadding": "カット余白", - "filler": "フィラー", - "fillerRange": "フィラー語", - "found": "検出済み", - "includeRange": "範囲を含める", - "maxPhrase": "最大フレーズ", - "maxWord": "最大単語", - "noEntriesFound": "項目が見つかりません", - "noRemovableDetectedShort": "フィラー語は見つかりませんでした。", - "none": "なし", - "phrases": "フレーズ", - "playThisRange": "この範囲を再生", - "rangesSelected": "範囲を選択済み", - "redo": "やり直し", - "redoEditTitle": "直前の編集をやり直す", - "remove": "削除", - "removed": "削除済み", - "scoreAudio": "音声をスコアリング", - "scoring": "スコアリング中", - "title": "フィラー語を削除", - "toastAudioScored": "オーディオの信頼度スコアリングが完了しました。", - "toastNoRemovable": "現在の設定に一致するフィラー語はありません。", - "toastNoneInClips": "選択したクリップに削除できるフィラー語はありません。", - "toastPreviewFailed": "削除のプレビューを表示できませんでした。", - "toastRemoveFailed": "フィラー語を削除できませんでした。", - "toastRemoved": "{{count}} 件のフィラー語範囲を削除しました。", - "toastScoreFailed": "オーディオの信頼度を解析できませんでした。", - "undo": "元に戻す", - "undoEditTitle": "直前の編集を元に戻す", - "updatePreview": "プレビューを更新", - "updating": "更新中", - "words": "単語" - }, - "header": { - "addMarker": "マーカーを追加", - "addMarkerTooltip": "マーカーを追加", - "clearAllMarkers": "すべてのマーカーをクリア", - "clearAllMarkersTooltip": "すべてのマーカーをクリア", - "clearInOutPoints": "イン/アウト点をクリア", - "clearInOutPointsTooltip": "イン/アウト点をクリア", - "controls": "コントロール", - "disableLinkedSelection": "リンク選択を無効化", - "disableSnapping": "スナップを無効化", - "enableLinkedSelection": "リンク選択を有効化", - "enableSnapping": "スナップを有効化", - "hideColorScopes": "カラースコープを非表示", - "hideColorScopesTooltip": "カラースコープを非表示", - "linkedSelectionOff": "リンク選択オフ", - "linkedSelectionOn": "リンク選択オン", - "linkedSelectionTooltip": "リンク選択: {{state}} ({{shortcut}})", - "rateStretchTool": "レート調整ツール", - "rateStretchToolTooltip": "レート調整ツール", - "razorTool": "レーザーツール", - "razorToolTooltip": "レーザーツール", - "redo": "やり直し", - "redoTooltip": "やり直し", - "redoWithLabel": "{{label}}をやり直し", - "redoWithLabelTooltip": "{{label}}をやり直し", - "removeSelectedMarker": "選択したマーカーを削除", - "removeSelectedMarkerTooltip": "選択したマーカーを削除", - "selectTool": "選択ツール", - "selectToolTooltip": "選択ツール", - "setInPoint": "イン点を設定", - "setInPointTooltip": "イン点を設定", - "setOutPoint": "アウト点を設定", - "setOutPointTooltip": "アウト点を設定", - "showColorScopes": "カラースコープを表示", - "showColorScopesTooltip": "カラースコープを表示", - "slideTool": "スライドツール", - "slipSlideTools": "スリップ/スライドツール", - "slipSlideToolsTooltip": "スリップ/スライドツール", - "slipTool": "スリップツール", - "snapDisabled": "スナップ無効", - "snapEnabled": "スナップ有効", - "title": "タイムライン", - "trimEditTool": "トリム編集ツール", - "trimEditToolTooltip": "トリム編集ツール", - "undo": "元に戻す", - "undoTooltip": "元に戻す", - "undoWithLabel": "{{label}}を元に戻す", - "undoWithLabelTooltip": "{{label}}を元に戻す", - "zoomIn": "拡大", - "zoomInTooltip": "拡大", - "zoomOut": "縮小", - "zoomOutTooltip": "縮小", - "zoomSlider": "ズームスライダー", - "zoomToFit": "全体表示", - "zoomToFitTooltip": "全体表示" - }, - "itemActions": { - "appliedEffectToClips": "クリップにエフェクトを適用しました", - "selectAvClipFirst": "先にAVクリップを選択してください" - }, - "joinIndicators": { - "canJoinNext": "次と結合できます", - "canJoinPrevious": "前と結合できます" - }, - "keyframeEditor": { - "bezier": "ベジェ", - "custom": "カスタム", - "dragHandlesHint": "ハンドルをドラッグしてカーブを調整します。", - "graph": "グラフ", - "mixedCurves": "混在カーブ", - "mixedSpring": "混在スプリング", - "movedKeyframes": "キーフレームを移動しました", - "noKeyframesPasted": "貼り付けるキーフレームがありません", - "pastedKeyframes": "キーフレームを貼り付けました", - "preset": "プリセット", - "reasonBlocked": "{{count}} 件が別のキーフレームによりブロックされました", - "reasonUnsupported": "{{count}} 件が対象プロパティで非対応です", - "selectItem": "項目を選択", - "sheet": "シート", - "skippedDescription": "{{count}} 件をスキップ: {{reasons}}", - "spring": "スプリング", - "springHint": "スプリング物理演算 — 自然で弾むような動き。", - "title": "キーフレーム", - "unableToPasteCut": "カットを貼り付けできません", - "unableToPasteCutDescription": "切り取ったキーフレームはここに貼り付けできません。{{reasons}}" - }, - "noTracksToRemove": "削除するトラックがありません", - "region": "リージョン", - "removeActiveTrack": "アクティブトラックを削除", - "removeSelectedTracks": "選択したトラックを削除", - "reverseConform": { - "cancellingDescription": "キャンセル中...", - "couldNotPrepare": "準備できませんでした", - "progressDescription": "処理中...", - "titleCancelling": "キャンセル中", - "titleFailed": "失敗しました", - "titlePreparing": "準備中" - }, - "sceneDetection": { - "detectingScenes": "シーンを検出中", - "failed": "失敗しました", - "noScenesDetected": "シーンが検出されませんでした", - "noScenesWithinBounds": "範囲内にシーンがありません", - "noValidSplitPoints": "有効な分割点がありません", - "requiresWebGpu": "WebGPUが必要です", - "splitAtScenes": "シーンで分割" - }, - "selectTrackToRemove": "削除するトラックを選択", - "silenceRemoval": { - "aboutWillBeRemoved": "約 {{duration}} の無音が削除されます。", - "keepPadding": "余白を保持", - "minimumSilence": "最小無音", - "noRemovableDetectedShort": "無音は見つかりませんでした。", - "rangesSelected": "範囲を選択済み", - "remove": "削除", - "threshold": "しきい値", - "title": "無音を削除", - "toastNoRemovable": "現在の設定に一致する無音はありません。", - "toastNoneInClips": "選択したクリップに削除できる無音はありません。", - "toastPreviewFailed": "無音削除のプレビューを表示できませんでした。", - "toastRemoved": "{{count}} 件の無音範囲を削除しました。", - "toastSettingsChanged": "設定が変更されました。適用前にプレビューしてください。", - "updatePreview": "プレビューを更新", - "updating": "更新中" - }, - "track": { - "locked": "ロック済み" - }, - "trackHeader": { - "addAudioTrack": "オーディオトラックを追加", - "addVideoTrack": "ビデオトラックを追加", - "clipCount": "クリップ数", - "closeAllGaps": "すべてのギャップを閉じる", - "deleteEmptyTracks": "空のトラックを削除", - "deleteTrack": "トラックを削除", - "disableSyncLock": "同期ロックを無効化", - "disableTrack": "トラックを無効化", - "enableSyncLock": "同期ロックを有効化", - "enableTrack": "トラックを有効化", - "lockTrack": "トラックをロック", - "soloTrack": "トラックをソロ", - "unlockTrack": "トラックのロックを解除", - "unsoloTrack": "ソロを解除" - }, - "trackRow": { - "resizeSections": "ビデオ/オーディオトラック領域のサイズを変更", - "resizeTrackHeight": "トラックの高さを変更" - }, - "tracks": "トラック", - "volumeControl": { - "adjustClipVolume": "クリップ音量を調整" - } - } - }, - "ko": { - "editor": { - "videoSection": { - "cropBottom": "아래 자르기", - "cropLeft": "왼쪽 자르기", - "cropRight": "오른쪽 자르기", - "cropTop": "위 자르기", - "cropping": "자르기", - "fadeIn": "페이드 인", - "fadeOut": "페이드 아웃", - "playback": "재생", - "resetCropBottom": "아래 자르기 재설정", - "resetCropLeft": "왼쪽 자르기 재설정", - "resetCropRight": "오른쪽 자르기 재설정", - "resetCropTop": "위 자르기 재설정", - "resetSoftness": "부드러움 재설정", - "resetSpeed": "속도 재설정", - "resetToZero": "0으로 재설정", - "softness": "부드러움", - "speed": "속도" - } - }, - "effects": { - "curves": { - "channel": "채널", - "dragHint": "{{channel}} 곡선의 점을 드래그하여 톤을 조절하세요.", - "resetChannel": "채널 초기화" - }, - "panel": { - "disableEffect": "효과 비활성화", - "enableEffect": "효과 활성화", - "off": "끔", - "on": "켬", - "removeEffect": "효과 제거", - "resetToDefaults": "기본값 복원" - }, - "section": { - "addEffect": "효과 추가", - "disableAll": "모두 비활성화", - "emptyState": "적용된 효과가 없습니다. 효과를 추가해 시작하세요.", - "enableAll": "모두 활성화", - "noEffectsFound": "효과를 찾을 수 없습니다", - "presets": "프리셋", - "searchEffects": "효과 검색", - "title": "효과" - }, - "wheels": { - "resetWheel": "휠 초기화" - } - }, - "export": { - "audioContainer": { - "aac": "AAC", - "mp3": "MP3", - "wav": "WAV" - }, - "cancelled": { - "message": "내보내기가 취소되었습니다. 파일이 저장되지 않았습니다." - }, - "complete": { - "audioSuccess": "오디오를 다운로드할 준비가 되었습니다.", - "download": "다운로드", - "fileSizeLabel": "크기", - "timeTakenLabel": "소요 시간", - "videoSuccess": "동영상을 다운로드할 준비가 되었습니다." - }, - "dialog": { - "descCancelled": "내보내기가 완료되기 전에 취소되었습니다.", - "descComplete": "파일이 준비되었습니다.", - "descError": "내보내는 중 문제가 발생했습니다.", - "descProgress": "동영상을 렌더링하는 중입니다. 몇 분 정도 걸릴 수 있습니다.", - "descSettings": "내보내기 옵션을 구성합니다.", - "titleCancelled": "내보내기가 취소됨", - "titleComplete": "내보내기 완료", - "titleError": "내보내기 실패", - "titleProgress": "동영상 내보내는 중", - "titleSettings": "내보내기" - }, - "errors": { - "verifyCodec": "현재 설정에 대한 코덱 지원 여부를 확인할 수 없습니다." - }, - "progress": { - "cancelExport": "취소", - "elapsedLabel": "경과 시간", - "encoding": "인코딩 중…", - "finalizing": "마무리하는 중…", - "framesLabel": "프레임", - "keepTabOpen": "내보내기가 완료될 때까지 이 탭을 열어 두세요.", - "preparing": "준비 중…", - "rendering": "렌더링 중…" - }, - "settings": { - "audio": "오디오", - "audioOnlyNote": "오디오만 내보내며 동영상은 포함되지 않습니다.", - "audioQualityHigh": "높음", - "audioQualityLow": "낮음", - "audioQualityMedium": "보통", - "audioQualityUltra": "최고", - "cannotEncode": "선택한 품질의 {{width}}×{{height}} 인코더를 사용할 수 없습니다.", - "codec": "코덱", - "codecSupportUnverified": "코덱 지원 여부를 확인할 수 없습니다. 내보내기는 가능할 수 있지만 호환성은 보장되지 않습니다.", - "duration": "길이", - "embedSubtitles": "자막 포함", - "embedSubtitlesDescription": "전사 자막을 내보낸 파일에 자막 트랙으로 포함합니다.", - "embedSubtitlesMp4Note": "MP4는 자막을 별도 트랙으로 저장합니다. 일부 플레이어에서는 기본적으로 표시되지 않을 수 있습니다.", - "embedSubtitlesUnsupported": "{{container}}는 자막 포함을 지원하지 않습니다. MP4, MKV 또는 WebM을 선택하세요.", - "exportAudio": "오디오 내보내기", - "exportRange": "내보내기 범위", - "exportType": "내보내기 형식", - "exportVideo": "동영상 내보내기", - "format": "형식", - "in": "시작", - "inOutRangeHint": "선택한 시작/종료 구간만 내보내집니다.", - "noTranscriptSegments": "포함할 전사 자막이 없습니다.", - "out": "끝", - "quality": "품질", - "qualityHigh": "높음", - "qualityLow": "낮음", - "qualityMedium": "보통", - "qualityUltra": "최고", - "quicktimeMov": "QuickTime MOV", - "renderWholeProject": "프로젝트 전체 렌더링", - "resolution": "해상도", - "resolutionSameAsProject": "프로젝트와 동일 ({{width}}×{{height}})", - "resolutionScaled": "{{p}}p ({{width}}×{{height}})", - "selectCodec": "코덱 선택", - "selectFormat": "형식 선택", - "selectQuality": "품질 선택", - "selectResolution": "해상도 선택", - "video": "동영상" - }, - "videoContainer": { - "mp4": "광범위한 지원, 공유에 적합", - "mov": "Apple 생태계, Final Cut 및 QuickTime에 적합", - "webm": "웹 재생을 위한 개방형 최신 컨테이너", - "mkv": "자막 지원이 강력한 유연한 컨테이너" - } - }, - "media": { - "card": { - "aiCaptionsCount": "AI 자막 {{count}}개", - "analyzeWithAI": "AI로 분석", - "analyzingWithAI": "AI로 분석 중", - "chooseMkvOrWebm": "MKV 또는 WebM 선택", - "deleteProxy": "프록시 삭제", - "deleteTranscript": "전사 삭제", - "extractEmbeddedSubtitles": "포함된 자막 추출", - "generateProxy": "프록시 생성", - "generateTranscript": "전사 생성", - "importing": "가져오는 중", - "menuAi": "AI", - "menuCaptions": "포함된 자막", - "menuFile": "파일", - "menuProxy": "프록시", - "menuTranscript": "전사", - "playAudio": "오디오 재생", - "refreshTranscript": "전사 새로 고침", - "relinkFile": "파일 다시 연결…", - "stopAudio": "오디오 정지", - "subtitlesCachedAll": "자막 캐시 완료", - "subtitlesCachedPartial": "자막이 일부만 캐시됨", - "subtitlesCannotRead": "FreeCut가 지금 \"{{name}}\"을(를) 읽을 수 없습니다. 사용 중인 앱을 닫고 다시 시도하세요.", - "subtitlesExtractFailed": "자막 추출에 실패했습니다", - "subtitlesFileMissing": "FreeCut가 더 이상 \"{{name}}\"을(를) 찾을 수 없습니다.", - "subtitlesNeedPermission": "자막을 추출하기 전에 \"{{name}}\"에 대한 권한이 필요합니다.", - "subtitlesScanFailed": "자막 검색 실패", - "transcribeFailed": "전사 실패", - "transcribing": "전사 중", - "transcriptDeleteFailed": "전사 삭제 실패", - "transcriptDeleteFailedFor": "\"{{name}}\"의 전사를 삭제할 수 없습니다", - "transcriptDeletedFor": "\"{{name}}\"의 전사를 삭제했습니다", - "transcriptProgressAria": "전사 진행률", - "transcriptReadyFor": "\"{{name}}\"의 전사가 준비되었습니다", - "transcriptionFailedFor": "\"{{name}}\"의 전사에 실패했습니다", - "transcriptsDeleted": "전사가 삭제되었습니다", - "transcriptsReady": "전사가 준비되었습니다" - }, - "compositions": { - "deleteBody": "복합 클립 \"{{name}}\"을(를) 삭제할까요? 이 작업은 되돌릴 수 없습니다.", - "deleteInstancesDetail": "이 복합 클립을 사용하는 타임라인 인스턴스 {{count}}개도 함께 제거됩니다.", - "deleteInstancesTitle": "연결된 인스턴스가 제거됩니다", - "deleteTitle": "복합 클립 삭제", - "enter": "열기", - "itemCount": "{{count}}개 항목", - "rename": "이름 변경", - "sectionTitle": "복합 클립" - }, - "deleteDialog": { - "bodyMultiple": "이 프로젝트에서 미디어 항목 {{count}}개를 영구적으로 제거합니다. 이 작업은 되돌릴 수 없습니다.", - "bodySingle": "이 프로젝트에서 \"{{name}}\"을(를) 영구적으로 제거합니다. 이 작업은 되돌릴 수 없습니다.", - "confirmWithClips": "클립과 함께 삭제", - "timelineClipsDetail": "이 미디어를 사용하는 타임라인 클립 {{count}}개도 함께 제거됩니다.", - "timelineClipsDetail_one": "이 미디어를 사용하는 타임라인 클립 {{count}}개도 함께 제거됩니다.", - "timelineClipsDetail_other": "이 미디어를 사용하는 타임라인 클립 {{count}}개도 함께 제거됩니다.", - "timelineClipsRemoved": "타임라인 클립도 제거됩니다", - "titleMultiple": "미디어 항목 {{count}}개를 삭제할까요?", - "titleSingle": "미디어 항목을 삭제할까요?" - }, - "embeddedSubtitles": { - "badgeAutoPick": "자동", - "badgeDefault": "기본", - "badgeForced": "강제", - "cuesCount": "{{count}}개 항목", - "desc": "타임라인에 삽입할 자막 트랙을 선택하세요.", - "descForFile": "\"{{name}}\"에 포함된 자막입니다.", - "empty": "이 파일에서 자막 트랙을 찾을 수 없습니다.", - "insert": "삽입", - "insertWithCues": "삽입 ({{count}}개)", - "loadedFromCache": "캐시에서 자막을 불러왔습니다.", - "scanning": "검색 중…", - "title": "포함된 자막", - "trackInfo": "트랙 {{n}} · {{lang}} · {{codec}}" - }, - "grid": { - "emptyHint": "파일을 끌어다 놓거나 가져오기를 클릭하세요.", - "emptyTitle": "아직 미디어가 없습니다", - "loadingSubtitle": "미디어를 준비하는 중…", - "loadingTitle": "미디어를 불러오는 중…" - }, - "info": { - "codec": "코덱", - "dimensions": "크기", - "duration": "길이", - "fpsValue": "{{value}} fps", - "frameRate": "프레임 속도", - "loadingTranscript": "전사를 불러오는 중…", - "mediaInfo": "미디어 정보", - "openInSourceMonitor": "소스 모니터에서 열기", - "size": "크기", - "transcript": "전사", - "transcriptWithCount": "전사 ({{count}})", - "type": "유형" - }, - "library": { - "aiAnalysisProgress": "AI 분석", - "allTypes": "모든 유형", - "analyzingMultiple": "클립 {{count}}개 분석 중…", - "analyzingSingle": "분석 중…", - "andJoiner": " 및 ", - "assetsCount": "에셋 {{count}}개", - "assetsCount_one": "에셋 {{count}}개", - "assetsCount_other": "에셋 {{count}}개", - "back": "뒤로", - "cancelAll": "모두 취소", - "cancelling": "취소 중…", - "clearSelection": "선택 해제", - "compoundClipsCount": "복합 클립 {{count}}개", - "compoundClipsCount_one": "복합 클립 {{count}}개", - "compoundClipsCount_other": "복합 클립 {{count}}개", - "copyToClipboard": "클립보드에 복사", - "deleteAssetsBody": "이 프로젝트에서 {{summary}}을(를) 영구적으로 제거합니다. 이 작업은 되돌릴 수 없습니다.", - "deleteAssetsTitle": "선택한 에셋을 삭제할까요?", - "deleteSelectedAssets": "선택한 항목 삭제", - "deleteSummary": "{{summary}} 삭제", - "deleteWithClips": "클립과 함께 삭제", - "dismiss": "닫기", - "dragDropUnsupported": "여기에서는 끌어다 놓기를 지원하지 않습니다.", - "dropFilesHere": "파일을 여기에 놓기", - "filesRejected": "일부 파일을 추가할 수 없습니다.", - "generateProxiesForSelected": "선택한 항목 {{count}}개의 프록시 생성", - "generatingProxies": "프록시 생성 중…", - "generatingTranscripts": "전사 생성 중…", - "gridItemSize": "썸네일 크기", - "groupAudio": "오디오 그룹", - "groupGifs": "GIF 그룹", - "groupImages": "이미지 그룹", - "groupVideos": "비디오 그룹", - "import": "가져오기", - "importFromUrlDescription": "직접 URL에서 미디어 파일을 가져옵니다.", - "importFromUrlHint": "비디오, 오디오, 이미지 또는 GIF 파일의 직접 링크를 붙여넣으세요.", - "importFromUrlTitle": "URL에서 가져오기", - "importMediaFiles": "미디어 파일 가져오기", - "importMediaFromUrl": "URL에서 미디어 가져오기", - "libraryView": "라이브러리 보기", - "linkedInstancesDetail": "이 에셋을 사용하는 타임라인 인스턴스 {{count}}개도 함께 제거됩니다.", - "linkedInstancesDetail_one": "이 에셋을 사용하는 타임라인 인스턴스 {{count}}개도 함께 제거됩니다.", - "linkedInstancesDetail_other": "이 에셋을 사용하는 타임라인 인스턴스 {{count}}개도 함께 제거됩니다.", - "linkedInstancesTitle": "연결된 타임라인 인스턴스도 제거됩니다", - "mediaItemsCount": "미디어 항목 {{count}}개", - "mediaItemsCount_one": "미디어 항목 {{count}}개", - "mediaItemsCount_other": "미디어 항목 {{count}}개", - "mediaTab": "미디어", - "missingCount": "{{count}}개 누락", - "proxyCount": "프록시 {{count}}개", - "proxyGenerationProgress": "프록시 진행률", - "scenesTab": "장면", - "searchScenes": "장면 검색", - "selected": "선택됨", - "selectedAssetsCount": "선택한 에셋 {{count}}개", - "selectedAssetsCount_one": "선택한 에셋 {{count}}개", - "selectedAssetsCount_other": "선택한 에셋 {{count}}개", - "showMediaLibrary": "미디어 라이브러리 표시", - "sortDate": "날짜순 정렬", - "sortName": "이름순 정렬", - "sortSize": "크기순 정렬", - "transcriptGenerationProgress": "전사 진행률", - "url": "URL", - "viewMissingMedia": "누락된 미디어 보기", - "allShort": "전체", - "typeShort": { - "video": "비디오", - "audio": "오디오", - "image": "이미지" - }, - "sortShort": { - "name": "이름", - "date": "날짜", - "size": "크기" - }, - "missingCount_one": "{{count}}개 누락", - "missingCount_other": "{{count}}개 누락", - "selectedCount": "{{count}}개 선택됨", - "selectedCount_one": "{{count}}개 선택됨", - "selectedCount_other": "{{count}}개 선택됨", - "generateProxiesForSelected_one": "선택한 항목 {{count}}개의 프록시 생성", - "generateProxiesForSelected_other": "선택한 항목 {{count}}개의 프록시 생성", - "proxyCount_one": "프록시 {{count}}개", - "proxyCount_other": "프록시 {{count}}개" - }, - "missingMedia": { - "title": "누락된 미디어", - "description": "FreeCut가 미디어 파일 {{count}}개에 접근할 수 없습니다. 파일을 찾거나 오프라인으로 계속 작업하세요.", - "description_one": "FreeCut가 미디어 파일 {{count}}개에 접근할 수 없습니다. 파일을 찾거나 오프라인으로 계속 작업하세요.", - "description_other": "FreeCut가 미디어 파일 {{count}}개에 접근할 수 없습니다. 파일을 찾거나 오프라인으로 계속 작업하세요.", - "needPermission": "{{count}}개 권한 필요", - "needPermission_one": "{{count}}개 권한 필요", - "needPermission_other": "{{count}}개 권한 필요", - "notFound": "{{count}}개 찾을 수 없음", - "notFound_one": "{{count}}개 찾을 수 없음", - "notFound_other": "{{count}}개 찾을 수 없음", - "browseAnotherFolder": "다른 폴더 찾아보기", - "fileMovedOrDeleted": "파일이 이동되었거나 삭제됨", - "grantAccess": "접근 허용", - "locate": "찾기", - "locateFolder": "폴더 찾기", - "permissionExpired": "권한 만료됨", - "scanProjectFolder": "{{name}} 검사", - "workOffline": "오프라인으로 작업" - }, - "orphanedClips": { - "autoMatch": "자동 매칭", - "description": "{{count}}개의 클립이 더 이상 찾을 수 없는 미디어를 참조합니다. 계속하려면 교체하거나 제거하세요.", - "keepAsBroken": "끊긴 상태로 유지", - "removeAll": "모두 제거", - "select": "선택", - "selectReplacement": "\"{{name}}\"의 대체 파일 선택", - "title": "누락된 미디어" - }, - "picker": { - "descAll": "프로젝트 라이브러리에서 파일을 선택하세요.", - "noFiles": "이 프로젝트에는 아직 미디어가 없습니다.", - "noSearchResults": "결과가 없습니다.", - "title": "미디어 선택" - }, - "searchMedia": "미디어 검색", - "subtitleScan": { - "cached": "캐시됨", - "cancelBatch": "모두 취소", - "descComplete": "자막 검색을 완료했습니다.", - "descScanning": "미디어에서 자막을 검색하는 중…", - "failed": "실패", - "titleComplete": "자막 검색을 완료했습니다", - "titleScanning": "자막을 검색하는 중…" - }, - "transcribe": { - "autoDetect": "자동 감지", - "generateTitle": "전사 생성", - "language": "언어", - "model": "모델", - "noLanguages": "사용 가능한 언어가 없습니다.", - "progressAria": "전사 진행률", - "quantization": "양자화", - "refreshTitle": "전사 새로 고침", - "searchLanguages": "언어 검색", - "start": "시작", - "stop": "정지" - }, - "type": { - "audio": "오디오", - "image": "이미지", - "video": "비디오" - }, - "unsupportedCodec": { - "bodyMultiple": "{{count}}개 파일이 브라우저가 디코딩할 수 없는 코덱을 사용합니다. 제대로 재생되지 않을 수 있습니다.", - "bodySingle": "이 파일은 브라우저가 디코딩할 수 없는 코덱을 사용합니다. 제대로 재생되지 않을 수 있습니다.", - "cancelImport": "가져오기 취소", - "importAnyway": "그래도 가져오기", - "note": "호환성을 위해 MP4 (H.264/AAC) 또는 WebM으로 변환하세요.", - "title": "지원되지 않는 코덱" - } - }, - "preview": { - "align": { - "disableCanvasSnap": "캔버스 스냅 비활성화", - "enableCanvasSnap": "캔버스 스냅 활성화", - "toggleCanvasSnap": "캔버스 스냅 전환" - }, - "controls": { - "captureFailed": "현재 프레임을 캡처할 수 없습니다.", - "disableProxyPlayback": "프록시 재생 비활성화", - "enableProxyPlayback": "프록시 재생 활성화", - "frameDownloadedNoProject": "프레임을 다운로드했습니다. 미디어 라이브러리에 저장하려면 프로젝트를 여세요.", - "frameDownloadedNotSaved": "프레임을 다운로드했지만 미디어 라이브러리에 저장하지 못했습니다: {{message}}", - "frameSaved": "\"{{name}}\"을(를) 미디어 라이브러리에 저장했습니다.", - "goToEnd": "끝으로", - "goToEndTooltip": "끝으로", - "goToStart": "처음으로", - "goToStartTooltip": "처음으로", - "nextFrame": "다음 프레임", - "nextFrameTooltip": "다음 프레임", - "pauseTooltip": "일시정지", - "playTooltip": "재생", - "prevFrame": "이전 프레임", - "prevFrameTooltip": "이전 프레임", - "proxyPlaybackOff": "프록시 재생 꺼짐", - "proxyPlaybackOn": "프록시 재생 켜짐", - "saveFrame": "프레임 저장", - "saveFrameFailed": "프레임을 저장할 수 없습니다.", - "saveFrameTooltip": "프레임 저장", - "savingFrame": "프레임을 저장하는 중…", - "savingFrameTooltip": "프레임을 저장하는 중…" - }, - "monitor": { - "mute": "음소거", - "muteShort": "음소거", - "muted": "음소거됨", - "percent": "{{value}}%", - "previewOnlyNote": "로컬 재생에만 적용됩니다. 내보내기에는 프로젝트 믹스가 사용됩니다.", - "thisDeviceOnly": "이 기기에서만", - "title": "미리보기 오디오", - "unmute": "음소거 해제", - "volume": "볼륨" - }, - "player": { - "exitFullscreen": "전체 화면 종료", - "fullscreen": "전체 화면", - "mute": "음소거", - "pause": "일시정지", - "play": "재생", - "seek": "탐색", - "unmute": "음소거 해제", - "volume": "볼륨" - }, - "stage": { - "loadingMedia": "미디어를 불러오는 중…", - "videoPreview": "동영상 미리보기" - }, - "zoom": { - "ariaLabel": "확대/축소: {{label}}", - "auto": "자동", - "tooltip": "확대/축소: {{label}}" - } - }, - "timeline": { - "addAudioTrackHint": "오디오 트랙 추가", - "addVideoTrackHint": "비디오 트랙 추가", - "bento": { - "apply": "적용", - "description": "선택한 클립 {{count}}개를 격자로 배치합니다.", - "gap": "간격", - "noItemsToArrange": "정렬할 항목이 없습니다", - "padding": "여백", - "presetNamePlaceholder": "프리셋 이름", - "saveAsPreset": "프리셋으로 저장", - "title": "벤토 레이아웃" - }, - "captions": { - "addedFromTranscript": "대본에서 추가됨", - "addedWithModel": "모델로 추가됨", - "failedGenerateSegment": "구간 생성 실패", - "failedUpdateSegment": "구간 업데이트 실패", - "refreshedWithModel": "모델로 새로 고침됨", - "removedFromSegment": "구간에서 제거됨", - "updatedFromTranscript": "대본에서 업데이트됨", - "updatedWithModel": "모델로 업데이트됨" - }, - "clipIndicators": { - "hasKeyframes": "키프레임 있음", - "mask": "마스크", - "mediaMissing": "미디어 없음", - "preparingReversed": "역방향 준비 중", - "reversePrepFailedShort": "역방향 준비 실패", - "reversedPlayback": "역방향 재생", - "reversedPrepFailed": "역방향 준비 실패", - "reversedPrepared": "역방향 준비 완료", - "speed": "속도: {{speed}}x" - }, - "contextMenu": { - "bentoLayout": "벤토 레이아웃", - "captions": "자막", - "clearAll": "모두 지우기", - "clearKeyframes": "키프레임 지우기", - "consolidateCaptionsToSegment": "자막을 구간으로 병합", - "createCompoundClip": "복합 클립 만들기", - "detectScenesAi": "AI ({{model}})", - "detectScenesAndSplit": "장면 감지 및 분할", - "detectScenesFast": "빠름 (히스토그램)", - "detectingFillers": "군더더기 감지 중", - "detectingScenes": "장면 감지 중", - "detectingSilence": "무음 감지 중", - "dissolveCompoundClip": "복합 클립 해제", - "extractEmbeddedSubtitles": "내장 자막 추출", - "generateAudioFromText": "텍스트에서 오디오 생성", - "generateCaptions": "자막 생성", - "insertExistingCaptions": "기존 자막 삽입", - "insertFreezeFrame": "정지 프레임 삽입", - "joinSelected": "선택 항목 결합", - "joinWithNext": "다음과 결합", - "joinWithPrevious": "이전과 결합", - "linkClips": "클립 연결", - "openCompoundClip": "복합 클립 열기", - "regenerateCaptions": "자막 다시 생성", - "removeFillerWords": "군더더기 단어 제거", - "removeSilence": "무음 제거", - "reverse": "반전", - "rippleDelete": "리플 삭제", - "unlinkClips": "클립 연결 해제", - "unreverse": "반전 해제", - "updatingCaptions": "자막 업데이트 중" - }, - "fadeHandles": { - "adjustAudioFadeIn": "오디오 페이드 인 조정", - "adjustAudioFadeInCurve": "오디오 페이드 인 곡선 조정", - "adjustAudioFadeOut": "오디오 페이드 아웃 조정", - "adjustAudioFadeOutCurve": "오디오 페이드 아웃 곡선 조정", - "adjustVideoFadeIn": "비디오 페이드 인 조정", - "adjustVideoFadeOut": "비디오 페이드 아웃 조정" - }, - "fillerRemoval": { - "aboutWillBeRemoved": "약 {{duration}}가 제거됩니다.", - "add": "추가", - "addPhrase": "구문 추가", - "addWord": "단어 추가", - "cutPadding": "컷 여백", - "filler": "군더더기", - "fillerRange": "필러 단어", - "found": "찾음", - "includeRange": "범위 포함", - "maxPhrase": "최대 구문", - "maxWord": "최대 단어", - "noEntriesFound": "항목을 찾을 수 없음", - "noRemovableDetectedShort": "제거할 필러 단어가 없습니다.", - "none": "없음", - "phrases": "구문", - "playThisRange": "이 범위 재생", - "rangesSelected": "범위 선택됨", - "redo": "다시 실행", - "redoEditTitle": "마지막 편집 다시 실행", - "remove": "제거", - "removed": "제거됨", - "scoreAudio": "오디오 점수 계산", - "scoring": "점수 계산 중", - "title": "필러 단어 제거", - "toastAudioScored": "오디오 신뢰도 분석을 완료했습니다.", - "toastNoRemovable": "현재 설정에 해당하는 필러 단어가 없습니다.", - "toastNoneInClips": "선택한 클립에 제거할 필러 단어가 없습니다.", - "toastPreviewFailed": "제거 미리보기를 표시할 수 없습니다.", - "toastRemoveFailed": "필러 단어를 제거할 수 없습니다.", - "toastRemoved": "필러 단어 구간 {{count}}개를 제거했습니다.", - "toastScoreFailed": "오디오 신뢰도를 분석할 수 없습니다.", - "undo": "실행 취소", - "undoEditTitle": "마지막 편집 실행 취소", - "updatePreview": "미리보기 업데이트", - "updating": "업데이트 중", - "words": "단어" - }, - "header": { - "addMarker": "마커 추가", - "addMarkerTooltip": "마커 추가", - "clearAllMarkers": "모든 마커 지우기", - "clearAllMarkersTooltip": "모든 마커 지우기", - "clearInOutPoints": "인/아웃 지점 지우기", - "clearInOutPointsTooltip": "인/아웃 지점 지우기", - "controls": "컨트롤", - "disableLinkedSelection": "연결 선택 비활성화", - "disableSnapping": "스냅 비활성화", - "enableLinkedSelection": "연결 선택 활성화", - "enableSnapping": "스냅 활성화", - "hideColorScopes": "색상 스코프 숨기기", - "hideColorScopesTooltip": "색상 스코프 숨기기", - "linkedSelectionOff": "연결 선택 꺼짐", - "linkedSelectionOn": "연결 선택 켜짐", - "linkedSelectionTooltip": "연결된 선택: {{state}} ({{shortcut}})", - "rateStretchTool": "속도 늘이기 도구", - "rateStretchToolTooltip": "속도 늘이기 도구", - "razorTool": "자르기 도구", - "razorToolTooltip": "자르기 도구", - "redo": "다시 실행", - "redoTooltip": "다시 실행", - "redoWithLabel": "{{label}} 다시 실행", - "redoWithLabelTooltip": "{{label}} 다시 실행", - "removeSelectedMarker": "선택한 마커 제거", - "removeSelectedMarkerTooltip": "선택한 마커 제거", - "selectTool": "선택 도구", - "selectToolTooltip": "선택 도구", - "setInPoint": "인 지점 설정", - "setInPointTooltip": "인 지점 설정", - "setOutPoint": "아웃 지점 설정", - "setOutPointTooltip": "아웃 지점 설정", - "showColorScopes": "색상 스코프 표시", - "showColorScopesTooltip": "색상 스코프 표시", - "slideTool": "슬라이드 도구", - "slipSlideTools": "슬립/슬라이드 도구", - "slipSlideToolsTooltip": "슬립/슬라이드 도구", - "slipTool": "슬립 도구", - "snapDisabled": "스냅 비활성화됨", - "snapEnabled": "스냅 활성화됨", - "title": "타임라인", - "trimEditTool": "트림 편집 도구", - "trimEditToolTooltip": "트림 편집 도구", - "undo": "실행 취소", - "undoTooltip": "실행 취소", - "undoWithLabel": "{{label}} 실행 취소", - "undoWithLabelTooltip": "{{label}} 실행 취소", - "zoomIn": "확대", - "zoomInTooltip": "확대", - "zoomOut": "축소", - "zoomOutTooltip": "축소", - "zoomSlider": "줌 슬라이더", - "zoomToFit": "화면에 맞추기", - "zoomToFitTooltip": "화면에 맞추기" - }, - "itemActions": { - "appliedEffectToClips": "클립에 효과 적용됨", - "selectAvClipFirst": "먼저 AV 클립을 선택하세요" - }, - "joinIndicators": { - "canJoinNext": "다음과 결합 가능", - "canJoinPrevious": "이전과 결합 가능" - }, - "keyframeEditor": { - "bezier": "베지어", - "custom": "사용자 지정", - "dragHandlesHint": "핸들을 드래그하여 곡선을 조정하세요.", - "graph": "그래프", - "mixedCurves": "혼합 곡선", - "mixedSpring": "혼합 스프링", - "movedKeyframes": "키프레임 이동됨", - "noKeyframesPasted": "붙여넣을 키프레임 없음", - "pastedKeyframes": "키프레임 붙여넣음", - "preset": "프리셋", - "reasonBlocked": "다른 키프레임으로 인해 {{count}}개 차단됨", - "reasonUnsupported": "대상 속성에서 {{count}}개 지원되지 않음", - "selectItem": "항목 선택", - "sheet": "시트", - "skippedDescription": "{{count}}개 건너뜀: {{reasons}}", - "spring": "스프링", - "springHint": "스프링 물리 — 자연스럽고 탄력적인 움직임.", - "title": "키프레임", - "unableToPasteCut": "컷을 붙여넣을 수 없음", - "unableToPasteCutDescription": "잘라낸 키프레임을 여기에 붙여넣을 수 없습니다. {{reasons}}" - }, - "noTracksToRemove": "제거할 트랙 없음", - "region": "영역", - "removeActiveTrack": "활성 트랙 제거", - "removeSelectedTracks": "선택한 트랙 제거", - "reverseConform": { - "cancellingDescription": "취소 중...", - "couldNotPrepare": "준비할 수 없음", - "progressDescription": "처리 중...", - "titleCancelling": "취소 중", - "titleFailed": "실패", - "titlePreparing": "준비 중" - }, - "sceneDetection": { - "detectingScenes": "장면 감지 중", - "failed": "실패", - "noScenesDetected": "감지된 장면 없음", - "noScenesWithinBounds": "범위 내 장면 없음", - "noValidSplitPoints": "유효한 분할 지점 없음", - "requiresWebGpu": "WebGPU 필요", - "splitAtScenes": "장면에서 분할" - }, - "selectTrackToRemove": "제거할 트랙 선택", - "silenceRemoval": { - "aboutWillBeRemoved": "약 {{duration}}의 무음이 제거됩니다.", - "keepPadding": "여백 유지", - "minimumSilence": "최소 무음", - "noRemovableDetectedShort": "무음을 찾지 못했습니다.", - "rangesSelected": "범위 선택됨", - "remove": "제거", - "threshold": "임계값", - "title": "무음 제거", - "toastNoRemovable": "현재 설정에 해당하는 무음이 없습니다.", - "toastNoneInClips": "선택한 클립에 제거할 무음이 없습니다.", - "toastPreviewFailed": "무음 제거 미리보기를 표시할 수 없습니다.", - "toastRemoved": "무음 구간 {{count}}개를 제거했습니다.", - "toastSettingsChanged": "설정이 변경되었습니다. 적용 전에 미리 보세요.", - "updatePreview": "미리보기 업데이트", - "updating": "업데이트 중" - }, - "track": { - "locked": "잠김" - }, - "trackHeader": { - "addAudioTrack": "오디오 트랙 추가", - "addVideoTrack": "비디오 트랙 추가", - "clipCount": "클립 수", - "closeAllGaps": "모든 간격 닫기", - "deleteEmptyTracks": "빈 트랙 삭제", - "deleteTrack": "트랙 삭제", - "disableSyncLock": "동기화 잠금 비활성화", - "disableTrack": "트랙 비활성화", - "enableSyncLock": "동기화 잠금 활성화", - "enableTrack": "트랙 활성화", - "lockTrack": "트랙 잠금", - "soloTrack": "트랙 솔로", - "unlockTrack": "트랙 잠금 해제", - "unsoloTrack": "솔로 해제" - }, - "trackRow": { - "resizeSections": "비디오 및 오디오 트랙 영역 크기 조정", - "resizeTrackHeight": "트랙 높이 조정" - }, - "tracks": "트랙", - "volumeControl": { - "adjustClipVolume": "클립 볼륨 조정" - } - } - }, - "zh": { - "editor": { - "videoSection": { - "cropBottom": "裁剪底部", - "cropLeft": "裁剪左侧", - "cropRight": "裁剪右侧", - "cropTop": "裁剪顶部", - "cropping": "裁剪", - "fadeIn": "淡入", - "fadeOut": "淡出", - "playback": "播放", - "resetCropBottom": "重置底部裁剪", - "resetCropLeft": "重置左侧裁剪", - "resetCropRight": "重置右侧裁剪", - "resetCropTop": "重置顶部裁剪", - "resetSoftness": "重置柔和度", - "resetSpeed": "重置速度", - "resetToZero": "重置为零", - "softness": "柔和度", - "speed": "速度" - } - }, - "effects": { - "curves": { - "channel": "通道", - "dragHint": "拖动 {{channel}} 曲线上的点以调整色调。", - "resetChannel": "重置通道" - }, - "panel": { - "disableEffect": "禁用特效", - "enableEffect": "启用特效", - "off": "关", - "on": "开", - "removeEffect": "删除特效", - "resetToDefaults": "恢复默认" - }, - "section": { - "addEffect": "添加特效", - "disableAll": "全部禁用", - "emptyState": "尚未应用特效。添加一个开始使用。", - "enableAll": "全部启用", - "noEffectsFound": "未找到特效", - "presets": "预设", - "searchEffects": "搜索特效", - "title": "特效" - }, - "wheels": { - "resetWheel": "重置色环" - } - }, - "export": { - "audioContainer": { - "aac": "AAC", - "mp3": "MP3", - "wav": "WAV" - }, - "cancelled": { - "message": "导出已取消,未保存任何文件。" - }, - "complete": { - "audioSuccess": "音频已准备好下载。", - "download": "下载", - "fileSizeLabel": "大小", - "timeTakenLabel": "用时", - "videoSuccess": "视频已准备好下载。" - }, - "dialog": { - "descCancelled": "导出在完成前已被取消。", - "descComplete": "您的文件已就绪。", - "descError": "导出过程中发生错误。", - "descProgress": "正在渲染您的视频,这可能需要几分钟。", - "descSettings": "配置导出选项。", - "titleCancelled": "导出已取消", - "titleComplete": "导出完成", - "titleError": "导出失败", - "titleProgress": "正在导出视频", - "titleSettings": "导出" - }, - "errors": { - "verifyCodec": "无法验证当前设置的编解码器支持情况。" - }, - "progress": { - "cancelExport": "取消", - "elapsedLabel": "已用时间", - "encoding": "正在编码…", - "finalizing": "正在完成…", - "framesLabel": "帧", - "keepTabOpen": "请保持此标签页打开,直至导出完成。", - "preparing": "正在准备…", - "rendering": "正在渲染…" - }, - "settings": { - "audio": "音频", - "audioOnlyNote": "仅导出音频 — 不会包含视频。", - "audioQualityHigh": "高", - "audioQualityLow": "低", - "audioQualityMedium": "中", - "audioQualityUltra": "超高", - "cannotEncode": "在所选画质下,没有可用于 {{width}}×{{height}} 的编码器。", - "codec": "编解码器", - "codecSupportUnverified": "无法验证编解码器支持情况。仍可尝试导出,但兼容性无法保证。", - "duration": "时长", - "embedSubtitles": "嵌入字幕", - "embedSubtitlesDescription": "将转录字幕作为字幕轨道嵌入导出的文件。", - "embedSubtitlesMp4Note": "MP4 将字幕保存为独立轨道。某些播放器默认可能不会显示。", - "embedSubtitlesUnsupported": "{{container}} 不支持嵌入字幕。请选择 MP4、MKV 或 WebM。", - "exportAudio": "导出音频", - "exportRange": "导出范围", - "exportType": "导出类型", - "exportVideo": "导出视频", - "format": "格式", - "in": "入点", - "inOutRangeHint": "仅会导出所选的入/出点范围。", - "noTranscriptSegments": "没有可嵌入的转录字幕。", - "out": "出点", - "quality": "画质", - "qualityHigh": "高", - "qualityLow": "低", - "qualityMedium": "中", - "qualityUltra": "超高", - "quicktimeMov": "QuickTime MOV", - "renderWholeProject": "渲染整个项目", - "resolution": "分辨率", - "resolutionSameAsProject": "与项目相同 ({{width}}×{{height}})", - "resolutionScaled": "{{p}}p ({{width}}×{{height}})", - "selectCodec": "选择编解码器", - "selectFormat": "选择格式", - "selectQuality": "选择画质", - "selectResolution": "选择分辨率", - "video": "视频" - }, - "videoContainer": { - "mp4": "兼容性广,适合分享", - "mov": "Apple 生态系统,最适合 Final Cut 与 QuickTime", - "webm": "面向网页播放的开放式现代容器", - "mkv": "灵活容器,字幕支持出色" - } - }, - "media": { - "card": { - "aiCaptionsCount": "AI 字幕数", - "analyzeWithAI": "用 AI 分析", - "analyzingWithAI": "正在用 AI 分析", - "chooseMkvOrWebm": "选择 MKV 或 WebM", - "deleteProxy": "删除代理", - "deleteTranscript": "删除转录", - "extractEmbeddedSubtitles": "提取内嵌字幕", - "generateProxy": "生成代理", - "generateTranscript": "生成转录", - "importing": "正在导入", - "menuAi": "AI", - "menuCaptions": "嵌入字幕", - "menuFile": "文件", - "menuProxy": "代理", - "menuTranscript": "字幕", - "playAudio": "播放音频", - "refreshTranscript": "刷新转录", - "relinkFile": "重新链接文件...", - "stopAudio": "停止音频", - "subtitlesCachedAll": "字幕已全部缓存", - "subtitlesCachedPartial": "字幕已部分缓存", - "subtitlesCannotRead": "FreeCut 当前无法读取 \"{{name}}\"。请关闭正在使用它的应用后重试。", - "subtitlesExtractFailed": "字幕提取失败", - "subtitlesFileMissing": "FreeCut 找不到 \"{{name}}\"。", - "subtitlesNeedPermission": "提取字幕前,FreeCut 需要读取 \"{{name}}\" 的权限。", - "subtitlesScanFailed": "字幕扫描失败", - "transcribeFailed": "转录失败", - "transcribing": "正在转录", - "transcriptDeleteFailed": "转录删除失败", - "transcriptDeleteFailedFor": "无法删除 \"{{name}}\" 的字幕", - "transcriptDeletedFor": "已删除 \"{{name}}\" 的字幕", - "transcriptProgressAria": "转录进度", - "transcriptReadyFor": "\"{{name}}\" 的字幕已准备好", - "transcriptionFailedFor": "\"{{name}}\" 的字幕生成失败", - "transcriptsDeleted": "转录已删除", - "transcriptsReady": "转录已准备就绪" - }, - "compositions": { - "deleteBody": "要删除复合剪辑“{{name}}”吗?此操作无法撤消。", - "deleteInstancesDetail": "使用此复合剪辑的 {{count}} 个时间线实例也将被移除。", - "deleteInstancesTitle": "已链接的实例将被移除", - "deleteTitle": "删除复合剪辑", - "enter": "进入", - "itemCount": "{{count}} 个项目", - "rename": "重命名", - "sectionTitle": "复合剪辑" - }, - "deleteDialog": { - "bodyMultiple": "这会从此项目中永久移除 {{count}} 个媒体项。此操作无法撤消。", - "bodySingle": "这会从此项目中永久移除“{{name}}”。此操作无法撤消。", - "confirmWithClips": "连同片段删除", - "timelineClipsDetail": "使用此媒体的 {{count}} 个时间线片段也会被移除。", - "timelineClipsDetail_one": "使用此媒体的 {{count}} 个时间线片段也会被移除。", - "timelineClipsDetail_other": "使用此媒体的 {{count}} 个时间线片段也会被移除。", - "timelineClipsRemoved": "时间线片段也会被移除", - "titleMultiple": "删除 {{count}} 个媒体项?", - "titleSingle": "删除媒体项?" - }, - "embeddedSubtitles": { - "badgeAutoPick": "自动", - "badgeDefault": "默认", - "badgeForced": "强制", - "cuesCount": "{{count}} 条", - "desc": "选择要插入到时间轴的字幕轨道。", - "descForFile": "\"{{name}}\" 中可用的字幕。", - "empty": "在此文件中未找到字幕轨道。", - "insert": "插入", - "insertWithCues": "插入({{count}} 条)", - "loadedFromCache": "已从缓存加载字幕。", - "scanning": "正在扫描…", - "title": "嵌入字幕", - "trackInfo": "轨道 {{n}} · {{lang}} · {{codec}}" - }, - "grid": { - "emptyHint": "将文件拖到此处,或点击导入添加媒体。", - "emptyTitle": "暂无媒体", - "loadingSubtitle": "正在准备媒体…", - "loadingTitle": "正在加载媒体…" - }, - "info": { - "codec": "编解码器", - "dimensions": "尺寸", - "duration": "时长", - "fpsValue": "{{value}} fps", - "frameRate": "帧率", - "loadingTranscript": "正在加载字幕…", - "mediaInfo": "媒体信息", - "openInSourceMonitor": "在源监视器中打开", - "size": "大小", - "transcript": "字幕", - "transcriptWithCount": "字幕({{count}})", - "type": "类型" - }, - "library": { - "aiAnalysisProgress": "AI 分析", - "allTypes": "所有类型", - "analyzingMultiple": "正在分析 {{count}} 个片段…", - "analyzingSingle": "正在分析…", - "andJoiner": "和", - "assetsCount": "{{count}} 个素材", - "assetsCount_one": "{{count}} 个素材", - "assetsCount_other": "{{count}} 个素材", - "back": "返回", - "cancelAll": "全部取消", - "cancelling": "正在取消…", - "clearSelection": "清除选择", - "compoundClipsCount": "{{count}} 个复合片段", - "compoundClipsCount_one": "{{count}} 个复合片段", - "compoundClipsCount_other": "{{count}} 个复合片段", - "copyToClipboard": "复制到剪贴板", - "deleteAssetsBody": "这会从此项目中永久移除 {{summary}}。此操作无法撤消。", - "deleteAssetsTitle": "删除所选素材?", - "deleteSelectedAssets": "删除所选项", - "deleteSummary": "删除 {{summary}}", - "deleteWithClips": "连同片段删除", - "dismiss": "关闭", - "dragDropUnsupported": "此处不支持拖放操作。", - "dropFilesHere": "将文件拖到此处", - "filesRejected": "部分文件无法添加。", - "generateProxiesForSelected": "为 {{count}} 个所选项生成代理", - "generatingProxies": "正在生成代理…", - "generatingTranscripts": "正在生成字幕…", - "gridItemSize": "缩略图大小", - "groupAudio": "音频分组", - "groupGifs": "GIF 分组", - "groupImages": "图像分组", - "groupVideos": "视频分组", - "import": "导入", - "importFromUrlDescription": "从直接 URL 导入媒体文件。", - "importFromUrlHint": "粘贴指向视频、音频、图片或 GIF 文件的直接链接。", - "importFromUrlTitle": "从 URL 导入", - "importMediaFiles": "导入媒体文件", - "importMediaFromUrl": "从 URL 导入媒体", - "libraryView": "媒体库视图", - "linkedInstancesDetail": "使用这些素材的 {{count}} 个时间线实例也会被移除。", - "linkedInstancesDetail_one": "使用这些素材的 {{count}} 个时间线实例也会被移除。", - "linkedInstancesDetail_other": "使用这些素材的 {{count}} 个时间线实例也会被移除。", - "linkedInstancesTitle": "关联的时间线实例也会被移除", - "mediaItemsCount": "{{count}} 个媒体项", - "mediaItemsCount_one": "{{count}} 个媒体项", - "mediaItemsCount_other": "{{count}} 个媒体项", - "mediaTab": "媒体", - "missingCount": "{{count}} 个缺失", - "proxyCount": "{{count}} 个代理", - "proxyGenerationProgress": "代理生成进度", - "scenesTab": "场景", - "searchScenes": "搜索场景", - "selected": "已选", - "selectedAssetsCount": "{{count}} 个所选素材", - "selectedAssetsCount_one": "{{count}} 个所选素材", - "selectedAssetsCount_other": "{{count}} 个所选素材", - "showMediaLibrary": "显示媒体库", - "sortDate": "按日期排序", - "sortName": "按名称排序", - "sortSize": "按大小排序", - "transcriptGenerationProgress": "字幕生成进度", - "url": "URL", - "viewMissingMedia": "查看缺失媒体", - "allShort": "全部", - "typeShort": { - "video": "视频", - "audio": "音频", - "image": "图像" - }, - "sortShort": { - "name": "名称", - "date": "日期", - "size": "大小" - }, - "missingCount_one": "{{count}} 个缺失", - "missingCount_other": "{{count}} 个缺失", - "selectedCount": "{{count}} 个已选", - "selectedCount_one": "{{count}} 个已选", - "selectedCount_other": "{{count}} 个已选", - "generateProxiesForSelected_one": "为 {{count}} 个所选项生成代理", - "generateProxiesForSelected_other": "为 {{count}} 个所选项生成代理", - "proxyCount_one": "{{count}} 个代理", - "proxyCount_other": "{{count}} 个代理" - }, - "missingMedia": { - "title": "缺失媒体", - "description": "FreeCut 无法访问 {{count}} 个媒体文件。请定位文件,或继续离线工作。", - "description_one": "FreeCut 无法访问 {{count}} 个媒体文件。请定位文件,或继续离线工作。", - "description_other": "FreeCut 无法访问 {{count}} 个媒体文件。请定位文件,或继续离线工作。", - "needPermission": "{{count}} 个需要权限", - "needPermission_one": "{{count}} 个需要权限", - "needPermission_other": "{{count}} 个需要权限", - "notFound": "{{count}} 个未找到", - "notFound_one": "{{count}} 个未找到", - "notFound_other": "{{count}} 个未找到", - "browseAnotherFolder": "浏览其他文件夹", - "fileMovedOrDeleted": "文件已移动或删除", - "grantAccess": "授予访问权限", - "locate": "定位", - "locateFolder": "定位文件夹", - "permissionExpired": "权限已过期", - "scanProjectFolder": "扫描 {{name}}", - "workOffline": "离线工作" - }, - "orphanedClips": { - "autoMatch": "自动匹配", - "description": "{{count}} 个片段引用的媒体已找不到。请替换或删除以继续。", - "keepAsBroken": "保留为损坏", - "removeAll": "全部删除", - "select": "选择", - "selectReplacement": "为 \"{{name}}\" 选择替代文件", - "title": "缺失的媒体" - }, - "picker": { - "descAll": "从项目媒体库中选择文件。", - "noFiles": "此项目中暂无媒体。", - "noSearchResults": "未找到结果。", - "title": "选择媒体" - }, - "searchMedia": "搜索媒体", - "subtitleScan": { - "cached": "已缓存", - "cancelBatch": "全部取消", - "descComplete": "字幕扫描已完成。", - "descScanning": "正在扫描媒体中的字幕…", - "failed": "失败", - "titleComplete": "字幕扫描完成", - "titleScanning": "正在扫描字幕…" - }, - "transcribe": { - "autoDetect": "自动检测", - "generateTitle": "生成字幕", - "language": "语言", - "model": "模型", - "noLanguages": "暂无可用语言。", - "progressAria": "字幕进度", - "quantization": "量化", - "refreshTitle": "刷新字幕", - "searchLanguages": "搜索语言", - "start": "开始", - "stop": "停止" - }, - "type": { - "audio": "音频", - "image": "图像", - "video": "视频" - }, - "unsupportedCodec": { - "bodyMultiple": "{{count}} 个文件使用了浏览器无法解码的编解码器,可能无法正常播放。", - "bodySingle": "此文件使用了浏览器无法解码的编解码器,可能无法正常播放。", - "cancelImport": "取消导入", - "importAnyway": "仍然导入", - "note": "为获得更好的兼容性,请转换为 MP4 (H.264/AAC) 或 WebM。", - "title": "不支持的编解码器" - } - }, - "preview": { - "align": { - "disableCanvasSnap": "禁用画布吸附", - "enableCanvasSnap": "启用画布吸附", - "toggleCanvasSnap": "切换画布吸附" - }, - "controls": { - "captureFailed": "无法捕获当前帧。", - "disableProxyPlayback": "禁用代理播放", - "enableProxyPlayback": "启用代理播放", - "frameDownloadedNoProject": "已下载帧 — 打开项目即可保存到媒体库。", - "frameDownloadedNotSaved": "帧已下载,但无法保存到媒体库:{{message}}", - "frameSaved": "已将 \"{{name}}\" 保存到媒体库。", - "goToEnd": "跳到结尾", - "goToEndTooltip": "跳到结尾", - "goToStart": "跳到开始", - "goToStartTooltip": "跳到开始", - "nextFrame": "下一帧", - "nextFrameTooltip": "下一帧", - "pauseTooltip": "暂停", - "playTooltip": "播放", - "prevFrame": "上一帧", - "prevFrameTooltip": "上一帧", - "proxyPlaybackOff": "代理播放已关闭", - "proxyPlaybackOn": "代理播放已开启", - "saveFrame": "保存帧", - "saveFrameFailed": "无法保存帧。", - "saveFrameTooltip": "保存帧", - "savingFrame": "正在保存帧…", - "savingFrameTooltip": "正在保存帧…" - }, - "monitor": { - "mute": "静音", - "muteShort": "静音", - "muted": "已静音", - "percent": "{{value}}%", - "previewOnlyNote": "仅影响本地播放 — 导出时使用项目混音。", - "thisDeviceOnly": "仅此设备", - "title": "预览音频", - "unmute": "取消静音", - "volume": "音量" - }, - "player": { - "exitFullscreen": "退出全屏", - "fullscreen": "全屏", - "mute": "静音", - "pause": "暂停", - "play": "播放", - "seek": "拖动", - "unmute": "取消静音", - "volume": "音量" - }, - "stage": { - "loadingMedia": "正在加载媒体…", - "videoPreview": "视频预览" - }, - "zoom": { - "ariaLabel": "缩放级别:{{label}}", - "auto": "自动", - "tooltip": "缩放:{{label}}" - } - }, - "timeline": { - "addAudioTrackHint": "添加音频轨道", - "addVideoTrackHint": "添加视频轨道", - "bento": { - "apply": "应用", - "description": "将所选的 {{count}} 个片段排列为网格。", - "gap": "间隙", - "noItemsToArrange": "没有可排列的项目", - "padding": "内边距", - "presetNamePlaceholder": "预设名称", - "saveAsPreset": "保存为预设", - "title": "便当布局" - }, - "captions": { - "addedFromTranscript": "已从转录添加", - "addedWithModel": "已使用模型添加", - "failedGenerateSegment": "生成片段失败", - "failedUpdateSegment": "更新片段失败", - "refreshedWithModel": "已使用模型刷新", - "removedFromSegment": "已从片段移除", - "updatedFromTranscript": "已从转录更新", - "updatedWithModel": "已使用模型更新" - }, - "clipIndicators": { - "hasKeyframes": "有关键帧", - "mask": "遮罩", - "mediaMissing": "媒体缺失", - "preparingReversed": "正在准备反向播放", - "reversePrepFailedShort": "反向准备失败", - "reversedPlayback": "反向播放", - "reversedPrepFailed": "反向播放准备失败", - "reversedPrepared": "反向播放已准备", - "speed": "速度: {{speed}}x" - }, - "contextMenu": { - "bentoLayout": " Bento 布局", - "captions": "字幕", - "clearAll": "全部清除", - "clearKeyframes": "清除关键帧", - "consolidateCaptionsToSegment": "将字幕合并到片段", - "createCompoundClip": "创建复合剪辑", - "detectScenesAi": "AI ({{model}})", - "detectScenesAndSplit": "检测场景并分割", - "detectScenesFast": "快速(直方图)", - "detectingFillers": "正在检测填充词", - "detectingScenes": "正在检测场景", - "detectingSilence": "正在检测静音", - "dissolveCompoundClip": "解散复合剪辑", - "extractEmbeddedSubtitles": "提取内嵌字幕", - "generateAudioFromText": "从文本生成音频", - "generateCaptions": "生成字幕", - "insertExistingCaptions": "插入现有字幕", - "insertFreezeFrame": "插入冻结帧", - "joinSelected": "合并所选", - "joinWithNext": "与下一个合并", - "joinWithPrevious": "与上一个合并", - "linkClips": "链接剪辑", - "openCompoundClip": "打开复合剪辑", - "regenerateCaptions": "重新生成字幕", - "removeFillerWords": "移除填充词", - "removeSilence": "移除静音", - "reverse": "反向", - "rippleDelete": "波纹删除", - "unlinkClips": "取消链接剪辑", - "unreverse": "取消反向", - "updatingCaptions": "正在更新字幕" - }, - "fadeHandles": { - "adjustAudioFadeIn": "调整音频淡入", - "adjustAudioFadeInCurve": "调整音频淡入曲线", - "adjustAudioFadeOut": "调整音频淡出", - "adjustAudioFadeOutCurve": "调整音频淡出曲线", - "adjustVideoFadeIn": "调整视频淡入", - "adjustVideoFadeOut": "调整视频淡出" - }, - "fillerRemoval": { - "aboutWillBeRemoved": "将删除约 {{duration}}。", - "add": "添加", - "addPhrase": "添加短语", - "addWord": "添加单词", - "cutPadding": "剪切留白", - "filler": "填充词", - "fillerRange": "口头语", - "found": "已找到", - "includeRange": "包含范围", - "maxPhrase": "最大短语", - "maxWord": "最大单词", - "noEntriesFound": "未找到条目", - "noRemovableDetectedShort": "未发现口头语。", - "none": "无", - "phrases": "短语", - "playThisRange": "播放此范围", - "rangesSelected": "已选择范围", - "redo": "重做", - "redoEditTitle": "重做最后一次编辑", - "remove": "移除", - "removed": "已移除", - "scoreAudio": "为音频评分", - "scoring": "正在评分", - "title": "删除口头语", - "toastAudioScored": "音频置信度分析完成。", - "toastNoRemovable": "没有口头语符合当前设置。", - "toastNoneInClips": "所选片段中没有可删除的口头语。", - "toastPreviewFailed": "无法预览删除结果。", - "toastRemoveFailed": "无法删除口头语。", - "toastRemoved": "已删除 {{count}} 段口头语。", - "toastScoreFailed": "无法分析音频置信度。", - "undo": "撤销", - "undoEditTitle": "撤销最后一次编辑", - "updatePreview": "更新预览", - "updating": "正在更新", - "words": "单词" - }, - "header": { - "addMarker": "添加标记", - "addMarkerTooltip": "添加标记", - "clearAllMarkers": "清除所有标记", - "clearAllMarkersTooltip": "清除所有标记", - "clearInOutPoints": "清除入点/出点", - "clearInOutPointsTooltip": "清除入点/出点", - "controls": "控制", - "disableLinkedSelection": "禁用链接选择", - "disableSnapping": "禁用吸附", - "enableLinkedSelection": "启用链接选择", - "enableSnapping": "启用吸附", - "hideColorScopes": "隐藏颜色示波器", - "hideColorScopesTooltip": "隐藏颜色示波器", - "linkedSelectionOff": "链接选择关闭", - "linkedSelectionOn": "链接选择开启", - "linkedSelectionTooltip": "联动选择:{{state}} ({{shortcut}})", - "rateStretchTool": "速率拉伸工具", - "rateStretchToolTooltip": "速率拉伸工具", - "razorTool": "剃刀工具", - "razorToolTooltip": "剃刀工具", - "redo": "重做", - "redoTooltip": "重做", - "redoWithLabel": "重做 {{label}}", - "redoWithLabelTooltip": "重做 {{label}}", - "removeSelectedMarker": "移除所选标记", - "removeSelectedMarkerTooltip": "移除所选标记", - "selectTool": "选择工具", - "selectToolTooltip": "选择工具", - "setInPoint": "设置入点", - "setInPointTooltip": "设置入点", - "setOutPoint": "设置出点", - "setOutPointTooltip": "设置出点", - "showColorScopes": "显示颜色示波器", - "showColorScopesTooltip": "显示颜色示波器", - "slideTool": "滑移工具", - "slipSlideTools": "滑动/滑移工具", - "slipSlideToolsTooltip": "滑动/滑移工具", - "slipTool": "滑动工具", - "snapDisabled": "吸附已禁用", - "snapEnabled": "吸附已启用", - "title": "时间轴", - "trimEditTool": "修剪编辑工具", - "trimEditToolTooltip": "修剪编辑工具", - "undo": "撤销", - "undoTooltip": "撤销", - "undoWithLabel": "撤销 {{label}}", - "undoWithLabelTooltip": "撤销 {{label}}", - "zoomIn": "放大", - "zoomInTooltip": "放大", - "zoomOut": "缩小", - "zoomOutTooltip": "缩小", - "zoomSlider": "缩放滑块", - "zoomToFit": "适应窗口", - "zoomToFitTooltip": "适应窗口" - }, - "itemActions": { - "appliedEffectToClips": "已将效果应用到剪辑", - "selectAvClipFirst": "请先选择音视频剪辑" - }, - "joinIndicators": { - "canJoinNext": "可与下一个合并", - "canJoinPrevious": "可与上一个合并" - }, - "keyframeEditor": { - "bezier": "贝塞尔", - "custom": "自定义", - "dragHandlesHint": "拖动控制点以调整曲线形状。", - "graph": "图表", - "mixedCurves": "混合曲线", - "mixedSpring": "混合弹簧", - "movedKeyframes": "已移动关键帧", - "noKeyframesPasted": "没有可粘贴的关键帧", - "pastedKeyframes": "已粘贴关键帧", - "preset": "预设", - "reasonBlocked": "{{count}} 个被其他关键帧阻挡", - "reasonUnsupported": "{{count}} 个不被目标属性支持", - "selectItem": "选择项目", - "sheet": "表格", - "skippedDescription": "已跳过 {{count}} 个:{{reasons}}", - "spring": "弹簧", - "springHint": "弹簧物理 — 自然且富有弹性。", - "title": "关键帧", - "unableToPasteCut": "无法粘贴剪切", - "unableToPasteCutDescription": "剪切的关键帧无法粘贴到此处。{{reasons}}" - }, - "noTracksToRemove": "没有可移除的轨道", - "region": "区域", - "removeActiveTrack": "移除活动轨道", - "removeSelectedTracks": "移除所选轨道", - "reverseConform": { - "cancellingDescription": "正在取消...", - "couldNotPrepare": "无法准备", - "progressDescription": "处理中...", - "titleCancelling": "正在取消", - "titleFailed": "失败", - "titlePreparing": "正在准备" - }, - "sceneDetection": { - "detectingScenes": "正在检测场景", - "failed": "失败", - "noScenesDetected": "未检测到场景", - "noScenesWithinBounds": "范围内没有场景", - "noValidSplitPoints": "没有有效分割点", - "requiresWebGpu": "需要 WebGPU", - "splitAtScenes": "按场景分割" - }, - "selectTrackToRemove": "选择要移除的轨道", - "silenceRemoval": { - "aboutWillBeRemoved": "将删除约 {{duration}} 的静音。", - "keepPadding": "保留留白", - "minimumSilence": "最小静音", - "noRemovableDetectedShort": "未发现静音。", - "rangesSelected": "已选择范围", - "remove": "移除", - "threshold": "阈值", - "title": "删除静音", - "toastNoRemovable": "没有静音符合当前设置。", - "toastNoneInClips": "所选片段中没有可删除的静音。", - "toastPreviewFailed": "无法预览静音删除结果。", - "toastRemoved": "已删除 {{count}} 段静音。", - "toastSettingsChanged": "设置已更改 — 应用前请预览。", - "updatePreview": "更新预览", - "updating": "正在更新" - }, - "track": { - "locked": "已锁定" - }, - "trackHeader": { - "addAudioTrack": "添加音频轨道", - "addVideoTrack": "添加视频轨道", - "clipCount": "剪辑数", - "closeAllGaps": "关闭所有间隙", - "deleteEmptyTracks": "删除空轨道", - "deleteTrack": "删除轨道", - "disableSyncLock": "禁用同步锁定", - "disableTrack": "禁用轨道", - "enableSyncLock": "启用同步锁定", - "enableTrack": "启用轨道", - "lockTrack": "锁定轨道", - "soloTrack": "独奏轨道", - "unlockTrack": "解锁轨道", - "unsoloTrack": "取消独奏" - }, - "trackRow": { - "resizeSections": "调整视频和音频轨道区域大小", - "resizeTrackHeight": "调整轨道高度" - }, - "tracks": "轨道", - "volumeControl": { - "adjustClipVolume": "调整剪辑音量" - } - } - }, - "tr": { - "editor": { - "videoSection": { - "cropBottom": "Altı kırp", - "cropLeft": "Solu kırp", - "cropRight": "Sağı kırp", - "cropTop": "Üstü kırp", - "cropping": "Kırpma", - "fadeIn": "Giriş yumuşatması", - "fadeOut": "Çıkış yumuşatması", - "playback": "Oynatma", - "resetCropBottom": "Alt kırpmayı sıfırla", - "resetCropLeft": "Sol kırpmayı sıfırla", - "resetCropRight": "Sağ kırpmayı sıfırla", - "resetCropTop": "Üst kırpmayı sıfırla", - "resetSoftness": "Yumuşaklığı sıfırla", - "resetSpeed": "Hızı sıfırla", - "resetToZero": "Sıfıra ayarla", - "softness": "Yumuşaklık", - "speed": "Hız" - } - }, - "effects": { - "curves": { - "channel": "Kanal", - "dragHint": "Tonları ayarlamak için {{channel}} eğrisindeki noktaları sürükleyin.", - "resetChannel": "Kanalı sıfırla" - }, - "panel": { - "disableEffect": "Efekti devre dışı bırak", - "enableEffect": "Efekti etkinleştir", - "off": "Kapalı", - "on": "Açık", - "removeEffect": "Efekti kaldır", - "resetToDefaults": "Varsayılanlara sıfırla" - }, - "section": { - "addEffect": "Efekt ekle", - "disableAll": "Tümünü devre dışı bırak", - "emptyState": "Henüz efekt yok. Başlamak için bir tane ekleyin.", - "enableAll": "Tümünü etkinleştir", - "noEffectsFound": "Efekt bulunamadı", - "presets": "Ön ayarlar", - "searchEffects": "Efektlerde ara", - "title": "Efektler" - }, - "wheels": { - "resetWheel": "Tekerleği sıfırla" - } - }, - "export": { - "audioContainer": { - "aac": "AAC", - "mp3": "MP3", - "wav": "WAV" - }, - "cancelled": { - "message": "Dışa aktarma iptal edildi. Dosya kaydedilmedi." - }, - "complete": { - "audioSuccess": "Sesiniz indirilmeye hazır.", - "download": "İndir", - "fileSizeLabel": "Boyut", - "timeTakenLabel": "Süre", - "videoSuccess": "Videonuz indirilmeye hazır." - }, - "dialog": { - "descCancelled": "Dışa aktarma tamamlanmadan iptal edildi.", - "descComplete": "Dosyanız hazır.", - "descError": "Dışa aktarma sırasında bir sorun oluştu.", - "descProgress": "Videonuz işleniyor.", - "descSettings": "Dışa aktarma seçeneklerini yapılandırın.", - "titleCancelled": "Dışa aktarma iptal edildi", - "titleComplete": "Dışa aktarma tamamlandı", - "titleError": "Dışa aktarma başarısız", - "titleProgress": "Dışa aktarılıyor", - "titleSettings": "Dışa aktar" - }, - "errors": { - "verifyCodec": "Mevcut ayarlar için codec desteği doğrulanamadı." - }, - "progress": { - "cancelExport": "Dışa aktarmayı iptal et", - "elapsedLabel": "Geçen süre", - "encoding": "Kodlanıyor…", - "finalizing": "Tamamlanıyor…", - "framesLabel": "Kareler", - "keepTabOpen": "Lütfen bu sekmeyi açık tutun.", - "preparing": "Hazırlanıyor…", - "rendering": "İşleniyor…" - }, - "settings": { - "audio": "Ses", - "audioOnlyNote": "Yalnızca ses dışa aktarılacak — video dahil edilmeyecek.", - "audioQualityHigh": "Yüksek", - "audioQualityLow": "Düşük", - "audioQualityMedium": "Orta", - "audioQualityUltra": "Ultra", - "cannotEncode": "Seçilen kalitede {{width}}×{{height}} için desteklenen bir kodlayıcı yok.", - "codec": "Codec", - "codecSupportUnverified": "Codec desteği doğrulanamadı. Dışa aktarma çalışabilir ancak uyumluluk garanti edilmez.", - "duration": "Süre", - "embedSubtitles": "Altyazıları göm", - "embedSubtitlesDescription": "Transkripsiyon altyazılarını dışa aktarılan dosyaya bir altyazı izi olarak ekle.", - "embedSubtitlesMp4Note": "MP4 altyazıları ayrı bir iz olarak saklar. Bazı oynatıcılar varsayılan olarak göstermeyebilir.", - "embedSubtitlesUnsupported": "{{container}} gömülü altyazıları desteklemiyor. MP4, MKV veya WebM seçin.", - "exportAudio": "Sesi dışa aktar", - "exportRange": "Dışa aktarma aralığı", - "exportType": "Dışa aktarma türü", - "exportVideo": "Videoyu dışa aktar", - "format": "Biçim", - "in": "Giriş", - "inOutRangeHint": "Yalnızca seçili giriş/çıkış aralığı dışa aktarılacak.", - "noTranscriptSegments": "Gömülecek transkripsiyon altyazısı bulunamadı.", - "out": "Çıkış", - "quality": "Kalite", - "qualityHigh": "Yüksek", - "qualityLow": "Düşük", - "qualityMedium": "Orta", - "qualityUltra": "Ultra", - "quicktimeMov": "QuickTime MOV", - "renderWholeProject": "Projenin tamamını işle", - "resolution": "Çözünürlük", - "resolutionSameAsProject": "Proje ile aynı ({{width}}×{{height}})", - "resolutionScaled": "{{p}}p ({{width}}×{{height}})", - "selectCodec": "Codec seçin", - "selectFormat": "Biçim seçin", - "selectQuality": "Kalite seçin", - "selectResolution": "Çözünürlük seçin", - "video": "Video" - }, - "videoContainer": { - "mp4": "Geniş uyumluluk, paylaşmak için ideal", - "mov": "Apple ekosistemi, Final Cut ve QuickTime için ideal", - "webm": "Web oynatma için açık ve modern bir kapsayıcı", - "mkv": "Esnek bir kapsayıcı, güçlü altyazı desteği" - } - }, - "media": { - "card": { - "aiCaptionsCount": "YZ altyazı sayısı", - "analyzeWithAI": "YZ ile analiz et", - "analyzingWithAI": "YZ ile analiz ediliyor", - "chooseMkvOrWebm": "MKV veya WebM seç", - "deleteProxy": "Proxy'yi sil", - "deleteTranscript": "Transkripti sil", - "extractEmbeddedSubtitles": "Gömülü altyazıları çıkar", - "generateProxy": "Proxy oluştur", - "generateTranscript": "Transkript oluştur", - "importing": "İçe aktarılıyor", - "menuAi": "YZ", - "menuCaptions": "Gömülü altyazılar", - "menuFile": "Dosya", - "menuProxy": "Proxy", - "menuTranscript": "Transkripsiyon", - "playAudio": "Sesi oynat", - "refreshTranscript": "Transkripti yenile", - "relinkFile": "Dosyayı yeniden bağla...", - "stopAudio": "Sesi durdur", - "subtitlesCachedAll": "Tüm altyazılar önbellekte", - "subtitlesCachedPartial": "Altyazıların bir kısmı önbellekte", - "subtitlesCannotRead": "FreeCut şu anda \"{{name}}\" dosyasını okuyamadı. Onu kullanan uygulamaları kapatıp tekrar deneyin.", - "subtitlesExtractFailed": "Altyazılar çıkarılamadı", - "subtitlesFileMissing": "FreeCut artık \"{{name}}\" dosyasını bulamıyor.", - "subtitlesNeedPermission": "Altyazıları çıkarmadan önce FreeCut'ın \"{{name}}\" dosyasını okuma iznine ihtiyacı var.", - "subtitlesScanFailed": "Altyazı taraması başarısız", - "transcribeFailed": "Transkripsiyon başarısız", - "transcribing": "Transkripsiyon yapılıyor", - "transcriptDeleteFailed": "Transkript silinemedi", - "transcriptDeleteFailedFor": "\"{{name}}\" için transkript silinemedi", - "transcriptDeletedFor": "\"{{name}}\" için transkript silindi", - "transcriptProgressAria": "Transkript ilerlemesi", - "transcriptReadyFor": "\"{{name}}\" için transkript hazır", - "transcriptionFailedFor": "\"{{name}}\" için transkripsiyon başarısız", - "transcriptsDeleted": "Transkriptler silindi", - "transcriptsReady": "Transkriptler hazır" - }, - "compositions": { - "deleteBody": "Bu bileşik klip silinsin mi?", - "deleteInstancesDetail": "Bu bileşik klibi kullanan {{count}} zaman çizelgesi örneği de kaldırılacak.", - "deleteInstancesTitle": "Bağlı örnekler kaldırılacak", - "deleteTitle": "Bileşik klibi sil", - "enter": "Gir", - "itemCount": "{{count}} öğe", - "rename": "Yeniden adlandır", - "sectionTitle": "Bileşik Klipler" - }, - "deleteDialog": { - "bodyMultiple": "Bu projeden {{count}} medya öğesi kalıcı olarak kaldırılacak. Bu işlem geri alınamaz.", - "bodySingle": "\"{{name}}\" bu projeden kalıcı olarak kaldırılacak. Bu işlem geri alınamaz.", - "confirmWithClips": "Kliplerle Birlikte Sil", - "timelineClipsDetail": "Bu medyayı kullanan {{count}} zaman çizelgesi klibi de kaldırılacak.", - "timelineClipsDetail_one": "Bu medyayı kullanan {{count}} zaman çizelgesi klibi de kaldırılacak.", - "timelineClipsDetail_other": "Bu medyayı kullanan {{count}} zaman çizelgesi klibi de kaldırılacak.", - "timelineClipsRemoved": "Zaman çizelgesi klipleri kaldırılacak", - "titleMultiple": "{{count}} medya öğesi silinsin mi?", - "titleSingle": "Medya öğesi silinsin mi?" - }, - "embeddedSubtitles": { - "badgeAutoPick": "Otomatik", - "badgeDefault": "Varsayılan", - "badgeForced": "Zorunlu", - "cuesCount": "İpucu sayısı", - "desc": "Gömülü altyazı parçalarını seçin.", - "descForFile": "\"{{name}}\" içindeki altyazıları seçin.", - "empty": "Altyazı bulunamadı", - "insert": "Ekle", - "insertWithCues": "{{count}} ipucuyla ekle", - "loadedFromCache": "Önbellekten yüklendi", - "scanning": "Taranıyor", - "title": "Gömülü altyazılar", - "trackInfo": "Parça bilgisi" - }, - "grid": { - "emptyHint": "Dosyaları buraya bırakın veya İçe Aktar'a tıklayın.", - "emptyTitle": "Henüz medya yok", - "loadingSubtitle": "Medya kitaplığı hazırlanıyor.", - "loadingTitle": "Medya yükleniyor…" - }, - "info": { - "codec": "Kodek", - "dimensions": "Boyutlar", - "duration": "Süre", - "fpsValue": "FPS değeri", - "frameRate": "Kare hızı", - "loadingTranscript": "Transkript yükleniyor", - "mediaInfo": "Medya bilgisi", - "openInSourceMonitor": "Kaynak monitöründe aç", - "size": "Boyut", - "transcript": "Transkript", - "transcriptWithCount": "Transkript ({{count}})", - "type": "Tür" - }, - "library": { - "aiAnalysisProgress": "YZ analiz ilerlemesi", - "allTypes": "Tüm türler", - "analyzingMultiple": "Seçili öğeler analiz ediliyor", - "analyzingSingle": "Öğe analiz ediliyor", - "andJoiner": " ve ", - "assetsCount": "{{count}} varlık", - "assetsCount_one": "{{count}} varlık", - "assetsCount_other": "{{count}} varlık", - "back": "Geri", - "cancelAll": "Tümünü iptal et", - "cancelling": "İptal ediliyor", - "clearSelection": "Seçimi temizle", - "compoundClipsCount": "{{count}} bileşik klip", - "compoundClipsCount_one": "{{count}} bileşik klip", - "compoundClipsCount_other": "{{count}} bileşik klip", - "copyToClipboard": "Panoya kopyala", - "deleteAssetsBody": "{{summary}} bu projeden kalıcı olarak kaldırılacak. Bu işlem geri alınamaz.", - "deleteAssetsTitle": "Seçili varlıklar silinsin mi?", - "deleteSelectedAssets": "Seçili öğeleri sil", - "deleteSummary": "{{summary}} sil", - "deleteWithClips": "Kliplerle birlikte sil", - "dismiss": "Kapat", - "dragDropUnsupported": "Sürükle bırak desteklenmiyor", - "dropFilesHere": "Dosyaları buraya bırakın", - "filesRejected": "Dosyalar reddedildi", - "generateProxiesForSelected": "{{count}} seçili öğe için proxy oluştur", - "generatingProxies": "Proxy'ler oluşturuluyor", - "generatingTranscripts": "Transkriptler oluşturuluyor", - "gridItemSize": "Izgara öğe boyutu", - "groupAudio": "Ses", - "groupGifs": "GIF'ler", - "groupImages": "Görseller", - "groupVideos": "Videolar", - "import": "İçe aktar", - "importFromUrlDescription": "Doğrudan URL'den bir medya dosyası içe aktarın.", - "importFromUrlHint": "Video, ses, görüntü veya GIF dosyasına doğrudan bağlantı yapıştırın.", - "importFromUrlTitle": "URL'den içe aktar", - "importMediaFiles": "Medya dosyalarını içe aktar", - "importMediaFromUrl": "URL'den medya içe aktar", - "libraryView": "Kitaplık görünümü", - "linkedInstancesDetail": "Bu varlıkları kullanan {{count}} zaman çizelgesi örneği de kaldırılacak.", - "linkedInstancesDetail_one": "Bu varlıkları kullanan {{count}} zaman çizelgesi örneği de kaldırılacak.", - "linkedInstancesDetail_other": "Bu varlıkları kullanan {{count}} zaman çizelgesi örneği de kaldırılacak.", - "linkedInstancesTitle": "Bağlı zaman çizelgesi örnekleri kaldırılacak", - "mediaItemsCount": "{{count}} medya öğesi", - "mediaItemsCount_one": "{{count}} medya öğesi", - "mediaItemsCount_other": "{{count}} medya öğesi", - "mediaTab": "Medya", - "missingCount": "{{count}} eksik", - "proxyCount": "{{count}} proxy", - "proxyGenerationProgress": "Proxy oluşturma ilerlemesi", - "scenesTab": "Sahneler", - "searchScenes": "Sahnelerde ara", - "selected": "Seçili", - "selectedAssetsCount": "{{count}} seçili varlık", - "selectedAssetsCount_one": "{{count}} seçili varlık", - "selectedAssetsCount_other": "{{count}} seçili varlık", - "showMediaLibrary": "Medya kitaplığını göster", - "sortDate": "Tarihe göre sırala", - "sortName": "Ada göre sırala", - "sortSize": "Boyuta göre sırala", - "transcriptGenerationProgress": "Transkript oluşturma ilerlemesi", - "url": "URL", - "viewMissingMedia": "Eksik medyayı görüntüle", - "allShort": "TÜMÜ", - "typeShort": { - "video": "VİDEO", - "audio": "SES", - "image": "GÖRSEL" - }, - "sortShort": { - "name": "AD", - "date": "TARİH", - "size": "BOYUT" - }, - "missingCount_one": "{{count}} eksik", - "missingCount_other": "{{count}} eksik", - "selectedCount": "{{count}} seçili", - "selectedCount_one": "{{count}} seçili", - "selectedCount_other": "{{count}} seçili", - "generateProxiesForSelected_one": "{{count}} seçili öğe için proxy oluştur", - "generateProxiesForSelected_other": "{{count}} seçili öğe için proxy oluştur", - "proxyCount_one": "{{count}} proxy", - "proxyCount_other": "{{count}} proxy" - }, - "missingMedia": { - "title": "Eksik medya", - "description": "FreeCut {{count}} medya dosyasına erişemiyor. Dosyaları bulun veya çevrimdışı çalışmaya devam edin.", - "description_one": "FreeCut {{count}} medya dosyasına erişemiyor. Dosyayı bulun veya çevrimdışı çalışmaya devam edin.", - "description_other": "FreeCut {{count}} medya dosyasına erişemiyor. Dosyaları bulun veya çevrimdışı çalışmaya devam edin.", - "needPermission": "{{count}} izin gerektiriyor", - "needPermission_one": "{{count}} izin gerektiriyor", - "needPermission_other": "{{count}} izin gerektiriyor", - "notFound": "{{count}} bulunamadı", - "notFound_one": "{{count}} bulunamadı", - "notFound_other": "{{count}} bulunamadı", - "browseAnotherFolder": "Başka klasöre göz at", - "fileMovedOrDeleted": "Dosya taşınmış veya silinmiş", - "grantAccess": "Erişim ver", - "locate": "Bul", - "locateFolder": "Klasörü bul", - "permissionExpired": "İzin süresi doldu", - "scanProjectFolder": "{{name}} klasörünü tara", - "workOffline": "Çevrimdışı çalış" - }, - "orphanedClips": { - "autoMatch": "Otomatik eşleştir", - "description": "{{count}} klip artık bulunamayan medyaya başvuruyor. Devam etmek için değiştirin veya kaldırın.", - "keepAsBroken": "Bozuk olarak tut", - "removeAll": "Tümünü kaldır", - "select": "Seç", - "selectReplacement": "Yedek seç", - "title": "Eksik medya" - }, - "picker": { - "descAll": "Projeden medya seçin.", - "noFiles": "Bu projede henüz medya yok.", - "noSearchResults": "Arama sonucu yok", - "title": "Medya seçin" - }, - "searchMedia": "Medyada ara", - "subtitleScan": { - "cached": "Önbellekte", - "cancelBatch": "Tümünü iptal et", - "descComplete": "Altyazı taraması tamamlandı.", - "descScanning": "Altyazılar taranıyor.", - "failed": "Başarısız", - "titleComplete": "Altyazı taraması tamamlandı", - "titleScanning": "Altyazılar taranıyor…" - }, - "transcribe": { - "autoDetect": "Otomatik algıla", - "generateTitle": "Transkripsiyon oluştur", - "language": "Dil", - "model": "Model", - "noLanguages": "Dil yok", - "progressAria": "İlerleme", - "quantization": "Niceleme", - "refreshTitle": "Transkripsiyonu yenile", - "searchLanguages": "Dillerde ara", - "start": "Başlat", - "stop": "Durdur" - }, - "type": { - "audio": "Ses", - "image": "Görsel", - "video": "Video" - }, - "unsupportedCodec": { - "bodyMultiple": "Bazı dosyalar desteklenmeyen kodekler kullanıyor olabilir.", - "bodySingle": "Bu dosya desteklenmeyen bir kodek kullanıyor olabilir.", - "cancelImport": "İçe aktarmayı iptal et", - "importAnyway": "Yine de içe aktar", - "note": "Not", - "title": "Desteklenmeyen codec" - } - }, - "preview": { - "align": { - "disableCanvasSnap": "Tuval yakalamayı kapat", - "enableCanvasSnap": "Tuval yakalamayı aç", - "toggleCanvasSnap": "Tuval yakalamayı aç/kapat" - }, - "controls": { - "captureFailed": "Geçerli kare yakalanamadı.", - "disableProxyPlayback": "Proxy oynatmayı kapat", - "enableProxyPlayback": "Proxy oynatmayı aç", - "frameDownloadedNoProject": "Kare indirildi — kütüphaneye kaydetmek için bir proje açın.", - "frameDownloadedNotSaved": "Kare indirildi ancak kütüphaneye kaydedilemedi: {{message}}", - "frameSaved": "\"{{name}}\" kütüphaneye kaydedildi.", - "goToEnd": "Sona git", - "goToEndTooltip": "Sona git", - "goToStart": "Başa git", - "goToStartTooltip": "Başa git", - "nextFrame": "Sonraki kare", - "nextFrameTooltip": "Sonraki kare", - "pauseTooltip": "Duraklat", - "playTooltip": "Oynat", - "prevFrame": "Önceki kare", - "prevFrameTooltip": "Önceki kare", - "proxyPlaybackOff": "Proxy oynatma kapalı", - "proxyPlaybackOn": "Proxy oynatma açık", - "saveFrame": "Kareyi kaydet", - "saveFrameFailed": "Kare kaydedilemedi.", - "saveFrameTooltip": "Kareyi kaydet", - "savingFrame": "Kare kaydediliyor", - "savingFrameTooltip": "Kare kaydediliyor" - }, - "monitor": { - "mute": "Sessize al", - "muteShort": "Sessiz", - "muted": "Sessiz", - "percent": "Yüzde", - "previewOnlyNote": "Yalnızca yerel oynatmayı etkiler — dışa aktarmada proje miksi kullanılır.", - "thisDeviceOnly": "Yalnızca bu cihaz", - "title": "Önizleme sesi", - "unmute": "Sesi aç", - "volume": "Ses düzeyi" - }, - "player": { - "exitFullscreen": "Tam ekrandan çık", - "fullscreen": "Tam ekran", - "mute": "Sessize al", - "pause": "Duraklat", - "play": "Oynat", - "seek": "Ara", - "unmute": "Sesi aç", - "volume": "Ses düzeyi" - }, - "stage": { - "loadingMedia": "Medya yükleniyor", - "videoPreview": "Video önizlemesi" - }, - "zoom": { - "ariaLabel": "Önizleme yakınlaştırması", - "auto": "Otomatik", - "tooltip": "Yakınlaştırma: {{label}}" - } - }, - "timeline": { - "addAudioTrackHint": "Ses parçası ekle", - "addVideoTrackHint": "Video parçası ekle", - "bento": { - "apply": "Uygula", - "description": "{{count}} seçili klibi bir ızgaraya yerleştir.", - "gap": "Boşluk", - "noItemsToArrange": "Düzenlenecek öğe yok", - "padding": "Dolgu", - "presetNamePlaceholder": "Ön ayar adı", - "saveAsPreset": "Ön ayar olarak kaydet", - "title": "Bento düzeni" - }, - "captions": { - "addedFromTranscript": "Transkriptten eklendi", - "addedWithModel": "{{model}} ile eklendi", - "failedGenerateSegment": "Segment oluşturulamadı", - "failedUpdateSegment": "Segment güncellenemedi", - "refreshedWithModel": "{{model}} ile yenilendi", - "removedFromSegment": "Segmentten kaldırıldı", - "updatedFromTranscript": "Transkriptten güncellendi", - "updatedWithModel": "{{model}} ile güncellendi" - }, - "clipIndicators": { - "hasKeyframes": "Ana kareler var", - "mask": "Maske", - "mediaMissing": "Medya eksik", - "preparingReversed": "Ters oynatma hazırlanıyor", - "reversePrepFailedShort": "Ters hazırlık başarısız", - "reversedPlayback": "Ters oynatma", - "reversedPrepFailed": "Ters oynatma hazırlığı başarısız", - "reversedPrepared": "Ters oynatma hazır", - "speed": "Hız: {{speed}}x" - }, - "contextMenu": { - "bentoLayout": "Bento düzeni", - "captions": "Altyazılar", - "clearAll": "Tümünü temizle", - "clearKeyframes": "Ana kareleri temizle", - "consolidateCaptionsToSegment": "Altyazıları segmente birleştir", - "createCompoundClip": "Bileşik klip oluştur", - "detectScenesAi": "YZ ({{model}})", - "detectScenesAndSplit": "Sahneleri algıla ve böl", - "detectScenesFast": "Hızlı (Histogram)", - "detectingFillers": "Dolgu sözcükleri algılanıyor", - "detectingScenes": "Sahneler algılanıyor", - "detectingSilence": "Sessizlik algılanıyor", - "dissolveCompoundClip": "Bileşik klibi çöz", - "extractEmbeddedSubtitles": "Gömülü altyazıları çıkar", - "generateAudioFromText": "Metinden ses oluştur", - "generateCaptions": "Altyazı oluştur", - "insertExistingCaptions": "Mevcut altyazıları ekle", - "insertFreezeFrame": "Donmuş kare ekle", - "joinSelected": "Seçilileri birleştir", - "joinWithNext": "Sonrakiyle birleştir", - "joinWithPrevious": "Öncekiyle birleştir", - "linkClips": "Klipleri bağla", - "openCompoundClip": "Bileşik klibi aç", - "regenerateCaptions": "Altyazıları yeniden oluştur", - "removeFillerWords": "Dolgu sözcüklerini kaldır", - "removeSilence": "Sessizliği kaldır", - "reverse": "Ters çevir", - "rippleDelete": "Ripple sil", - "unlinkClips": "Kliplerin bağını kaldır", - "unreverse": "Tersi kaldır", - "updatingCaptions": "Altyazılar güncelleniyor" - }, - "fadeHandles": { - "adjustAudioFadeIn": "Ses giriş yumuşatmasını ayarla", - "adjustAudioFadeInCurve": "Ses giriş yumuşatma eğrisini ayarla", - "adjustAudioFadeOut": "Ses çıkış yumuşatmasını ayarla", - "adjustAudioFadeOutCurve": "Ses çıkış yumuşatma eğrisini ayarla", - "adjustVideoFadeIn": "Video giriş yumuşatmasını ayarla", - "adjustVideoFadeOut": "Video çıkış yumuşatmasını ayarla" - }, - "fillerRemoval": { - "aboutWillBeRemoved": "Yaklaşık {{duration}} kaldırılacak.", - "add": "Ekle", - "addPhrase": "İfade ekle", - "addWord": "Sözcük ekle", - "cutPadding": "Kesim payı", - "filler": "Dolgu", - "fillerRange": "dolgu kelimesi", - "found": "Bulundu", - "includeRange": "Aralığı dahil et", - "maxPhrase": "Maks. ifade", - "maxWord": "Maks. sözcük", - "noEntriesFound": "Girdi bulunamadı", - "noRemovableDetectedShort": "Kaldırılacak dolgu kelimesi bulunamadı.", - "none": "Yok", - "phrases": "İfadeler", - "playThisRange": "Bu aralığı oynat", - "rangesSelected": "Aralık seçildi", - "redo": "Yinele", - "redoEditTitle": "Son düzenlemeyi yinele", - "remove": "Kaldır", - "removed": "Kaldırıldı", - "scoreAudio": "Sesi puanla", - "scoring": "Puanlanıyor", - "title": "Dolgu kelimelerini kaldır", - "toastAudioScored": "Ses güven puanlaması tamamlandı.", - "toastNoRemovable": "Mevcut ayarlara uyan dolgu kelimesi yok.", - "toastNoneInClips": "Seçili kliplerde kaldırılacak dolgu kelimesi yok.", - "toastPreviewFailed": "Önizleme oluşturulamadı.", - "toastRemoveFailed": "Dolgu kelimeleri kaldırılamadı.", - "toastRemoved": "{{count}} dolgu aralığı kaldırıldı.", - "toastScoreFailed": "Ses güven puanı analiz edilemedi.", - "undo": "Geri al", - "undoEditTitle": "Son düzenlemeyi geri al", - "updatePreview": "Önizlemeyi güncelle", - "updating": "Güncelleniyor", - "words": "Sözcükler" - }, - "header": { - "addMarker": "İşaretçi ekle", - "addMarkerTooltip": "İşaretçi ekle", - "clearAllMarkers": "Tüm işaretçileri temizle", - "clearAllMarkersTooltip": "Tüm işaretçileri temizle", - "clearInOutPoints": "Giriş/çıkış noktalarını temizle", - "clearInOutPointsTooltip": "Giriş/çıkış noktalarını temizle", - "controls": "Kontroller", - "disableLinkedSelection": "Bağlı seçimi kapat", - "disableSnapping": "Yakalamayı kapat", - "enableLinkedSelection": "Bağlı seçimi aç", - "enableSnapping": "Yakalamayı aç", - "hideColorScopes": "Renk kapsamlarını gizle", - "hideColorScopesTooltip": "Renk kapsamlarını gizle", - "linkedSelectionOff": "Bağlı seçim kapalı", - "linkedSelectionOn": "Bağlı seçim açık", - "linkedSelectionTooltip": "Bağlı seçim: {{state}} ({{shortcut}})", - "rateStretchTool": "Hız uzatma aracı", - "rateStretchToolTooltip": "Hız uzatma aracı", - "razorTool": "Kesici aracı", - "razorToolTooltip": "Kesici aracı", - "redo": "Yinele", - "redoTooltip": "Yinele", - "redoWithLabel": "{{label}} yinele", - "redoWithLabelTooltip": "{{label}} yinele", - "removeSelectedMarker": "Seçili işaretçiyi kaldır", - "removeSelectedMarkerTooltip": "Seçili işaretçiyi kaldır", - "selectTool": "Seçim aracı", - "selectToolTooltip": "Seçim aracı", - "setInPoint": "Giriş noktası ayarla", - "setInPointTooltip": "Giriş noktası ayarla", - "setOutPoint": "Çıkış noktası ayarla", - "setOutPointTooltip": "Çıkış noktası ayarla", - "showColorScopes": "Renk kapsamlarını göster", - "showColorScopesTooltip": "Renk kapsamlarını göster", - "slideTool": "Slide aracı", - "slipSlideTools": "Slip/Slide araçları", - "slipSlideToolsTooltip": "Slip/Slide araçları", - "slipTool": "Slip aracı", - "snapDisabled": "Yakalama kapalı", - "snapEnabled": "Yakalama açık", - "title": "Zaman çizelgesi", - "trimEditTool": "Kırpma düzenleme aracı", - "trimEditToolTooltip": "Kırpma düzenleme aracı", - "undo": "Geri al", - "undoTooltip": "Geri al", - "undoWithLabel": "{{label}} geri al", - "undoWithLabelTooltip": "{{label}} geri al", - "zoomIn": "Yakınlaştır", - "zoomInTooltip": "Yakınlaştır", - "zoomOut": "Uzaklaştır", - "zoomOutTooltip": "Uzaklaştır", - "zoomSlider": "Yakınlaştırma kaydırıcısı", - "zoomToFit": "Sığdır", - "zoomToFitTooltip": "Sığdır" - }, - "itemActions": { - "appliedEffectToClips": "Efekt kliplere uygulandı", - "selectAvClipFirst": "Önce bir ses/video klibi seçin" - }, - "joinIndicators": { - "canJoinNext": "Sonrakiyle birleştirilebilir", - "canJoinPrevious": "Öncekiyle birleştirilebilir" - }, - "keyframeEditor": { - "bezier": "Bezier", - "custom": "Özel", - "dragHandlesHint": "Eğriyi şekillendirmek için tutamaçları sürükleyin.", - "graph": "Grafik", - "mixedCurves": "Karışık eğriler", - "mixedSpring": "Karışık yay", - "movedKeyframes": "Ana kareler taşındı", - "noKeyframesPasted": "Yapıştırılacak ana kare yok", - "pastedKeyframes": "Ana kareler yapıştırıldı", - "preset": "Ön ayar", - "reasonBlocked": "{{count}} başka bir anahtar kare tarafından engellendi", - "reasonUnsupported": "{{count}} hedef özellik tarafından desteklenmiyor", - "selectItem": "Bir öğe seçin", - "sheet": "Zaman çizelgesi", - "skippedDescription": "{{count}} atlandı: {{reasons}}", - "spring": "Yay", - "springHint": "Yay fiziği — doğal ve esnek bir his.", - "title": "Anahtar kareler", - "unableToPasteCut": "Kesilen ana kareler yapıştırılamadı", - "unableToPasteCutDescription": "Kesilen anahtar kareler buraya yapıştırılamaz. {{reasons}}" - }, - "noTracksToRemove": "Kaldırılacak parça yok", - "region": "Bölge", - "removeActiveTrack": "Etkin parçayı kaldır", - "removeSelectedTracks": "Seçili parçaları kaldır", - "reverseConform": { - "cancellingDescription": "Ters oynatma hazırlığı iptal ediliyor.", - "couldNotPrepare": "Ters oynatma hazırlanamadı", - "progressDescription": "Medya ters oynatma için hazırlanıyor.", - "titleCancelling": "İptal ediliyor", - "titleFailed": "Hazırlama başarısız", - "titlePreparing": "Ters oynatma hazırlanıyor" - }, - "sceneDetection": { - "detectingScenes": "Sahneler algılanıyor", - "failed": "Başarısız", - "noScenesDetected": "Sahne algılanmadı", - "noScenesWithinBounds": "Sınırlar içinde sahne yok", - "noValidSplitPoints": "Geçerli bölme noktası yok", - "requiresWebGpu": "WebGPU gerektirir", - "splitAtScenes": "Sahnelerde böl" - }, - "selectTrackToRemove": "Kaldırılacak parçayı seçin", - "silenceRemoval": { - "aboutWillBeRemoved": "Yaklaşık {{duration}} sessizlik kaldırılacak.", - "keepPadding": "Payı koru", - "minimumSilence": "Minimum sessizlik", - "noRemovableDetectedShort": "Sessizlik bulunamadı.", - "rangesSelected": "Aralık seçildi", - "remove": "Kaldır", - "threshold": "Eşik", - "title": "Sessizliği kaldır", - "toastNoRemovable": "Mevcut ayarlara uyan sessizlik yok.", - "toastNoneInClips": "Seçili kliplerde kaldırılacak sessizlik yok.", - "toastPreviewFailed": "Sessizlik kaldırma önizlemesi oluşturulamadı.", - "toastRemoved": "{{count}} sessizlik aralığı kaldırıldı.", - "toastSettingsChanged": "Ayarlar değişti — uygulamadan önce önizleyin.", - "updatePreview": "Önizlemeyi güncelle", - "updating": "Güncelleniyor" - }, - "track": { - "locked": "Kilitli" - }, - "trackHeader": { - "addAudioTrack": "Ses parçası ekle", - "addVideoTrack": "Video parçası ekle", - "clipCount": "Klip sayısı", - "closeAllGaps": "Tüm boşlukları kapat", - "deleteEmptyTracks": "Boş parçaları sil", - "deleteTrack": "Parçayı sil", - "disableSyncLock": "Senkron kilidini kapat", - "disableTrack": "Parçayı devre dışı bırak", - "enableSyncLock": "Senkron kilidini aç", - "enableTrack": "Parçayı etkinleştir", - "lockTrack": "Parçayı kilitle", - "soloTrack": "Parçayı solo yap", - "unlockTrack": "Parça kilidini aç", - "unsoloTrack": "Soloyu kapat" - }, - "trackRow": { - "resizeSections": "Video ve ses parçası bölümlerini yeniden boyutlandır", - "resizeTrackHeight": "Parça yüksekliğini yeniden boyutlandır" - }, - "tracks": "Parçalar", - "volumeControl": { - "adjustClipVolume": "Klip ses düzeyini ayarla" - } - } - } -} diff --git a/src/i18n/locales/partials/preview.json b/src/i18n/locales/partials/preview.json new file mode 100644 index 000000000..98e93f751 --- /dev/null +++ b/src/i18n/locales/partials/preview.json @@ -0,0 +1,794 @@ +{ + "en": { + "preview": { + "align": { + "disableCanvasSnap": "Disable Canvas Snap", + "enableCanvasSnap": "Enable Canvas Snap", + "toggleCanvasSnap": "Toggle Canvas Snap" + }, + "controls": { + "captureFailed": "Couldn't capture the current frame.", + "disableProxyPlayback": "Disable Proxy Playback", + "enableProxyPlayback": "Enable Proxy Playback", + "frameDownloadedNoProject": "Frame downloaded — open a project to save it to the media library.", + "frameDownloadedNotSaved": "Frame downloaded, but couldn't save to the media library: {{message}}", + "frameSaved": "Saved \"{{name}}\" to the media library.", + "goToEnd": "Go To End", + "goToEndTooltip": "Go To End", + "goToStart": "Go To Start", + "goToStartTooltip": "Go To Start", + "nextFrame": "Next Frame", + "nextFrameTooltip": "Next Frame", + "pauseTooltip": "Pause", + "playTooltip": "Play", + "prevFrame": "Prev Frame", + "prevFrameTooltip": "Prev Frame", + "proxyPlaybackOff": "Proxy Playback Off", + "proxyPlaybackOn": "Proxy Playback On", + "saveFrame": "Save frame", + "saveFrameFailed": "Couldn't save the frame.", + "saveFrameTooltip": "Save frame", + "savingFrame": "Saving frame", + "savingFrameTooltip": "Saving frame" + }, + "monitor": { + "mute": "Mute", + "muteShort": "Mute Short", + "muted": "Muted", + "percent": "Percent", + "previewOnlyNote": "Only affects local playback — exports use the project mix.", + "thisDeviceOnly": "This Device Only", + "title": "Preview audio", + "unmute": "Unmute", + "volume": "Volume" + }, + "player": { + "exitFullscreen": "Exit Fullscreen", + "fullscreen": "Fullscreen", + "mute": "Mute", + "pause": "Pause", + "play": "Play", + "seek": "Seek", + "unmute": "Unmute", + "volume": "Volume" + }, + "stage": { + "loadingMedia": "Loading Media", + "videoPreview": "Video Preview" + }, + "zoom": { + "ariaLabel": "ARIA Label", + "auto": "Auto", + "tooltip": "Zoom: {{label}}" + }, + "canvasDrop": { + "dropOnTimeline": "Drop on timeline", + "compoundClipsTimeline": "Compound clips still place best on the timeline.", + "presetsTimeline": "Text and shape presets still place best on the timeline.", + "dropOneItem": "Drop one item", + "oneMediaAtATime": "Place one media item at a time on the canvas.", + "audioGoesOnTimeline": "Audio goes on timeline", + "visualLayersOnly": "Canvas drop places visual layers only.", + "dropToPlace": "Drop to place", + "addAtPlayhead": "Add this media at the playhead and drop position.", + "dropOneFile": "Drop one file", + "oneVisualFileAtATime": "Canvas drop imports one visual file at a time.", + "importAndPlace": "Import and place", + "importAndPlaceDescription": "Import this file and place it on the canvas.", + "noCompatibleTrack": "No unlocked compatible track is available for this drop.", + "unableToLoadMedia": "Unable to load dropped media.", + "mediaNoLongerAvailable": "Dropped media is no longer available.", + "dragDropUnsupported": "Drag-drop is not supported in this browser. Use Chrome or Edge.", + "filesRejected": "Some files were rejected: {{errors}}", + "dropAudioOnTimeline": "Drop audio on the timeline instead.", + "unableToInspectVideo": "Unable to inspect dropped video.", + "unableToInspectFile": "Unable to inspect dropped file.", + "unableToImportFile": "Unable to import dropped file." + } + } + }, + "es": { + "preview": { + "align": { + "disableCanvasSnap": "Desactivar ajuste al lienzo", + "enableCanvasSnap": "Activar ajuste al lienzo", + "toggleCanvasSnap": "Alternar ajuste al lienzo" + }, + "controls": { + "captureFailed": "No se pudo capturar el fotograma actual.", + "disableProxyPlayback": "Desactivar reproducción con proxy", + "enableProxyPlayback": "Activar reproducción con proxy", + "frameDownloadedNoProject": "Fotograma descargado. Abre un proyecto para guardarlo en la biblioteca.", + "frameDownloadedNotSaved": "Fotograma descargado, pero no se pudo guardar en la biblioteca: {{message}}", + "frameSaved": "Se guardó \"{{name}}\" en la biblioteca.", + "goToEnd": "Ir al final", + "goToEndTooltip": "Ir al final", + "goToStart": "Ir al inicio", + "goToStartTooltip": "Ir al inicio", + "nextFrame": "Fotograma siguiente", + "nextFrameTooltip": "Fotograma siguiente", + "pauseTooltip": "Pausar", + "playTooltip": "Reproducir", + "prevFrame": "Fotograma anterior", + "prevFrameTooltip": "Fotograma anterior", + "proxyPlaybackOff": "Reproducción con proxy desactivada", + "proxyPlaybackOn": "Reproducción con proxy activada", + "saveFrame": "Guardar fotograma", + "saveFrameFailed": "No se pudo guardar el fotograma.", + "saveFrameTooltip": "Guardar fotograma", + "savingFrame": "Guardando fotograma…", + "savingFrameTooltip": "Guardando fotograma…" + }, + "monitor": { + "mute": "Silenciar", + "muteShort": "Mudo", + "muted": "Silenciado", + "percent": "{{value}}%", + "previewOnlyNote": "Solo afecta a la reproducción local; la exportación usa la mezcla del proyecto.", + "thisDeviceOnly": "Solo en este dispositivo", + "title": "Audio de previsualización", + "unmute": "Activar sonido", + "volume": "Volumen" + }, + "player": { + "exitFullscreen": "Salir de pantalla completa", + "fullscreen": "Pantalla completa", + "mute": "Silenciar", + "pause": "Pausar", + "play": "Reproducir", + "seek": "Buscar", + "unmute": "Activar sonido", + "volume": "Volumen" + }, + "stage": { + "loadingMedia": "Cargando medios…", + "videoPreview": "Previsualización de vídeo" + }, + "zoom": { + "ariaLabel": "Nivel de zoom: {{label}}", + "auto": "Automático", + "tooltip": "Zoom: {{label}}" + }, + "canvasDrop": { + "dropOnTimeline": "Soltar en la línea de tiempo", + "compoundClipsTimeline": "Los clips compuestos siguen funcionando mejor en la línea de tiempo.", + "presetsTimeline": "Los preajustes de texto y forma siguen funcionando mejor en la línea de tiempo.", + "dropOneItem": "Suelta un elemento", + "oneMediaAtATime": "Coloca un elemento multimedia a la vez en el lienzo.", + "audioGoesOnTimeline": "El audio va en la línea de tiempo", + "visualLayersOnly": "Soltar en el lienzo solo coloca capas visuales.", + "dropToPlace": "Soltar para colocar", + "addAtPlayhead": "Añade este medio en el cabezal de reproducción y la posición de soltar.", + "dropOneFile": "Suelta un archivo", + "oneVisualFileAtATime": "Soltar en el lienzo importa un archivo visual a la vez.", + "importAndPlace": "Importar y colocar", + "importAndPlaceDescription": "Importa este archivo y colócalo en el lienzo.", + "noCompatibleTrack": "No hay una pista compatible desbloqueada disponible para este elemento.", + "unableToLoadMedia": "No se pudo cargar el medio soltado.", + "mediaNoLongerAvailable": "El medio soltado ya no está disponible.", + "dragDropUnsupported": "Arrastrar y soltar no es compatible con este navegador. Usa Chrome o Edge.", + "filesRejected": "Se rechazaron algunos archivos: {{errors}}", + "dropAudioOnTimeline": "Suelta el audio en la línea de tiempo.", + "unableToInspectVideo": "No se pudo inspeccionar el video soltado.", + "unableToInspectFile": "No se pudo inspeccionar el archivo soltado.", + "unableToImportFile": "No se pudo importar el archivo soltado." + } + } + }, + "fr": { + "preview": { + "align": { + "disableCanvasSnap": "Désactiver l'aimantation", + "enableCanvasSnap": "Activer l'aimantation", + "toggleCanvasSnap": "Basculer l'aimantation" + }, + "controls": { + "captureFailed": "Impossible de capturer l'image actuelle.", + "disableProxyPlayback": "Désactiver la lecture proxy", + "enableProxyPlayback": "Activer la lecture proxy", + "frameDownloadedNoProject": "Image téléchargée — ouvrez un projet pour l'ajouter à la médiathèque.", + "frameDownloadedNotSaved": "Image téléchargée, mais impossible de l'enregistrer dans la médiathèque : {{message}}", + "frameSaved": "« {{name}} » enregistré dans la médiathèque.", + "goToEnd": "Aller à la fin", + "goToEndTooltip": "Aller à la fin", + "goToStart": "Aller au début", + "goToStartTooltip": "Aller au début", + "nextFrame": "Image suivante", + "nextFrameTooltip": "Image suivante", + "pauseTooltip": "Pause", + "playTooltip": "Lecture", + "prevFrame": "Image précédente", + "prevFrameTooltip": "Image précédente", + "proxyPlaybackOff": "Lecture proxy désactivée", + "proxyPlaybackOn": "Lecture proxy activée", + "saveFrame": "Enregistrer l'image", + "saveFrameFailed": "Impossible d'enregistrer l'image.", + "saveFrameTooltip": "Enregistrer l'image", + "savingFrame": "Enregistrement de l'image…", + "savingFrameTooltip": "Enregistrement de l'image…" + }, + "monitor": { + "mute": "Couper le son", + "muteShort": "Muet", + "muted": "Muet", + "percent": "{{value}} %", + "previewOnlyNote": "Concerne uniquement la lecture locale — l'exportation utilise le mixage du projet.", + "thisDeviceOnly": "Cet appareil uniquement", + "title": "Audio de prévisualisation", + "unmute": "Réactiver le son", + "volume": "Volume" + }, + "player": { + "exitFullscreen": "Quitter le plein écran", + "fullscreen": "Plein écran", + "mute": "Couper le son", + "pause": "Pause", + "play": "Lecture", + "seek": "Rechercher", + "unmute": "Réactiver le son", + "volume": "Volume" + }, + "stage": { + "loadingMedia": "Chargement des médias…", + "videoPreview": "Prévisualisation vidéo" + }, + "zoom": { + "ariaLabel": "Niveau de zoom : {{label}}", + "auto": "Auto", + "tooltip": "Zoom : {{label}}" + }, + "canvasDrop": { + "dropOnTimeline": "Déposer sur la timeline", + "compoundClipsTimeline": "Les clips composés se placent toujours mieux sur la timeline.", + "presetsTimeline": "Les préréglages de texte et de forme se placent toujours mieux sur la timeline.", + "dropOneItem": "Déposer un élément", + "oneMediaAtATime": "Placez un média à la fois sur le canevas.", + "audioGoesOnTimeline": "L'audio va sur la timeline", + "visualLayersOnly": "Le dépôt sur le canevas place uniquement des calques visuels.", + "dropToPlace": "Déposer pour placer", + "addAtPlayhead": "Ajoutez ce média à la tête de lecture et à la position de dépôt.", + "dropOneFile": "Déposer un fichier", + "oneVisualFileAtATime": "Le dépôt sur le canevas importe un fichier visuel à la fois.", + "importAndPlace": "Importer et placer", + "importAndPlaceDescription": "Importez ce fichier et placez-le sur le canevas.", + "noCompatibleTrack": "Aucune piste compatible déverrouillée n'est disponible pour ce dépôt.", + "unableToLoadMedia": "Impossible de charger le média déposé.", + "mediaNoLongerAvailable": "Le média déposé n'est plus disponible.", + "dragDropUnsupported": "Le glisser-déposer n'est pas pris en charge par ce navigateur. Utilisez Chrome ou Edge.", + "filesRejected": "Certains fichiers ont été refusés : {{errors}}", + "dropAudioOnTimeline": "Déposez plutôt l'audio sur la timeline.", + "unableToInspectVideo": "Impossible d'inspecter la vidéo déposée.", + "unableToInspectFile": "Impossible d'inspecter le fichier déposé.", + "unableToImportFile": "Impossible d'importer le fichier déposé." + } + } + }, + "de": { + "preview": { + "align": { + "disableCanvasSnap": "Canvas-Einrasten deaktivieren", + "enableCanvasSnap": "Canvas-Einrasten aktivieren", + "toggleCanvasSnap": "Canvas-Einrasten umschalten" + }, + "controls": { + "captureFailed": "Aktuelles Bild konnte nicht erfasst werden.", + "disableProxyPlayback": "Proxy-Wiedergabe deaktivieren", + "enableProxyPlayback": "Proxy-Wiedergabe aktivieren", + "frameDownloadedNoProject": "Bild heruntergeladen — öffne ein Projekt, um es zur Mediathek hinzuzufügen.", + "frameDownloadedNotSaved": "Bild heruntergeladen, konnte aber nicht zur Mediathek hinzugefügt werden: {{message}}", + "frameSaved": "„{{name}}“ zur Mediathek hinzugefügt.", + "goToEnd": "Zum Ende", + "goToEndTooltip": "Zum Ende", + "goToStart": "Zum Anfang", + "goToStartTooltip": "Zum Anfang", + "nextFrame": "Nächstes Bild", + "nextFrameTooltip": "Nächstes Bild", + "pauseTooltip": "Pause", + "playTooltip": "Wiedergabe", + "prevFrame": "Vorheriges Bild", + "prevFrameTooltip": "Vorheriges Bild", + "proxyPlaybackOff": "Proxy-Wiedergabe aus", + "proxyPlaybackOn": "Proxy-Wiedergabe an", + "saveFrame": "Bild speichern", + "saveFrameFailed": "Bild konnte nicht gespeichert werden.", + "saveFrameTooltip": "Bild speichern", + "savingFrame": "Bild wird gespeichert…", + "savingFrameTooltip": "Bild wird gespeichert…" + }, + "monitor": { + "mute": "Stummschalten", + "muteShort": "Stumm", + "muted": "Stummgeschaltet", + "percent": "{{value}} %", + "previewOnlyNote": "Wirkt nur auf die lokale Wiedergabe — Exporte nutzen den Projektmix.", + "thisDeviceOnly": "Nur dieses Gerät", + "title": "Vorschau-Audio", + "unmute": "Stummschaltung aufheben", + "volume": "Lautstärke" + }, + "player": { + "exitFullscreen": "Vollbild beenden", + "fullscreen": "Vollbild", + "mute": "Stummschalten", + "pause": "Pause", + "play": "Wiedergabe", + "seek": "Suchen", + "unmute": "Stummschaltung aufheben", + "volume": "Lautstärke" + }, + "stage": { + "loadingMedia": "Medien werden geladen…", + "videoPreview": "Videovorschau" + }, + "zoom": { + "ariaLabel": "Zoomstufe: {{label}}", + "auto": "Automatisch", + "tooltip": "Zoom: {{label}}" + }, + "canvasDrop": { + "dropOnTimeline": "Auf Timeline ablegen", + "compoundClipsTimeline": "Compound-Clips werden weiterhin am besten auf der Timeline platziert.", + "presetsTimeline": "Text- und Formvorgaben werden weiterhin am besten auf der Timeline platziert.", + "dropOneItem": "Ein Element ablegen", + "oneMediaAtATime": "Platziere jeweils ein Medienelement auf der Leinwand.", + "audioGoesOnTimeline": "Audio gehört auf die Timeline", + "visualLayersOnly": "Auf der Leinwand werden nur visuelle Ebenen platziert.", + "dropToPlace": "Zum Platzieren ablegen", + "addAtPlayhead": "Füge dieses Medium am Abspielkopf und an der Ablageposition hinzu.", + "dropOneFile": "Eine Datei ablegen", + "oneVisualFileAtATime": "Auf der Leinwand wird jeweils eine visuelle Datei importiert.", + "importAndPlace": "Importieren und platzieren", + "importAndPlaceDescription": "Importiere diese Datei und platziere sie auf der Leinwand.", + "noCompatibleTrack": "Für dieses Ablegen ist keine entsperrte kompatible Spur verfügbar.", + "unableToLoadMedia": "Abgelegtes Medium konnte nicht geladen werden.", + "mediaNoLongerAvailable": "Das abgelegte Medium ist nicht mehr verfügbar.", + "dragDropUnsupported": "Drag-and-drop wird in diesem Browser nicht unterstützt. Verwende Chrome oder Edge.", + "filesRejected": "Einige Dateien wurden abgelehnt: {{errors}}", + "dropAudioOnTimeline": "Lege Audio stattdessen auf der Timeline ab.", + "unableToInspectVideo": "Abgelegtes Video konnte nicht geprüft werden.", + "unableToInspectFile": "Abgelegte Datei konnte nicht geprüft werden.", + "unableToImportFile": "Abgelegte Datei konnte nicht importiert werden." + } + } + }, + "pt-BR": { + "preview": { + "align": { + "disableCanvasSnap": "Desativar encaixe no canvas", + "enableCanvasSnap": "Ativar encaixe no canvas", + "toggleCanvasSnap": "Alternar encaixe no canvas" + }, + "controls": { + "captureFailed": "Não foi possível capturar o quadro atual.", + "disableProxyPlayback": "Desativar reproducao por proxy", + "enableProxyPlayback": "Ativar reproducao por proxy", + "frameDownloadedNoProject": "Quadro baixado — abra um projeto para salvar na biblioteca.", + "frameDownloadedNotSaved": "Quadro baixado, mas não foi possível salvar na biblioteca: {{message}}", + "frameSaved": "\"{{name}}\" salvo na biblioteca de mídia.", + "goToEnd": "Ir para o fim", + "goToEndTooltip": "Ir para o fim", + "goToStart": "Ir para o inicio", + "goToStartTooltip": "Ir para o inicio", + "nextFrame": "Proximo quadro", + "nextFrameTooltip": "Proximo quadro", + "pauseTooltip": "Pausar", + "playTooltip": "Reproduzir", + "prevFrame": "Quadro anterior", + "prevFrameTooltip": "Quadro anterior", + "proxyPlaybackOff": "Reproducao por proxy desativada", + "proxyPlaybackOn": "Reproducao por proxy ativada", + "saveFrame": "Salvar quadro", + "saveFrameFailed": "Não foi possível salvar o quadro.", + "saveFrameTooltip": "Salvar quadro", + "savingFrame": "Salvando quadro", + "savingFrameTooltip": "Salvando quadro" + }, + "monitor": { + "mute": "Silenciar", + "muteShort": "Silenciar", + "muted": "Silenciado", + "percent": "Porcentagem", + "previewOnlyNote": "Afeta apenas a reprodução local — a exportação usa a mixagem do projeto.", + "thisDeviceOnly": "Somente este dispositivo", + "title": "Áudio da prévia", + "unmute": "Ativar som", + "volume": "Volume de som" + }, + "player": { + "exitFullscreen": "Sair da tela cheia", + "fullscreen": "Tela cheia", + "mute": "Silenciar", + "pause": "Pausar", + "play": "Reproduzir", + "seek": "Buscar", + "unmute": "Ativar som", + "volume": "Volume de som" + }, + "stage": { + "loadingMedia": "Carregando midia", + "videoPreview": "Pre-visualizacao de video" + }, + "zoom": { + "ariaLabel": "Rotulo ARIA", + "auto": "Automático", + "tooltip": "Zoom: {{label}}" + }, + "canvasDrop": { + "dropOnTimeline": "Soltar na linha do tempo", + "compoundClipsTimeline": "Clipes compostos ainda ficam melhor na linha do tempo.", + "presetsTimeline": "Predefinições de texto e forma ainda ficam melhor na linha do tempo.", + "dropOneItem": "Solte um item", + "oneMediaAtATime": "Coloque um item de mídia por vez na tela.", + "audioGoesOnTimeline": "Áudio vai na linha do tempo", + "visualLayersOnly": "Soltar na tela coloca apenas camadas visuais.", + "dropToPlace": "Soltar para posicionar", + "addAtPlayhead": "Adicione esta mídia no indicador de reprodução e na posição de soltura.", + "dropOneFile": "Solte um arquivo", + "oneVisualFileAtATime": "Soltar na tela importa um arquivo visual por vez.", + "importAndPlace": "Importar e posicionar", + "importAndPlaceDescription": "Importe este arquivo e coloque-o na tela.", + "noCompatibleTrack": "Nenhuma faixa compatível desbloqueada está disponível para este item.", + "unableToLoadMedia": "Não foi possível carregar a mídia solta.", + "mediaNoLongerAvailable": "A mídia solta não está mais disponível.", + "dragDropUnsupported": "Arrastar e soltar não é compatível com este navegador. Use Chrome ou Edge.", + "filesRejected": "Alguns arquivos foram rejeitados: {{errors}}", + "dropAudioOnTimeline": "Solte o áudio na linha do tempo.", + "unableToInspectVideo": "Não foi possível inspecionar o vídeo solto.", + "unableToInspectFile": "Não foi possível inspecionar o arquivo solto.", + "unableToImportFile": "Não foi possível importar o arquivo solto." + } + } + }, + "tr": { + "preview": { + "align": { + "disableCanvasSnap": "Tuval yakalamayı kapat", + "enableCanvasSnap": "Tuval yakalamayı aç", + "toggleCanvasSnap": "Tuval yakalamayı aç/kapat" + }, + "controls": { + "captureFailed": "Geçerli kare yakalanamadı.", + "disableProxyPlayback": "Proxy oynatmayı kapat", + "enableProxyPlayback": "Proxy oynatmayı aç", + "frameDownloadedNoProject": "Kare indirildi — kütüphaneye kaydetmek için bir proje açın.", + "frameDownloadedNotSaved": "Kare indirildi ancak kütüphaneye kaydedilemedi: {{message}}", + "frameSaved": "\"{{name}}\" kütüphaneye kaydedildi.", + "goToEnd": "Sona git", + "goToEndTooltip": "Sona git", + "goToStart": "Başa git", + "goToStartTooltip": "Başa git", + "nextFrame": "Sonraki kare", + "nextFrameTooltip": "Sonraki kare", + "pauseTooltip": "Duraklat", + "playTooltip": "Oynat", + "prevFrame": "Önceki kare", + "prevFrameTooltip": "Önceki kare", + "proxyPlaybackOff": "Proxy oynatma kapalı", + "proxyPlaybackOn": "Proxy oynatma açık", + "saveFrame": "Kareyi kaydet", + "saveFrameFailed": "Kare kaydedilemedi.", + "saveFrameTooltip": "Kareyi kaydet", + "savingFrame": "Kare kaydediliyor", + "savingFrameTooltip": "Kare kaydediliyor" + }, + "monitor": { + "mute": "Sessize al", + "muteShort": "Sessiz", + "muted": "Sessiz", + "percent": "Yüzde", + "previewOnlyNote": "Yalnızca yerel oynatmayı etkiler — dışa aktarmada proje miksi kullanılır.", + "thisDeviceOnly": "Yalnızca bu cihaz", + "title": "Önizleme sesi", + "unmute": "Sesi aç", + "volume": "Ses düzeyi" + }, + "player": { + "exitFullscreen": "Tam ekrandan çık", + "fullscreen": "Tam ekran", + "mute": "Sessize al", + "pause": "Duraklat", + "play": "Oynat", + "seek": "Ara", + "unmute": "Sesi aç", + "volume": "Ses düzeyi" + }, + "stage": { + "loadingMedia": "Medya yükleniyor", + "videoPreview": "Video önizlemesi" + }, + "zoom": { + "ariaLabel": "Önizleme yakınlaştırması", + "auto": "Otomatik", + "tooltip": "Yakınlaştırma: {{label}}" + }, + "canvasDrop": { + "dropOnTimeline": "Zaman çizelgesine bırak", + "compoundClipsTimeline": "Bileşik klipler hâlâ en iyi zaman çizelgesine yerleşir.", + "presetsTimeline": "Metin ve şekil ön ayarları hâlâ en iyi zaman çizelgesine yerleşir.", + "dropOneItem": "Bir öğe bırak", + "oneMediaAtATime": "Tuvale tek seferde bir medya öğesi yerleştirin.", + "audioGoesOnTimeline": "Ses zaman çizelgesine gider", + "visualLayersOnly": "Tuvale bırakma yalnızca görsel katmanları yerleştirir.", + "dropToPlace": "Yerleştirmek için bırak", + "addAtPlayhead": "Bu medyayı oynatma kafasında ve bırakma konumunda ekle.", + "dropOneFile": "Bir dosya bırak", + "oneVisualFileAtATime": "Tuvale bırakma tek seferde bir görsel dosya içe aktarır.", + "importAndPlace": "İçe aktar ve yerleştir", + "importAndPlaceDescription": "Bu dosyayı içe aktar ve tuvale yerleştir.", + "noCompatibleTrack": "Bu bırakma için kilidi açık uyumlu iz yok.", + "unableToLoadMedia": "Bırakılan medya yüklenemedi.", + "mediaNoLongerAvailable": "Bırakılan medya artık kullanılamıyor.", + "dragDropUnsupported": "Bu tarayıcıda sürükle-bırak desteklenmiyor. Chrome veya Edge kullanın.", + "filesRejected": "Bazı dosyalar reddedildi: {{errors}}", + "dropAudioOnTimeline": "Sesi bunun yerine zaman çizelgesine bırakın.", + "unableToInspectVideo": "Bırakılan video incelenemedi.", + "unableToInspectFile": "Bırakılan dosya incelenemedi.", + "unableToImportFile": "Bırakılan dosya içe aktarılamadı." + } + } + }, + "ja": { + "preview": { + "align": { + "disableCanvasSnap": "キャンバスへのスナップを無効化", + "enableCanvasSnap": "キャンバスへのスナップを有効化", + "toggleCanvasSnap": "キャンバススナップを切り替え" + }, + "controls": { + "captureFailed": "現在のフレームをキャプチャできませんでした。", + "disableProxyPlayback": "プロキシ再生を無効化", + "enableProxyPlayback": "プロキシ再生を有効化", + "frameDownloadedNoProject": "フレームをダウンロードしました — メディアライブラリに保存するにはプロジェクトを開いてください。", + "frameDownloadedNotSaved": "フレームはダウンロードしましたが、メディアライブラリに保存できませんでした: {{message}}", + "frameSaved": "「{{name}}」をメディアライブラリに保存しました。", + "goToEnd": "末尾へ", + "goToEndTooltip": "末尾へ", + "goToStart": "先頭へ", + "goToStartTooltip": "先頭へ", + "nextFrame": "次のフレーム", + "nextFrameTooltip": "次のフレーム", + "pauseTooltip": "一時停止", + "playTooltip": "再生", + "prevFrame": "前のフレーム", + "prevFrameTooltip": "前のフレーム", + "proxyPlaybackOff": "プロキシ再生はオフ", + "proxyPlaybackOn": "プロキシ再生はオン", + "saveFrame": "フレームを保存", + "saveFrameFailed": "フレームを保存できませんでした。", + "saveFrameTooltip": "フレームを保存", + "savingFrame": "フレームを保存中…", + "savingFrameTooltip": "フレームを保存中…" + }, + "monitor": { + "mute": "ミュート", + "muteShort": "ミュート", + "muted": "ミュート中", + "percent": "{{value}}%", + "previewOnlyNote": "ローカル再生にのみ反映されます。書き出しはプロジェクトミックスを使用します。", + "thisDeviceOnly": "このデバイスのみ", + "title": "プレビュー音声", + "unmute": "ミュート解除", + "volume": "音量" + }, + "player": { + "exitFullscreen": "全画面表示を終了", + "fullscreen": "全画面表示", + "mute": "ミュート", + "pause": "一時停止", + "play": "再生", + "seek": "シーク", + "unmute": "ミュート解除", + "volume": "音量" + }, + "stage": { + "loadingMedia": "メディアを読み込み中…", + "videoPreview": "動画プレビュー" + }, + "zoom": { + "ariaLabel": "ズーム倍率: {{label}}", + "auto": "自動", + "tooltip": "ズーム: {{label}}" + }, + "canvasDrop": { + "dropOnTimeline": "タイムラインにドロップ", + "compoundClipsTimeline": "コンパウンドクリップはタイムラインに配置するのが最適です。", + "presetsTimeline": "テキストと図形のプリセットはタイムラインに配置するのが最適です。", + "dropOneItem": "1項目をドロップ", + "oneMediaAtATime": "キャンバスにはメディアを1つずつ配置してください。", + "audioGoesOnTimeline": "音声はタイムラインへ", + "visualLayersOnly": "キャンバスへのドロップはビジュアルレイヤーのみ配置します。", + "dropToPlace": "ドロップして配置", + "addAtPlayhead": "このメディアを再生ヘッドとドロップ位置に追加します。", + "dropOneFile": "1ファイルをドロップ", + "oneVisualFileAtATime": "キャンバスへのドロップではビジュアルファイルを1つずつ読み込みます。", + "importAndPlace": "読み込んで配置", + "importAndPlaceDescription": "このファイルを読み込み、キャンバスに配置します。", + "noCompatibleTrack": "このドロップに使えるロック解除済みの互換トラックがありません。", + "unableToLoadMedia": "ドロップしたメディアを読み込めませんでした。", + "mediaNoLongerAvailable": "ドロップしたメディアはもう利用できません。", + "dragDropUnsupported": "このブラウザーはドラッグ&ドロップに対応していません。Chrome または Edge を使用してください。", + "filesRejected": "一部のファイルが拒否されました: {{errors}}", + "dropAudioOnTimeline": "音声はタイムラインにドロップしてください。", + "unableToInspectVideo": "ドロップした動画を検査できませんでした。", + "unableToInspectFile": "ドロップしたファイルを検査できませんでした。", + "unableToImportFile": "ドロップしたファイルを読み込めませんでした。" + } + } + }, + "ko": { + "preview": { + "align": { + "disableCanvasSnap": "캔버스 스냅 비활성화", + "enableCanvasSnap": "캔버스 스냅 활성화", + "toggleCanvasSnap": "캔버스 스냅 전환" + }, + "controls": { + "captureFailed": "현재 프레임을 캡처할 수 없습니다.", + "disableProxyPlayback": "프록시 재생 비활성화", + "enableProxyPlayback": "프록시 재생 활성화", + "frameDownloadedNoProject": "프레임을 다운로드했습니다. 미디어 라이브러리에 저장하려면 프로젝트를 여세요.", + "frameDownloadedNotSaved": "프레임을 다운로드했지만 미디어 라이브러리에 저장하지 못했습니다: {{message}}", + "frameSaved": "\"{{name}}\"을(를) 미디어 라이브러리에 저장했습니다.", + "goToEnd": "끝으로", + "goToEndTooltip": "끝으로", + "goToStart": "처음으로", + "goToStartTooltip": "처음으로", + "nextFrame": "다음 프레임", + "nextFrameTooltip": "다음 프레임", + "pauseTooltip": "일시정지", + "playTooltip": "재생", + "prevFrame": "이전 프레임", + "prevFrameTooltip": "이전 프레임", + "proxyPlaybackOff": "프록시 재생 꺼짐", + "proxyPlaybackOn": "프록시 재생 켜짐", + "saveFrame": "프레임 저장", + "saveFrameFailed": "프레임을 저장할 수 없습니다.", + "saveFrameTooltip": "프레임 저장", + "savingFrame": "프레임을 저장하는 중…", + "savingFrameTooltip": "프레임을 저장하는 중…" + }, + "monitor": { + "mute": "음소거", + "muteShort": "음소거", + "muted": "음소거됨", + "percent": "{{value}}%", + "previewOnlyNote": "로컬 재생에만 적용됩니다. 내보내기에는 프로젝트 믹스가 사용됩니다.", + "thisDeviceOnly": "이 기기에서만", + "title": "미리보기 오디오", + "unmute": "음소거 해제", + "volume": "볼륨" + }, + "player": { + "exitFullscreen": "전체 화면 종료", + "fullscreen": "전체 화면", + "mute": "음소거", + "pause": "일시정지", + "play": "재생", + "seek": "탐색", + "unmute": "음소거 해제", + "volume": "볼륨" + }, + "stage": { + "loadingMedia": "미디어를 불러오는 중…", + "videoPreview": "동영상 미리보기" + }, + "zoom": { + "ariaLabel": "확대/축소: {{label}}", + "auto": "자동", + "tooltip": "확대/축소: {{label}}" + }, + "canvasDrop": { + "dropOnTimeline": "타임라인에 놓기", + "compoundClipsTimeline": "컴파운드 클립은 타임라인에 배치하는 것이 가장 좋습니다.", + "presetsTimeline": "텍스트 및 도형 프리셋은 타임라인에 배치하는 것이 가장 좋습니다.", + "dropOneItem": "항목 하나 놓기", + "oneMediaAtATime": "캔버스에는 미디어 항목을 한 번에 하나씩 배치하세요.", + "audioGoesOnTimeline": "오디오는 타임라인으로", + "visualLayersOnly": "캔버스 드롭은 시각 레이어만 배치합니다.", + "dropToPlace": "놓아서 배치", + "addAtPlayhead": "이 미디어를 재생 헤드와 드롭 위치에 추가합니다.", + "dropOneFile": "파일 하나 놓기", + "oneVisualFileAtATime": "캔버스 드롭은 시각 파일을 한 번에 하나씩 가져옵니다.", + "importAndPlace": "가져와서 배치", + "importAndPlaceDescription": "이 파일을 가져와 캔버스에 배치합니다.", + "noCompatibleTrack": "이 드롭에 사용할 수 있는 잠금 해제된 호환 트랙이 없습니다.", + "unableToLoadMedia": "드롭한 미디어를 불러올 수 없습니다.", + "mediaNoLongerAvailable": "드롭한 미디어를 더 이상 사용할 수 없습니다.", + "dragDropUnsupported": "이 브라우저는 드래그 앤 드롭을 지원하지 않습니다. Chrome 또는 Edge를 사용하세요.", + "filesRejected": "일부 파일이 거부되었습니다: {{errors}}", + "dropAudioOnTimeline": "오디오는 타임라인에 놓으세요.", + "unableToInspectVideo": "드롭한 비디오를 검사할 수 없습니다.", + "unableToInspectFile": "드롭한 파일을 검사할 수 없습니다.", + "unableToImportFile": "드롭한 파일을 가져올 수 없습니다." + } + } + }, + "zh": { + "preview": { + "align": { + "disableCanvasSnap": "禁用画布吸附", + "enableCanvasSnap": "启用画布吸附", + "toggleCanvasSnap": "切换画布吸附" + }, + "controls": { + "captureFailed": "无法捕获当前帧。", + "disableProxyPlayback": "禁用代理播放", + "enableProxyPlayback": "启用代理播放", + "frameDownloadedNoProject": "已下载帧 — 打开项目即可保存到媒体库。", + "frameDownloadedNotSaved": "帧已下载,但无法保存到媒体库:{{message}}", + "frameSaved": "已将 \"{{name}}\" 保存到媒体库。", + "goToEnd": "跳到结尾", + "goToEndTooltip": "跳到结尾", + "goToStart": "跳到开始", + "goToStartTooltip": "跳到开始", + "nextFrame": "下一帧", + "nextFrameTooltip": "下一帧", + "pauseTooltip": "暂停", + "playTooltip": "播放", + "prevFrame": "上一帧", + "prevFrameTooltip": "上一帧", + "proxyPlaybackOff": "代理播放已关闭", + "proxyPlaybackOn": "代理播放已开启", + "saveFrame": "保存帧", + "saveFrameFailed": "无法保存帧。", + "saveFrameTooltip": "保存帧", + "savingFrame": "正在保存帧…", + "savingFrameTooltip": "正在保存帧…" + }, + "monitor": { + "mute": "静音", + "muteShort": "静音", + "muted": "已静音", + "percent": "{{value}}%", + "previewOnlyNote": "仅影响本地播放 — 导出时使用项目混音。", + "thisDeviceOnly": "仅此设备", + "title": "预览音频", + "unmute": "取消静音", + "volume": "音量" + }, + "player": { + "exitFullscreen": "退出全屏", + "fullscreen": "全屏", + "mute": "静音", + "pause": "暂停", + "play": "播放", + "seek": "拖动", + "unmute": "取消静音", + "volume": "音量" + }, + "stage": { + "loadingMedia": "正在加载媒体…", + "videoPreview": "视频预览" + }, + "zoom": { + "ariaLabel": "缩放级别:{{label}}", + "auto": "自动", + "tooltip": "缩放:{{label}}" + }, + "canvasDrop": { + "dropOnTimeline": "拖放到时间线", + "compoundClipsTimeline": "复合剪辑仍然最适合放在时间线上。", + "presetsTimeline": "文本和形状预设仍然最适合放在时间线上。", + "dropOneItem": "拖放一个项目", + "oneMediaAtATime": "一次只能在画布上放置一个媒体项目。", + "audioGoesOnTimeline": "音频应放到时间线", + "visualLayersOnly": "画布拖放只会放置视觉图层。", + "dropToPlace": "拖放以放置", + "addAtPlayhead": "在播放头和拖放位置添加此媒体。", + "dropOneFile": "拖放一个文件", + "oneVisualFileAtATime": "画布拖放一次只导入一个视觉文件。", + "importAndPlace": "导入并放置", + "importAndPlaceDescription": "导入此文件并将其放在画布上。", + "noCompatibleTrack": "没有可用于此拖放的未锁定兼容轨道。", + "unableToLoadMedia": "无法加载拖放的媒体。", + "mediaNoLongerAvailable": "拖放的媒体已不可用。", + "dragDropUnsupported": "此浏览器不支持拖放。请使用 Chrome 或 Edge。", + "filesRejected": "部分文件被拒绝:{{errors}}", + "dropAudioOnTimeline": "请将音频拖放到时间线。", + "unableToInspectVideo": "无法检查拖放的视频。", + "unableToInspectFile": "无法检查拖放的文件。", + "unableToImportFile": "无法导入拖放的文件。" + } + } + } +} diff --git a/src/i18n/locales/partials/timeline.json b/src/i18n/locales/partials/timeline.json index 86658bdba..65cd159e9 100644 --- a/src/i18n/locales/partials/timeline.json +++ b/src/i18n/locales/partials/timeline.json @@ -6,7 +6,265 @@ "unableToAddDroppedMediaItems": "Unable to add dropped media items", "unableToAddDroppedFiles": "Unable to add dropped files to the timeline", "droppedMediaItems": "dropped media items", - "droppedFiles": "dropped files" + "droppedFiles": "dropped files", + "locked": "Locked" + }, + "addAudioTrackHint": "Add audio track", + "addVideoTrackHint": "Add video track", + "bento": { + "apply": "Apply", + "description": "Arrange {{count}} selected clips into a grid.", + "gap": "Gap", + "noItemsToArrange": "No Items To Arrange", + "padding": "Padding", + "presetNamePlaceholder": "Preset Name Placeholder", + "saveAsPreset": "Save As Preset", + "title": "Bento layout" + }, + "captions": { + "addedFromTranscript": "Added From Transcript", + "addedWithModel": "Added With Model", + "failedGenerateSegment": "Failed Generate Segment", + "failedUpdateSegment": "Failed Update Segment", + "refreshedWithModel": "Refreshed With Model", + "removedFromSegment": "Removed From Segment", + "updatedFromTranscript": "Updated From Transcript", + "updatedWithModel": "Updated With Model" + }, + "clipIndicators": { + "hasKeyframes": "Has Keyframes", + "mask": "Mask", + "mediaMissing": "Media Missing", + "preparingReversed": "Preparing Reversed", + "reversePrepFailedShort": "Reverse Prep Failed Short", + "reversedPlayback": "Reversed Playback", + "reversedPrepFailed": "Reversed Prep Failed", + "reversedPrepared": "Reversed Prepared", + "speed": "Speed: {{speed}}x" + }, + "contextMenu": { + "bentoLayout": "Bento Layout", + "captions": "Captions", + "clearAll": "Clear All", + "clearKeyframes": "Clear Keyframes", + "consolidateCaptionsToSegment": "Consolidate Captions To Segment", + "createCompoundClip": "Create Compound Clip", + "detectScenesAi": "AI ({{model}})", + "detectScenesAndSplit": "Detect Scenes & Split", + "detectScenesFast": "Fast (Histogram)", + "detectingFillers": "Detecting Fillers", + "detectingScenes": "Detecting Scenes", + "detectingSilence": "Detecting Silence", + "dissolveCompoundClip": "Dissolve Compound Clip", + "extractEmbeddedSubtitles": "Extract Embedded Subtitles", + "generateAudioFromText": "Generate Audio From Text", + "generateCaptions": "Generate Captions", + "insertExistingCaptions": "Insert Existing Captions", + "insertFreezeFrame": "Insert Freeze Frame", + "joinSelected": "Join Selected", + "joinWithNext": "Join With Next", + "joinWithPrevious": "Join With Previous", + "linkClips": "Link Clips", + "openCompoundClip": "Open Compound Clip", + "regenerateCaptions": "Regenerate Captions", + "removeFillerWords": "Remove Filler Words", + "removeSilence": "Remove Silence", + "reverse": "Reverse", + "rippleDelete": "Ripple Delete", + "unlinkClips": "Unlink Clips", + "unreverse": "Unreverse", + "updatingCaptions": "Updating Captions" + }, + "fadeHandles": { + "adjustAudioFadeIn": "Adjust audio fade in", + "adjustAudioFadeInCurve": "Adjust Audio Fade In Curve", + "adjustAudioFadeOut": "Adjust audio fade out", + "adjustAudioFadeOutCurve": "Adjust Audio Fade Out Curve", + "adjustVideoFadeIn": "Adjust video fade in", + "adjustVideoFadeOut": "Adjust video fade out" + }, + "fillerRemoval": { + "aboutWillBeRemoved": "About {{duration}} will be removed.", + "add": "Add", + "addPhrase": "Add Phrase", + "addWord": "Add Word", + "cutPadding": "Cut Padding", + "filler": "Filler", + "fillerRange": "filler word", + "found": "Found", + "includeRange": "Include Range", + "maxPhrase": "Max Phrase", + "maxWord": "Max Word", + "noEntriesFound": "No Entries Found", + "noRemovableDetectedShort": "No filler words found.", + "none": "None", + "phrases": "Phrases", + "playThisRange": "Play This Range", + "rangesSelected": "Ranges Selected", + "redo": "Redo", + "redoEditTitle": "Redo last edit", + "remove": "Remove", + "removed": "Removed", + "scoreAudio": "Score Audio", + "scoring": "Scoring", + "title": "Remove filler words", + "toastAudioScored": "Audio confidence scoring done.", + "toastNoRemovable": "No filler words matched the current settings.", + "toastNoneInClips": "No filler words to remove in the selected clips.", + "toastPreviewFailed": "Couldn't preview filler removal.", + "toastRemoveFailed": "Couldn't remove filler words.", + "toastRemoved": "Removed {{count}} filler ranges.", + "toastScoreFailed": "Couldn't analyze audio confidence.", + "undo": "Undo", + "undoEditTitle": "Undo last edit", + "updatePreview": "Update Preview", + "updating": "Updating", + "words": "Words" + }, + "header": { + "addMarker": "Add Marker", + "addMarkerTooltip": "Add Marker", + "clearAllMarkers": "Clear All Markers", + "clearAllMarkersTooltip": "Clear All Markers", + "clearInOutPoints": "Clear In Out Points", + "clearInOutPointsTooltip": "Clear In Out Points", + "controls": "Controls", + "disableLinkedSelection": "Disable Linked Selection", + "disableSnapping": "Disable Snapping", + "enableLinkedSelection": "Enable Linked Selection", + "enableSnapping": "Enable Snapping", + "hideColorScopes": "Hide Color Scopes", + "hideColorScopesTooltip": "Hide Color Scopes", + "linkedSelectionOff": "Linked Selection Off", + "linkedSelectionOn": "Linked Selection On", + "linkedSelectionTooltip": "Linked selection: {{state}} ({{shortcut}})", + "rateStretchTool": "Rate Stretch Tool", + "rateStretchToolTooltip": "Rate Stretch Tool", + "razorTool": "Razor Tool", + "razorToolTooltip": "Razor Tool", + "redo": "Redo", + "redoTooltip": "Redo", + "redoWithLabel": "Redo {{label}}", + "redoWithLabelTooltip": "Redo {{label}}", + "removeSelectedMarker": "Remove Selected Marker", + "removeSelectedMarkerTooltip": "Remove Selected Marker", + "selectTool": "Select Tool", + "selectToolTooltip": "Select Tool", + "setInPoint": "Set In Point", + "setInPointTooltip": "Set In Point", + "setOutPoint": "Set Out Point", + "setOutPointTooltip": "Set Out Point", + "showColorScopes": "Show Color Scopes", + "showColorScopesTooltip": "Show Color Scopes", + "slideTool": "Slide Tool", + "slipSlideTools": "Slip Slide Tools", + "slipSlideToolsTooltip": "Slip Slide Tools", + "slipTool": "Slip Tool", + "snapDisabled": "Snap Disabled", + "snapEnabled": "Snap Enabled", + "title": "Timeline", + "trimEditTool": "Trim Edit Tool", + "trimEditToolTooltip": "Trim Edit Tool", + "undo": "Undo", + "undoTooltip": "Undo", + "undoWithLabel": "Undo {{label}}", + "undoWithLabelTooltip": "Undo {{label}}", + "zoomIn": "Zoom In", + "zoomInTooltip": "Zoom In", + "zoomOut": "Zoom Out", + "zoomOutTooltip": "Zoom Out", + "zoomSlider": "Zoom Slider", + "zoomToFit": "Zoom To Fit", + "zoomToFitTooltip": "Zoom To Fit" + }, + "itemActions": { + "appliedEffectToClips": "Applied Effect To Clips", + "selectAvClipFirst": "Select Av Clip First" + }, + "joinIndicators": { + "canJoinNext": "Can Join Next", + "canJoinPrevious": "Can Join Previous" + }, + "keyframeEditor": { + "bezier": "Bezier", + "custom": "Custom", + "dragHandlesHint": "Drag the handles to shape the curve.", + "mixedCurves": "Mixed Curves", + "mixedSpring": "Mixed Spring", + "movedKeyframes": "Moved Keyframes", + "noKeyframesPasted": "No Keyframes Pasted", + "pastedKeyframes": "Pasted Keyframes", + "preset": "Preset", + "reasonBlocked": "{{count}} blocked by another keyframe", + "reasonUnsupported": "{{count}} unsupported by the target property", + "skippedDescription": "Skipped {{count}}: {{reasons}}", + "spring": "Spring", + "springHint": "Spring physics — feels natural and bouncy.", + "unableToPasteCut": "Unable To Paste Cut", + "unableToPasteCutDescription": "Cut keyframes can't be pasted here. {{reasons}}" + }, + "noTracksToRemove": "No Tracks To Remove", + "region": "Region", + "removeActiveTrack": "Remove Active Track", + "removeSelectedTracks": "Remove Selected Tracks", + "reverseConform": { + "cancellingDescription": "Cancelling reverse preparation…", + "couldNotPrepare": "Could not prepare clip for reverse playback.", + "progressDescription": "Preparing clip {{current}} of {{total}}…", + "titleCancelling": "Cancelling", + "titleFailed": "Reverse failed", + "titlePreparing": "Preparing reverse playback" + }, + "sceneDetection": { + "detectingScenes": "Detecting Scenes", + "failed": "Failed", + "noScenesDetected": "No Scenes Detected", + "noScenesWithinBounds": "No Scenes Within Bounds", + "noValidSplitPoints": "No Valid Split Points", + "requiresWebGpu": "Requires Web GPU", + "splitAtScenes": "Split At Scenes" + }, + "selectTrackToRemove": "Select Track To Remove", + "silenceRemoval": { + "aboutWillBeRemoved": "About {{duration}} of silence will be removed.", + "keepPadding": "Keep Padding", + "minimumSilence": "Minimum Silence", + "noRemovableDetectedShort": "No silence found.", + "rangesSelected": "Ranges Selected", + "remove": "Remove", + "threshold": "Threshold", + "title": "Remove silence", + "toastNoRemovable": "No silence matched the current settings.", + "toastNoneInClips": "No silence to remove in the selected clips.", + "toastPreviewFailed": "Couldn't preview silence removal.", + "toastRemoved": "Removed {{count}} silent ranges.", + "toastSettingsChanged": "Settings changed — preview before applying.", + "updatePreview": "Update Preview", + "updating": "Updating" + }, + "trackHeader": { + "addAudioTrack": "Add Audio Track", + "addVideoTrack": "Add Video Track", + "clipCount": "Clip Count", + "closeAllGaps": "Close All Gaps", + "deleteEmptyTracks": "Delete Empty Tracks", + "deleteTrack": "Delete Track", + "disableSyncLock": "Disable sync lock", + "disableTrack": "Disable track", + "enableSyncLock": "Enable sync lock", + "enableTrack": "Enable track", + "lockTrack": "Lock Track", + "soloTrack": "Solo Track", + "unlockTrack": "Unlock Track", + "unsoloTrack": "Unsolo Track" + }, + "trackRow": { + "resizeSections": "Resize video and audio track sections", + "resizeTrackHeight": "Resize track height" + }, + "tracks": "Tracks", + "volumeControl": { + "adjustClipVolume": "Adjust clip volume" } } }, @@ -17,7 +275,269 @@ "unableToAddDroppedMediaItems": "No se pudo a Agregar soltados Medios elementos", "unableToAddDroppedFiles": "No se pudo a Agregar soltados archivos a the timeline", "droppedMediaItems": "soltados Medios elementos", - "droppedFiles": "soltados archivos" + "droppedFiles": "soltados archivos", + "locked": "bloqueado" + }, + "addAudioTrackHint": "Agregar Audio Pista Ayuda", + "addVideoTrackHint": "Agregar Video Pista Ayuda", + "bento": { + "apply": "Aplicar", + "description": "Organiza {{count}} clips seleccionados en una cuadrícula.", + "gap": "Espacio", + "noItemsToArrange": "Sin elementos a ordenar", + "padding": "Margen", + "presetNamePlaceholder": "Preajuste Nombre marcador", + "saveAsPreset": "Guardar como Preajuste", + "title": "Diseño bento" + }, + "captions": { + "addedFromTranscript": "Agregado desde transcripcion", + "addedWithModel": "Agregado con modelo", + "failedGenerateSegment": "Error Generar segmento", + "failedUpdateSegment": "Error actualizar segmento", + "refreshedWithModel": "Actualizado con modelo", + "removedFromSegment": "Eliminado desde segmento", + "updatedFromTranscript": "Updated desde transcripcion", + "updatedWithModel": "Updated con modelo" + }, + "clipIndicators": { + "hasKeyframes": "Has fotogramas clave", + "mask": "Mascara", + "mediaMissing": "Medios faltante", + "preparingReversed": "Preparando invertido", + "reversePrepFailedShort": "Invertir preparacion Error Short", + "reversedPlayback": "invertido Reproduccion", + "reversedPrepFailed": "invertido preparacion Error", + "reversedPrepared": "invertido Prepared", + "speed": "Velocidad: {{speed}}x" + }, + "contextMenu": { + "bentoLayout": "Bento Diseno", + "captions": "Subtitulos", + "clearAll": "Limpiar todo", + "clearKeyframes": "Limpiar fotogramas clave", + "consolidateCaptionsToSegment": "Consolidar Subtitulos a segmento", + "createCompoundClip": "Crear compuesto clip", + "detectScenesAi": "IA ({{model}})", + "detectScenesAndSplit": "Detectar escenas & dividir", + "detectScenesFast": "Rapido (Histograma)", + "detectingFillers": "Detectando muletillas", + "detectingScenes": "Detectando escenas", + "detectingSilence": "Detectando silencio", + "dissolveCompoundClip": "Disolver compuesto clip", + "extractEmbeddedSubtitles": "Extraer incrustados subtitulos", + "generateAudioFromText": "Generar Audio desde Texto", + "generateCaptions": "Generar Subtitulos", + "insertExistingCaptions": "Insertar existentes Subtitulos", + "insertFreezeFrame": "Insertar congelado fotograma", + "joinSelected": "Unir seleccionados", + "joinWithNext": "Unir con siguiente", + "joinWithPrevious": "Unir con anterior", + "linkClips": "Vincular clips", + "openCompoundClip": "Abrir compuesto clip", + "regenerateCaptions": "Regenerar Subtitulos", + "removeFillerWords": "Eliminar muletilla palabras", + "removeSilence": "Eliminar silencio", + "reverse": "Invertir", + "rippleDelete": "Ripple Eliminar", + "unlinkClips": "Desvincular clips", + "unreverse": "Quitar inversion", + "updatingCaptions": "Actualizando Subtitulos" + }, + "fadeHandles": { + "adjustAudioFadeIn": "Ajustar Audio fundido entrada", + "adjustAudioFadeInCurve": "Ajustar Audio fundido entrada curva", + "adjustAudioFadeOut": "Ajustar Audio fundido salida", + "adjustAudioFadeOutCurve": "Ajustar Audio fundido salida curva", + "adjustVideoFadeIn": "Ajustar Video fundido entrada", + "adjustVideoFadeOut": "Ajustar Video fundido salida" + }, + "fillerRemoval": { + "aboutWillBeRemoved": "Se eliminarán unos {{duration}}.", + "add": "Agregar", + "addPhrase": "Agregar frase", + "addWord": "Agregar palabra", + "cutPadding": "corte Margen", + "filler": "muletilla", + "fillerRange": "muletilla", + "found": "Encontrado", + "includeRange": "Incluir rango", + "maxPhrase": "Maximo frase", + "maxWord": "Maximo palabra", + "noEntriesFound": "Sin entradas Encontrado", + "noRemovableDetectedShort": "No se encontraron muletillas.", + "none": "Ninguno", + "phrases": "frases", + "playThisRange": "Reproducir este rango", + "rangesSelected": "rangos seleccionados", + "redo": "Rehacer", + "redoEditTitle": "Rehacer la última edición", + "remove": "Eliminar", + "removed": "Eliminado", + "scoreAudio": "Puntuar Audio", + "scoring": "Puntuando", + "title": "Eliminar muletillas", + "toastAudioScored": "Análisis de confianza del audio completado.", + "toastNoRemovable": "Ninguna muletilla coincide con la configuración actual.", + "toastNoneInClips": "No hay muletillas que eliminar en los clips seleccionados.", + "toastPreviewFailed": "No se pudo previsualizar la eliminación.", + "toastRemoveFailed": "No se pudieron eliminar las muletillas.", + "toastRemoved": "Se eliminaron {{count}} rangos de muletillas.", + "toastScoreFailed": "No se pudo analizar la confianza del audio.", + "undo": "Deshacer", + "undoEditTitle": "Deshacer la última edición", + "updatePreview": "actualizar vista previa", + "updating": "Actualizando", + "words": "palabras" + }, + "header": { + "addMarker": "Agregar marcador", + "addMarkerTooltip": "Agregar marcador", + "clearAllMarkers": "Limpiar todo marcadores", + "clearAllMarkersTooltip": "Limpiar todo marcadores", + "clearInOutPoints": "Limpiar entrada salida puntos", + "clearInOutPointsTooltip": "Limpiar entrada salida puntos", + "controls": "Controles", + "disableLinkedSelection": "Desactivar vinculada seleccion", + "disableSnapping": "Desactivar ajuste", + "enableLinkedSelection": "Activar vinculada seleccion", + "enableSnapping": "Activar ajuste", + "hideColorScopes": "Ocultar color scopes", + "hideColorScopesTooltip": "Ocultar color scopes", + "linkedSelectionOff": "vinculada seleccion apagado", + "linkedSelectionOn": "vinculada seleccion encendido", + "linkedSelectionTooltip": "Selección vinculada: {{state}} ({{shortcut}})", + "rateStretchTool": "tasa estirar herramienta", + "rateStretchToolTooltip": "tasa estirar herramienta", + "razorTool": "cuchilla herramienta", + "razorToolTooltip": "cuchilla herramienta", + "redo": "Rehacer", + "redoTooltip": "Rehacer", + "redoWithLabel": "Rehacer {{label}}", + "redoWithLabelTooltip": "Rehacer {{label}}", + "removeSelectedMarker": "Eliminar seleccionados marcador", + "removeSelectedMarkerTooltip": "Eliminar seleccionados marcador", + "selectTool": "Seleccionar herramienta", + "selectToolTooltip": "Seleccionar herramienta", + "setInPoint": "Definir entrada punto", + "setInPointTooltip": "Definir entrada punto", + "setOutPoint": "Definir salida punto", + "setOutPointTooltip": "Definir salida punto", + "showColorScopes": "Mostrar color scopes", + "showColorScopesTooltip": "Mostrar color scopes", + "slideTool": "slide herramienta", + "slipSlideTools": "slip slide Tools", + "slipSlideToolsTooltip": "slip slide Tools", + "slipTool": "slip herramienta", + "snapDisabled": "Ajuste desactivado", + "snapEnabled": "Ajuste activado", + "title": "Línea de tiempo", + "trimEditTool": "recorte edicion herramienta", + "trimEditToolTooltip": "recorte edicion herramienta", + "undo": "Deshacer", + "undoTooltip": "Deshacer", + "undoWithLabel": "Deshacer {{label}}", + "undoWithLabelTooltip": "Deshacer {{label}}", + "zoomIn": "zoom entrada", + "zoomInTooltip": "zoom entrada", + "zoomOut": "zoom salida", + "zoomOutTooltip": "zoom salida", + "zoomSlider": "zoom control", + "zoomToFit": "zoom a ajustar", + "zoomToFitTooltip": "zoom a ajustar" + }, + "itemActions": { + "appliedEffectToClips": "Applied efecto a clips", + "selectAvClipFirst": "Seleccionar AV clip primero" + }, + "joinIndicators": { + "canJoinNext": "Puede Unir siguiente", + "canJoinPrevious": "Puede Unir anterior" + }, + "keyframeEditor": { + "bezier": "Curva Bezier", + "custom": "Personalizado", + "dragHandlesHint": "Arrastra los puntos para dar forma a la curva.", + "graph": "grafico", + "mixedCurves": "mixto curvas", + "mixedSpring": "mixto resorte", + "movedKeyframes": "movidos fotogramas clave", + "noKeyframesPasted": "Sin fotogramas clave pegados", + "pastedKeyframes": "pegados fotogramas clave", + "preset": "Preajuste", + "reasonBlocked": "{{count}} bloqueados por otro fotograma clave", + "reasonUnsupported": "{{count}} no admitidos por la propiedad de destino", + "selectItem": "Seleccionar elemento", + "sheet": "hoja", + "skippedDescription": "Se omitieron {{count}}: {{reasons}}", + "spring": "resorte", + "springHint": "Física de resorte: movimiento natural y elástico.", + "title": "Fotogramas clave", + "unableToPasteCut": "No se pudo a pegar corte", + "unableToPasteCutDescription": "Los fotogramas clave cortados no se pueden pegar aquí. {{reasons}}" + }, + "noTracksToRemove": "Sin pistas a Eliminar", + "region": "region", + "removeActiveTrack": "Eliminar activa Pista", + "removeSelectedTracks": "Eliminar seleccionados pistas", + "reverseConform": { + "cancellingDescription": "Cancelando la preparación de la inversión…", + "couldNotPrepare": "No se pudo preparar el clip para reproducción invertida.", + "progressDescription": "Preparando clip {{current}} de {{total}}…", + "titleCancelling": "Cancelando", + "titleFailed": "Error al invertir", + "titlePreparing": "Preparando reproducción invertida" + }, + "sceneDetection": { + "detectingScenes": "Detectando escenas", + "failed": "Error", + "noScenesDetected": "Sin escenas Detected", + "noScenesWithinBounds": "Sin escenas dentro limites", + "noValidSplitPoints": "Sin valido dividir puntos", + "requiresWebGpu": "Requiere Web GPU", + "splitAtScenes": "dividir At escenas" + }, + "selectTrackToRemove": "Seleccionar Pista a Eliminar", + "silenceRemoval": { + "aboutWillBeRemoved": "Se eliminarán unos {{duration}} de silencio.", + "keepPadding": "Mantener Margen", + "minimumSilence": "Minimo silencio", + "noRemovableDetectedShort": "No se encontraron silencios.", + "rangesSelected": "rangos seleccionados", + "remove": "Eliminar", + "threshold": "Umbral", + "title": "Eliminar silencios", + "toastNoRemovable": "Ningún silencio coincide con la configuración actual.", + "toastNoneInClips": "No hay silencios que eliminar en los clips seleccionados.", + "toastPreviewFailed": "No se pudo previsualizar la eliminación de silencios.", + "toastRemoved": "Se eliminaron {{count}} rangos de silencio.", + "toastSettingsChanged": "Ajustes modificados: previsualiza antes de aplicar.", + "updatePreview": "actualizar vista previa", + "updating": "Actualizando" + }, + "trackHeader": { + "addAudioTrack": "Agregar Audio Pista", + "addVideoTrack": "Agregar Video Pista", + "clipCount": "clip Count", + "closeAllGaps": "Cerrar todo espacios", + "deleteEmptyTracks": "Eliminar vacias pistas", + "deleteTrack": "Eliminar Pista", + "disableSyncLock": "Desactivar sincronizacion bloqueo", + "disableTrack": "Desactivar Pista", + "enableSyncLock": "Activar sincronizacion bloqueo", + "enableTrack": "Activar Pista", + "lockTrack": "bloqueo Pista", + "soloTrack": "solo Pista", + "unlockTrack": "desbloquear Pista", + "unsoloTrack": "quitar solo Pista" + }, + "trackRow": { + "resizeSections": "redimensionar Video and Audio Pista secciones", + "resizeTrackHeight": "redimensionar Pista altura" + }, + "tracks": "pistas", + "volumeControl": { + "adjustClipVolume": "Ajustar clip volumen" } } }, @@ -28,7 +548,269 @@ "unableToAddDroppedMediaItems": "Impossible a Ajouter deposes Media elements", "unableToAddDroppedFiles": "Impossible a Ajouter deposes fichiers a the timeline", "droppedMediaItems": "deposes Media elements", - "droppedFiles": "deposes fichiers" + "droppedFiles": "deposes fichiers", + "locked": "verrouille" + }, + "addAudioTrackHint": "Ajouter Audio Piste Aide", + "addVideoTrackHint": "Ajouter Video Piste Aide", + "bento": { + "apply": "Appliquer", + "description": "Disposer les {{count}} clips sélectionnés en grille.", + "gap": "Ecart", + "noItemsToArrange": "Aucun elements a organiser", + "padding": "Marge", + "presetNamePlaceholder": "Prereglage Nom indication", + "saveAsPreset": "Enregistrer comme Prereglage", + "title": "Mise en page bento" + }, + "captions": { + "addedFromTranscript": "Ajoute depuis transcription", + "addedWithModel": "Ajoute avec modele", + "failedGenerateSegment": "Echec Generer segment", + "failedUpdateSegment": "Echec mettre a jour segment", + "refreshedWithModel": "Actualise avec modele", + "removedFromSegment": "Supprime depuis segment", + "updatedFromTranscript": "Updated depuis transcription", + "updatedWithModel": "Updated avec modele" + }, + "clipIndicators": { + "hasKeyframes": "Has images cles", + "mask": "Masque", + "mediaMissing": "Media manquant", + "preparingReversed": "Preparation inverse", + "reversePrepFailedShort": "Inverser preparation Echec Short", + "reversedPlayback": "inverse Lecture", + "reversedPrepFailed": "inverse preparation Echec", + "reversedPrepared": "inverse Prepared", + "speed": "Vitesse: {{speed}}x" + }, + "contextMenu": { + "bentoLayout": "Bento Mise en page", + "captions": "Sous-titres", + "clearAll": "Effacer tout", + "clearKeyframes": "Effacer images cles", + "consolidateCaptionsToSegment": "Consolider Sous-titres a segment", + "createCompoundClip": "Creer compose clip", + "detectScenesAi": "IA ({{model}})", + "detectScenesAndSplit": "Detecter scenes & diviser", + "detectScenesFast": "Rapide (Histogramme)", + "detectingFillers": "Detection tics", + "detectingScenes": "Detection scenes", + "detectingSilence": "Detection silence", + "dissolveCompoundClip": "Dissoudre compose clip", + "extractEmbeddedSubtitles": "Extraire integres sous-titres", + "generateAudioFromText": "Generer Audio depuis Texte", + "generateCaptions": "Generer Sous-titres", + "insertExistingCaptions": "Inserer existants Sous-titres", + "insertFreezeFrame": "Inserer fige image", + "joinSelected": "Joindre selectionnes", + "joinWithNext": "Joindre avec suivant", + "joinWithPrevious": "Joindre avec precedent", + "linkClips": "Lier clips", + "openCompoundClip": "Ouvrir compose clip", + "regenerateCaptions": "Regenerer Sous-titres", + "removeFillerWords": "Supprimer tic mots", + "removeSilence": "Supprimer silence", + "reverse": "Inverser", + "rippleDelete": "Ripple Supprimer", + "unlinkClips": "Delier clips", + "unreverse": "Annuler inversion", + "updatingCaptions": "Mise a jour Sous-titres" + }, + "fadeHandles": { + "adjustAudioFadeIn": "Ajuster Audio fondu entree", + "adjustAudioFadeInCurve": "Ajuster Audio fondu entree courbe", + "adjustAudioFadeOut": "Ajuster Audio fondu sortie", + "adjustAudioFadeOutCurve": "Ajuster Audio fondu sortie courbe", + "adjustVideoFadeIn": "Ajuster Video fondu entree", + "adjustVideoFadeOut": "Ajuster Video fondu sortie" + }, + "fillerRemoval": { + "aboutWillBeRemoved": "Environ {{duration}} seront supprimés.", + "add": "Ajouter", + "addPhrase": "Ajouter phrase", + "addWord": "Ajouter mot", + "cutPadding": "coupe Marge", + "filler": "tic", + "fillerRange": "hésitation", + "found": "Trouve", + "includeRange": "Inclure plage", + "maxPhrase": "Max phrase", + "maxWord": "Max mot", + "noEntriesFound": "Aucun entrees Trouve", + "noRemovableDetectedShort": "Aucune hésitation détectée.", + "none": "Aucun", + "phrases": "phrases", + "playThisRange": "Lire cette plage", + "rangesSelected": "plages selectionnes", + "redo": "Retablir", + "redoEditTitle": "Rétablir la dernière modification", + "remove": "Supprimer", + "removed": "Supprime", + "scoreAudio": "Evaluer Audio", + "scoring": "Evaluation", + "title": "Supprimer les hésitations", + "toastAudioScored": "Analyse de confiance audio terminée.", + "toastNoRemovable": "Aucune hésitation ne correspond aux réglages actuels.", + "toastNoneInClips": "Aucune hésitation à supprimer dans les clips sélectionnés.", + "toastPreviewFailed": "Impossible de prévisualiser la suppression.", + "toastRemoveFailed": "Impossible de supprimer les hésitations.", + "toastRemoved": "{{count}} plages d'hésitations supprimées.", + "toastScoreFailed": "Impossible d'analyser la confiance audio.", + "undo": "Annuler", + "undoEditTitle": "Annuler la dernière modification", + "updatePreview": "mettre a jour apercu", + "updating": "Mise a jour", + "words": "mots" + }, + "header": { + "addMarker": "Ajouter marqueur", + "addMarkerTooltip": "Ajouter marqueur", + "clearAllMarkers": "Effacer tout marqueurs", + "clearAllMarkersTooltip": "Effacer tout marqueurs", + "clearInOutPoints": "Effacer entree sortie points", + "clearInOutPointsTooltip": "Effacer entree sortie points", + "controls": "Controles", + "disableLinkedSelection": "Desactiver liee selection", + "disableSnapping": "Desactiver aimantation", + "enableLinkedSelection": "Activer liee selection", + "enableSnapping": "Activer aimantation", + "hideColorScopes": "Masquer couleur scopes", + "hideColorScopesTooltip": "Masquer couleur scopes", + "linkedSelectionOff": "liee selection desactive", + "linkedSelectionOn": "liee selection active", + "linkedSelectionTooltip": "Sélection liée : {{state}} ({{shortcut}})", + "rateStretchTool": "vitesse etirer outil", + "rateStretchToolTooltip": "vitesse etirer outil", + "razorTool": "rasoir outil", + "razorToolTooltip": "rasoir outil", + "redo": "Retablir", + "redoTooltip": "Retablir", + "redoWithLabel": "Retablir {{label}}", + "redoWithLabelTooltip": "Retablir {{label}}", + "removeSelectedMarker": "Supprimer selectionnes marqueur", + "removeSelectedMarkerTooltip": "Supprimer selectionnes marqueur", + "selectTool": "Selectionner outil", + "selectToolTooltip": "Selectionner outil", + "setInPoint": "Definir entree point", + "setInPointTooltip": "Definir entree point", + "setOutPoint": "Definir sortie point", + "setOutPointTooltip": "Definir sortie point", + "showColorScopes": "Afficher couleur scopes", + "showColorScopesTooltip": "Afficher couleur scopes", + "slideTool": "slide outil", + "slipSlideTools": "slip slide Tools", + "slipSlideToolsTooltip": "slip slide Tools", + "slipTool": "slip outil", + "snapDisabled": "Aimantation desactivee", + "snapEnabled": "Aimantation activee", + "title": "Montage", + "trimEditTool": "ajustement edition outil", + "trimEditToolTooltip": "ajustement edition outil", + "undo": "Annuler", + "undoTooltip": "Annuler", + "undoWithLabel": "Annuler {{label}}", + "undoWithLabelTooltip": "Annuler {{label}}", + "zoomIn": "zoom entree", + "zoomInTooltip": "zoom entree", + "zoomOut": "zoom sortie", + "zoomOutTooltip": "zoom sortie", + "zoomSlider": "zoom curseur", + "zoomToFit": "zoom a adapter", + "zoomToFitTooltip": "zoom a adapter" + }, + "itemActions": { + "appliedEffectToClips": "Applied effet a clips", + "selectAvClipFirst": "Selectionner AV clip d abord" + }, + "joinIndicators": { + "canJoinNext": "Peut Joindre suivant", + "canJoinPrevious": "Peut Joindre precedent" + }, + "keyframeEditor": { + "bezier": "Courbe Bezier", + "custom": "Personnalise", + "dragHandlesHint": "Faites glisser les poignées pour modeler la courbe.", + "graph": "graphe", + "mixedCurves": "mixte courbes", + "mixedSpring": "mixte ressort", + "movedKeyframes": "deplacees images cles", + "noKeyframesPasted": "Aucun images cles collees", + "pastedKeyframes": "collees images cles", + "preset": "Prereglage", + "reasonBlocked": "{{count}} bloqué(s) par une autre image clé", + "reasonUnsupported": "{{count}} non pris en charge par la propriété cible", + "selectItem": "Selectionner element", + "sheet": "feuille", + "skippedDescription": "{{count}} ignoré(s) : {{reasons}}", + "spring": "ressort", + "springHint": "Physique de ressort — naturel et rebondissant.", + "title": "Images clés", + "unableToPasteCut": "Impossible a coller coupe", + "unableToPasteCutDescription": "Impossible de coller ici les images clés coupées. {{reasons}}" + }, + "noTracksToRemove": "Aucun pistes a Supprimer", + "region": "region", + "removeActiveTrack": "Supprimer active Piste", + "removeSelectedTracks": "Supprimer selectionnes pistes", + "reverseConform": { + "cancellingDescription": "Annulation de la préparation de l'inversion…", + "couldNotPrepare": "Impossible de préparer le clip pour la lecture inversée.", + "progressDescription": "Préparation du clip {{current}} sur {{total}}…", + "titleCancelling": "Annulation", + "titleFailed": "Échec de l'inversion", + "titlePreparing": "Préparation de la lecture inversée" + }, + "sceneDetection": { + "detectingScenes": "Detection scenes", + "failed": "Echec", + "noScenesDetected": "Aucun scenes Detected", + "noScenesWithinBounds": "Aucun scenes dans limites", + "noValidSplitPoints": "Aucun valide diviser points", + "requiresWebGpu": "Requiert Web GPU", + "splitAtScenes": "diviser At scenes" + }, + "selectTrackToRemove": "Selectionner Piste a Supprimer", + "silenceRemoval": { + "aboutWillBeRemoved": "Environ {{duration}} de silence seront supprimés.", + "keepPadding": "Garder Marge", + "minimumSilence": "Minimum silence", + "noRemovableDetectedShort": "Aucun silence détecté.", + "rangesSelected": "plages selectionnes", + "remove": "Supprimer", + "threshold": "Seuil", + "title": "Supprimer les silences", + "toastNoRemovable": "Aucun silence ne correspond aux réglages actuels.", + "toastNoneInClips": "Aucun silence à supprimer dans les clips sélectionnés.", + "toastPreviewFailed": "Impossible de prévisualiser la suppression du silence.", + "toastRemoved": "{{count}} plages de silence supprimées.", + "toastSettingsChanged": "Réglages modifiés — prévisualisez avant d'appliquer.", + "updatePreview": "mettre a jour apercu", + "updating": "Mise a jour" + }, + "trackHeader": { + "addAudioTrack": "Ajouter Audio Piste", + "addVideoTrack": "Ajouter Video Piste", + "clipCount": "clip Count", + "closeAllGaps": "Fermer tout ecarts", + "deleteEmptyTracks": "Supprimer vides pistes", + "deleteTrack": "Supprimer Piste", + "disableSyncLock": "Desactiver synchro verrou", + "disableTrack": "Desactiver Piste", + "enableSyncLock": "Activer synchro verrou", + "enableTrack": "Activer Piste", + "lockTrack": "verrou Piste", + "soloTrack": "solo Piste", + "unlockTrack": "deverrouiller Piste", + "unsoloTrack": "retirer solo Piste" + }, + "trackRow": { + "resizeSections": "redimensionner Video and Audio Piste sections", + "resizeTrackHeight": "redimensionner Piste hauteur" + }, + "tracks": "pistes", + "volumeControl": { + "adjustClipVolume": "Ajuster clip volume" } } }, @@ -39,7 +821,269 @@ "unableToAddDroppedMediaItems": "Nicht moglich zu Hinzufugen abgelegte Medien Elemente", "unableToAddDroppedFiles": "Nicht moglich zu Hinzufugen abgelegte Dateien zu the timeline", "droppedMediaItems": "abgelegte Medien Elemente", - "droppedFiles": "abgelegte Dateien" + "droppedFiles": "abgelegte Dateien", + "locked": "gesperrt" + }, + "addAudioTrackHint": "Hinzufugen Audio Spur Hinweis", + "addVideoTrackHint": "Hinzufugen Video Spur Hinweis", + "bento": { + "apply": "Anwenden", + "description": "{{count}} ausgewählte Clips in einem Raster anordnen.", + "gap": "Lucke", + "noItemsToArrange": "Keine Elemente zu anordnen", + "padding": "Abstand", + "presetNamePlaceholder": "Vorgabe Name Platzhalter", + "saveAsPreset": "Speichern als Vorgabe", + "title": "Bento-Layout" + }, + "captions": { + "addedFromTranscript": "Hinzugefugt aus Transkript", + "addedWithModel": "Hinzugefugt mit Modell", + "failedGenerateSegment": "Fehlgeschlagen Generieren Segment", + "failedUpdateSegment": "Fehlgeschlagen aktualisieren Segment", + "refreshedWithModel": "Aktualisiert mit Modell", + "removedFromSegment": "Entfernt aus Segment", + "updatedFromTranscript": "Updated aus Transkript", + "updatedWithModel": "Updated mit Modell" + }, + "clipIndicators": { + "hasKeyframes": "Hat Keyframes", + "mask": "Maske", + "mediaMissing": "Medien fehlt", + "preparingReversed": "Vorbereiten ruckwarts", + "reversePrepFailedShort": "Umkehren Vorbereitung Fehlgeschlagen Short", + "reversedPlayback": "ruckwarts Wiedergabe", + "reversedPrepFailed": "ruckwarts Vorbereitung Fehlgeschlagen", + "reversedPrepared": "ruckwarts Prepared", + "speed": "Geschwindigkeit: {{speed}}x" + }, + "contextMenu": { + "bentoLayout": "Bento-Layout", + "captions": "Untertitel", + "clearAll": "Loschen alle", + "clearKeyframes": "Loschen Keyframes", + "consolidateCaptionsToSegment": "Zusammenfassen Untertitel zu Segment", + "createCompoundClip": "Erstellen zusammengesetzt Clip", + "detectScenesAi": "KI ({{model}})", + "detectScenesAndSplit": "Erkennen Szenen & teilen", + "detectScenesFast": "Schnell (Histogramm)", + "detectingFillers": "Erkennen Fuller", + "detectingScenes": "Erkennen Szenen", + "detectingSilence": "Erkennen Stille", + "dissolveCompoundClip": "Auflosen zusammengesetzt Clip", + "extractEmbeddedSubtitles": "Extrahieren eingebettet Untertitel", + "generateAudioFromText": "Generieren Audio aus Text", + "generateCaptions": "Generieren Untertitel", + "insertExistingCaptions": "Einfugen vorhandene Untertitel", + "insertFreezeFrame": "Einfugen Standbild Frame", + "joinSelected": "Verbinden ausgewahlt", + "joinWithNext": "Verbinden mit nachstem", + "joinWithPrevious": "Verbinden mit vorherigem", + "linkClips": "Verknupfen Clips", + "openCompoundClip": "Offnen zusammengesetzt Clip", + "regenerateCaptions": "Neu generieren Untertitel", + "removeFillerWords": "Entfernen Fuller Worter", + "removeSilence": "Entfernen Stille", + "reverse": "Umkehren", + "rippleDelete": "Ripple Loschen", + "unlinkClips": "Trennen Clips", + "unreverse": "Umkehrung aufheben", + "updatingCaptions": "Aktualisieren Untertitel" + }, + "fadeHandles": { + "adjustAudioFadeIn": "Anpassen Audio Fade ein", + "adjustAudioFadeInCurve": "Anpassen Audio Fade ein Kurve", + "adjustAudioFadeOut": "Anpassen Audio Fade aus", + "adjustAudioFadeOutCurve": "Anpassen Audio Fade aus Kurve", + "adjustVideoFadeIn": "Anpassen Video Fade ein", + "adjustVideoFadeOut": "Anpassen Video Fade aus" + }, + "fillerRemoval": { + "aboutWillBeRemoved": "Etwa {{duration}} werden entfernt.", + "add": "Hinzufugen", + "addPhrase": "Hinzufugen Phrase", + "addWord": "Hinzufugen Wort", + "cutPadding": "Schnitt Abstand", + "filler": "Fuller", + "fillerRange": "Füllwort", + "found": "Gefunden", + "includeRange": "Einschliessen Bereich", + "maxPhrase": "Maximale Phrase", + "maxWord": "Max Wort", + "noEntriesFound": "Keine Eintrage Gefunden", + "noRemovableDetectedShort": "Keine Füllwörter gefunden.", + "none": "Keine", + "phrases": "Phrasen", + "playThisRange": "Abspielen diesen Bereich", + "rangesSelected": "Bereiche ausgewahlt", + "redo": "Wiederholen", + "redoEditTitle": "Letzte Änderung wiederherstellen", + "remove": "Entfernen", + "removed": "Entfernt", + "scoreAudio": "Bewerten Audio", + "scoring": "Bewertung", + "title": "Füllwörter entfernen", + "toastAudioScored": "Audio-Vertrauensbewertung abgeschlossen.", + "toastNoRemovable": "Keine Füllwörter passten zu den aktuellen Einstellungen.", + "toastNoneInClips": "Keine zu entfernenden Füllwörter in den ausgewählten Clips.", + "toastPreviewFailed": "Vorschau der Entfernung konnte nicht erstellt werden.", + "toastRemoveFailed": "Füllwörter konnten nicht entfernt werden.", + "toastRemoved": "{{count}} Füllwort-Bereiche entfernt.", + "toastScoreFailed": "Audio-Vertrauen konnte nicht analysiert werden.", + "undo": "Ruckgangig", + "undoEditTitle": "Letzte Änderung rückgängig machen", + "updatePreview": "aktualisieren Vorschau", + "updating": "Aktualisieren", + "words": "Worter" + }, + "header": { + "addMarker": "Hinzufugen Marker", + "addMarkerTooltip": "Hinzufugen Marker", + "clearAllMarkers": "Loschen alle Marker", + "clearAllMarkersTooltip": "Loschen alle Marker", + "clearInOutPoints": "Loschen ein aus Punkte", + "clearInOutPointsTooltip": "Loschen ein aus Punkte", + "controls": "Steuerung", + "disableLinkedSelection": "Deaktivieren verknupfte Auswahl", + "disableSnapping": "Deaktivieren Einrasten", + "enableLinkedSelection": "Aktivieren verknupfte Auswahl", + "enableSnapping": "Aktivieren Einrasten", + "hideColorScopes": "Ausblenden Farbe Scopes", + "hideColorScopesTooltip": "Ausblenden Farbe Scopes", + "linkedSelectionOff": "verknupfte Auswahl aus", + "linkedSelectionOn": "verknupfte Auswahl an", + "linkedSelectionTooltip": "Verknüpfte Auswahl: {{state}} ({{shortcut}})", + "rateStretchTool": "Rate Strecken Werkzeug", + "rateStretchToolTooltip": "Rate Strecken Werkzeug", + "razorTool": "Rasiermesser Werkzeug", + "razorToolTooltip": "Rasiermesser Werkzeug", + "redo": "Wiederholen", + "redoTooltip": "Wiederholen", + "redoWithLabel": "Wiederholen {{label}}", + "redoWithLabelTooltip": "Wiederholen {{label}}", + "removeSelectedMarker": "Entfernen ausgewahlt Marker", + "removeSelectedMarkerTooltip": "Entfernen ausgewahlt Marker", + "selectTool": "Auswahlen Werkzeug", + "selectToolTooltip": "Auswahlen Werkzeug", + "setInPoint": "Setzen ein Punkt", + "setInPointTooltip": "Setzen ein Punkt", + "setOutPoint": "Setzen aus Punkt", + "setOutPointTooltip": "Setzen aus Punkt", + "showColorScopes": "Anzeigen Farbe Scopes", + "showColorScopesTooltip": "Anzeigen Farbe Scopes", + "slideTool": "Slide Werkzeug", + "slipSlideTools": "Slip/Slide-Werkzeuge", + "slipSlideToolsTooltip": "Slip/Slide-Werkzeuge", + "slipTool": "Slip Werkzeug", + "snapDisabled": "Einrasten deaktiviert", + "snapEnabled": "Einrasten aktiviert", + "title": "Timeline", + "trimEditTool": "Trimmen Bearbeitung Werkzeug", + "trimEditToolTooltip": "Trimmen Bearbeitung Werkzeug", + "undo": "Ruckgangig", + "undoTooltip": "Ruckgangig", + "undoWithLabel": "Ruckgangig {{label}}", + "undoWithLabelTooltip": "Ruckgangig {{label}}", + "zoomIn": "Zoom ein", + "zoomInTooltip": "Zoom ein", + "zoomOut": "Zoom aus", + "zoomOutTooltip": "Zoom aus", + "zoomSlider": "Zoom Regler", + "zoomToFit": "Zoom zu anpassen", + "zoomToFitTooltip": "Zoom zu anpassen" + }, + "itemActions": { + "appliedEffectToClips": "Applied Effekt zu Clips", + "selectAvClipFirst": "Auswahlen AV Clip zuerst" + }, + "joinIndicators": { + "canJoinNext": "Kann Verbinden nachstem", + "canJoinPrevious": "Kann Verbinden vorherigem" + }, + "keyframeEditor": { + "bezier": "Bezier-Kurve", + "custom": "Benutzerdefiniert", + "dragHandlesHint": "Ziehe die Griffe, um die Kurve zu formen.", + "graph": "Diagramm", + "mixedCurves": "gemischt Kurven", + "mixedSpring": "gemischt Feder", + "movedKeyframes": "verschoben Keyframes", + "noKeyframesPasted": "Keine Keyframes eingefugt", + "pastedKeyframes": "eingefugt Keyframes", + "preset": "Vorgabe", + "reasonBlocked": "{{count}} von einem anderen Keyframe blockiert", + "reasonUnsupported": "{{count}} von der Zieleigenschaft nicht unterstützt", + "selectItem": "Auswahlen Element", + "sheet": "Tabelle", + "skippedDescription": "{{count}} übersprungen: {{reasons}}", + "spring": "Feder", + "springHint": "Federphysik — natürlich und federnd.", + "title": "Keyframes", + "unableToPasteCut": "Nicht moglich zu einfugen Schnitt", + "unableToPasteCutDescription": "Ausgeschnittene Keyframes können hier nicht eingefügt werden. {{reasons}}" + }, + "noTracksToRemove": "Keine Spuren zu Entfernen", + "region": "Bereich", + "removeActiveTrack": "Entfernen aktive Spur", + "removeSelectedTracks": "Entfernen ausgewahlt Spuren", + "reverseConform": { + "cancellingDescription": "Vorbereitung der Umkehrung wird abgebrochen…", + "couldNotPrepare": "Clip konnte nicht für die Rückwärtswiedergabe vorbereitet werden.", + "progressDescription": "Clip {{current}} von {{total}} wird vorbereitet…", + "titleCancelling": "Wird abgebrochen", + "titleFailed": "Umkehrung fehlgeschlagen", + "titlePreparing": "Rückwärtswiedergabe wird vorbereitet" + }, + "sceneDetection": { + "detectingScenes": "Erkennen Szenen", + "failed": "Fehlgeschlagen", + "noScenesDetected": "Keine Szenen Detected", + "noScenesWithinBounds": "Keine Szenen innerhalb Grenzen", + "noValidSplitPoints": "Keine gultig teilen Punkte", + "requiresWebGpu": "Benotigt Web GPU", + "splitAtScenes": "teilen At Szenen" + }, + "selectTrackToRemove": "Auswahlen Spur zu Entfernen", + "silenceRemoval": { + "aboutWillBeRemoved": "Etwa {{duration}} Stille werden entfernt.", + "keepPadding": "Behalten Abstand", + "minimumSilence": "Minimum Stille", + "noRemovableDetectedShort": "Keine Stille gefunden.", + "rangesSelected": "Bereiche ausgewahlt", + "remove": "Entfernen", + "threshold": "Schwellwert", + "title": "Stille entfernen", + "toastNoRemovable": "Keine Stille passte zu den aktuellen Einstellungen.", + "toastNoneInClips": "Keine zu entfernende Stille in den ausgewählten Clips.", + "toastPreviewFailed": "Vorschau der Stilleentfernung konnte nicht erstellt werden.", + "toastRemoved": "{{count}} Stille-Bereiche entfernt.", + "toastSettingsChanged": "Einstellungen geändert — vor dem Anwenden Vorschau anzeigen.", + "updatePreview": "aktualisieren Vorschau", + "updating": "Aktualisieren" + }, + "trackHeader": { + "addAudioTrack": "Hinzufugen Audio Spur", + "addVideoTrack": "Hinzufugen Video Spur", + "clipCount": "Clip-Anzahl", + "closeAllGaps": "Schliessen alle Lucken", + "deleteEmptyTracks": "Loschen leere Spuren", + "deleteTrack": "Loschen Spur", + "disableSyncLock": "Deaktivieren Sync Sperre", + "disableTrack": "Deaktivieren Spur", + "enableSyncLock": "Aktivieren Sync Sperre", + "enableTrack": "Aktivieren Spur", + "lockTrack": "Sperre Spur", + "soloTrack": "Solo Spur", + "unlockTrack": "entsperren Spur", + "unsoloTrack": "Solo aus Spur" + }, + "trackRow": { + "resizeSections": "Grosse andern Video and Audio Spur Abschnitte", + "resizeTrackHeight": "Grosse andern Spur Hohe" + }, + "tracks": "Spuren", + "volumeControl": { + "adjustClipVolume": "Anpassen Clip Lautstarke" } } }, @@ -50,7 +1094,538 @@ "unableToAddDroppedMediaItems": "Nao foi possivel adicionar os itens de midia soltos", "unableToAddDroppedFiles": "Nao foi possivel adicionar os arquivos soltos a linha do tempo", "droppedMediaItems": "itens de midia soltos", - "droppedFiles": "arquivos soltos" + "droppedFiles": "arquivos soltos", + "locked": "Bloqueada" + }, + "addAudioTrackHint": "Adicionar faixa de audio", + "addVideoTrackHint": "Adicionar faixa de video", + "bento": { + "apply": "Aplicar", + "description": "Organize {{count}} clipes selecionados em uma grade.", + "gap": "Espacamento", + "noItemsToArrange": "Nenhum item para organizar", + "padding": "Preenchimento", + "presetNamePlaceholder": "Nome da predefinicao", + "saveAsPreset": "Salvar como predefinicao", + "title": "Layout bento" + }, + "captions": { + "addedFromTranscript": "Adicionado da transcricao", + "addedWithModel": "Adicionado com modelo", + "failedGenerateSegment": "Falha ao gerar segmento", + "failedUpdateSegment": "Falha ao atualizar segmento", + "refreshedWithModel": "Atualizado com modelo", + "removedFromSegment": "Removido do segmento", + "updatedFromTranscript": "Atualizado da transcricao", + "updatedWithModel": "Atualizado com modelo" + }, + "clipIndicators": { + "hasKeyframes": "Tem keyframes", + "mask": "Mascara", + "mediaMissing": "Midia ausente", + "preparingReversed": "Preparando reverso", + "reversePrepFailedShort": "Falha ao preparar reverso", + "reversedPlayback": "Reproducao reversa", + "reversedPrepFailed": "Falha ao preparar reverso", + "reversedPrepared": "Reverso preparado", + "speed": "Velocidade: {{speed}}x" + }, + "contextMenu": { + "bentoLayout": "Layout bento", + "captions": "Legendas", + "clearAll": "Limpar tudo", + "clearKeyframes": "Limpar keyframes", + "consolidateCaptionsToSegment": "Consolidar legendas em segmento", + "createCompoundClip": "Criar clipe composto", + "detectScenesAi": "IA ({{model}})", + "detectScenesAndSplit": "Detectar cenas e dividir", + "detectScenesFast": "Rapido (histograma)", + "detectingFillers": "Detectando vicios de fala", + "detectingScenes": "Detectando cenas", + "detectingSilence": "Detectando silencio", + "dissolveCompoundClip": "Dissolver clipe composto", + "extractEmbeddedSubtitles": "Extrair legendas incorporadas", + "generateAudioFromText": "Gerar audio a partir de texto", + "generateCaptions": "Gerar legendas", + "insertExistingCaptions": "Inserir legendas existentes", + "insertFreezeFrame": "Inserir quadro congelado", + "joinSelected": "Unir selecionados", + "joinWithNext": "Unir com o proximo", + "joinWithPrevious": "Unir com o anterior", + "linkClips": "Vincular clipes", + "openCompoundClip": "Abrir clipe composto", + "regenerateCaptions": "Gerar legendas novamente", + "removeFillerWords": "Remover vicios de fala", + "removeSilence": "Remover silencio", + "reverse": "Reverter", + "rippleDelete": "Exclusao ripple", + "unlinkClips": "Desvincular clipes", + "unreverse": "Desfazer reversao", + "updatingCaptions": "Atualizando legendas" + }, + "fadeHandles": { + "adjustAudioFadeIn": "Ajustar fade de entrada do audio", + "adjustAudioFadeInCurve": "Ajustar curva do fade de entrada do audio", + "adjustAudioFadeOut": "Ajustar fade de saida do audio", + "adjustAudioFadeOutCurve": "Ajustar curva do fade de saida do audio", + "adjustVideoFadeIn": "Ajustar fade de entrada do video", + "adjustVideoFadeOut": "Ajustar fade de saida do video" + }, + "fillerRemoval": { + "aboutWillBeRemoved": "Cerca de {{duration}} serão removidos.", + "add": "Adicionar", + "addPhrase": "Adicionar frase", + "addWord": "Adicionar palavra", + "cutPadding": "Margem de corte", + "filler": "Vicio de fala", + "fillerRange": "bordão", + "found": "Encontrado", + "includeRange": "Incluir intervalo", + "maxPhrase": "Frase maxima", + "maxWord": "Palavra maxima", + "noEntriesFound": "Nenhuma entrada encontrada", + "noRemovableDetectedShort": "Nenhum bordão encontrado.", + "none": "Nenhum", + "phrases": "Frases", + "playThisRange": "Reproduzir este intervalo", + "rangesSelected": "Intervalos selecionados", + "redo": "Refazer", + "redoEditTitle": "Refazer última edição", + "remove": "Remover", + "removed": "Removido", + "scoreAudio": "Pontuar audio", + "scoring": "Pontuando", + "title": "Remover bordões", + "toastAudioScored": "Análise de confiança do áudio concluída.", + "toastNoRemovable": "Nenhum bordão corresponde aos ajustes atuais.", + "toastNoneInClips": "Nenhum bordão a remover nos clipes selecionados.", + "toastPreviewFailed": "Não foi possível visualizar a remoção.", + "toastRemoveFailed": "Não foi possível remover os bordões.", + "toastRemoved": "{{count}} trechos de bordões removidos.", + "toastScoreFailed": "Não foi possível analisar a confiança do áudio.", + "undo": "Desfazer", + "undoEditTitle": "Desfazer última edição", + "updatePreview": "Atualizar pre-visualizacao", + "updating": "Atualizando", + "words": "Palavras" + }, + "header": { + "addMarker": "Adicionar marcador", + "addMarkerTooltip": "Adicionar marcador", + "clearAllMarkers": "Limpar todos os marcadores", + "clearAllMarkersTooltip": "Limpar todos os marcadores", + "clearInOutPoints": "Limpar pontos de entrada e saida", + "clearInOutPointsTooltip": "Limpar pontos de entrada e saida", + "controls": "Controles", + "disableLinkedSelection": "Desativar selecao vinculada", + "disableSnapping": "Desativar encaixe", + "enableLinkedSelection": "Ativar selecao vinculada", + "enableSnapping": "Ativar encaixe", + "hideColorScopes": "Ocultar escopos de cor", + "hideColorScopesTooltip": "Ocultar escopos de cor", + "linkedSelectionOff": "Selecao vinculada desligada", + "linkedSelectionOn": "Selecao vinculada ligada", + "linkedSelectionTooltip": "Seleção vinculada: {{state}} ({{shortcut}})", + "rateStretchTool": "Ferramenta de esticar taxa", + "rateStretchToolTooltip": "Ferramenta de esticar taxa", + "razorTool": "Ferramenta navalha", + "razorToolTooltip": "Ferramenta navalha", + "redo": "Refazer", + "redoTooltip": "Refazer", + "redoWithLabel": "Refazer {{label}}", + "redoWithLabelTooltip": "Refazer {{label}}", + "removeSelectedMarker": "Remover marcador selecionado", + "removeSelectedMarkerTooltip": "Remover marcador selecionado", + "selectTool": "Ferramenta de selecao", + "selectToolTooltip": "Ferramenta de selecao", + "setInPoint": "Definir ponto de entrada", + "setInPointTooltip": "Definir ponto de entrada", + "setOutPoint": "Definir ponto de saida", + "setOutPointTooltip": "Definir ponto de saida", + "showColorScopes": "Mostrar escopos de cor", + "showColorScopesTooltip": "Mostrar escopos de cor", + "slideTool": "Ferramenta slide", + "slipSlideTools": "Ferramentas slip/slide", + "slipSlideToolsTooltip": "Ferramentas slip/slide", + "slipTool": "Ferramenta slip", + "snapDisabled": "Encaixe desativado", + "snapEnabled": "Encaixe ativado", + "title": "Linha do tempo", + "trimEditTool": "Ferramenta de ajuste", + "trimEditToolTooltip": "Ferramenta de ajuste", + "undo": "Desfazer", + "undoTooltip": "Desfazer", + "undoWithLabel": "Desfazer {{label}}", + "undoWithLabelTooltip": "Desfazer {{label}}", + "zoomIn": "Aproximar", + "zoomInTooltip": "Aproximar", + "zoomOut": "Afastar", + "zoomOutTooltip": "Afastar", + "zoomSlider": "Controle de zoom", + "zoomToFit": "Ajustar zoom", + "zoomToFitTooltip": "Ajustar zoom" + }, + "itemActions": { + "appliedEffectToClips": "Efeito aplicado aos clipes", + "selectAvClipFirst": "Selecione primeiro um clipe de audio/video" + }, + "joinIndicators": { + "canJoinNext": "Pode unir ao proximo", + "canJoinPrevious": "Pode unir ao anterior" + }, + "keyframeEditor": { + "bezier": "Bézier", + "custom": "Personalizado", + "dragHandlesHint": "Arraste as alças para moldar a curva.", + "graph": "Grafico", + "mixedCurves": "Curvas mistas", + "mixedSpring": "Mola mista", + "movedKeyframes": "Keyframes movidos", + "noKeyframesPasted": "Nenhum keyframe colado", + "pastedKeyframes": "Keyframes colados", + "preset": "Predefinicao", + "reasonBlocked": "{{count}} bloqueado(s) por outro quadro-chave", + "reasonUnsupported": "{{count}} não suportado(s) pela propriedade de destino", + "selectItem": "Selecionar item", + "sheet": "Planilha", + "skippedDescription": "{{count}} ignorado(s): {{reasons}}", + "spring": "Mola", + "springHint": "Física de mola — natural e elástica.", + "title": "Quadros-chave", + "unableToPasteCut": "Nao foi possivel colar corte", + "unableToPasteCutDescription": "Quadros-chave recortados não podem ser colados aqui. {{reasons}}" + }, + "noTracksToRemove": "Nenhuma faixa para remover", + "region": "Regiao", + "removeActiveTrack": "Remover faixa ativa", + "removeSelectedTracks": "Remover faixas selecionadas", + "reverseConform": { + "cancellingDescription": "Cancelando...", + "couldNotPrepare": "Nao foi possivel preparar", + "progressDescription": "Preparando...", + "titleCancelling": "Cancelando", + "titleFailed": "Falhou", + "titlePreparing": "Preparando" + }, + "sceneDetection": { + "detectingScenes": "Detectando cenas", + "failed": "Falhou", + "noScenesDetected": "Nenhuma cena detectada", + "noScenesWithinBounds": "Nenhuma cena dentro dos limites", + "noValidSplitPoints": "Nenhum ponto de divisao valido", + "requiresWebGpu": "Requer WebGPU", + "splitAtScenes": "Dividir nas cenas" + }, + "selectTrackToRemove": "Selecione a faixa para remover", + "silenceRemoval": { + "aboutWillBeRemoved": "Cerca de {{duration}} de silêncio serão removidos.", + "keepPadding": "Manter margem", + "minimumSilence": "Silencio minimo", + "noRemovableDetectedShort": "Nenhum silêncio encontrado.", + "rangesSelected": "Intervalos selecionados", + "remove": "Remover", + "threshold": "Limiar", + "title": "Remover silêncio", + "toastNoRemovable": "Nenhum silêncio corresponde aos ajustes atuais.", + "toastNoneInClips": "Nenhum silêncio a remover nos clipes selecionados.", + "toastPreviewFailed": "Não foi possível visualizar a remoção do silêncio.", + "toastRemoved": "{{count}} trechos de silêncio removidos.", + "toastSettingsChanged": "Ajustes alterados — visualize antes de aplicar.", + "updatePreview": "Atualizar pre-visualizacao", + "updating": "Atualizando" + }, + "trackHeader": { + "addAudioTrack": "Adicionar faixa de audio", + "addVideoTrack": "Adicionar faixa de video", + "clipCount": "Contagem de clipes", + "closeAllGaps": "Fechar todos os espacos", + "deleteEmptyTracks": "Excluir faixas vazias", + "deleteTrack": "Excluir faixa", + "disableSyncLock": "Desativar bloqueio de sincronia", + "disableTrack": "Desativar faixa", + "enableSyncLock": "Ativar bloqueio de sincronia", + "enableTrack": "Ativar faixa", + "lockTrack": "Bloquear faixa", + "soloTrack": "Faixa solo", + "unlockTrack": "Desbloquear faixa", + "unsoloTrack": "Remover solo da faixa" + }, + "trackRow": { + "resizeSections": "Redimensionar secoes das faixas de video e audio", + "resizeTrackHeight": "Redimensionar altura da faixa" + }, + "tracks": "Faixas", + "volumeControl": { + "adjustClipVolume": "Ajustar volume do clipe" + } + } + }, + "tr": { + "timeline": { + "track": { + "unableToAddDroppedItem": "Bırakılan öğe eklenemedi", + "unableToAddDroppedMediaItems": "Bırakılan medya öğeleri eklenemedi", + "unableToAddDroppedFiles": "Bırakılan dosyalar zaman çizelgesine eklenemedi", + "droppedMediaItems": "bırakılan medya öğeleri", + "droppedFiles": "bırakılan dosyalar", + "locked": "Kilitli" + }, + "addAudioTrackHint": "Ses parçası ekle", + "addVideoTrackHint": "Video parçası ekle", + "bento": { + "apply": "Uygula", + "description": "{{count}} seçili klibi bir ızgaraya yerleştir.", + "gap": "Boşluk", + "noItemsToArrange": "Düzenlenecek öğe yok", + "padding": "Dolgu", + "presetNamePlaceholder": "Ön ayar adı", + "saveAsPreset": "Ön ayar olarak kaydet", + "title": "Bento düzeni" + }, + "captions": { + "addedFromTranscript": "Transkriptten eklendi", + "addedWithModel": "{{model}} ile eklendi", + "failedGenerateSegment": "Segment oluşturulamadı", + "failedUpdateSegment": "Segment güncellenemedi", + "refreshedWithModel": "{{model}} ile yenilendi", + "removedFromSegment": "Segmentten kaldırıldı", + "updatedFromTranscript": "Transkriptten güncellendi", + "updatedWithModel": "{{model}} ile güncellendi" + }, + "clipIndicators": { + "hasKeyframes": "Ana kareler var", + "mask": "Maske", + "mediaMissing": "Medya eksik", + "preparingReversed": "Ters oynatma hazırlanıyor", + "reversePrepFailedShort": "Ters hazırlık başarısız", + "reversedPlayback": "Ters oynatma", + "reversedPrepFailed": "Ters oynatma hazırlığı başarısız", + "reversedPrepared": "Ters oynatma hazır", + "speed": "Hız: {{speed}}x" + }, + "contextMenu": { + "bentoLayout": "Bento düzeni", + "captions": "Altyazılar", + "clearAll": "Tümünü temizle", + "clearKeyframes": "Ana kareleri temizle", + "consolidateCaptionsToSegment": "Altyazıları segmente birleştir", + "createCompoundClip": "Bileşik klip oluştur", + "detectScenesAi": "YZ ({{model}})", + "detectScenesAndSplit": "Sahneleri algıla ve böl", + "detectScenesFast": "Hızlı (Histogram)", + "detectingFillers": "Dolgu sözcükleri algılanıyor", + "detectingScenes": "Sahneler algılanıyor", + "detectingSilence": "Sessizlik algılanıyor", + "dissolveCompoundClip": "Bileşik klibi çöz", + "extractEmbeddedSubtitles": "Gömülü altyazıları çıkar", + "generateAudioFromText": "Metinden ses oluştur", + "generateCaptions": "Altyazı oluştur", + "insertExistingCaptions": "Mevcut altyazıları ekle", + "insertFreezeFrame": "Donmuş kare ekle", + "joinSelected": "Seçilileri birleştir", + "joinWithNext": "Sonrakiyle birleştir", + "joinWithPrevious": "Öncekiyle birleştir", + "linkClips": "Klipleri bağla", + "openCompoundClip": "Bileşik klibi aç", + "regenerateCaptions": "Altyazıları yeniden oluştur", + "removeFillerWords": "Dolgu sözcüklerini kaldır", + "removeSilence": "Sessizliği kaldır", + "reverse": "Ters çevir", + "rippleDelete": "Ripple sil", + "unlinkClips": "Kliplerin bağını kaldır", + "unreverse": "Tersi kaldır", + "updatingCaptions": "Altyazılar güncelleniyor" + }, + "fadeHandles": { + "adjustAudioFadeIn": "Ses giriş yumuşatmasını ayarla", + "adjustAudioFadeInCurve": "Ses giriş yumuşatma eğrisini ayarla", + "adjustAudioFadeOut": "Ses çıkış yumuşatmasını ayarla", + "adjustAudioFadeOutCurve": "Ses çıkış yumuşatma eğrisini ayarla", + "adjustVideoFadeIn": "Video giriş yumuşatmasını ayarla", + "adjustVideoFadeOut": "Video çıkış yumuşatmasını ayarla" + }, + "fillerRemoval": { + "aboutWillBeRemoved": "Yaklaşık {{duration}} kaldırılacak.", + "add": "Ekle", + "addPhrase": "İfade ekle", + "addWord": "Sözcük ekle", + "cutPadding": "Kesim payı", + "filler": "Dolgu", + "fillerRange": "dolgu kelimesi", + "found": "Bulundu", + "includeRange": "Aralığı dahil et", + "maxPhrase": "Maks. ifade", + "maxWord": "Maks. sözcük", + "noEntriesFound": "Girdi bulunamadı", + "noRemovableDetectedShort": "Kaldırılacak dolgu kelimesi bulunamadı.", + "none": "Yok", + "phrases": "İfadeler", + "playThisRange": "Bu aralığı oynat", + "rangesSelected": "Aralık seçildi", + "redo": "Yinele", + "redoEditTitle": "Son düzenlemeyi yinele", + "remove": "Kaldır", + "removed": "Kaldırıldı", + "scoreAudio": "Sesi puanla", + "scoring": "Puanlanıyor", + "title": "Dolgu kelimelerini kaldır", + "toastAudioScored": "Ses güven puanlaması tamamlandı.", + "toastNoRemovable": "Mevcut ayarlara uyan dolgu kelimesi yok.", + "toastNoneInClips": "Seçili kliplerde kaldırılacak dolgu kelimesi yok.", + "toastPreviewFailed": "Önizleme oluşturulamadı.", + "toastRemoveFailed": "Dolgu kelimeleri kaldırılamadı.", + "toastRemoved": "{{count}} dolgu aralığı kaldırıldı.", + "toastScoreFailed": "Ses güven puanı analiz edilemedi.", + "undo": "Geri al", + "undoEditTitle": "Son düzenlemeyi geri al", + "updatePreview": "Önizlemeyi güncelle", + "updating": "Güncelleniyor", + "words": "Sözcükler" + }, + "header": { + "addMarker": "İşaretçi ekle", + "addMarkerTooltip": "İşaretçi ekle", + "clearAllMarkers": "Tüm işaretçileri temizle", + "clearAllMarkersTooltip": "Tüm işaretçileri temizle", + "clearInOutPoints": "Giriş/çıkış noktalarını temizle", + "clearInOutPointsTooltip": "Giriş/çıkış noktalarını temizle", + "controls": "Kontroller", + "disableLinkedSelection": "Bağlı seçimi kapat", + "disableSnapping": "Yakalamayı kapat", + "enableLinkedSelection": "Bağlı seçimi aç", + "enableSnapping": "Yakalamayı aç", + "hideColorScopes": "Renk kapsamlarını gizle", + "hideColorScopesTooltip": "Renk kapsamlarını gizle", + "linkedSelectionOff": "Bağlı seçim kapalı", + "linkedSelectionOn": "Bağlı seçim açık", + "linkedSelectionTooltip": "Bağlı seçim: {{state}} ({{shortcut}})", + "rateStretchTool": "Hız uzatma aracı", + "rateStretchToolTooltip": "Hız uzatma aracı", + "razorTool": "Kesici aracı", + "razorToolTooltip": "Kesici aracı", + "redo": "Yinele", + "redoTooltip": "Yinele", + "redoWithLabel": "{{label}} yinele", + "redoWithLabelTooltip": "{{label}} yinele", + "removeSelectedMarker": "Seçili işaretçiyi kaldır", + "removeSelectedMarkerTooltip": "Seçili işaretçiyi kaldır", + "selectTool": "Seçim aracı", + "selectToolTooltip": "Seçim aracı", + "setInPoint": "Giriş noktası ayarla", + "setInPointTooltip": "Giriş noktası ayarla", + "setOutPoint": "Çıkış noktası ayarla", + "setOutPointTooltip": "Çıkış noktası ayarla", + "showColorScopes": "Renk kapsamlarını göster", + "showColorScopesTooltip": "Renk kapsamlarını göster", + "slideTool": "Slide aracı", + "slipSlideTools": "Slip/Slide araçları", + "slipSlideToolsTooltip": "Slip/Slide araçları", + "slipTool": "Slip aracı", + "snapDisabled": "Yakalama kapalı", + "snapEnabled": "Yakalama açık", + "title": "Zaman çizelgesi", + "trimEditTool": "Kırpma düzenleme aracı", + "trimEditToolTooltip": "Kırpma düzenleme aracı", + "undo": "Geri al", + "undoTooltip": "Geri al", + "undoWithLabel": "{{label}} geri al", + "undoWithLabelTooltip": "{{label}} geri al", + "zoomIn": "Yakınlaştır", + "zoomInTooltip": "Yakınlaştır", + "zoomOut": "Uzaklaştır", + "zoomOutTooltip": "Uzaklaştır", + "zoomSlider": "Yakınlaştırma kaydırıcısı", + "zoomToFit": "Sığdır", + "zoomToFitTooltip": "Sığdır" + }, + "itemActions": { + "appliedEffectToClips": "Efekt kliplere uygulandı", + "selectAvClipFirst": "Önce bir ses/video klibi seçin" + }, + "joinIndicators": { + "canJoinNext": "Sonrakiyle birleştirilebilir", + "canJoinPrevious": "Öncekiyle birleştirilebilir" + }, + "keyframeEditor": { + "bezier": "Bezier", + "custom": "Özel", + "dragHandlesHint": "Eğriyi şekillendirmek için tutamaçları sürükleyin.", + "mixedCurves": "Karışık eğriler", + "mixedSpring": "Karışık yay", + "movedKeyframes": "Ana kareler taşındı", + "noKeyframesPasted": "Yapıştırılacak ana kare yok", + "pastedKeyframes": "Ana kareler yapıştırıldı", + "preset": "Ön ayar", + "reasonBlocked": "{{count}} başka bir anahtar kare tarafından engellendi", + "reasonUnsupported": "{{count}} hedef özellik tarafından desteklenmiyor", + "skippedDescription": "{{count}} atlandı: {{reasons}}", + "spring": "Yay", + "springHint": "Yay fiziği — doğal ve esnek bir his.", + "unableToPasteCut": "Kesilen ana kareler yapıştırılamadı", + "unableToPasteCutDescription": "Kesilen anahtar kareler buraya yapıştırılamaz. {{reasons}}" + }, + "noTracksToRemove": "Kaldırılacak parça yok", + "region": "Bölge", + "removeActiveTrack": "Etkin parçayı kaldır", + "removeSelectedTracks": "Seçili parçaları kaldır", + "reverseConform": { + "cancellingDescription": "Ters oynatma hazırlığı iptal ediliyor.", + "couldNotPrepare": "Ters oynatma hazırlanamadı", + "progressDescription": "Medya ters oynatma için hazırlanıyor.", + "titleCancelling": "İptal ediliyor", + "titleFailed": "Hazırlama başarısız", + "titlePreparing": "Ters oynatma hazırlanıyor" + }, + "sceneDetection": { + "detectingScenes": "Sahneler algılanıyor", + "failed": "Başarısız", + "noScenesDetected": "Sahne algılanmadı", + "noScenesWithinBounds": "Sınırlar içinde sahne yok", + "noValidSplitPoints": "Geçerli bölme noktası yok", + "requiresWebGpu": "WebGPU gerektirir", + "splitAtScenes": "Sahnelerde böl" + }, + "selectTrackToRemove": "Kaldırılacak parçayı seçin", + "silenceRemoval": { + "aboutWillBeRemoved": "Yaklaşık {{duration}} sessizlik kaldırılacak.", + "keepPadding": "Payı koru", + "minimumSilence": "Minimum sessizlik", + "noRemovableDetectedShort": "Sessizlik bulunamadı.", + "rangesSelected": "Aralık seçildi", + "remove": "Kaldır", + "threshold": "Eşik", + "title": "Sessizliği kaldır", + "toastNoRemovable": "Mevcut ayarlara uyan sessizlik yok.", + "toastNoneInClips": "Seçili kliplerde kaldırılacak sessizlik yok.", + "toastPreviewFailed": "Sessizlik kaldırma önizlemesi oluşturulamadı.", + "toastRemoved": "{{count}} sessizlik aralığı kaldırıldı.", + "toastSettingsChanged": "Ayarlar değişti — uygulamadan önce önizleyin.", + "updatePreview": "Önizlemeyi güncelle", + "updating": "Güncelleniyor" + }, + "trackHeader": { + "addAudioTrack": "Ses parçası ekle", + "addVideoTrack": "Video parçası ekle", + "clipCount": "Klip sayısı", + "closeAllGaps": "Tüm boşlukları kapat", + "deleteEmptyTracks": "Boş parçaları sil", + "deleteTrack": "Parçayı sil", + "disableSyncLock": "Senkron kilidini kapat", + "disableTrack": "Parçayı devre dışı bırak", + "enableSyncLock": "Senkron kilidini aç", + "enableTrack": "Parçayı etkinleştir", + "lockTrack": "Parçayı kilitle", + "soloTrack": "Parçayı solo yap", + "unlockTrack": "Parça kilidini aç", + "unsoloTrack": "Soloyu kapat" + }, + "trackRow": { + "resizeSections": "Video ve ses parçası bölümlerini yeniden boyutlandır", + "resizeTrackHeight": "Parça yüksekliğini yeniden boyutlandır" + }, + "tracks": "Parçalar", + "volumeControl": { + "adjustClipVolume": "Klip ses düzeyini ayarla" } } }, @@ -61,7 +1636,269 @@ "unableToAddDroppedMediaItems": "ドロップしたメディア項目を追加できません", "unableToAddDroppedFiles": "ドロップしたファイルをタイムラインに追加できません", "droppedMediaItems": "ドロップしたメディア項目", - "droppedFiles": "ドロップしたファイル" + "droppedFiles": "ドロップしたファイル", + "locked": "ロック済み" + }, + "addAudioTrackHint": "オーディオトラックを追加", + "addVideoTrackHint": "ビデオトラックを追加", + "bento": { + "apply": "適用", + "description": "選択した {{count}} 件のクリップをグリッドに配置します。", + "gap": "ギャップ", + "noItemsToArrange": "配置する項目がありません", + "padding": "余白", + "presetNamePlaceholder": "プリセット名", + "saveAsPreset": "プリセットとして保存", + "title": "ベントーレイアウト" + }, + "captions": { + "addedFromTranscript": "文字起こしから追加しました", + "addedWithModel": "モデルで追加しました", + "failedGenerateSegment": "セグメントの生成に失敗しました", + "failedUpdateSegment": "セグメントの更新に失敗しました", + "refreshedWithModel": "モデルで更新しました", + "removedFromSegment": "セグメントから削除しました", + "updatedFromTranscript": "文字起こしから更新しました", + "updatedWithModel": "モデルで更新しました" + }, + "clipIndicators": { + "hasKeyframes": "キーフレームあり", + "mask": "マスク", + "mediaMissing": "メディアが見つかりません", + "preparingReversed": "逆再生を準備中", + "reversePrepFailedShort": "逆再生準備失敗", + "reversedPlayback": "逆再生", + "reversedPrepFailed": "逆再生の準備に失敗しました", + "reversedPrepared": "逆再生の準備が完了しました", + "speed": "速度: {{speed}}x" + }, + "contextMenu": { + "bentoLayout": "ベントレイアウト", + "captions": "キャプション", + "clearAll": "すべてクリア", + "clearKeyframes": "キーフレームをクリア", + "consolidateCaptionsToSegment": "キャプションをセグメントに統合", + "createCompoundClip": "複合クリップを作成", + "detectScenesAi": "AI ({{model}})", + "detectScenesAndSplit": "シーンを検出して分割", + "detectScenesFast": "高速 (ヒストグラム)", + "detectingFillers": "フィラーを検出中", + "detectingScenes": "シーンを検出中", + "detectingSilence": "無音を検出中", + "dissolveCompoundClip": "複合クリップを解除", + "extractEmbeddedSubtitles": "埋め込み字幕を抽出", + "generateAudioFromText": "テキストから音声を生成", + "generateCaptions": "キャプションを生成", + "insertExistingCaptions": "既存のキャプションを挿入", + "insertFreezeFrame": "フリーズフレームを挿入", + "joinSelected": "選択項目を結合", + "joinWithNext": "次と結合", + "joinWithPrevious": "前と結合", + "linkClips": "クリップをリンク", + "openCompoundClip": "複合クリップを開く", + "regenerateCaptions": "キャプションを再生成", + "removeFillerWords": "フィラー語を削除", + "removeSilence": "無音を削除", + "reverse": "反転", + "rippleDelete": "リップル削除", + "unlinkClips": "クリップのリンクを解除", + "unreverse": "反転を解除", + "updatingCaptions": "キャプションを更新中" + }, + "fadeHandles": { + "adjustAudioFadeIn": "オーディオのフェードインを調整", + "adjustAudioFadeInCurve": "オーディオのフェードインカーブを調整", + "adjustAudioFadeOut": "オーディオのフェードアウトを調整", + "adjustAudioFadeOutCurve": "オーディオのフェードアウトカーブを調整", + "adjustVideoFadeIn": "ビデオのフェードインを調整", + "adjustVideoFadeOut": "ビデオのフェードアウトを調整" + }, + "fillerRemoval": { + "aboutWillBeRemoved": "約 {{duration}} が削除されます。", + "add": "追加", + "addPhrase": "フレーズを追加", + "addWord": "単語を追加", + "cutPadding": "カット余白", + "filler": "フィラー", + "fillerRange": "フィラー語", + "found": "検出済み", + "includeRange": "範囲を含める", + "maxPhrase": "最大フレーズ", + "maxWord": "最大単語", + "noEntriesFound": "項目が見つかりません", + "noRemovableDetectedShort": "フィラー語は見つかりませんでした。", + "none": "なし", + "phrases": "フレーズ", + "playThisRange": "この範囲を再生", + "rangesSelected": "範囲を選択済み", + "redo": "やり直し", + "redoEditTitle": "直前の編集をやり直す", + "remove": "削除", + "removed": "削除済み", + "scoreAudio": "音声をスコアリング", + "scoring": "スコアリング中", + "title": "フィラー語を削除", + "toastAudioScored": "オーディオの信頼度スコアリングが完了しました。", + "toastNoRemovable": "現在の設定に一致するフィラー語はありません。", + "toastNoneInClips": "選択したクリップに削除できるフィラー語はありません。", + "toastPreviewFailed": "削除のプレビューを表示できませんでした。", + "toastRemoveFailed": "フィラー語を削除できませんでした。", + "toastRemoved": "{{count}} 件のフィラー語範囲を削除しました。", + "toastScoreFailed": "オーディオの信頼度を解析できませんでした。", + "undo": "元に戻す", + "undoEditTitle": "直前の編集を元に戻す", + "updatePreview": "プレビューを更新", + "updating": "更新中", + "words": "単語" + }, + "header": { + "addMarker": "マーカーを追加", + "addMarkerTooltip": "マーカーを追加", + "clearAllMarkers": "すべてのマーカーをクリア", + "clearAllMarkersTooltip": "すべてのマーカーをクリア", + "clearInOutPoints": "イン/アウト点をクリア", + "clearInOutPointsTooltip": "イン/アウト点をクリア", + "controls": "コントロール", + "disableLinkedSelection": "リンク選択を無効化", + "disableSnapping": "スナップを無効化", + "enableLinkedSelection": "リンク選択を有効化", + "enableSnapping": "スナップを有効化", + "hideColorScopes": "カラースコープを非表示", + "hideColorScopesTooltip": "カラースコープを非表示", + "linkedSelectionOff": "リンク選択オフ", + "linkedSelectionOn": "リンク選択オン", + "linkedSelectionTooltip": "リンク選択: {{state}} ({{shortcut}})", + "rateStretchTool": "レート調整ツール", + "rateStretchToolTooltip": "レート調整ツール", + "razorTool": "レーザーツール", + "razorToolTooltip": "レーザーツール", + "redo": "やり直し", + "redoTooltip": "やり直し", + "redoWithLabel": "{{label}}をやり直し", + "redoWithLabelTooltip": "{{label}}をやり直し", + "removeSelectedMarker": "選択したマーカーを削除", + "removeSelectedMarkerTooltip": "選択したマーカーを削除", + "selectTool": "選択ツール", + "selectToolTooltip": "選択ツール", + "setInPoint": "イン点を設定", + "setInPointTooltip": "イン点を設定", + "setOutPoint": "アウト点を設定", + "setOutPointTooltip": "アウト点を設定", + "showColorScopes": "カラースコープを表示", + "showColorScopesTooltip": "カラースコープを表示", + "slideTool": "スライドツール", + "slipSlideTools": "スリップ/スライドツール", + "slipSlideToolsTooltip": "スリップ/スライドツール", + "slipTool": "スリップツール", + "snapDisabled": "スナップ無効", + "snapEnabled": "スナップ有効", + "title": "タイムライン", + "trimEditTool": "トリム編集ツール", + "trimEditToolTooltip": "トリム編集ツール", + "undo": "元に戻す", + "undoTooltip": "元に戻す", + "undoWithLabel": "{{label}}を元に戻す", + "undoWithLabelTooltip": "{{label}}を元に戻す", + "zoomIn": "拡大", + "zoomInTooltip": "拡大", + "zoomOut": "縮小", + "zoomOutTooltip": "縮小", + "zoomSlider": "ズームスライダー", + "zoomToFit": "全体表示", + "zoomToFitTooltip": "全体表示" + }, + "itemActions": { + "appliedEffectToClips": "クリップにエフェクトを適用しました", + "selectAvClipFirst": "先にAVクリップを選択してください" + }, + "joinIndicators": { + "canJoinNext": "次と結合できます", + "canJoinPrevious": "前と結合できます" + }, + "keyframeEditor": { + "bezier": "ベジェ", + "custom": "カスタム", + "dragHandlesHint": "ハンドルをドラッグしてカーブを調整します。", + "graph": "グラフ", + "mixedCurves": "混在カーブ", + "mixedSpring": "混在スプリング", + "movedKeyframes": "キーフレームを移動しました", + "noKeyframesPasted": "貼り付けるキーフレームがありません", + "pastedKeyframes": "キーフレームを貼り付けました", + "preset": "プリセット", + "reasonBlocked": "{{count}} 件が別のキーフレームによりブロックされました", + "reasonUnsupported": "{{count}} 件が対象プロパティで非対応です", + "selectItem": "項目を選択", + "sheet": "シート", + "skippedDescription": "{{count}} 件をスキップ: {{reasons}}", + "spring": "スプリング", + "springHint": "スプリング物理演算 — 自然で弾むような動き。", + "title": "キーフレーム", + "unableToPasteCut": "カットを貼り付けできません", + "unableToPasteCutDescription": "切り取ったキーフレームはここに貼り付けできません。{{reasons}}" + }, + "noTracksToRemove": "削除するトラックがありません", + "region": "リージョン", + "removeActiveTrack": "アクティブトラックを削除", + "removeSelectedTracks": "選択したトラックを削除", + "reverseConform": { + "cancellingDescription": "キャンセル中...", + "couldNotPrepare": "準備できませんでした", + "progressDescription": "処理中...", + "titleCancelling": "キャンセル中", + "titleFailed": "失敗しました", + "titlePreparing": "準備中" + }, + "sceneDetection": { + "detectingScenes": "シーンを検出中", + "failed": "失敗しました", + "noScenesDetected": "シーンが検出されませんでした", + "noScenesWithinBounds": "範囲内にシーンがありません", + "noValidSplitPoints": "有効な分割点がありません", + "requiresWebGpu": "WebGPUが必要です", + "splitAtScenes": "シーンで分割" + }, + "selectTrackToRemove": "削除するトラックを選択", + "silenceRemoval": { + "aboutWillBeRemoved": "約 {{duration}} の無音が削除されます。", + "keepPadding": "余白を保持", + "minimumSilence": "最小無音", + "noRemovableDetectedShort": "無音は見つかりませんでした。", + "rangesSelected": "範囲を選択済み", + "remove": "削除", + "threshold": "しきい値", + "title": "無音を削除", + "toastNoRemovable": "現在の設定に一致する無音はありません。", + "toastNoneInClips": "選択したクリップに削除できる無音はありません。", + "toastPreviewFailed": "無音削除のプレビューを表示できませんでした。", + "toastRemoved": "{{count}} 件の無音範囲を削除しました。", + "toastSettingsChanged": "設定が変更されました。適用前にプレビューしてください。", + "updatePreview": "プレビューを更新", + "updating": "更新中" + }, + "trackHeader": { + "addAudioTrack": "オーディオトラックを追加", + "addVideoTrack": "ビデオトラックを追加", + "clipCount": "クリップ数", + "closeAllGaps": "すべてのギャップを閉じる", + "deleteEmptyTracks": "空のトラックを削除", + "deleteTrack": "トラックを削除", + "disableSyncLock": "同期ロックを無効化", + "disableTrack": "トラックを無効化", + "enableSyncLock": "同期ロックを有効化", + "enableTrack": "トラックを有効化", + "lockTrack": "トラックをロック", + "soloTrack": "トラックをソロ", + "unlockTrack": "トラックのロックを解除", + "unsoloTrack": "ソロを解除" + }, + "trackRow": { + "resizeSections": "ビデオ/オーディオトラック領域のサイズを変更", + "resizeTrackHeight": "トラックの高さを変更" + }, + "tracks": "トラック", + "volumeControl": { + "adjustClipVolume": "クリップ音量を調整" } } }, @@ -72,7 +1909,269 @@ "unableToAddDroppedMediaItems": "드롭한 미디어 항목을 추가할 수 없음", "unableToAddDroppedFiles": "드롭한 파일을 타임라인에 추가할 수 없음", "droppedMediaItems": "드롭한 미디어 항목", - "droppedFiles": "드롭한 파일" + "droppedFiles": "드롭한 파일", + "locked": "잠김" + }, + "addAudioTrackHint": "오디오 트랙 추가", + "addVideoTrackHint": "비디오 트랙 추가", + "bento": { + "apply": "적용", + "description": "선택한 클립 {{count}}개를 격자로 배치합니다.", + "gap": "간격", + "noItemsToArrange": "정렬할 항목이 없습니다", + "padding": "여백", + "presetNamePlaceholder": "프리셋 이름", + "saveAsPreset": "프리셋으로 저장", + "title": "벤토 레이아웃" + }, + "captions": { + "addedFromTranscript": "대본에서 추가됨", + "addedWithModel": "모델로 추가됨", + "failedGenerateSegment": "구간 생성 실패", + "failedUpdateSegment": "구간 업데이트 실패", + "refreshedWithModel": "모델로 새로 고침됨", + "removedFromSegment": "구간에서 제거됨", + "updatedFromTranscript": "대본에서 업데이트됨", + "updatedWithModel": "모델로 업데이트됨" + }, + "clipIndicators": { + "hasKeyframes": "키프레임 있음", + "mask": "마스크", + "mediaMissing": "미디어 없음", + "preparingReversed": "역방향 준비 중", + "reversePrepFailedShort": "역방향 준비 실패", + "reversedPlayback": "역방향 재생", + "reversedPrepFailed": "역방향 준비 실패", + "reversedPrepared": "역방향 준비 완료", + "speed": "속도: {{speed}}x" + }, + "contextMenu": { + "bentoLayout": "벤토 레이아웃", + "captions": "자막", + "clearAll": "모두 지우기", + "clearKeyframes": "키프레임 지우기", + "consolidateCaptionsToSegment": "자막을 구간으로 병합", + "createCompoundClip": "복합 클립 만들기", + "detectScenesAi": "AI ({{model}})", + "detectScenesAndSplit": "장면 감지 및 분할", + "detectScenesFast": "빠름 (히스토그램)", + "detectingFillers": "군더더기 감지 중", + "detectingScenes": "장면 감지 중", + "detectingSilence": "무음 감지 중", + "dissolveCompoundClip": "복합 클립 해제", + "extractEmbeddedSubtitles": "내장 자막 추출", + "generateAudioFromText": "텍스트에서 오디오 생성", + "generateCaptions": "자막 생성", + "insertExistingCaptions": "기존 자막 삽입", + "insertFreezeFrame": "정지 프레임 삽입", + "joinSelected": "선택 항목 결합", + "joinWithNext": "다음과 결합", + "joinWithPrevious": "이전과 결합", + "linkClips": "클립 연결", + "openCompoundClip": "복합 클립 열기", + "regenerateCaptions": "자막 다시 생성", + "removeFillerWords": "군더더기 단어 제거", + "removeSilence": "무음 제거", + "reverse": "반전", + "rippleDelete": "리플 삭제", + "unlinkClips": "클립 연결 해제", + "unreverse": "반전 해제", + "updatingCaptions": "자막 업데이트 중" + }, + "fadeHandles": { + "adjustAudioFadeIn": "오디오 페이드 인 조정", + "adjustAudioFadeInCurve": "오디오 페이드 인 곡선 조정", + "adjustAudioFadeOut": "오디오 페이드 아웃 조정", + "adjustAudioFadeOutCurve": "오디오 페이드 아웃 곡선 조정", + "adjustVideoFadeIn": "비디오 페이드 인 조정", + "adjustVideoFadeOut": "비디오 페이드 아웃 조정" + }, + "fillerRemoval": { + "aboutWillBeRemoved": "약 {{duration}}가 제거됩니다.", + "add": "추가", + "addPhrase": "구문 추가", + "addWord": "단어 추가", + "cutPadding": "컷 여백", + "filler": "군더더기", + "fillerRange": "필러 단어", + "found": "찾음", + "includeRange": "범위 포함", + "maxPhrase": "최대 구문", + "maxWord": "최대 단어", + "noEntriesFound": "항목을 찾을 수 없음", + "noRemovableDetectedShort": "제거할 필러 단어가 없습니다.", + "none": "없음", + "phrases": "구문", + "playThisRange": "이 범위 재생", + "rangesSelected": "범위 선택됨", + "redo": "다시 실행", + "redoEditTitle": "마지막 편집 다시 실행", + "remove": "제거", + "removed": "제거됨", + "scoreAudio": "오디오 점수 계산", + "scoring": "점수 계산 중", + "title": "필러 단어 제거", + "toastAudioScored": "오디오 신뢰도 분석을 완료했습니다.", + "toastNoRemovable": "현재 설정에 해당하는 필러 단어가 없습니다.", + "toastNoneInClips": "선택한 클립에 제거할 필러 단어가 없습니다.", + "toastPreviewFailed": "제거 미리보기를 표시할 수 없습니다.", + "toastRemoveFailed": "필러 단어를 제거할 수 없습니다.", + "toastRemoved": "필러 단어 구간 {{count}}개를 제거했습니다.", + "toastScoreFailed": "오디오 신뢰도를 분석할 수 없습니다.", + "undo": "실행 취소", + "undoEditTitle": "마지막 편집 실행 취소", + "updatePreview": "미리보기 업데이트", + "updating": "업데이트 중", + "words": "단어" + }, + "header": { + "addMarker": "마커 추가", + "addMarkerTooltip": "마커 추가", + "clearAllMarkers": "모든 마커 지우기", + "clearAllMarkersTooltip": "모든 마커 지우기", + "clearInOutPoints": "인/아웃 지점 지우기", + "clearInOutPointsTooltip": "인/아웃 지점 지우기", + "controls": "컨트롤", + "disableLinkedSelection": "연결 선택 비활성화", + "disableSnapping": "스냅 비활성화", + "enableLinkedSelection": "연결 선택 활성화", + "enableSnapping": "스냅 활성화", + "hideColorScopes": "색상 스코프 숨기기", + "hideColorScopesTooltip": "색상 스코프 숨기기", + "linkedSelectionOff": "연결 선택 꺼짐", + "linkedSelectionOn": "연결 선택 켜짐", + "linkedSelectionTooltip": "연결된 선택: {{state}} ({{shortcut}})", + "rateStretchTool": "속도 늘이기 도구", + "rateStretchToolTooltip": "속도 늘이기 도구", + "razorTool": "자르기 도구", + "razorToolTooltip": "자르기 도구", + "redo": "다시 실행", + "redoTooltip": "다시 실행", + "redoWithLabel": "{{label}} 다시 실행", + "redoWithLabelTooltip": "{{label}} 다시 실행", + "removeSelectedMarker": "선택한 마커 제거", + "removeSelectedMarkerTooltip": "선택한 마커 제거", + "selectTool": "선택 도구", + "selectToolTooltip": "선택 도구", + "setInPoint": "인 지점 설정", + "setInPointTooltip": "인 지점 설정", + "setOutPoint": "아웃 지점 설정", + "setOutPointTooltip": "아웃 지점 설정", + "showColorScopes": "색상 스코프 표시", + "showColorScopesTooltip": "색상 스코프 표시", + "slideTool": "슬라이드 도구", + "slipSlideTools": "슬립/슬라이드 도구", + "slipSlideToolsTooltip": "슬립/슬라이드 도구", + "slipTool": "슬립 도구", + "snapDisabled": "스냅 비활성화됨", + "snapEnabled": "스냅 활성화됨", + "title": "타임라인", + "trimEditTool": "트림 편집 도구", + "trimEditToolTooltip": "트림 편집 도구", + "undo": "실행 취소", + "undoTooltip": "실행 취소", + "undoWithLabel": "{{label}} 실행 취소", + "undoWithLabelTooltip": "{{label}} 실행 취소", + "zoomIn": "확대", + "zoomInTooltip": "확대", + "zoomOut": "축소", + "zoomOutTooltip": "축소", + "zoomSlider": "줌 슬라이더", + "zoomToFit": "화면에 맞추기", + "zoomToFitTooltip": "화면에 맞추기" + }, + "itemActions": { + "appliedEffectToClips": "클립에 효과 적용됨", + "selectAvClipFirst": "먼저 AV 클립을 선택하세요" + }, + "joinIndicators": { + "canJoinNext": "다음과 결합 가능", + "canJoinPrevious": "이전과 결합 가능" + }, + "keyframeEditor": { + "bezier": "베지어", + "custom": "사용자 지정", + "dragHandlesHint": "핸들을 드래그하여 곡선을 조정하세요.", + "graph": "그래프", + "mixedCurves": "혼합 곡선", + "mixedSpring": "혼합 스프링", + "movedKeyframes": "키프레임 이동됨", + "noKeyframesPasted": "붙여넣을 키프레임 없음", + "pastedKeyframes": "키프레임 붙여넣음", + "preset": "프리셋", + "reasonBlocked": "다른 키프레임으로 인해 {{count}}개 차단됨", + "reasonUnsupported": "대상 속성에서 {{count}}개 지원되지 않음", + "selectItem": "항목 선택", + "sheet": "시트", + "skippedDescription": "{{count}}개 건너뜀: {{reasons}}", + "spring": "스프링", + "springHint": "스프링 물리 — 자연스럽고 탄력적인 움직임.", + "title": "키프레임", + "unableToPasteCut": "컷을 붙여넣을 수 없음", + "unableToPasteCutDescription": "잘라낸 키프레임을 여기에 붙여넣을 수 없습니다. {{reasons}}" + }, + "noTracksToRemove": "제거할 트랙 없음", + "region": "영역", + "removeActiveTrack": "활성 트랙 제거", + "removeSelectedTracks": "선택한 트랙 제거", + "reverseConform": { + "cancellingDescription": "취소 중...", + "couldNotPrepare": "준비할 수 없음", + "progressDescription": "처리 중...", + "titleCancelling": "취소 중", + "titleFailed": "실패", + "titlePreparing": "준비 중" + }, + "sceneDetection": { + "detectingScenes": "장면 감지 중", + "failed": "실패", + "noScenesDetected": "감지된 장면 없음", + "noScenesWithinBounds": "범위 내 장면 없음", + "noValidSplitPoints": "유효한 분할 지점 없음", + "requiresWebGpu": "WebGPU 필요", + "splitAtScenes": "장면에서 분할" + }, + "selectTrackToRemove": "제거할 트랙 선택", + "silenceRemoval": { + "aboutWillBeRemoved": "약 {{duration}}의 무음이 제거됩니다.", + "keepPadding": "여백 유지", + "minimumSilence": "최소 무음", + "noRemovableDetectedShort": "무음을 찾지 못했습니다.", + "rangesSelected": "범위 선택됨", + "remove": "제거", + "threshold": "임계값", + "title": "무음 제거", + "toastNoRemovable": "현재 설정에 해당하는 무음이 없습니다.", + "toastNoneInClips": "선택한 클립에 제거할 무음이 없습니다.", + "toastPreviewFailed": "무음 제거 미리보기를 표시할 수 없습니다.", + "toastRemoved": "무음 구간 {{count}}개를 제거했습니다.", + "toastSettingsChanged": "설정이 변경되었습니다. 적용 전에 미리 보세요.", + "updatePreview": "미리보기 업데이트", + "updating": "업데이트 중" + }, + "trackHeader": { + "addAudioTrack": "오디오 트랙 추가", + "addVideoTrack": "비디오 트랙 추가", + "clipCount": "클립 수", + "closeAllGaps": "모든 간격 닫기", + "deleteEmptyTracks": "빈 트랙 삭제", + "deleteTrack": "트랙 삭제", + "disableSyncLock": "동기화 잠금 비활성화", + "disableTrack": "트랙 비활성화", + "enableSyncLock": "동기화 잠금 활성화", + "enableTrack": "트랙 활성화", + "lockTrack": "트랙 잠금", + "soloTrack": "트랙 솔로", + "unlockTrack": "트랙 잠금 해제", + "unsoloTrack": "솔로 해제" + }, + "trackRow": { + "resizeSections": "비디오 및 오디오 트랙 영역 크기 조정", + "resizeTrackHeight": "트랙 높이 조정" + }, + "tracks": "트랙", + "volumeControl": { + "adjustClipVolume": "클립 볼륨 조정" } } }, @@ -83,18 +2182,265 @@ "unableToAddDroppedMediaItems": "无法添加拖放的媒体项目", "unableToAddDroppedFiles": "无法将拖放的文件添加到时间线", "droppedMediaItems": "拖放的媒体项目", - "droppedFiles": "拖放的文件" - } - } - }, - "tr": { - "timeline": { - "track": { - "unableToAddDroppedItem": "Bırakılan öğe eklenemedi", - "unableToAddDroppedMediaItems": "Bırakılan medya öğeleri eklenemedi", - "unableToAddDroppedFiles": "Bırakılan dosyalar zaman çizelgesine eklenemedi", - "droppedMediaItems": "bırakılan medya öğeleri", - "droppedFiles": "bırakılan dosyalar" + "droppedFiles": "拖放的文件", + "locked": "已锁定" + }, + "addAudioTrackHint": "添加音频轨道", + "addVideoTrackHint": "添加视频轨道", + "bento": { + "apply": "应用", + "description": "将所选的 {{count}} 个片段排列为网格。", + "gap": "间隙", + "noItemsToArrange": "没有可排列的项目", + "padding": "内边距", + "presetNamePlaceholder": "预设名称", + "saveAsPreset": "保存为预设", + "title": "便当布局" + }, + "captions": { + "addedFromTranscript": "已从转录添加", + "addedWithModel": "已使用模型添加", + "failedGenerateSegment": "生成片段失败", + "failedUpdateSegment": "更新片段失败", + "refreshedWithModel": "已使用模型刷新", + "removedFromSegment": "已从片段移除", + "updatedFromTranscript": "已从转录更新", + "updatedWithModel": "已使用模型更新" + }, + "clipIndicators": { + "hasKeyframes": "有关键帧", + "mask": "遮罩", + "mediaMissing": "媒体缺失", + "preparingReversed": "正在准备反向播放", + "reversePrepFailedShort": "反向准备失败", + "reversedPlayback": "反向播放", + "reversedPrepFailed": "反向播放准备失败", + "reversedPrepared": "反向播放已准备", + "speed": "速度: {{speed}}x" + }, + "contextMenu": { + "bentoLayout": " Bento 布局", + "captions": "字幕", + "clearAll": "全部清除", + "clearKeyframes": "清除关键帧", + "consolidateCaptionsToSegment": "将字幕合并到片段", + "createCompoundClip": "创建复合剪辑", + "detectScenesAi": "AI ({{model}})", + "detectScenesAndSplit": "检测场景并分割", + "detectScenesFast": "快速(直方图)", + "detectingFillers": "正在检测填充词", + "detectingScenes": "正在检测场景", + "detectingSilence": "正在检测静音", + "dissolveCompoundClip": "解散复合剪辑", + "extractEmbeddedSubtitles": "提取内嵌字幕", + "generateAudioFromText": "从文本生成音频", + "generateCaptions": "生成字幕", + "insertExistingCaptions": "插入现有字幕", + "insertFreezeFrame": "插入冻结帧", + "joinSelected": "合并所选", + "joinWithNext": "与下一个合并", + "joinWithPrevious": "与上一个合并", + "linkClips": "链接剪辑", + "openCompoundClip": "打开复合剪辑", + "regenerateCaptions": "重新生成字幕", + "removeFillerWords": "移除填充词", + "removeSilence": "移除静音", + "reverse": "反向", + "rippleDelete": "波纹删除", + "unlinkClips": "取消链接剪辑", + "unreverse": "取消反向", + "updatingCaptions": "正在更新字幕" + }, + "fadeHandles": { + "adjustAudioFadeIn": "调整音频淡入", + "adjustAudioFadeInCurve": "调整音频淡入曲线", + "adjustAudioFadeOut": "调整音频淡出", + "adjustAudioFadeOutCurve": "调整音频淡出曲线", + "adjustVideoFadeIn": "调整视频淡入", + "adjustVideoFadeOut": "调整视频淡出" + }, + "fillerRemoval": { + "aboutWillBeRemoved": "将删除约 {{duration}}。", + "add": "添加", + "addPhrase": "添加短语", + "addWord": "添加单词", + "cutPadding": "剪切留白", + "filler": "填充词", + "fillerRange": "口头语", + "found": "已找到", + "includeRange": "包含范围", + "maxPhrase": "最大短语", + "maxWord": "最大单词", + "noEntriesFound": "未找到条目", + "noRemovableDetectedShort": "未发现口头语。", + "none": "无", + "phrases": "短语", + "playThisRange": "播放此范围", + "rangesSelected": "已选择范围", + "redo": "重做", + "redoEditTitle": "重做最后一次编辑", + "remove": "移除", + "removed": "已移除", + "scoreAudio": "为音频评分", + "scoring": "正在评分", + "title": "删除口头语", + "toastAudioScored": "音频置信度分析完成。", + "toastNoRemovable": "没有口头语符合当前设置。", + "toastNoneInClips": "所选片段中没有可删除的口头语。", + "toastPreviewFailed": "无法预览删除结果。", + "toastRemoveFailed": "无法删除口头语。", + "toastRemoved": "已删除 {{count}} 段口头语。", + "toastScoreFailed": "无法分析音频置信度。", + "undo": "撤销", + "undoEditTitle": "撤销最后一次编辑", + "updatePreview": "更新预览", + "updating": "正在更新", + "words": "单词" + }, + "header": { + "addMarker": "添加标记", + "addMarkerTooltip": "添加标记", + "clearAllMarkers": "清除所有标记", + "clearAllMarkersTooltip": "清除所有标记", + "clearInOutPoints": "清除入点/出点", + "clearInOutPointsTooltip": "清除入点/出点", + "controls": "控制", + "disableLinkedSelection": "禁用链接选择", + "disableSnapping": "禁用吸附", + "enableLinkedSelection": "启用链接选择", + "enableSnapping": "启用吸附", + "hideColorScopes": "隐藏颜色示波器", + "hideColorScopesTooltip": "隐藏颜色示波器", + "linkedSelectionOff": "链接选择关闭", + "linkedSelectionOn": "链接选择开启", + "linkedSelectionTooltip": "联动选择:{{state}} ({{shortcut}})", + "rateStretchTool": "速率拉伸工具", + "rateStretchToolTooltip": "速率拉伸工具", + "razorTool": "剃刀工具", + "razorToolTooltip": "剃刀工具", + "redo": "重做", + "redoTooltip": "重做", + "redoWithLabel": "重做 {{label}}", + "redoWithLabelTooltip": "重做 {{label}}", + "removeSelectedMarker": "移除所选标记", + "removeSelectedMarkerTooltip": "移除所选标记", + "selectTool": "选择工具", + "selectToolTooltip": "选择工具", + "setInPoint": "设置入点", + "setInPointTooltip": "设置入点", + "setOutPoint": "设置出点", + "setOutPointTooltip": "设置出点", + "showColorScopes": "显示颜色示波器", + "showColorScopesTooltip": "显示颜色示波器", + "slideTool": "滑移工具", + "slipSlideTools": "滑动/滑移工具", + "slipSlideToolsTooltip": "滑动/滑移工具", + "slipTool": "滑动工具", + "snapDisabled": "吸附已禁用", + "snapEnabled": "吸附已启用", + "title": "时间轴", + "trimEditTool": "修剪编辑工具", + "trimEditToolTooltip": "修剪编辑工具", + "undo": "撤销", + "undoTooltip": "撤销", + "undoWithLabel": "撤销 {{label}}", + "undoWithLabelTooltip": "撤销 {{label}}", + "zoomIn": "放大", + "zoomInTooltip": "放大", + "zoomOut": "缩小", + "zoomOutTooltip": "缩小", + "zoomSlider": "缩放滑块", + "zoomToFit": "适应窗口", + "zoomToFitTooltip": "适应窗口" + }, + "itemActions": { + "appliedEffectToClips": "已将效果应用到剪辑", + "selectAvClipFirst": "请先选择音视频剪辑" + }, + "joinIndicators": { + "canJoinNext": "可与下一个合并", + "canJoinPrevious": "可与上一个合并" + }, + "keyframeEditor": { + "bezier": "贝塞尔", + "custom": "自定义", + "dragHandlesHint": "拖动控制点以调整曲线形状。", + "mixedCurves": "混合曲线", + "mixedSpring": "混合弹簧", + "movedKeyframes": "已移动关键帧", + "noKeyframesPasted": "没有可粘贴的关键帧", + "pastedKeyframes": "已粘贴关键帧", + "preset": "预设", + "reasonBlocked": "{{count}} 个被其他关键帧阻挡", + "reasonUnsupported": "{{count}} 个不被目标属性支持", + "skippedDescription": "已跳过 {{count}} 个:{{reasons}}", + "spring": "弹簧", + "springHint": "弹簧物理 — 自然且富有弹性。", + "unableToPasteCut": "无法粘贴剪切", + "unableToPasteCutDescription": "剪切的关键帧无法粘贴到此处。{{reasons}}" + }, + "noTracksToRemove": "没有可移除的轨道", + "region": "区域", + "removeActiveTrack": "移除活动轨道", + "removeSelectedTracks": "移除所选轨道", + "reverseConform": { + "cancellingDescription": "正在取消...", + "couldNotPrepare": "无法准备", + "progressDescription": "处理中...", + "titleCancelling": "正在取消", + "titleFailed": "失败", + "titlePreparing": "正在准备" + }, + "sceneDetection": { + "detectingScenes": "正在检测场景", + "failed": "失败", + "noScenesDetected": "未检测到场景", + "noScenesWithinBounds": "范围内没有场景", + "noValidSplitPoints": "没有有效分割点", + "requiresWebGpu": "需要 WebGPU", + "splitAtScenes": "按场景分割" + }, + "selectTrackToRemove": "选择要移除的轨道", + "silenceRemoval": { + "aboutWillBeRemoved": "将删除约 {{duration}} 的静音。", + "keepPadding": "保留留白", + "minimumSilence": "最小静音", + "noRemovableDetectedShort": "未发现静音。", + "rangesSelected": "已选择范围", + "remove": "移除", + "threshold": "阈值", + "title": "删除静音", + "toastNoRemovable": "没有静音符合当前设置。", + "toastNoneInClips": "所选片段中没有可删除的静音。", + "toastPreviewFailed": "无法预览静音删除结果。", + "toastRemoved": "已删除 {{count}} 段静音。", + "toastSettingsChanged": "设置已更改 — 应用前请预览。", + "updatePreview": "更新预览", + "updating": "正在更新" + }, + "trackHeader": { + "addAudioTrack": "添加音频轨道", + "addVideoTrack": "添加视频轨道", + "clipCount": "剪辑数", + "closeAllGaps": "关闭所有间隙", + "deleteEmptyTracks": "删除空轨道", + "deleteTrack": "删除轨道", + "disableSyncLock": "禁用同步锁定", + "disableTrack": "禁用轨道", + "enableSyncLock": "启用同步锁定", + "enableTrack": "启用轨道", + "lockTrack": "锁定轨道", + "soloTrack": "独奏轨道", + "unlockTrack": "解锁轨道", + "unsoloTrack": "取消独奏" + }, + "trackRow": { + "resizeSections": "调整视频和音频轨道区域大小", + "resizeTrackHeight": "调整轨道高度" + }, + "tracks": "轨道", + "volumeControl": { + "adjustClipVolume": "调整剪辑音量" } } } diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index 1a05fb8ad..0330ae5a6 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -86,7 +86,9 @@ "captionEstimate": "~{{sceneCount}} {{scenes}} por clipe de 1 min a {{fps}} fps", "scene_one": "cena", "scene_other": "cenas", - "captionIntervalHint": "{{estimate}}. Intervalos menores produzem cenas mais densas, mas levam mais tempo para gerar." + "captionIntervalHint": "{{estimate}}. Intervalos menores produzem cenas mais densas, mas levam mais tempo para gerar.", + "defaultCaptionStyle": "Estilo de legenda padrão", + "defaultCaptionStyleDescription": "Estilo aplicado a legendas geradas a partir de transcrições ou legendagem por IA." }, "timeline": { "snapByDefault": "Encaixe por padrão", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 96ece6608..a304acc2b 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -86,7 +86,9 @@ "captionEstimate": "{{fps}}fps hızında 1 dakikalık klip başına yaklaşık {{sceneCount}} {{scenes}}", "scene_one": "sahne", "scene_other": "sahne", - "captionIntervalHint": "{{estimate}}. Daha küçük aralıklar daha yoğun sahneler üretir ancak oluşturma daha uzun sürer." + "captionIntervalHint": "{{estimate}}. Daha küçük aralıklar daha yoğun sahneler üretir ancak oluşturma daha uzun sürer.", + "defaultCaptionStyle": "Varsayılan altyazı stili", + "defaultCaptionStyleDescription": "Transkripsiyonlardan veya yapay zekâ altyazısından oluşturulan altyazılara uygulanan stil." }, "timeline": { "snapByDefault": "Varsayılan olarak yapıştır", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index c006f757a..c88496fa5 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -86,7 +86,9 @@ "captionEstimate": "在 {{fps}} fps 下每 1 分钟片段约 {{sceneCount}} 个{{scenes}}", "scene_one": "场景", "scene_other": "场景", - "captionIntervalHint": "{{estimate}}。间隔越小场景越密集,但生成耗时越长。" + "captionIntervalHint": "{{estimate}}。间隔越小场景越密集,但生成耗时越长。", + "defaultCaptionStyle": "默认字幕样式", + "defaultCaptionStyleDescription": "应用于由转录或 AI 字幕生成的字幕的样式。" }, "timeline": { "snapByDefault": "默认吸附", diff --git a/src/infrastructure/gpu-compositor/compositor-pipeline.ts b/src/infrastructure/gpu-compositor/compositor-pipeline.ts index b92435448..a052b9a09 100644 --- a/src/infrastructure/gpu-compositor/compositor-pipeline.ts +++ b/src/infrastructure/gpu-compositor/compositor-pipeline.ts @@ -15,31 +15,14 @@ import type { BlendMode } from '@/types/blend-modes' import { BLEND_MODE_INDEX } from '@/types/blend-modes' import { createLogger } from '@/shared/logging/logger' import { BLEND_MODES_WGSL } from '@/infrastructure/gpu-shared/blend-modes' +import { FULLSCREEN_QUAD_WGSL } from '@/infrastructure/gpu-shared/fullscreen-quad' const logger = createLogger('CompositorPipeline') // ─── Shader ─── const BLIT_SHADER = /* wgsl */ ` -struct VertexOutput { - @builtin(position) position: vec4f, - @location(0) uv: vec2f, -}; -@vertex -fn vertexMain(@builtin(vertex_index) vi: u32) -> VertexOutput { - var pos = array( - vec2f(-1,-1), vec2f(1,-1), vec2f(-1,1), - vec2f(-1,1), vec2f(1,-1), vec2f(1,1) - ); - var uv = array( - vec2f(0,1), vec2f(1,1), vec2f(0,0), - vec2f(0,0), vec2f(1,1), vec2f(1,0) - ); - var o: VertexOutput; - o.position = vec4f(pos[vi], 0, 1); - o.uv = uv[vi]; - return o; -} +${FULLSCREEN_QUAD_WGSL} @group(0) @binding(0) var texSampler: sampler; @group(0) @binding(1) var inputTex: texture_2d; @@ -52,27 +35,7 @@ fn blitFragment(input: VertexOutput) -> @location(0) vec4f { } ` -const VERTEX_SHADER = /* wgsl */ ` -struct VertexOutput { - @builtin(position) position: vec4f, - @location(0) uv: vec2f, -}; -@vertex -fn vertexMain(@builtin(vertex_index) vi: u32) -> VertexOutput { - var pos = array( - vec2f(-1,-1), vec2f(1,-1), vec2f(-1,1), - vec2f(-1,1), vec2f(1,-1), vec2f(1,1) - ); - var uv = array( - vec2f(0,1), vec2f(1,1), vec2f(0,0), - vec2f(0,0), vec2f(1,1), vec2f(1,0) - ); - var o: VertexOutput; - o.position = vec4f(pos[vi], 0, 1); - o.uv = uv[vi]; - return o; -} -` +const VERTEX_SHADER = FULLSCREEN_QUAD_WGSL const COMPOSITE_UNIFORMS = /* wgsl */ ` struct CompositeUniforms { diff --git a/src/infrastructure/gpu-effects/common.ts b/src/infrastructure/gpu-effects/common.ts index 15676f7b1..6e69c6afe 100644 --- a/src/infrastructure/gpu-effects/common.ts +++ b/src/infrastructure/gpu-effects/common.ts @@ -1,33 +1,8 @@ +import { FULLSCREEN_QUAD_WGSL } from '@/infrastructure/gpu-shared/fullscreen-quad' + /** Shared WGSL code prepended to every effect shader */ export const COMMON_WGSL = /* wgsl */ ` -struct VertexOutput { - @builtin(position) position: vec4f, - @location(0) uv: vec2f, -}; - -@vertex -fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput { - var positions = array( - vec2f(-1.0, -1.0), - vec2f(1.0, -1.0), - vec2f(-1.0, 1.0), - vec2f(-1.0, 1.0), - vec2f(1.0, -1.0), - vec2f(1.0, 1.0) - ); - var uvs = array( - vec2f(0.0, 1.0), - vec2f(1.0, 1.0), - vec2f(0.0, 0.0), - vec2f(0.0, 0.0), - vec2f(1.0, 1.0), - vec2f(1.0, 0.0) - ); - var output: VertexOutput; - output.position = vec4f(positions[vertexIndex], 0.0, 1.0); - output.uv = uvs[vertexIndex]; - return output; -} +${FULLSCREEN_QUAD_WGSL} fn rgb2hsv(c: vec3f) -> vec3f { let K = vec4f(0.0, -1.0/3.0, 2.0/3.0, -1.0); diff --git a/src/infrastructure/gpu-effects/effects-pipeline.ts b/src/infrastructure/gpu-effects/effects-pipeline.ts index 2a02a88df..d2e5aa7c0 100644 --- a/src/infrastructure/gpu-effects/effects-pipeline.ts +++ b/src/infrastructure/gpu-effects/effects-pipeline.ts @@ -1,4 +1,5 @@ import { createLogger } from '@/shared/logging/logger' +import { FULLSCREEN_QUAD_WGSL } from '@/infrastructure/gpu-shared/fullscreen-quad' import { COMMON_WGSL } from './common' import type { GpuEffectDefinition, GpuEffectInstance } from './types' import { GPU_EFFECT_REGISTRY, getGpuEffect } from './index' @@ -7,27 +8,7 @@ function getLogger() { return createLogger('EffectsPipeline') } -const FULLSCREEN_VERTEX = /* wgsl */ ` -struct VertexOutput { - @builtin(position) position: vec4f, - @location(0) uv: vec2f, -}; -@vertex -fn vertexMain(@builtin(vertex_index) vi: u32) -> VertexOutput { - var pos = array( - vec2f(-1,-1), vec2f(1,-1), vec2f(-1,1), - vec2f(-1,1), vec2f(1,-1), vec2f(1,1) - ); - var uv = array( - vec2f(0,1), vec2f(1,1), vec2f(0,0), - vec2f(0,0), vec2f(1,1), vec2f(1,0) - ); - var o: VertexOutput; - o.position = vec4f(pos[vi], 0, 1); - o.uv = uv[vi]; - return o; -} -` +const FULLSCREEN_VERTEX = FULLSCREEN_QUAD_WGSL const BLIT_SHADER = /* wgsl */ ` ${FULLSCREEN_VERTEX} diff --git a/src/infrastructure/gpu-effects/index.test.ts b/src/infrastructure/gpu-effects/index.test.ts index bdeb28401..67f7b1b51 100644 --- a/src/infrastructure/gpu-effects/index.test.ts +++ b/src/infrastructure/gpu-effects/index.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vite-plus/test' -import { GPU_EFFECT_REGISTRY, getGpuEffect, getGpuEffectDefaultParams } from './index' +import { + GPU_EFFECT_REGISTRY, + getGpuCategoriesWithEffects, + getGpuEffect, + getGpuEffectDefaultParams, + getGpuEffectsByCategory, +} from './index' describe('GPU effect registry', () => { it('registers every effect with shader metadata and valid default uniforms', () => { @@ -119,4 +125,67 @@ describe('GPU effect registry', () => { effect!.params.colorSaturation!.visibleWhen?.({ ...defaults, matchSourceColor: false }), ).toBe(false) }) + + it('returns undefined for unknown effect ids without throwing', () => { + expect(getGpuEffect('nope-not-here')).toBeUndefined() + expect(getGpuEffect('')).toBeUndefined() + expect(getGpuEffectDefaultParams('nope-not-here')).toEqual({}) + }) + + it('groups effects under their declared category', () => { + // Every effect should be reachable via its category. The categories + // returned by getGpuCategoriesWithEffects() should exactly cover the + // registry. + const categorized = getGpuCategoriesWithEffects() + expect(categorized.length).toBeGreaterThan(0) + + const seenIds = new Set() + for (const { category, effects } of categorized) { + expect(effects.length).toBeGreaterThan(0) + for (const effect of effects) { + expect(effect.category, effect.id).toBe(category) + expect(seenIds.has(effect.id), `duplicate id ${effect.id}`).toBe(false) + seenIds.add(effect.id) + } + } + expect(seenIds.size).toBe(GPU_EFFECT_REGISTRY.size) + }) + + it('returns the same effect list via category lookup as via direct getters', () => { + for (const { category, effects } of getGpuCategoriesWithEffects()) { + const byCategory = getGpuEffectsByCategory(category) + expect(byCategory.map((e) => e.id).sort()).toEqual(effects.map((e) => e.id).sort()) + } + }) + + it('returns empty list for an empty category', () => { + // Categories with no registered effects (none today) should return [], + // not throw. Pass a name that is in the union but lacks effects — if + // none exists, any unknown key returns [] via nullish coalescing. + // We assert the well-typed surface area here. + const result = getGpuEffectsByCategory('color') + expect(Array.isArray(result)).toBe(true) + }) + + it('tolerates unknown params without crashing', () => { + for (const [id, effect] of GPU_EFFECT_REGISTRY) { + if (effect.uniformSize === 0) continue + const defaults = getGpuEffectDefaultParams(id) + const polluted = { ...defaults, __unknownExtraParam__: 'ignored' } + expect(() => effect.packUniforms(polluted, 1920, 1080), id).not.toThrow() + } + }) + + it('declares uniform sizes as multiples of 16 (WebGPU alignment)', () => { + // WebGPU requires uniform buffers be multiples of 16 bytes. The packed + // Float32Array must also fit within the declared size. + for (const [id, effect] of GPU_EFFECT_REGISTRY) { + expect(effect.uniformSize % 16, id).toBe(0) + if (effect.uniformSize === 0) continue + const defaults = getGpuEffectDefaultParams(id) + const packed = effect.packUniforms(defaults, 1920, 1080) + expect(packed, id).not.toBeNull() + expect(packed!.byteLength, id).toBeLessThanOrEqual(effect.uniformSize) + } + }) }) diff --git a/src/infrastructure/gpu-masks/mask-combine-pipeline.ts b/src/infrastructure/gpu-masks/mask-combine-pipeline.ts index 611eea4c0..19757da38 100644 --- a/src/infrastructure/gpu-masks/mask-combine-pipeline.ts +++ b/src/infrastructure/gpu-masks/mask-combine-pipeline.ts @@ -1,24 +1,7 @@ -const MASK_COMBINE_SHADER = /* wgsl */ ` -struct VertexOutput { - @builtin(position) position: vec4f, - @location(0) uv: vec2f, -}; +import { FULLSCREEN_QUAD_WGSL } from '@/infrastructure/gpu-shared/fullscreen-quad' -@vertex -fn vertexMain(@builtin(vertex_index) vi: u32) -> VertexOutput { - var pos = array( - vec2f(-1,-1), vec2f(1,-1), vec2f(-1,1), - vec2f(-1,1), vec2f(1,-1), vec2f(1,1) - ); - var uv = array( - vec2f(0,1), vec2f(1,1), vec2f(0,0), - vec2f(0,0), vec2f(1,1), vec2f(1,0) - ); - var o: VertexOutput; - o.position = vec4f(pos[vi], 0, 1); - o.uv = uv[vi]; - return o; -} +const MASK_COMBINE_SHADER = /* wgsl */ ` +${FULLSCREEN_QUAD_WGSL} @group(0) @binding(0) var texSampler: sampler; @group(0) @binding(1) var baseMask: texture_2d; diff --git a/src/infrastructure/gpu-media/media-blend-pipeline.ts b/src/infrastructure/gpu-media/media-blend-pipeline.ts index b9668cb5d..061694838 100644 --- a/src/infrastructure/gpu-media/media-blend-pipeline.ts +++ b/src/infrastructure/gpu-media/media-blend-pipeline.ts @@ -1,28 +1,10 @@ import type { BlendMode } from '@/types/blend-modes' import { BLEND_MODE_INDEX } from '@/types/blend-modes' import { BLEND_MODES_WGSL } from '@/infrastructure/gpu-shared/blend-modes' +import { FULLSCREEN_QUAD_WGSL } from '@/infrastructure/gpu-shared/fullscreen-quad' const MEDIA_BLEND_SHADER = /* wgsl */ ` -struct VertexOutput { - @builtin(position) position: vec4f, - @location(0) uv: vec2f, -}; - -@vertex -fn vertexMain(@builtin(vertex_index) vi: u32) -> VertexOutput { - var pos = array( - vec2f(-1,-1), vec2f(1,-1), vec2f(-1,1), - vec2f(-1,1), vec2f(1,-1), vec2f(1,1) - ); - var uv = array( - vec2f(0,1), vec2f(1,1), vec2f(0,0), - vec2f(0,0), vec2f(1,1), vec2f(1,0) - ); - var o: VertexOutput; - o.position = vec4f(pos[vi], 0, 1); - o.uv = uv[vi]; - return o; -} +${FULLSCREEN_QUAD_WGSL} ${BLEND_MODES_WGSL} diff --git a/src/infrastructure/gpu-media/media-render-pipeline.ts b/src/infrastructure/gpu-media/media-render-pipeline.ts index 78b7aef65..c5993631e 100644 --- a/src/infrastructure/gpu-media/media-render-pipeline.ts +++ b/src/infrastructure/gpu-media/media-render-pipeline.ts @@ -1,3 +1,5 @@ +import { FULLSCREEN_QUAD_WGSL } from '@/infrastructure/gpu-shared/fullscreen-quad' + type GpuMediaSource = | OffscreenCanvas | HTMLCanvasElement @@ -46,26 +48,7 @@ export interface GpuMediaRenderParams { } const MEDIA_RENDER_SHADER = /* wgsl */ ` -struct VertexOutput { - @builtin(position) position: vec4f, - @location(0) uv: vec2f, -}; - -@vertex -fn vertexMain(@builtin(vertex_index) vi: u32) -> VertexOutput { - var pos = array( - vec2f(-1,-1), vec2f(1,-1), vec2f(-1,1), - vec2f(-1,1), vec2f(1,-1), vec2f(1,1) - ); - var uv = array( - vec2f(0,1), vec2f(1,1), vec2f(0,0), - vec2f(0,0), vec2f(1,1), vec2f(1,0) - ); - var o: VertexOutput; - o.position = vec4f(pos[vi], 0, 1); - o.uv = uv[vi]; - return o; -} +${FULLSCREEN_QUAD_WGSL} struct MediaUniforms { outputSize: vec2f, diff --git a/src/infrastructure/gpu-shapes/shape-render-pipeline.ts b/src/infrastructure/gpu-shapes/shape-render-pipeline.ts index add0dc040..79d72e92e 100644 --- a/src/infrastructure/gpu-shapes/shape-render-pipeline.ts +++ b/src/infrastructure/gpu-shapes/shape-render-pipeline.ts @@ -1,4 +1,5 @@ import type { ShapeItem } from '@/types/timeline' +import { FULLSCREEN_QUAD_WGSL } from '@/infrastructure/gpu-shared/fullscreen-quad' export interface GpuShapeRect { x: number @@ -32,26 +33,7 @@ export const MAX_GPU_SHAPE_PATH_VERTICES = 32 const SHAPE_UNIFORM_FLOAT_COUNT = 24 + MAX_GPU_SHAPE_PATH_VERTICES * 4 const SHAPE_RENDER_SHADER = /* wgsl */ ` -struct VertexOutput { - @builtin(position) position: vec4f, - @location(0) uv: vec2f, -}; - -@vertex -fn vertexMain(@builtin(vertex_index) vi: u32) -> VertexOutput { - var pos = array( - vec2f(-1,-1), vec2f(1,-1), vec2f(-1,1), - vec2f(-1,1), vec2f(1,-1), vec2f(1,1) - ); - var uv = array( - vec2f(0,1), vec2f(1,1), vec2f(0,0), - vec2f(0,0), vec2f(1,1), vec2f(1,0) - ); - var o: VertexOutput; - o.position = vec4f(pos[vi], 0, 1); - o.uv = uv[vi]; - return o; -} +${FULLSCREEN_QUAD_WGSL} struct ShapeUniforms { outputSize: vec2f, diff --git a/src/infrastructure/gpu-shared/fullscreen-quad.ts b/src/infrastructure/gpu-shared/fullscreen-quad.ts new file mode 100644 index 000000000..ac0e9039c --- /dev/null +++ b/src/infrastructure/gpu-shared/fullscreen-quad.ts @@ -0,0 +1,43 @@ +/** + * Shared WGSL fullscreen-quad vertex stage. + * + * Used by every fragment-only GPU pass (effects, transitions, mask combine, + * media blend, etc.). Prepend this to a fragment-shader source to get a + * complete shader module that draws a screen-filling quad with UVs in + * `VertexOutput.uv` (location 0). + * + * Contract: + * - Issue `draw(6)` with no vertex buffer + * - Fragment input: `@location(0) uv: vec2f` + * - UV origin: top-left, Y flipped from clip space + */ +export const FULLSCREEN_QUAD_WGSL = /* wgsl */ ` +struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) uv: vec2f, +}; + +@vertex +fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput { + var positions = array( + vec2f(-1.0, -1.0), + vec2f(1.0, -1.0), + vec2f(-1.0, 1.0), + vec2f(-1.0, 1.0), + vec2f(1.0, -1.0), + vec2f(1.0, 1.0) + ); + var uvs = array( + vec2f(0.0, 1.0), + vec2f(1.0, 1.0), + vec2f(0.0, 0.0), + vec2f(0.0, 0.0), + vec2f(1.0, 1.0), + vec2f(1.0, 0.0) + ); + var output: VertexOutput; + output.position = vec4f(positions[vertexIndex], 0.0, 1.0); + output.uv = uvs[vertexIndex]; + return output; +} +` diff --git a/src/infrastructure/gpu-transitions/common.ts b/src/infrastructure/gpu-transitions/common.ts index 31025688a..028a79ef0 100644 --- a/src/infrastructure/gpu-transitions/common.ts +++ b/src/infrastructure/gpu-transitions/common.ts @@ -1,33 +1,8 @@ +import { FULLSCREEN_QUAD_WGSL } from '@/infrastructure/gpu-shared/fullscreen-quad' + /** Shared WGSL code prepended to every GPU transition shader */ export const TRANSITION_COMMON_WGSL = /* wgsl */ ` -struct VertexOutput { - @builtin(position) position: vec4f, - @location(0) uv: vec2f, -}; - -@vertex -fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput { - var positions = array( - vec2f(-1.0, -1.0), - vec2f(1.0, -1.0), - vec2f(-1.0, 1.0), - vec2f(-1.0, 1.0), - vec2f(1.0, -1.0), - vec2f(1.0, 1.0) - ); - var uvs = array( - vec2f(0.0, 1.0), - vec2f(1.0, 1.0), - vec2f(0.0, 0.0), - vec2f(0.0, 0.0), - vec2f(1.0, 1.0), - vec2f(1.0, 0.0) - ); - var output: VertexOutput; - output.position = vec4f(positions[vertexIndex], 0.0, 1.0); - output.uv = uvs[vertexIndex]; - return output; -} +${FULLSCREEN_QUAD_WGSL} const PI: f32 = 3.14159265359; const TAU: f32 = 6.28318530718; diff --git a/src/infrastructure/gpu-transitions/index.test.ts b/src/infrastructure/gpu-transitions/index.test.ts index ec67639c8..184ca884a 100644 --- a/src/infrastructure/gpu-transitions/index.test.ts +++ b/src/infrastructure/gpu-transitions/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vite-plus/test' -import { getGpuTransition, getGpuTransitionIds } from './index' +import { GPU_TRANSITION_REGISTRY, getGpuTransition, getGpuTransitionIds } from './index' describe('GPU transition registry', () => { it('registers the Resolve-style dissolve family', () => { @@ -222,4 +222,85 @@ describe('GPU transition registry', () => { 0, ]) }) + + it('exposes every transition with shader metadata and valid default uniforms', () => { + expect(GPU_TRANSITION_REGISTRY.size).toBeGreaterThan(0) + + for (const [id, def] of GPU_TRANSITION_REGISTRY) { + expect(def.id, id).toBe(id) + expect(def.name.trim().length, id).toBeGreaterThan(0) + expect(def.shader.trim().length, id).toBeGreaterThan(0) + expect(def.entryPoint.trim().length, id).toBeGreaterThan(0) + // WebGPU requires uniform buffer sizes to be a multiple of 16 bytes. + expect(def.uniformSize % 16, id).toBe(0) + expect(def.uniformSize, id).toBeGreaterThan(0) + if (def.hasDirection) { + expect(def.directions, id).toBeDefined() + expect(def.directions!.length, id).toBeGreaterThan(0) + } + // packUniforms must produce a buffer that fits inside the declared size, + // with all finite values, even when no custom properties are provided. + const uniforms = def.packUniforms(0.5, 1920, 1080, 0, {}) + expect(uniforms, id).toBeInstanceOf(Float32Array) + expect(uniforms.byteLength, id).toBeLessThanOrEqual(def.uniformSize) + expect(Array.from(uniforms).every(Number.isFinite), id).toBe(true) + } + }) + + it('always packs progress as the first float', () => { + // The pipeline assumes lane 0 is progress so it can update per-frame + // state without re-packing the full uniform buffer. Lanes 1+ are + // shader-specific (pixelate, for example, replaces width/height with + // pre-computed block sizes derived from the resolution). + for (const [id, def] of GPU_TRANSITION_REGISTRY) { + const uniforms = def.packUniforms(0.42, 1280, 720, 0, {}) + expect(uniforms[0], `${id} progress`).toBeCloseTo(0.42) + } + }) + + it('clamps and accepts unknown properties without throwing', () => { + // packUniforms should be defensive: unknown keys or out-of-range progress + // values must not crash. This guards against UI passing partial state. + for (const [id, def] of GPU_TRANSITION_REGISTRY) { + expect( + () => def.packUniforms(-0.5, 1920, 1080, 0, { unknownProp: 'ignored' }), + id, + ).not.toThrow() + expect(() => def.packUniforms(1.5, 1920, 1080, 0, { anotherUnknown: 42 }), id).not.toThrow() + } + }) + + it('registers the basic transition family with stable contract', () => { + const expected = [ + { id: 'fade', entryPoint: 'fadeFragment', hasDirection: false, category: 'basic' }, + { id: 'wipe', entryPoint: 'wipeFragment', hasDirection: true, category: 'wipe' }, + { id: 'slide', entryPoint: 'slideFragment', hasDirection: true, category: 'slide' }, + { id: 'flip', entryPoint: 'flipFragment', hasDirection: true, category: 'custom' }, + { id: 'clockWipe', entryPoint: 'clockWipeFragment', hasDirection: false, category: 'mask' }, + { id: 'iris', entryPoint: 'irisFragment', hasDirection: false, category: 'iris' }, + ] as const + + for (const expectation of expected) { + const def = getGpuTransition(expectation.id) + expect(def, expectation.id).toBeDefined() + expect(def).toMatchObject(expectation) + } + }) + + it('packs fade progress/width/height as the canonical 4-float layout', () => { + const def = getGpuTransition('fade')! + const uniforms = def.packUniforms(0.25, 1920, 1080, 0, {}) + expect(Array.from(uniforms)).toEqual([0.25, 1920, 1080, 0]) + }) + + it('packs wipe with the direction byte in lane 3', () => { + const def = getGpuTransition('wipe')! + const uniforms = def.packUniforms(0.6, 1280, 720, 3, {}) + expect(Array.from(uniforms)).toEqual([expect.closeTo(0.6), 1280, 720, 3]) + }) + + it('returns undefined for unknown transition ids without throwing', () => { + expect(getGpuTransition('nope-not-here')).toBeUndefined() + expect(getGpuTransition('')).toBeUndefined() + }) }) diff --git a/src/infrastructure/storage/workspace-fs/decoded-preview-audio.ts b/src/infrastructure/storage/workspace-fs/decoded-preview-audio.ts index 851a78fef..b09e6f735 100644 --- a/src/infrastructure/storage/workspace-fs/decoded-preview-audio.ts +++ b/src/infrastructure/storage/workspace-fs/decoded-preview-audio.ts @@ -90,8 +90,15 @@ export async function getDecodedPreviewAudio(id: string): Promise { - const root = requireWorkspaceRoot() +/** + * Root-parameterized write. Identical format to {@link saveDecodedPreviewAudio} + * but takes the workspace handle explicitly so it can run in a Web Worker that + * was handed the workspace root (which has no module-global root of its own). + */ +export async function writeDecodedPreviewAudioToRoot( + root: FileSystemDirectoryHandle, + data: DecodedPreviewAudio, +): Promise { try { if (data.kind === 'meta') { await writeJsonAtomic(root, decodedAudioMetaPath(data.mediaId), data) @@ -113,11 +120,15 @@ export async function saveDecodedPreviewAudio(data: DecodedPreviewAudio): Promis return } } catch (error) { - logger.error(`saveDecodedPreviewAudio(${data.id}) failed`, error) + logger.error(`writeDecodedPreviewAudioToRoot(${data.id}) failed`, error) throw error } } +export async function saveDecodedPreviewAudio(data: DecodedPreviewAudio): Promise { + await writeDecodedPreviewAudioToRoot(requireWorkspaceRoot(), data) +} + export async function deleteDecodedPreviewAudio(mediaId: string): Promise { const root = requireWorkspaceRoot() try { diff --git a/src/runtime/composition-runtime/components/item.tsx b/src/runtime/composition-runtime/components/item.tsx index a8a295ce0..a813d47c2 100644 --- a/src/runtime/composition-runtime/components/item.tsx +++ b/src/runtime/composition-runtime/components/item.tsx @@ -100,7 +100,7 @@ export const Item = React.memo( const { fps: timelineFps } = useVideoConfig() const nestedMediaResolutionMode = useNestedMediaResolutionMode() const mediaItem = useMediaLibraryStore((s) => - item.mediaId ? s.mediaItems.find((media) => media.id === item.mediaId) : undefined, + item.mediaId ? s.mediaById[item.mediaId] : undefined, ) const mediaSourceFps = mediaItem?.fps const itemAudioEqStages = React.useMemo( diff --git a/src/runtime/composition-runtime/components/stable-video-sequence.test.tsx b/src/runtime/composition-runtime/components/stable-video-sequence.test.tsx index 174a83af7..69ebf835e 100644 --- a/src/runtime/composition-runtime/components/stable-video-sequence.test.tsx +++ b/src/runtime/composition-runtime/components/stable-video-sequence.test.tsx @@ -19,9 +19,12 @@ vi.mock('../hooks/use-transition-participant-sync', () => ({ useTransitionParticipantSync: vi.fn(), })) -type MediaLibraryMockState = { mediaItems: Array<{ id: string; fps: number }> } +type MediaLibraryMockState = { + mediaItems: Array<{ id: string; fps: number }> + mediaById: Record +} const mediaLibraryMock = vi.hoisted(() => { - const state: MediaLibraryMockState = { mediaItems: [] } + const state: MediaLibraryMockState = { mediaItems: [], mediaById: {} } const store = Object.assign( (selector: (state: MediaLibraryMockState) => unknown) => selector(state), { getState: () => state }, @@ -29,6 +32,11 @@ const mediaLibraryMock = vi.hoisted(() => { return { state, store } }) +function setMediaLibraryItems(items: Array<{ id: string; fps: number }>) { + mediaLibraryMock.state.mediaItems = items + mediaLibraryMock.state.mediaById = Object.fromEntries(items.map((m) => [m.id, m])) +} + vi.mock('@/runtime/composition-runtime/deps/stores', () => ({ useMediaLibraryStore: mediaLibraryMock.store, })) @@ -274,7 +282,7 @@ describe('StableVideoSequence', () => { beforeEach(() => { ensureReadyLanesMock.mockClear() vi.mocked(useTransitionParticipantSync).mockClear() - mediaLibraryMock.state.mediaItems = [] + setMediaLibraryItems([]) sequenceContextValue.localFrame = 28 }) diff --git a/src/runtime/composition-runtime/components/stable-video-sequence.tsx b/src/runtime/composition-runtime/components/stable-video-sequence.tsx index 362ea4464..736a65d73 100644 --- a/src/runtime/composition-runtime/components/stable-video-sequence.tsx +++ b/src/runtime/composition-runtime/components/stable-video-sequence.tsx @@ -98,7 +98,7 @@ const HiddenShadowVideoBridge = React.memo(({ item }: { item: StableVideoSequenc useProxy: nestedMediaResolutionMode === 'proxy', }) const mediaSourceFps = useMediaLibraryStore((s) => - item.mediaId ? s.mediaItems.find((media) => media.id === item.mediaId)?.fps : undefined, + item.mediaId ? s.mediaById[item.mediaId]?.fps : undefined, ) const audioEqStages = useMemo( @@ -314,12 +314,10 @@ const GroupRenderer: React.FC<{ return [] } - const mediaItems = useMediaLibraryStore.getState().mediaItems + const mediaById = useMediaLibraryStore.getState().mediaById const toParticipant = (item: StableVideoSequenceItem, role: 'leader' | 'follower') => { const trimBefore = item.sourceStart ?? item.trimStart ?? item.offset ?? 0 - const mediaSourceFps = item.mediaId - ? mediaItems.find((media) => media.id === item.mediaId)?.fps - : undefined + const mediaSourceFps = item.mediaId ? mediaById[item.mediaId]?.fps : undefined const sourceFps = item.sourceFps ?? mediaSourceFps ?? fps const playbackRate = item.speed ?? DEFAULT_SPEED const safeTrimBefore = getSafeTrimBefore( diff --git a/src/runtime/composition-runtime/components/text-content.tsx b/src/runtime/composition-runtime/components/text-content.tsx index f5a5cb844..a41badc69 100644 --- a/src/runtime/composition-runtime/components/text-content.tsx +++ b/src/runtime/composition-runtime/components/text-content.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useMemo } from 'react' import { useGizmoStore, useTimelineStore } from '@/runtime/composition-runtime/deps/stores' +import { DEFAULT_PROJECT_HEIGHT, DEFAULT_PROJECT_WIDTH } from '@/shared/projects/defaults' import type { TextItem } from '@/types/timeline' import { loadFont, FONT_WEIGHT_MAP } from '../utils/fonts' import { useCompositionSpace } from '../contexts/composition-space-context' @@ -30,8 +31,8 @@ export const TextContent: React.FC<{ item: TextItem & { _sequenceFrameOffset?: n const scale = compositionSpace?.scale ?? 1 const logicalCanvas = useMemo( () => ({ - width: compositionSpace?.projectWidth ?? 1920, - height: compositionSpace?.projectHeight ?? 1080, + width: compositionSpace?.projectWidth ?? DEFAULT_PROJECT_WIDTH, + height: compositionSpace?.projectHeight ?? DEFAULT_PROJECT_HEIGHT, fps, }), [compositionSpace?.projectHeight, compositionSpace?.projectWidth, fps], diff --git a/src/runtime/composition-runtime/utils/audio-buffer-wav.ts b/src/runtime/composition-runtime/utils/audio-buffer-wav.ts index a469ef862..7d08b7bbc 100644 --- a/src/runtime/composition-runtime/utils/audio-buffer-wav.ts +++ b/src/runtime/composition-runtime/utils/audio-buffer-wav.ts @@ -3,17 +3,12 @@ function floatToInt16(value: number): number { return clamped < 0 ? Math.round(clamped * 0x8000) : Math.round(clamped * 0x7fff) } -export function audioBufferToWavBlob(buffer: AudioBuffer): Blob { - const channels = Math.max(1, Math.min(2, buffer.numberOfChannels)) - const frameCount = buffer.length - const sampleRate = buffer.sampleRate - const bytesPerSample = 2 - const blockAlign = channels * bytesPerSample - const byteRate = sampleRate * blockAlign - const pcmByteLength = frameCount * blockAlign - const headerSize = 44 +const HEADER_SIZE = 44 - const out = new ArrayBuffer(headerSize + pcmByteLength) +function allocWav(channels: number, frameCount: number, sampleRate: number): ArrayBuffer { + const blockAlign = channels * 2 + const pcmByteLength = frameCount * blockAlign + const out = new ArrayBuffer(HEADER_SIZE + pcmByteLength) const view = new DataView(out) const writeAscii = (offset: number, text: string) => { for (let index = 0; index < text.length; index += 1) { @@ -29,24 +24,64 @@ export function audioBufferToWavBlob(buffer: AudioBuffer): Blob { view.setUint16(20, 1, true) view.setUint16(22, channels, true) view.setUint32(24, sampleRate, true) - view.setUint32(28, byteRate, true) + view.setUint32(28, sampleRate * blockAlign, true) view.setUint16(32, blockAlign, true) view.setUint16(34, 16, true) writeAscii(36, 'data') view.setUint32(40, pcmByteLength, true) + return out +} +export function audioBufferToWavBlob(buffer: AudioBuffer): Blob { + const channels = Math.max(1, Math.min(2, buffer.numberOfChannels)) + const frameCount = buffer.length + const out = allocWav(channels, frameCount, buffer.sampleRate) + + // Write PCM samples through an Int16Array view over the data region rather + // than per-sample DataView.setInt16. Int16Array writes in platform byte + // order, and every browser target runs on little-endian hardware (which is + // what WAV requires), so this is correct and ~7x faster on long buffers. + const pcm = new Int16Array(out, HEADER_SIZE, frameCount * channels) const left = buffer.getChannelData(0) - const right = channels > 1 ? buffer.getChannelData(1) : left - let offset = headerSize - for (let index = 0; index < frameCount; index += 1) { - view.setInt16(offset, floatToInt16(left[index] ?? 0), true) - offset += 2 - if (channels > 1) { - view.setInt16(offset, floatToInt16(right[index] ?? 0), true) - offset += 2 + if (channels > 1) { + const right = buffer.getChannelData(1) + let p = 0 + for (let index = 0; index < frameCount; index += 1) { + pcm[p] = floatToInt16(left[index] ?? 0) + pcm[p + 1] = floatToInt16(right[index] ?? 0) + p += 2 + } + } else { + for (let index = 0; index < frameCount; index += 1) { + pcm[index] = floatToInt16(left[index] ?? 0) } } return new Blob([out], { type: 'audio/wav' }) } + +/** + * Build a stereo WAV blob directly from already-quantized Int16 channels. + * + * The decode pipeline persists bins as Int16 PCM, so the conform asset can be + * interleaved straight from those samples — skipping the Int16→Float32→Int16 + * round-trip (and per-sample clamp/round) that `audioBufferToWavBlob` pays when + * it has to re-quantize an AudioBuffer. + */ +export function int16StereoToWavBlob( + left: Int16Array, + right: Int16Array, + sampleRate: number, +): Blob { + const frameCount = Math.min(left.length, right.length) + const out = allocWav(2, frameCount, sampleRate) + const pcm = new Int16Array(out, HEADER_SIZE, frameCount * 2) + let p = 0 + for (let index = 0; index < frameCount; index += 1) { + pcm[p] = left[index]! + pcm[p + 1] = right[index]! + p += 2 + } + return new Blob([out], { type: 'audio/wav' }) +} diff --git a/src/runtime/composition-runtime/utils/audio-decode-cache.test.ts b/src/runtime/composition-runtime/utils/audio-decode-cache.test.ts index 1c7c84d47..2a12d2b68 100644 --- a/src/runtime/composition-runtime/utils/audio-decode-cache.test.ts +++ b/src/runtime/composition-runtime/utils/audio-decode-cache.test.ts @@ -21,7 +21,9 @@ const objectUrlRegistryMocks = vi.hoisted(() => ({ })) const previewAudioConformMocks = vi.hoisted(() => ({ + isPreviewAudioConformed: vi.fn(async () => false), persistPreviewAudioConform: vi.fn(async () => undefined), + persistPreviewAudioConformFromInt16: vi.fn(async () => undefined), })) const mediabunnyMocks = vi.hoisted(() => { @@ -394,4 +396,14 @@ describe('audio-decode-cache targeted slice reuse', () => { expect(decodedPreviewAudioMocks.saveDecodedPreviewAudio).toHaveBeenCalled() expect(previewAudioConformMocks.persistPreviewAudioConform).toHaveBeenCalled() }) + + it('skips decode entirely when the conform asset already exists', async () => { + previewAudioConformMocks.isPreviewAudioConformed.mockResolvedValueOnce(true) + mediabunnyMocks.__setPendingSamples([makeSample(4)]) + + await expect(startPreviewAudioConform('media-conformed', 'blob:src')).resolves.toBeUndefined() + + expect(mediabunnyMocks.__stats.inputConstructed).toBe(0) + expect(previewAudioConformMocks.persistPreviewAudioConform).not.toHaveBeenCalled() + }) }) diff --git a/src/runtime/composition-runtime/utils/audio-decode-cache.ts b/src/runtime/composition-runtime/utils/audio-decode-cache.ts index 2244c4903..0313e8589 100644 --- a/src/runtime/composition-runtime/utils/audio-decode-cache.ts +++ b/src/runtime/composition-runtime/utils/audio-decode-cache.ts @@ -17,15 +17,34 @@ import { createLogger } from '@/shared/logging/logger' import { createMediabunnyInputSource } from '@/infrastructure/browser/mediabunny-input-source' +import { + getObjectUrlBlob, + getObjectUrlSourceMetadata, + type ObjectUrlSourceMetadata, +} from '@/infrastructure/browser/object-url-registry' import { getDecodedPreviewAudio, saveDecodedPreviewAudio, deleteDecodedPreviewAudio, getMedia, } from '@/infrastructure/storage' +import { getWorkspaceRoot } from '@/infrastructure/storage/workspace-fs/root' import { ensureAc3DecoderRegistered, isAc3AudioCodec } from '@/shared/utils/ac3-decoder' +import { createManagedWorker, type ManagedWorker } from '@/shared/utils/managed-worker' import type { DecodedPreviewAudioMeta, DecodedPreviewAudioBin } from '@/types/storage' -import { persistPreviewAudioConform } from './preview-audio-conform' +import { + isPreviewAudioConformed, + persistPreviewAudioConform, + persistPreviewAudioConformFromInt16, +} from './preview-audio-conform' +import { + buildDownsampledStereo, + downmixToStereo, + int16ToFloat32Into, + produceDecodedBin, + type DecodedAudioBinData, +} from './audio-decode-dsp' +import type { AudioDecodeWorkerResponse } from './audio-decode-worker.types' const log = createLogger('PreviewAudioCache') export type PreviewAudioSource = string | Blob @@ -143,63 +162,6 @@ function rememberPlaybackSlice(mediaId: string, slice: PlaybackAudioSlice): void } } -// --------------------------------------------------------------------------- -// Int16 <-> Float32 conversion -// --------------------------------------------------------------------------- - -function float32ToInt16(float32: Float32Array): Int16Array { - const int16 = new Int16Array(float32.length) - for (let i = 0; i < float32.length; i++) { - const s = Math.max(-1, Math.min(1, float32[i]!)) - int16[i] = s < 0 ? s * 0x8000 : s * 0x7fff - } - return int16 -} - -function int16ToFloat32(int16: Int16Array): Float32Array { - const float32 = new Float32Array(int16.length) - for (let i = 0; i < int16.length; i++) { - const s = int16[i]! - float32[i] = s / (s < 0 ? 0x8000 : 0x7fff) - } - return float32 -} - -// --------------------------------------------------------------------------- -// Resampling -// --------------------------------------------------------------------------- - -async function downsampleBuffer(buffer: AudioBuffer, targetRate: number): Promise { - if (buffer.sampleRate <= targetRate) return buffer - - const ratio = targetRate / buffer.sampleRate - const numChannels = buffer.numberOfChannels - const sourceFrames = buffer.length - const targetFrames = Math.ceil(sourceFrames * ratio) - - // Manual linear interpolation — ~10x faster than OfflineAudioContext - // for preview-quality downsampling (22050 Hz). Quality is sufficient - // since we're going from 48kHz?22kHz with anti-aliasing handled by - // the Nyquist limit at the target rate. - const ctx = new OfflineAudioContext(numChannels, targetFrames, targetRate) - const outBuffer = ctx.createBuffer(numChannels, targetFrames, targetRate) - - for (let ch = 0; ch < numChannels; ch++) { - const input = buffer.getChannelData(ch) - const output = outBuffer.getChannelData(ch) - for (let i = 0; i < targetFrames; i++) { - const srcPos = i / ratio - const idx = Math.floor(srcPos) - const frac = srcPos - idx - const s0 = input[idx] ?? 0 - const s1 = input[idx + 1] ?? s0 - output[i] = s0 + (s1 - s0) * frac - } - } - - return outBuffer -} - // --------------------------------------------------------------------------- // Bin key helpers // --------------------------------------------------------------------------- @@ -264,6 +226,13 @@ export async function startPreviewAudioConform( mediaId: string, src: PreviewAudioSource, ): Promise { + // Bail before the decode/AudioBuffer rebuild when the conform asset already + // exists. Otherwise every time the clip scrolls back into view we pay the + // full `loadFromBins` Int16→Float32 reconstruction (~hundreds of ms, on the + // main thread) just to feed a WAV that is already persisted. + if (await isPreviewAudioConformed(mediaId)) { + return + } const buffer = await ensureDecodeStarted(mediaId, src) await persistPreviewAudioConform(mediaId, buffer) } @@ -366,8 +335,8 @@ async function loadPartialFromBins( const right = new Int16Array(bin.right) const frames = Math.min(bin.frames, left.length, right.length) if (frames <= 0) continue - leftChannel.set(int16ToFloat32(left.subarray(0, frames)), offset) - rightChannel.set(int16ToFloat32(right.subarray(0, frames)), offset) + int16ToFloat32Into(left.subarray(0, frames), leftChannel, offset) + int16ToFloat32Into(right.subarray(0, frames), rightChannel, offset) offset += frames } if (offset <= 0) { @@ -572,7 +541,12 @@ export async function getOrDecodeAudioSliceForPlayback( } try { - const slice = await decodeAudioWindow(mediaId, src, partialStartTime, partialDurationSeconds) + const slice = await decodeAudioWindowPreferWorker( + mediaId, + src, + partialStartTime, + partialDurationSeconds, + ) rememberPlaybackSlice(mediaId, slice) return slice } catch (windowError) { @@ -643,6 +617,272 @@ export function clearPreviewAudioCache(): void { log.debug('Preview audio cache cleared') } +// --------------------------------------------------------------------------- +// Off-thread full decode (worker) +// --------------------------------------------------------------------------- + +// Two lanes so a foreground playback-window decode is never stuck behind a slow +// background full decode on the same worker thread. +let audioDecodeWorkerManager: ManagedWorker | null = null +let audioWindowWorkerManager: ManagedWorker | null = null +let audioDecodeRequestCounter = 0 + +function canUseAudioDecodeWorker(): boolean { + return typeof Worker !== 'undefined' +} + +function createAudioDecodeWorker(): Worker { + return new Worker(new URL('./audio-decode-worker.ts', import.meta.url), { type: 'module' }) +} + +/** Background lane for full decodes. */ +function getAudioDecodeWorker(): Worker { + if (!audioDecodeWorkerManager) { + audioDecodeWorkerManager = createManagedWorker({ createWorker: createAudioDecodeWorker }) + } + return audioDecodeWorkerManager.getWorker() +} + +/** Foreground lane for latency-sensitive playback-window decodes. */ +function getAudioWindowWorker(): Worker { + if (!audioWindowWorkerManager) { + audioWindowWorkerManager = createManagedWorker({ createWorker: createAudioDecodeWorker }) + } + return audioWindowWorkerManager.getWorker() +} + +/** + * Resolve a preview-audio source into a form the worker can use. Blobs cross the + * worker boundary directly; object-URL strings carry along their registry + * metadata (file handle / fallback blob) so the worker can stream from disk. + */ +function prepareWorkerSource(src: PreviewAudioSource): { + src: string | Blob + sourceMetadata: ObjectUrlSourceMetadata | null + fallbackBlob: Blob | null +} { + if (src instanceof Blob) { + return { src, sourceMetadata: null, fallbackBlob: null } + } + return { + src, + sourceMetadata: getObjectUrlSourceMetadata(src), + fallbackBlob: getObjectUrlBlob(src), + } +} + +function decodeFullAudioViaWorker(mediaId: string, src: PreviewAudioSource): Promise { + return new Promise((resolve, reject) => { + const worker = getAudioDecodeWorker() + const requestId = `audio-decode-${++audioDecodeRequestCounter}` + // When the worker is handed the workspace root it persists bins itself, so + // the main thread only accumulates them for AudioBuffer assembly. + const workspaceRoot = getWorkspaceRoot() + const workerPersistsBins = workspaceRoot !== null + const persistedBins: DecodedAudioBinData[] = [] + const persistPromises: Array> = [] + + const cleanup = () => { + worker.removeEventListener('message', onMessage) + worker.removeEventListener('error', onError) + } + + const onMessage = (event: MessageEvent) => { + const message = event.data + if (message.requestId !== requestId) { + return + } + + if (message.type === 'bin') { + const bin: DecodedAudioBinData = { + binIndex: message.binIndex, + frames: message.frames, + sampleRate: message.sampleRate, + left: new Int16Array(message.left), + right: new Int16Array(message.right), + } + persistedBins.push(bin) + if (!workerPersistsBins) { + persistPromises.push( + saveDecodedBin(mediaId, bin).catch((err) => { + log.warn('Failed to persist decoded audio bin from worker', { + mediaId, + binIndex: bin.binIndex, + err, + }) + }), + ) + } + } else if (message.type === 'complete') { + cleanup() + const totalBins = message.totalBins + void Promise.all(persistPromises).then(() => { + try { + resolve(finalizeDecodedAudio(mediaId, persistedBins, totalBins)) + } catch (err) { + reject(err instanceof Error ? err : new Error(String(err))) + } + }) + } else if (message.type === 'error') { + cleanup() + reject(new Error(message.error)) + } + } + + const onError = (event: ErrorEvent) => { + cleanup() + reject(event.error instanceof Error ? event.error : new Error('Audio decode worker error')) + } + + worker.addEventListener('message', onMessage) + worker.addEventListener('error', onError) + + const prepared = prepareWorkerSource(src) + worker.postMessage({ + type: 'decode', + requestId, + mediaId, + src: prepared.src, + sourceMetadata: prepared.sourceMetadata, + fallbackBlob: prepared.fallbackBlob, + binDurationSec: BIN_DURATION_SEC, + storageSampleRate: STORAGE_SAMPLE_RATE, + workspaceRoot, + }) + }) +} + +interface AssembleBinInput { + frames: number + left: ArrayBuffer + right: ArrayBuffer +} + +/** + * Reassemble persisted Int16 bins into Float32 stereo channels on the decode + * worker so the (potentially ~second-long) dequant loop stays off the main + * thread. Bin buffers are copied (not transferred) so the caller can fall back + * to the synchronous main-thread path if the worker errors. + */ +function assembleBinsViaWorker( + totalFrames: number, + bins: AssembleBinInput[], +): Promise<{ left: Float32Array; right: Float32Array }> { + return new Promise((resolve, reject) => { + const worker = getAudioDecodeWorker() + const requestId = `audio-assemble-${++audioDecodeRequestCounter}` + + const cleanup = () => { + worker.removeEventListener('message', onMessage) + worker.removeEventListener('error', onError) + } + + const onMessage = (event: MessageEvent) => { + const message = event.data + if (message.requestId !== requestId) return + + if (message.type === 'assembled') { + cleanup() + resolve({ left: new Float32Array(message.left), right: new Float32Array(message.right) }) + } else if (message.type === 'error') { + cleanup() + reject(new Error(message.error)) + } + } + + const onError = (event: ErrorEvent) => { + cleanup() + reject(event.error instanceof Error ? event.error : new Error('Audio assemble worker error')) + } + + worker.addEventListener('message', onMessage) + worker.addEventListener('error', onError) + + worker.postMessage({ type: 'assemble-bins', requestId, totalFrames, bins }) + }) +} + +function decodeAudioWindowViaWorker( + mediaId: string, + src: PreviewAudioSource, + startTime: number, + durationSeconds: number, +): Promise { + return new Promise((resolve, reject) => { + const worker = getAudioWindowWorker() + const requestId = `audio-window-${++audioDecodeRequestCounter}` + + const cleanup = () => { + worker.removeEventListener('message', onMessage) + worker.removeEventListener('error', onError) + } + + const onMessage = (event: MessageEvent) => { + const message = event.data + if (message.requestId !== requestId) { + return + } + + if (message.type === 'window') { + cleanup() + try { + const ctx = new OfflineAudioContext(2, message.frames, message.sampleRate) + const buffer = ctx.createBuffer(2, message.frames, message.sampleRate) + buffer.getChannelData(0).set(new Float32Array(message.left)) + buffer.getChannelData(1).set(new Float32Array(message.right)) + resolve({ buffer, startTime: message.startTime, isComplete: false }) + } catch (err) { + reject(err instanceof Error ? err : new Error(String(err))) + } + } else if (message.type === 'error') { + cleanup() + reject(new Error(message.error)) + } + } + + const onError = (event: ErrorEvent) => { + cleanup() + reject(event.error instanceof Error ? event.error : new Error('Audio window worker error')) + } + + worker.addEventListener('message', onMessage) + worker.addEventListener('error', onError) + + const prepared = prepareWorkerSource(src) + worker.postMessage({ + type: 'decode-window', + requestId, + mediaId, + src: prepared.src, + sourceMetadata: prepared.sourceMetadata, + fallbackBlob: prepared.fallbackBlob, + startTime, + durationSeconds, + storageSampleRate: STORAGE_SAMPLE_RATE, + }) + }) +} + +/** Prefer an off-thread window decode; fall back to the main thread on failure. */ +async function decodeAudioWindowPreferWorker( + mediaId: string, + src: PreviewAudioSource, + startTime: number, + durationSeconds: number, +): Promise { + if (canUseAudioDecodeWorker()) { + try { + return await decodeAudioWindowViaWorker(mediaId, src, startTime, durationSeconds) + } catch (err) { + log.warn('Worker window decode failed, falling back to main-thread window decode', { + mediaId, + err, + }) + } + } + return decodeAudioWindow(mediaId, src, startTime, durationSeconds) +} + // --------------------------------------------------------------------------- // Load from persisted bins // --------------------------------------------------------------------------- @@ -666,7 +906,16 @@ async function loadOrDecodeAudio(mediaId: string, src: PreviewAudioSource): Prom log.warn('Failed to load persisted decoded audio, will decode', { mediaId, err }) } - // Full decode with progressive bin persistence + // Full decode with progressive bin persistence. Prefer the worker so the + // decode + DSP stays off the main thread; fall back to a main-thread decode + // when workers are unavailable (e.g. tests) or the worker errors. + if (canUseAudioDecodeWorker()) { + try { + return await decodeFullAudioViaWorker(mediaId, src) + } catch (err) { + log.warn('Worker audio decode failed, falling back to main-thread decode', { mediaId, err }) + } + } return decodeFullAudio(mediaId, src) } @@ -695,6 +944,7 @@ async function loadFromBins(meta: DecodedPreviewAudioMeta): Promise const bins = await Promise.all(binPromises) let offset = 0 + const validatedBins: AssembleBinInput[] = [] for (let i = 0; i < bins.length; i++) { const bin = bins[i] if (!(bin && 'kind' in bin && bin.kind === 'bin')) { @@ -705,19 +955,14 @@ async function loadFromBins(meta: DecodedPreviewAudioMeta): Promise if (b.frames <= 0) { throw new Error(`Invalid frame count in decoded audio bin ${i}`) } - - const leftInt16 = new Int16Array(b.left) - const rightInt16 = new Int16Array(b.right) - - if (leftInt16.length !== b.frames || rightInt16.length !== b.frames) { + if (b.left.byteLength / 2 !== b.frames || b.right.byteLength / 2 !== b.frames) { throw new Error(`Corrupt decoded audio bin ${i}`) } if (offset + b.frames > totalFrames) { throw new Error(`Decoded audio bins exceed expected frame length (${mediaId})`) } - leftChannel.set(int16ToFloat32(leftInt16), offset) - rightChannel.set(int16ToFloat32(rightInt16), offset) + validatedBins.push({ frames: b.frames, left: b.left, right: b.right }) offset += b.frames } @@ -725,6 +970,33 @@ async function loadFromBins(meta: DecodedPreviewAudioMeta): Promise throw new Error(`Decoded audio bins incomplete: ${offset}/${totalFrames} frames`) } + // Reassemble off the main thread when possible — the Int16→Float32 dequant is + // O(totalFrames) and blocks the main thread for ~hundreds of ms on long clips. + // Falls back to the synchronous loop when the worker is unavailable or errors. + let assembledViaWorker = false + if (canUseAudioDecodeWorker()) { + try { + const assembled = await assembleBinsViaWorker(totalFrames, validatedBins) + leftChannel.set(assembled.left) + rightChannel.set(assembled.right) + assembledViaWorker = true + } catch (err) { + log.warn('Worker bin assembly failed, falling back to main-thread assembly', { + mediaId, + err, + }) + } + } + + if (!assembledViaWorker) { + let writeOffset = 0 + for (const b of validatedBins) { + int16ToFloat32Into(new Int16Array(b.left), leftChannel, writeOffset) + int16ToFloat32Into(new Int16Array(b.right), rightChannel, writeOffset) + writeOffset += b.frames + } + } + log.info('Loaded decoded audio from workspace cache', { mediaId, binCount, @@ -736,102 +1008,42 @@ async function loadFromBins(meta: DecodedPreviewAudioMeta): Promise return buffer } -// --------------------------------------------------------------------------- -// Downmix surround -> stereo (ITU-R BS.775) -// --------------------------------------------------------------------------- - -/** - * Downmix N-channel audio to stereo using standard ITU-R BS.775 coefficients. - * 5.1 layout: L R C LFE Ls Rs - * 7.1 layout: L R C LFE Ls Rs Lrs Rrs (rear surrounds folded into Ls/Rs) - * - * For mono/stereo input, returns the data unchanged (or duplicated for mono). - */ -function downmixToStereo( - channels: Float32Array[], - totalFrames: number, -): { left: Float32Array; right: Float32Array } { - const numCh = channels.length - - if (numCh <= 2) { - const left = channels[0] ?? new Float32Array(totalFrames) - const right = channels[1] ?? left - return { left, right } - } - - // ITU coefficients for 5.1 downmix - const centerGain = 0.7071 // -3 dB - const lfeGain = 0 // discard LFE for preview - const surroundGain = 0.7071 - - const left = new Float32Array(totalFrames) - const right = new Float32Array(totalFrames) - - const L = channels[0]! - const R = channels[1]! - const C = channels[2] - const LFE = channels[3] // used with lfeGain (0) - const Ls = channels[4] - const Rs = channels[5] - // 7.1 rear surrounds (fold into Ls/Rs) - const Lrs = channels[6] - const Rrs = channels[7] - - for (let i = 0; i < totalFrames; i++) { - let l = L[i]! - let r = R[i]! - - if (C) { - const c = C[i]! * centerGain - l += c - r += c - } - if (lfeGain !== 0 && LFE) { - const lfe = LFE[i]! * lfeGain - l += lfe - r += lfe - } - if (Ls) l += Ls[i]! * surroundGain - if (Rs) r += Rs[i]! * surroundGain - if (Lrs) l += Lrs[i]! * surroundGain - if (Rrs) r += Rrs[i]! * surroundGain - - left[i] = l - right[i] = r - } - - return { left, right } -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -function assembleChunks(chunks: Float32Array[], totalFrames: number): Float32Array { - const result = new Float32Array(totalFrames) - let offset = 0 - for (const chunk of chunks) { - result.set(chunk, offset) - offset += chunk.length - } - return result -} - async function buildPreviewStereoBuffer( leftChunks: Float32Array[], rightChunks: Float32Array[], totalFrames: number, sampleRate: number, ): Promise { - const left = assembleChunks(leftChunks, totalFrames) - const right = assembleChunks(rightChunks, totalFrames) - - const tempCtx = new OfflineAudioContext(2, totalFrames, sampleRate) - const tempBuffer = tempCtx.createBuffer(2, totalFrames, sampleRate) - tempBuffer.getChannelData(0).set(left) - tempBuffer.getChannelData(1).set(right) + const ds = buildDownsampledStereo( + leftChunks, + rightChunks, + totalFrames, + sampleRate, + STORAGE_SAMPLE_RATE, + ) + const ctx = new OfflineAudioContext(2, ds.frames, ds.sampleRate) + const buffer = ctx.createBuffer(2, ds.frames, ds.sampleRate) + buffer.getChannelData(0).set(ds.left) + buffer.getChannelData(1).set(ds.right) + return buffer +} - return downsampleBuffer(tempBuffer, STORAGE_SAMPLE_RATE) +async function saveDecodedBin(mediaId: string, bin: DecodedAudioBinData): Promise { + await saveDecodedPreviewAudio({ + id: binKey(mediaId, bin.binIndex), + mediaId, + kind: 'bin', + binIndex: bin.binIndex, + left: bin.left.buffer as ArrayBuffer, + right: bin.right.buffer as ArrayBuffer, + frames: bin.frames, + sampleRate: bin.sampleRate, + createdAt: Date.now(), + }) } /** @@ -846,37 +1058,88 @@ async function persistBin( rightChunks: Float32Array[], frames: number, sampleRate: number, -): Promise<{ - binIndex: number - frames: number - sampleRate: number - left: Int16Array - right: Int16Array -}> { - const downsampled = await buildPreviewStereoBuffer(leftChunks, rightChunks, frames, sampleRate) +): Promise { + const bin = produceDecodedBin( + binIdx, + leftChunks, + rightChunks, + frames, + sampleRate, + STORAGE_SAMPLE_RATE, + ) + await saveDecodedBin(mediaId, bin) + return bin +} - const leftInt16 = float32ToInt16(downsampled.getChannelData(0)) - const rightInt16 = float32ToInt16(downsampled.getChannelData(1)) +/** + * Assemble persisted Int16 bins into a playback AudioBuffer, then fire-and-forget + * the WAV conform asset and the decode-complete meta marker. Shared by the + * main-thread and worker decode paths so their output stays identical. + */ +function finalizeDecodedAudio( + mediaId: string, + persistedBins: DecodedAudioBinData[], + totalBins: number, +): AudioBuffer { + persistedBins.sort((a, b) => a.binIndex - b.binIndex) + + const storedTotalFrames = persistedBins.reduce((sum, b) => sum + b.frames, 0) + if (persistedBins.length === 0 || storedTotalFrames === 0) { + throw new Error(`Audio decode produced no output for media ${mediaId}`) + } - await saveDecodedPreviewAudio({ - id: binKey(mediaId, binIdx), + const storedSampleRate = persistedBins[0]?.sampleRate ?? STORAGE_SAMPLE_RATE + const outCtx = new OfflineAudioContext(2, storedTotalFrames, storedSampleRate) + const combined = outCtx.createBuffer(2, storedTotalFrames, storedSampleRate) + const outLeft = combined.getChannelData(0) + const outRight = combined.getChannelData(1) + + // Assemble the playback float buffer and a planar Int16 copy in one pass. The + // Int16 copy feeds the conform WAV directly, avoiding a redundant + // Float32→Int16 re-quantization of the whole buffer. + const wavLeft = new Int16Array(storedTotalFrames) + const wavRight = new Int16Array(storedTotalFrames) + let offset = 0 + for (const bin of persistedBins) { + int16ToFloat32Into(bin.left, outLeft, offset) + int16ToFloat32Into(bin.right, outRight, offset) + wavLeft.set(bin.left, offset) + wavRight.set(bin.right, offset) + offset += bin.frames + } + if (offset !== storedTotalFrames) { + throw new Error(`Decoded audio assembly mismatch: ${offset}/${storedTotalFrames} frames`) + } + + log.info('Audio decoded for preview', { mediaId, - kind: 'bin', - binIndex: binIdx, - left: leftInt16.buffer as ArrayBuffer, - right: rightInt16.buffer as ArrayBuffer, - frames: downsampled.length, - sampleRate: downsampled.sampleRate, + sampleRate: storedSampleRate, + duration: combined.duration.toFixed(2), + bins: totalBins, + sizeMB: ((storedTotalFrames * 2 * 2) / (1024 * 1024)).toFixed(1), + }) + + void persistPreviewAudioConformFromInt16(mediaId, wavLeft, wavRight, storedSampleRate) + + // Save meta last as the decode-complete marker. + void saveDecodedPreviewAudio({ + id: mediaId, + mediaId, + kind: 'meta', + sampleRate: storedSampleRate, + totalFrames: storedTotalFrames, + binCount: totalBins, + binDurationSec: BIN_DURATION_SEC, createdAt: Date.now(), }) + .then(() => { + log.info('All bins persisted to workspace cache', { mediaId, binCount: totalBins }) + }) + .catch((err) => { + log.warn('Failed to persist bins to workspace cache', { mediaId, err }) + }) - return { - binIndex: binIdx, - frames: downsampled.length, - sampleRate: downsampled.sampleRate, - left: leftInt16, - right: rightInt16, - } + return combined } // --------------------------------------------------------------------------- @@ -935,15 +1198,7 @@ async function decodeFullAudio( let binRightChunks: Float32Array[] = [] let binAccumFrames = 0 let binIndex = 0 - const binFlushPromises: Array< - Promise<{ - binIndex: number - frames: number - sampleRate: number - left: Int16Array - right: Int16Array - }> - > = [] + const binFlushPromises: Array> = [] for await (const sample of sink.samples()) { try { @@ -1013,58 +1268,7 @@ async function decodeFullAudio( // Wait for all bins and assemble playback buffer from downsampled bins. const totalBins = binIndex const persistedBins = await Promise.all(binFlushPromises) - persistedBins.sort((a, b) => a.binIndex - b.binIndex) - - const storedTotalFrames = persistedBins.reduce((sum, b) => sum + b.frames, 0) - if (persistedBins.length === 0 || storedTotalFrames === 0) { - throw new Error(`Audio decode produced no output for media ${mediaId}`) - } - - const storedSampleRate = persistedBins[0]?.sampleRate ?? STORAGE_SAMPLE_RATE - const outCtx = new OfflineAudioContext(2, storedTotalFrames, storedSampleRate) - const combined = outCtx.createBuffer(2, storedTotalFrames, storedSampleRate) - const outLeft = combined.getChannelData(0) - const outRight = combined.getChannelData(1) - - let offset = 0 - for (const bin of persistedBins) { - outLeft.set(int16ToFloat32(bin.left), offset) - outRight.set(int16ToFloat32(bin.right), offset) - offset += bin.frames - } - if (offset !== storedTotalFrames) { - throw new Error(`Decoded audio assembly mismatch: ${offset}/${storedTotalFrames} frames`) - } - - log.info('Audio decoded for preview', { - mediaId, - sampleRate: storedSampleRate, - duration: combined.duration.toFixed(2), - bins: totalBins, - sizeMB: ((storedTotalFrames * 2 * 2) / (1024 * 1024)).toFixed(1), - }) - - void persistPreviewAudioConform(mediaId, combined) - - // Save meta last as the decode-complete marker. - void saveDecodedPreviewAudio({ - id: mediaId, - mediaId, - kind: 'meta', - sampleRate: storedSampleRate, - totalFrames: storedTotalFrames, - binCount: totalBins, - binDurationSec: BIN_DURATION_SEC, - createdAt: Date.now(), - }) - .then(() => { - log.info('All bins persisted to workspace cache', { mediaId, binCount: totalBins }) - }) - .catch((err) => { - log.warn('Failed to persist bins to workspace cache', { mediaId, err }) - }) - - return combined + return finalizeDecodedAudio(mediaId, persistedBins, totalBins) } finally { input.dispose() } diff --git a/src/runtime/composition-runtime/utils/audio-decode-dsp.test.ts b/src/runtime/composition-runtime/utils/audio-decode-dsp.test.ts new file mode 100644 index 000000000..35100a128 --- /dev/null +++ b/src/runtime/composition-runtime/utils/audio-decode-dsp.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vite-plus/test' +import { + assembleChannelChunks, + downmixToStereo, + downsampleStereo, + float32ToInt16, + int16ToFloat32, + int16ToFloat32Into, + produceDecodedBin, +} from './audio-decode-dsp' + +describe('audio-decode-dsp', () => { + it('round-trips Float32 -> Int16 -> Float32 within quantization error', () => { + const input = new Float32Array([0, 0.5, -0.5, 1, -1, 0.123, -0.987]) + const back = int16ToFloat32(float32ToInt16(input)) + for (let i = 0; i < input.length; i++) { + expect(Math.abs(back[i]! - input[i]!)).toBeLessThan(1 / 0x7fff + 1e-6) + } + }) + + it('int16ToFloat32Into writes the same values as int16ToFloat32 at an offset', () => { + const int16 = float32ToInt16(new Float32Array([0, 0.5, -0.5, 1, -1, 0.123])) + const expected = int16ToFloat32(int16) + const dst = new Float32Array(int16.length + 3) + int16ToFloat32Into(int16, dst, 3) + for (let i = 0; i < int16.length; i++) { + expect(dst[3 + i]).toBe(expected[i]) + } + expect(dst[0]).toBe(0) + }) + + it('clamps out-of-range float samples before Int16 conversion', () => { + const int16 = float32ToInt16(new Float32Array([2, -2])) + expect(int16[0]).toBe(0x7fff) + expect(int16[1]).toBe(-0x8000) + }) + + it('passes mono/stereo through downmix unchanged', () => { + const left = new Float32Array([0.1, 0.2]) + const right = new Float32Array([0.3, 0.4]) + const mono = downmixToStereo([left], 2) + expect(mono.left).toBe(left) + expect(mono.right).toBe(left) + + const stereo = downmixToStereo([left, right], 2) + expect(stereo.left).toBe(left) + expect(stereo.right).toBe(right) + }) + + it('downmixes 5.1 with ITU center/surround coefficients', () => { + const frames = 1 + const L = new Float32Array([1]) + const R = new Float32Array([1]) + const C = new Float32Array([1]) + const LFE = new Float32Array([1]) + const Ls = new Float32Array([1]) + const Rs = new Float32Array([1]) + const { left, right } = downmixToStereo([L, R, C, LFE, Ls, Rs], frames) + // L + C*0.7071 + Ls*0.7071 (LFE discarded) + expect(left[0]).toBeCloseTo(1 + 0.7071 + 0.7071, 5) + expect(right[0]).toBeCloseTo(1 + 0.7071 + 0.7071, 5) + }) + + it('returns inputs unchanged when source rate is at or below target', () => { + const left = new Float32Array([0.1, 0.2, 0.3]) + const right = new Float32Array([0.4, 0.5, 0.6]) + const result = downsampleStereo(left, right, 22050, 22050) + expect(result.left).toBe(left) + expect(result.right).toBe(right) + expect(result.frames).toBe(3) + expect(result.sampleRate).toBe(22050) + }) + + it('downsamples by the rate ratio with linear interpolation', () => { + const left = new Float32Array([0, 1, 2, 3, 4, 5, 6, 7]) + const right = new Float32Array(left) + const result = downsampleStereo(left, right, 48000, 24000) + expect(result.sampleRate).toBe(24000) + expect(result.frames).toBe(4) + // ratio 0.5 -> output[i] samples input at 2i: 0, 2, 4, 6 + expect(Array.from(result.left)).toEqual([0, 2, 4, 6]) + }) + + it('assembles chunks into a contiguous channel', () => { + const out = assembleChannelChunks([new Float32Array([1, 2]), new Float32Array([3, 4, 5])], 5) + expect(Array.from(out)).toEqual([1, 2, 3, 4, 5]) + }) + + it('produces an Int16 bin from accumulated chunks', () => { + const bin = produceDecodedBin( + 2, + [new Float32Array([0.5, -0.5])], + [new Float32Array([0.25, -0.25])], + 2, + 22050, + 22050, + ) + expect(bin.binIndex).toBe(2) + expect(bin.frames).toBe(2) + expect(bin.sampleRate).toBe(22050) + expect(bin.left).toBeInstanceOf(Int16Array) + expect(bin.left.length).toBe(2) + expect(bin.right.length).toBe(2) + expect(bin.left[0]).toBe(new Int16Array([0.5 * 0x7fff])[0]) + }) +}) diff --git a/src/runtime/composition-runtime/utils/audio-decode-dsp.ts b/src/runtime/composition-runtime/utils/audio-decode-dsp.ts new file mode 100644 index 000000000..5ca59a574 --- /dev/null +++ b/src/runtime/composition-runtime/utils/audio-decode-dsp.ts @@ -0,0 +1,210 @@ +/** + * Pure audio decode DSP — no AudioBuffer, no storage, no DOM. + * + * These helpers operate purely on TypedArrays so they can run in a Web Worker + * (where `AudioBuffer`/`OfflineAudioContext` are not available) as well as on + * the main thread. The main-thread decode path and the worker decode path both + * import from here so their bin output stays byte-identical. + */ + +export interface DecodedAudioBinData { + binIndex: number + frames: number + sampleRate: number + left: Int16Array + right: Int16Array +} + +export interface StereoChannels { + left: Float32Array + right: Float32Array + frames: number + sampleRate: number +} + +const INT16_NEG_SCALE = 1 / 0x8000 +const INT16_POS_SCALE = 1 / 0x7fff + +export function float32ToInt16(float32: Float32Array): Int16Array { + const n = float32.length + const int16 = new Int16Array(n) + for (let i = 0; i < n; i++) { + const s = Math.max(-1, Math.min(1, float32[i]!)) + int16[i] = s < 0 ? s * 0x8000 : s * 0x7fff + } + return int16 +} + +export function int16ToFloat32(int16: Int16Array): Float32Array { + const n = int16.length + const float32 = new Float32Array(n) + for (let i = 0; i < n; i++) { + const s = int16[i]! + float32[i] = s < 0 ? s * INT16_NEG_SCALE : s * INT16_POS_SCALE + } + return float32 +} + +/** + * Dequantize Int16 samples straight into a destination Float32 buffer at the + * given offset. Avoids the throwaway Float32Array allocation (and the second + * copy) that `int16ToFloat32` + `Float32Array.set` pays when filling a larger + * channel buffer bin-by-bin — both the allocation churn and the extra pass show + * up on the AudioBuffer-assembly hot path. + */ +export function int16ToFloat32Into(int16: Int16Array, dst: Float32Array, dstOffset: number): void { + const n = int16.length + for (let i = 0; i < n; i++) { + const s = int16[i]! + dst[dstOffset + i] = s < 0 ? s * INT16_NEG_SCALE : s * INT16_POS_SCALE + } +} + +/** + * Downmix N-channel audio to stereo using standard ITU-R BS.775 coefficients. + * 5.1 layout: L R C LFE Ls Rs + * 7.1 layout: L R C LFE Ls Rs Lrs Rrs (rear surrounds folded into Ls/Rs) + * + * For mono/stereo input, returns the data unchanged (or duplicated for mono). + */ +export function downmixToStereo( + channels: Float32Array[], + totalFrames: number, +): { left: Float32Array; right: Float32Array } { + const numCh = channels.length + + if (numCh <= 2) { + const left = channels[0] ?? new Float32Array(totalFrames) + const right = channels[1] ?? left + return { left, right } + } + + // ITU coefficients for 5.1 downmix + const centerGain = 0.7071 // -3 dB + const lfeGain = 0 // discard LFE for preview + const surroundGain = 0.7071 + + const left = new Float32Array(totalFrames) + const right = new Float32Array(totalFrames) + + const L = channels[0]! + const R = channels[1]! + const C = channels[2] + const LFE = channels[3] // used with lfeGain (0) + const Ls = channels[4] + const Rs = channels[5] + // 7.1 rear surrounds (fold into Ls/Rs) + const Lrs = channels[6] + const Rrs = channels[7] + + for (let i = 0; i < totalFrames; i++) { + let l = L[i]! + let r = R[i]! + + if (C) { + const c = C[i]! * centerGain + l += c + r += c + } + if (lfeGain !== 0 && LFE) { + const lfe = LFE[i]! * lfeGain + l += lfe + r += lfe + } + if (Ls) l += Ls[i]! * surroundGain + if (Rs) r += Rs[i]! * surroundGain + if (Lrs) l += Lrs[i]! * surroundGain + if (Rrs) r += Rrs[i]! * surroundGain + + left[i] = l + right[i] = r + } + + return { left, right } +} + +export function assembleChannelChunks(chunks: Float32Array[], totalFrames: number): Float32Array { + const result = new Float32Array(totalFrames) + let offset = 0 + for (const chunk of chunks) { + result.set(chunk, offset) + offset += chunk.length + } + return result +} + +function resampleChannel( + input: Float32Array, + output: Float32Array, + ratio: number, + targetFrames: number, +): void { + for (let i = 0; i < targetFrames; i++) { + const srcPos = i / ratio + const idx = Math.floor(srcPos) + const frac = srcPos - idx + const s0 = input[idx] ?? 0 + const s1 = input[idx + 1] ?? s0 + output[i] = s0 + (s1 - s0) * frac + } +} + +/** + * Downsample a stereo pair via manual linear interpolation — ~10x faster than + * OfflineAudioContext for preview-quality downsampling (22050 Hz). Anti-aliasing + * is handled by the Nyquist limit at the target rate. + * + * When the source is already at or below the target rate, the inputs are + * returned unchanged at the source rate. + */ +export function downsampleStereo( + left: Float32Array, + right: Float32Array, + sourceRate: number, + targetRate: number, +): StereoChannels { + if (sourceRate <= targetRate) { + return { left, right, frames: left.length, sampleRate: sourceRate } + } + + const ratio = targetRate / sourceRate + const sourceFrames = left.length + const targetFrames = Math.ceil(sourceFrames * ratio) + const outLeft = new Float32Array(targetFrames) + const outRight = new Float32Array(targetFrames) + resampleChannel(left, outLeft, ratio, targetFrames) + resampleChannel(right, outRight, ratio, targetFrames) + return { left: outLeft, right: outRight, frames: targetFrames, sampleRate: targetRate } +} + +/** Assemble accumulated chunks into a stereo pair and downsample to the target rate. */ +export function buildDownsampledStereo( + leftChunks: Float32Array[], + rightChunks: Float32Array[], + totalFrames: number, + sourceRate: number, + targetRate: number, +): StereoChannels { + const left = assembleChannelChunks(leftChunks, totalFrames) + const right = assembleChannelChunks(rightChunks, totalFrames) + return downsampleStereo(left, right, sourceRate, targetRate) +} + +/** Build one persisted bin (downsampled, Int16) from accumulated decode chunks. */ +export function produceDecodedBin( + binIndex: number, + leftChunks: Float32Array[], + rightChunks: Float32Array[], + frames: number, + sourceRate: number, + targetRate: number, +): DecodedAudioBinData { + const ds = buildDownsampledStereo(leftChunks, rightChunks, frames, sourceRate, targetRate) + return { + binIndex, + frames: ds.frames, + sampleRate: ds.sampleRate, + left: float32ToInt16(ds.left), + right: float32ToInt16(ds.right), + } +} diff --git a/src/runtime/composition-runtime/utils/audio-decode-worker.ts b/src/runtime/composition-runtime/utils/audio-decode-worker.ts new file mode 100644 index 000000000..b82b527a9 --- /dev/null +++ b/src/runtime/composition-runtime/utils/audio-decode-worker.ts @@ -0,0 +1,377 @@ +/** + * Audio Decode Worker + * + * Runs the expensive full-audio decode off the main thread: mediabunny decode → + * downmix to stereo → downsample → Int16. Decoded bins are streamed back as + * transferable Int16 PCM so the main thread only has to persist them and wrap + * the assembled result in an AudioBuffer (a cheap memcpy). + * + * AudioBuffer / OfflineAudioContext are unavailable in workers, so all DSP runs + * on plain TypedArrays via the shared audio-decode-dsp module. + */ + +import { createMediabunnyInputSource } from '@/infrastructure/browser/mediabunny-input-source' +import { writeDecodedPreviewAudioToRoot } from '@/infrastructure/storage/workspace-fs/decoded-preview-audio' +import { createLogger } from '@/shared/logging/logger' +import { ensureAc3DecoderRegistered, isAc3AudioCodec } from '@/shared/utils/ac3-decoder' +import { + buildDownsampledStereo, + downmixToStereo, + int16ToFloat32Into, + produceDecodedBin, + type DecodedAudioBinData, +} from './audio-decode-dsp' +import type { + AudioAssembleBinsRequest, + AudioAssembledResponse, + AudioDecodeBinResponse, + AudioDecodeCompleteResponse, + AudioDecodeErrorResponse, + AudioDecodeWindowResponse, + AudioDecodeWorkerMessage, +} from './audio-decode-worker.types' + +const log = createLogger('AudioDecodeWorker') + +interface DecodeSampleData { + numberOfFrames?: number + numberOfChannels?: number + sampleRate?: number + timestamp?: number + duration?: number + copyTo: (destination: Float32Array, options: { planeIndex: number; format: 'f32-planar' }) => void + close: () => void +} + +function extractStereoChunk(sample: DecodeSampleData): { + left: Float32Array + right: Float32Array + frameCount: number +} | null { + const frameCount = Math.max(0, sample.numberOfFrames ?? 0) + const channelCount = Math.max(1, sample.numberOfChannels ?? 1) + if (frameCount === 0) { + return null + } + const channels: Float32Array[] = [] + for (let c = 0; c < channelCount; c++) { + const channelData = new Float32Array(frameCount) + sample.copyTo(channelData, { planeIndex: c, format: 'f32-planar' }) + channels.push(channelData) + } + const { left, right } = downmixToStereo(channels, frameCount) + return { left, right, frameCount } +} + +async function persistBinToWorkspace( + root: FileSystemDirectoryHandle, + mediaId: string, + bin: DecodedAudioBinData, +): Promise { + await writeDecodedPreviewAudioToRoot(root, { + id: `${mediaId}:bin:${bin.binIndex}`, + mediaId, + kind: 'bin', + binIndex: bin.binIndex, + left: bin.left.buffer as ArrayBuffer, + right: bin.right.buffer as ArrayBuffer, + frames: bin.frames, + sampleRate: bin.sampleRate, + createdAt: Date.now(), + }) +} + +async function decode( + message: Extract, +): Promise { + const { + requestId, + mediaId, + src, + sourceMetadata, + fallbackBlob, + binDurationSec, + storageSampleRate, + workspaceRoot, + } = message + + const mb = await import('mediabunny') + const input = new mb.Input({ + formats: mb.ALL_FORMATS, + source: createMediabunnyInputSource(mb, src, { + metadata: sourceMetadata ?? null, + fallbackBlob: fallbackBlob ?? null, + }), + }) + + try { + const audioTrack = await input.getPrimaryAudioTrack() + if (!audioTrack) { + throw new Error(`No audio track found for media ${mediaId}`) + } + + const audioCodec = typeof audioTrack.codec === 'string' ? audioTrack.codec : undefined + if (isAc3AudioCodec(audioCodec)) { + await ensureAc3DecoderRegistered() + } + + const sink = new mb.AudioSampleSink(audioTrack) + + let sampleRate = 48000 + let binLeftChunks: Float32Array[] = [] + let binRightChunks: Float32Array[] = [] + let binAccumFrames = 0 + let binIndex = 0 + + // Persist the bin (when we own the workspace handle) BEFORE transferring its + // buffers to the main thread — transfer neuters the ArrayBuffers, so the + // write must read them first. + const flushBin = async () => { + const bin = produceDecodedBin( + binIndex, + binLeftChunks, + binRightChunks, + binAccumFrames, + sampleRate, + storageSampleRate, + ) + binIndex++ + binLeftChunks = [] + binRightChunks = [] + binAccumFrames = 0 + + if (workspaceRoot) { + await persistBinToWorkspace(workspaceRoot, mediaId, bin) + } + + const response: AudioDecodeBinResponse = { + type: 'bin', + requestId, + binIndex: bin.binIndex, + frames: bin.frames, + sampleRate: bin.sampleRate, + left: bin.left.buffer as ArrayBuffer, + right: bin.right.buffer as ArrayBuffer, + } + self.postMessage(response, { transfer: [response.left, response.right] }) + } + + for await (const sample of sink.samples() as AsyncIterable) { + try { + if (sample.sampleRate && sample.sampleRate > 0) { + sampleRate = sample.sampleRate + } + + const chunk = extractStereoChunk(sample) + if (!chunk) { + continue + } + + binLeftChunks.push(chunk.left) + binRightChunks.push(chunk.right) + binAccumFrames += chunk.frameCount + + const binFramesAtSource = binDurationSec * sampleRate + if (binAccumFrames >= binFramesAtSource) { + await flushBin() + } + } finally { + sample.close() + } + } + + if (binAccumFrames > 0) { + await flushBin() + } + + const complete: AudioDecodeCompleteResponse = { + type: 'complete', + requestId, + totalBins: binIndex, + } + self.postMessage(complete) + } finally { + input.dispose() + } +} + +/** + * Decode a single playback window for fast preview start. Mirrors the + * main-thread decodeAudioWindow but uses AudioSampleSink (AudioBuffer is not + * available in workers) and returns Float32 stereo for direct playback. + */ +async function decodeWindow( + message: Extract, +): Promise { + const { + requestId, + mediaId, + src, + sourceMetadata, + fallbackBlob, + startTime, + durationSeconds, + storageSampleRate, + } = message + + const mb = await import('mediabunny') + const input = new mb.Input({ + formats: mb.ALL_FORMATS, + source: createMediabunnyInputSource(mb, src, { + metadata: sourceMetadata ?? null, + fallbackBlob: fallbackBlob ?? null, + }), + }) + + try { + const audioTrack = await input.getPrimaryAudioTrack() + if (!audioTrack) { + throw new Error(`No audio track found for media ${mediaId}`) + } + + const audioCodec = typeof audioTrack.codec === 'string' ? audioTrack.codec : undefined + if (isAc3AudioCodec(audioCodec)) { + await ensureAc3DecoderRegistered() + } + + const safeStartTime = Math.max(0, startTime) + const targetCoverageEndTime = safeStartTime + Math.max(0.5, durationSeconds) + const sink = new mb.AudioSampleSink(audioTrack) + + let sliceStartTime: number | null = null + let coverageEndTime = safeStartTime + let sampleRate = 48000 + let totalFrames = 0 + const leftChunks: Float32Array[] = [] + const rightChunks: Float32Array[] = [] + const seenSampleKeys = new Set() + + const append = (sample: DecodeSampleData) => { + const timestamp = Number.isFinite(sample.timestamp) + ? (sample.timestamp as number) + : coverageEndTime + const duration = Number.isFinite(sample.duration) ? (sample.duration as number) : 0 + + const dedupeKey = `${timestamp}:${duration}` + if (seenSampleKeys.has(dedupeKey)) { + return + } + const chunk = extractStereoChunk(sample) + if (!chunk) { + return + } + seenSampleKeys.add(dedupeKey) + + if (sliceStartTime === null) { + sliceStartTime = timestamp + } + coverageEndTime = Math.max(coverageEndTime, timestamp + duration) + if (sample.sampleRate && sample.sampleRate > 0) { + sampleRate = sample.sampleRate + } + + leftChunks.push(chunk.left) + rightChunks.push(chunk.right) + totalFrames += chunk.frameCount + } + + const initialSample = (await sink.getSample(safeStartTime)) as DecodeSampleData | null + if (initialSample) { + try { + append(initialSample) + } finally { + initialSample.close() + } + } + + const iteratorStartTime = sliceStartTime ?? safeStartTime + for await (const sample of sink.samples( + iteratorStartTime, + targetCoverageEndTime, + ) as AsyncIterable) { + try { + append(sample) + } finally { + sample.close() + } + if (coverageEndTime >= targetCoverageEndTime) { + break + } + } + + if (totalFrames <= 0 || sliceStartTime === null) { + throw new Error(`Audio window decode produced no output for media ${mediaId}`) + } + + const ds = buildDownsampledStereo( + leftChunks, + rightChunks, + totalFrames, + sampleRate, + storageSampleRate, + ) + const response: AudioDecodeWindowResponse = { + type: 'window', + requestId, + startTime: sliceStartTime, + frames: ds.frames, + sampleRate: ds.sampleRate, + left: ds.left.buffer as ArrayBuffer, + right: ds.right.buffer as ArrayBuffer, + } + self.postMessage(response, { transfer: [response.left, response.right] }) + } finally { + input.dispose() + } +} + +/** Reassemble persisted Int16 bins into Float32 stereo channels off-thread. */ +function assembleBins(message: AudioAssembleBinsRequest): void { + const { requestId, totalFrames, bins } = message + const left = new Float32Array(totalFrames) + const right = new Float32Array(totalFrames) + + let offset = 0 + for (const bin of bins) { + int16ToFloat32Into(new Int16Array(bin.left), left, offset) + int16ToFloat32Into(new Int16Array(bin.right), right, offset) + offset += bin.frames + } + + const response: AudioAssembledResponse = { + type: 'assembled', + requestId, + frames: totalFrames, + left: left.buffer as ArrayBuffer, + right: right.buffer as ArrayBuffer, + } + self.postMessage(response, { transfer: [response.left, response.right] }) +} + +self.onmessage = async (event: MessageEvent) => { + const message = event.data + + try { + if (message.type === 'decode') { + await decode(message) + } else if (message.type === 'decode-window') { + await decodeWindow(message) + } else if (message.type === 'assemble-bins') { + assembleBins(message) + } + } catch (err) { + log.warn('Audio decode worker failed', { + mediaId: 'mediaId' in message ? message.mediaId : undefined, + type: message.type, + err, + }) + const response: AudioDecodeErrorResponse = { + type: 'error', + requestId: message.requestId, + error: err instanceof Error ? err.message : String(err), + } + self.postMessage(response) + } +} + +export {} diff --git a/src/runtime/composition-runtime/utils/audio-decode-worker.types.ts b/src/runtime/composition-runtime/utils/audio-decode-worker.types.ts new file mode 100644 index 000000000..3fb0fa5fb --- /dev/null +++ b/src/runtime/composition-runtime/utils/audio-decode-worker.types.ts @@ -0,0 +1,101 @@ +import type { ObjectUrlSourceMetadata } from '@/infrastructure/browser/object-url-registry' + +export interface AudioDecodeRequest { + type: 'decode' + requestId: string + mediaId: string + /** Blob source, or an object-URL string resolved via the passed metadata/fallback. */ + src: string | Blob + sourceMetadata?: ObjectUrlSourceMetadata | null + fallbackBlob?: Blob | null + binDurationSec: number + storageSampleRate: number + /** + * Workspace root handle. When present, the worker persists decoded bins to + * disk itself (off the main thread); otherwise it only streams them back for + * the main thread to persist. + */ + workspaceRoot?: FileSystemDirectoryHandle | null +} + +export interface AudioDecodeWindowRequest { + type: 'decode-window' + requestId: string + mediaId: string + src: string | Blob + sourceMetadata?: ObjectUrlSourceMetadata | null + fallbackBlob?: Blob | null + startTime: number + durationSeconds: number + storageSampleRate: number +} + +/** + * Reassemble persisted Int16 bins into Float32 stereo channels off the main + * thread. The main thread reads the bins from disk, hands their Int16 buffers + * here, and copies the returned Float32 channels straight into an AudioBuffer. + */ +export interface AudioAssembleBinsRequest { + type: 'assemble-bins' + requestId: string + totalFrames: number + /** Per-bin Int16 PCM, in playback order. */ + bins: { frames: number; left: ArrayBuffer; right: ArrayBuffer }[] +} + +export type AudioDecodeWorkerMessage = + | AudioDecodeRequest + | AudioDecodeWindowRequest + | AudioAssembleBinsRequest + +export interface AudioDecodeBinResponse { + type: 'bin' + requestId: string + binIndex: number + frames: number + sampleRate: number + /** Int16 PCM, transferred. */ + left: ArrayBuffer + right: ArrayBuffer +} + +export interface AudioDecodeCompleteResponse { + type: 'complete' + requestId: string + totalBins: number +} + +/** A decoded playback window — Float32 stereo (not persisted, no quantization). */ +export interface AudioDecodeWindowResponse { + type: 'window' + requestId: string + startTime: number + frames: number + sampleRate: number + /** Float32 PCM, transferred. */ + left: ArrayBuffer + right: ArrayBuffer +} + +/** Reassembled Float32 stereo channels for AudioBuffer construction. */ +export interface AudioAssembledResponse { + type: 'assembled' + requestId: string + frames: number + /** Float32 PCM, transferred. */ + left: ArrayBuffer + right: ArrayBuffer +} + +export interface AudioDecodeErrorResponse { + type: 'error' + requestId: string + error: string +} + +export type AudioDecodeWorkerResponse = + | AudioDecodeBinResponse + | AudioDecodeCompleteResponse + | AudioDecodeWindowResponse + | AudioAssembledResponse + | AudioDecodeErrorResponse diff --git a/src/runtime/composition-runtime/utils/preview-audio-conform.ts b/src/runtime/composition-runtime/utils/preview-audio-conform.ts index f16cd93b8..eb5c17674 100644 --- a/src/runtime/composition-runtime/utils/preview-audio-conform.ts +++ b/src/runtime/composition-runtime/utils/preview-audio-conform.ts @@ -9,8 +9,8 @@ import { } from '@/infrastructure/storage/workspace-fs/cache-mirror' import { previewAudioPath } from '@/infrastructure/storage/workspace-fs/paths' import { requireWorkspaceRoot } from '@/infrastructure/storage/workspace-fs/root' -import { writeBlob } from '@/infrastructure/storage/workspace-fs/fs-primitives' -import { audioBufferToWavBlob } from './audio-buffer-wav' +import { exists, writeBlob } from '@/infrastructure/storage/workspace-fs/fs-primitives' +import { audioBufferToWavBlob, int16StereoToWavBlob } from './audio-buffer-wav' const log = createLogger('PreviewAudioConform') @@ -27,6 +27,25 @@ export function getCachedPreviewAudioConformUrl(mediaId: string): string | null return blobUrlManager.get(getPreviewAudioConformCacheKey(mediaId)) } +/** + * True when the conform WAV has already been persisted for this media and the + * file is still present. Callers use this to skip the (expensive) decode + + * AudioBuffer rebuild that would otherwise run purely to feed a conform that is + * already on disk — the dominant source of jank when audio clips scroll back + * into view. + */ +export async function isPreviewAudioConformed(mediaId: string): Promise { + const media = await getMedia(mediaId) + if (!media?.previewAudioConformedAt) { + return false + } + try { + return await exists(requireWorkspaceRoot(), previewAudioPath(mediaId)) + } catch { + return false + } +} + export async function resolvePreviewAudioConformUrl(mediaId: string): Promise { const cacheKey = getPreviewAudioConformCacheKey(mediaId) const cached = blobUrlManager.get(cacheKey) @@ -86,10 +105,12 @@ export async function resolvePreviewAudioConformUrl(mediaId: string): Promise { +/** + * Encode and persist the conform WAV. `buildBlob` is a lazy factory so the + * (potentially expensive) encode only runs once the in-flight guard and the + * already-conformed check have cleared — never speculatively per call. + */ +function persistConform(mediaId: string, buildBlob: () => Blob): Promise { const pending = pendingPreviewAudioConformPersists.get(mediaId) if (pending) { return pending @@ -101,16 +122,27 @@ export async function persistPreviewAudioConform( return } - const cacheKey = getPreviewAudioConformCacheKey(mediaId) - let wavBlob: Blob | null = null + // Already conformed in a prior visit/session — the WAV is on disk. Clips + // re-enter the viewport constantly while scrolling and each remount retries + // the conform; re-encoding the whole WAV every time is pure main-thread + // waste, so bail when the persisted asset is still present. + if (media.previewAudioConformedAt) { + try { + if (await exists(requireWorkspaceRoot(), previewAudioPath(mediaId))) { + return + } + } catch { + // Existence check failed — fall through and re-persist defensively. + } + } + const cacheKey = getPreviewAudioConformCacheKey(mediaId) + const wavBlob = buildBlob() if (!blobUrlManager.get(cacheKey)) { - wavBlob = audioBufferToWavBlob(buffer) blobUrlManager.acquire(cacheKey, wavBlob) } - const nextBlob = wavBlob ?? audioBufferToWavBlob(buffer) - const bytes = await nextBlob.arrayBuffer() + const bytes = await wavBlob.arrayBuffer() await writeBlob(requireWorkspaceRoot(), previewAudioPath(mediaId), new Uint8Array(bytes)) await updateMedia(mediaId, { @@ -129,6 +161,24 @@ export async function persistPreviewAudioConform( return promise } +export function persistPreviewAudioConform(mediaId: string, buffer: AudioBuffer): Promise { + return persistConform(mediaId, () => audioBufferToWavBlob(buffer)) +} + +/** + * Conform straight from persisted Int16 bins, avoiding the AudioBuffer + * Int16→Float32→Int16 round-trip. Preferred on the fresh-decode path where the + * raw Int16 samples are already in hand. + */ +export function persistPreviewAudioConformFromInt16( + mediaId: string, + left: Int16Array, + right: Int16Array, + sampleRate: number, +): Promise { + return persistConform(mediaId, () => int16StereoToWavBlob(left, right, sampleRate)) +} + export async function deletePreviewAudioConform( mediaOrId: MediaMetadata | string, options?: { clearMetadata?: boolean }, diff --git a/src/shared/logging/perf-marks.ts b/src/shared/logging/perf-marks.ts new file mode 100644 index 000000000..ee44bfa02 --- /dev/null +++ b/src/shared/logging/perf-marks.ts @@ -0,0 +1,87 @@ +/** + * Thin wrappers over the User Timing API (`performance.mark` / `performance.measure`). + * + * Entries land on the "Timings" / "User Timing" track in Chrome DevTools + * Performance, so named timeline hotspots show up as labeled bars instead of + * minified flame-chart frames. Safe in production — the underlying APIs are + * noop-fast browser primitives. + */ + +const HAS_PERF = + typeof performance !== 'undefined' && + typeof performance.mark === 'function' && + typeof performance.measure === 'function' + +export function perfMark(name: string): void { + if (!HAS_PERF) return + try { + performance.mark(name) + } catch { + // ignore — bad name / detached buffer + } +} + +/** + * Mark a React component render with a `tl.render.` instant mark. The + * count of these marks during a gesture reveals which components are doing the + * bulk of the per-zoom (or per-anything) re-render work. + * + * Opt-in: fires only when `window.__TL_RENDER_MARKS__` is truthy, so it adds + * zero overhead (and no unbounded mark-buffer growth) in normal use. Enable it + * from the console before profiling: `window.__TL_RENDER_MARKS__ = true`. + */ +export function perfMarkRender(name: string): void { + if (!HAS_PERF) return + if (!(globalThis as { __TL_RENDER_MARKS__?: boolean }).__TL_RENDER_MARKS__) return + try { + performance.mark(`tl.render.${name}`) + } catch { + // ignore + } +} + +export function perfMeasure(name: string, startMark: string, endMark?: string): void { + if (!HAS_PERF) return + try { + performance.measure(name, startMark, endMark) + } catch { + // ignore — missing start mark + } +} + +/** + * Measure the synchronous duration of `fn` under `name`. Shows up as a + * single labeled entry on the User Timing track. + * + * Opt-in: records only when `window.__TL_PERF__` is truthy. Each call leaves a + * `performance.measure` entry in the buffer (the marks are cleared, the measure + * is not — that's what `__DEBUG__.perfSummary()` reads), so leaving it always-on + * grows the User Timing buffer unbounded over a session. Gating it (like + * `perfMarkRender`) keeps normal use zero-overhead; enable before profiling: + * `window.__TL_PERF__ = true`. + */ +let perfMeasureCounter = 0 + +export function withPerfMeasure(name: string, fn: () => T): T { + if (!HAS_PERF || !(globalThis as { __TL_PERF__?: boolean }).__TL_PERF__) return fn() + const unique = ++perfMeasureCounter + const startMark = `${name}:s:${unique}` + const endMark = `${name}:e:${unique}` + try { + performance.mark(startMark) + } catch { + return fn() + } + try { + return fn() + } finally { + try { + performance.mark(endMark) + performance.measure(name, startMark, endMark) + performance.clearMarks(startMark) + performance.clearMarks(endMark) + } catch { + // ignore + } + } +} diff --git a/src/shared/projects/defaults.ts b/src/shared/projects/defaults.ts new file mode 100644 index 000000000..2dcbd26d1 --- /dev/null +++ b/src/shared/projects/defaults.ts @@ -0,0 +1,10 @@ +/** + * Project default constants. + * + * Use these instead of hardcoded 1920/1080/30 literals when falling back from + * missing project metadata or initializing new projects. + */ + +export const DEFAULT_PROJECT_WIDTH = 1920 +export const DEFAULT_PROJECT_HEIGHT = 1080 +export const DEFAULT_PROJECT_FPS = 30 diff --git a/src/shared/projects/migrations/normalize.ts b/src/shared/projects/migrations/normalize.ts index d5cccbce1..916f8fdc4 100644 --- a/src/shared/projects/migrations/normalize.ts +++ b/src/shared/projects/migrations/normalize.ts @@ -18,34 +18,8 @@ import type { Project, ProjectTimeline } from '@/types/project' import { DEFAULT_TRACK_HEIGHT, DEFAULT_FPS } from '@/shared/timeline/defaults' -import { - AUDIO_EQ_HIGH_CUT_FREQUENCY_HZ, - AUDIO_EQ_HIGH_CUT_MAX_FREQUENCY_HZ, - AUDIO_EQ_HIGH_CUT_MIN_FREQUENCY_HZ, - AUDIO_EQ_HIGH_FREQUENCY_HZ, - AUDIO_EQ_HIGH_MAX_FREQUENCY_HZ, - AUDIO_EQ_HIGH_MID_FREQUENCY_HZ, - AUDIO_EQ_HIGH_MID_MAX_FREQUENCY_HZ, - AUDIO_EQ_HIGH_MID_MIN_FREQUENCY_HZ, - AUDIO_EQ_HIGH_MID_Q, - AUDIO_EQ_HIGH_MIN_FREQUENCY_HZ, - AUDIO_EQ_LOW_CUT_FREQUENCY_HZ, - AUDIO_EQ_LOW_CUT_MAX_FREQUENCY_HZ, - AUDIO_EQ_LOW_CUT_MIN_FREQUENCY_HZ, - AUDIO_EQ_LOW_FREQUENCY_HZ, - AUDIO_EQ_LOW_MAX_FREQUENCY_HZ, - AUDIO_EQ_LOW_MID_FREQUENCY_HZ, - AUDIO_EQ_LOW_MID_MAX_FREQUENCY_HZ, - AUDIO_EQ_LOW_MID_MIN_FREQUENCY_HZ, - AUDIO_EQ_LOW_MID_Q, - AUDIO_EQ_LOW_MIN_FREQUENCY_HZ, - clampAudioEqCutSlopeDbPerOct, - clampAudioEqFrequencyHz, - clampAudioEqGainDb, - clampAudioEqQ, - normalizeAudioEqSettings, -} from '@/shared/utils/audio-eq' -import { clampAudioPitchCents, clampAudioPitchSemitones } from '@/shared/utils/audio-pitch' +import { normalizeAudioEqSettings } from '@/shared/utils/audio-eq' +import { applyOptionalClamps } from '@/shared/timeline/item-clamps' /** * Normalize a track to ensure all fields have valid values. @@ -79,34 +53,14 @@ function normalizeTrack( */ function normalizeItem(item: ProjectTimeline['items'][number]): ProjectTimeline['items'][number] { const normalized = { ...item } - const maybeFrameFields = normalized as typeof normalized & { - trimStart?: number - trimEnd?: number - sourceStart?: number - sourceEnd?: number - sourceDuration?: number - sourceFps?: number - } - // Keep timeline and source coordinates aligned to whole frames. + // Keep timeline coordinates aligned to whole frames. normalized.from = Math.max(0, Math.round(normalized.from ?? 0)) normalized.durationInFrames = Math.max(1, Math.round(normalized.durationInFrames ?? 1)) - if (maybeFrameFields.trimStart !== undefined) - maybeFrameFields.trimStart = Math.max(0, Math.round(maybeFrameFields.trimStart)) - if (maybeFrameFields.trimEnd !== undefined) - maybeFrameFields.trimEnd = Math.max(0, Math.round(maybeFrameFields.trimEnd)) - if (maybeFrameFields.sourceStart !== undefined) - maybeFrameFields.sourceStart = Math.max(0, Math.round(maybeFrameFields.sourceStart)) - if (maybeFrameFields.sourceEnd !== undefined) - maybeFrameFields.sourceEnd = Math.max(0, Math.round(maybeFrameFields.sourceEnd)) - if (maybeFrameFields.sourceDuration !== undefined) - maybeFrameFields.sourceDuration = Math.max(0, Math.round(maybeFrameFields.sourceDuration)) - if (maybeFrameFields.sourceFps !== undefined) { - maybeFrameFields.sourceFps = - Number.isFinite(maybeFrameFields.sourceFps) && maybeFrameFields.sourceFps > 0 - ? Math.round(maybeFrameFields.sourceFps * 1000) / 1000 - : undefined - } + + // Frame/audio/EQ optional-field clamps — shared with the runtime items-store + // normalizer so adding a new clamped field only needs registering once. + applyOptionalClamps(normalized as Record) // Ensure speed is valid (default 1.0, range 0.1-10.0) if (normalized.speed !== undefined) { @@ -131,162 +85,6 @@ function normalizeItem(item: ProjectTimeline['items'][number]): ProjectTimeline[ if (normalized.audioFadeOut !== undefined) { normalized.audioFadeOut = Math.max(0, normalized.audioFadeOut) } - if (normalized.audioPitchSemitones !== undefined) { - normalized.audioPitchSemitones = clampAudioPitchSemitones(normalized.audioPitchSemitones) - } - if (normalized.audioPitchCents !== undefined) { - normalized.audioPitchCents = clampAudioPitchCents(normalized.audioPitchCents) - } - if (normalized.audioEqOutputGainDb !== undefined) { - normalized.audioEqOutputGainDb = clampAudioEqGainDb(normalized.audioEqOutputGainDb) - } - if (normalized.audioEqBand1Enabled !== undefined) { - normalized.audioEqBand1Enabled = !!normalized.audioEqBand1Enabled - } - if (normalized.audioEqBand1FrequencyHz !== undefined) { - normalized.audioEqBand1FrequencyHz = clampAudioEqFrequencyHz( - normalized.audioEqBand1FrequencyHz, - AUDIO_EQ_LOW_CUT_MIN_FREQUENCY_HZ, - AUDIO_EQ_LOW_CUT_MAX_FREQUENCY_HZ, - AUDIO_EQ_LOW_CUT_FREQUENCY_HZ, - ) - } - if (normalized.audioEqBand1GainDb !== undefined) { - normalized.audioEqBand1GainDb = clampAudioEqGainDb(normalized.audioEqBand1GainDb) - } - if (normalized.audioEqBand1Q !== undefined) { - normalized.audioEqBand1Q = clampAudioEqQ(normalized.audioEqBand1Q) - } - if (normalized.audioEqBand1SlopeDbPerOct !== undefined) { - normalized.audioEqBand1SlopeDbPerOct = clampAudioEqCutSlopeDbPerOct( - normalized.audioEqBand1SlopeDbPerOct, - ) - } - if (normalized.audioEqLowCutEnabled !== undefined) { - normalized.audioEqLowCutEnabled = !!normalized.audioEqLowCutEnabled - } - if (normalized.audioEqLowCutFrequencyHz !== undefined) { - normalized.audioEqLowCutFrequencyHz = clampAudioEqFrequencyHz( - normalized.audioEqLowCutFrequencyHz, - AUDIO_EQ_LOW_CUT_MIN_FREQUENCY_HZ, - AUDIO_EQ_LOW_CUT_MAX_FREQUENCY_HZ, - AUDIO_EQ_LOW_CUT_FREQUENCY_HZ, - ) - } - if (normalized.audioEqLowCutSlopeDbPerOct !== undefined) { - normalized.audioEqLowCutSlopeDbPerOct = clampAudioEqCutSlopeDbPerOct( - normalized.audioEqLowCutSlopeDbPerOct, - ) - } - if (normalized.audioEqLowEnabled !== undefined) { - normalized.audioEqLowEnabled = !!normalized.audioEqLowEnabled - } - if (normalized.audioEqLowGainDb !== undefined) { - normalized.audioEqLowGainDb = clampAudioEqGainDb(normalized.audioEqLowGainDb) - } - if (normalized.audioEqLowFrequencyHz !== undefined) { - normalized.audioEqLowFrequencyHz = clampAudioEqFrequencyHz( - normalized.audioEqLowFrequencyHz, - AUDIO_EQ_LOW_MIN_FREQUENCY_HZ, - AUDIO_EQ_LOW_MAX_FREQUENCY_HZ, - AUDIO_EQ_LOW_FREQUENCY_HZ, - ) - } - if (normalized.audioEqLowQ !== undefined) { - normalized.audioEqLowQ = clampAudioEqQ(normalized.audioEqLowQ) - } - if (normalized.audioEqLowMidEnabled !== undefined) { - normalized.audioEqLowMidEnabled = !!normalized.audioEqLowMidEnabled - } - if (normalized.audioEqLowMidGainDb !== undefined) { - normalized.audioEqLowMidGainDb = clampAudioEqGainDb(normalized.audioEqLowMidGainDb) - } - if (normalized.audioEqLowMidFrequencyHz !== undefined) { - normalized.audioEqLowMidFrequencyHz = clampAudioEqFrequencyHz( - normalized.audioEqLowMidFrequencyHz, - AUDIO_EQ_LOW_MID_MIN_FREQUENCY_HZ, - AUDIO_EQ_LOW_MID_MAX_FREQUENCY_HZ, - AUDIO_EQ_LOW_MID_FREQUENCY_HZ, - ) - } - if (normalized.audioEqLowMidQ !== undefined) { - normalized.audioEqLowMidQ = clampAudioEqQ(normalized.audioEqLowMidQ, AUDIO_EQ_LOW_MID_Q) - } - if (normalized.audioEqMidGainDb !== undefined) { - normalized.audioEqMidGainDb = clampAudioEqGainDb(normalized.audioEqMidGainDb) - } - if (normalized.audioEqHighMidEnabled !== undefined) { - normalized.audioEqHighMidEnabled = !!normalized.audioEqHighMidEnabled - } - if (normalized.audioEqHighMidGainDb !== undefined) { - normalized.audioEqHighMidGainDb = clampAudioEqGainDb(normalized.audioEqHighMidGainDb) - } - if (normalized.audioEqHighMidFrequencyHz !== undefined) { - normalized.audioEqHighMidFrequencyHz = clampAudioEqFrequencyHz( - normalized.audioEqHighMidFrequencyHz, - AUDIO_EQ_HIGH_MID_MIN_FREQUENCY_HZ, - AUDIO_EQ_HIGH_MID_MAX_FREQUENCY_HZ, - AUDIO_EQ_HIGH_MID_FREQUENCY_HZ, - ) - } - if (normalized.audioEqHighMidQ !== undefined) { - normalized.audioEqHighMidQ = clampAudioEqQ(normalized.audioEqHighMidQ, AUDIO_EQ_HIGH_MID_Q) - } - if (normalized.audioEqHighEnabled !== undefined) { - normalized.audioEqHighEnabled = !!normalized.audioEqHighEnabled - } - if (normalized.audioEqHighGainDb !== undefined) { - normalized.audioEqHighGainDb = clampAudioEqGainDb(normalized.audioEqHighGainDb) - } - if (normalized.audioEqHighFrequencyHz !== undefined) { - normalized.audioEqHighFrequencyHz = clampAudioEqFrequencyHz( - normalized.audioEqHighFrequencyHz, - AUDIO_EQ_HIGH_MIN_FREQUENCY_HZ, - AUDIO_EQ_HIGH_MAX_FREQUENCY_HZ, - AUDIO_EQ_HIGH_FREQUENCY_HZ, - ) - } - if (normalized.audioEqHighQ !== undefined) { - normalized.audioEqHighQ = clampAudioEqQ(normalized.audioEqHighQ) - } - if (normalized.audioEqBand6Enabled !== undefined) { - normalized.audioEqBand6Enabled = !!normalized.audioEqBand6Enabled - } - if (normalized.audioEqBand6FrequencyHz !== undefined) { - normalized.audioEqBand6FrequencyHz = clampAudioEqFrequencyHz( - normalized.audioEqBand6FrequencyHz, - AUDIO_EQ_HIGH_CUT_MIN_FREQUENCY_HZ, - AUDIO_EQ_HIGH_CUT_MAX_FREQUENCY_HZ, - AUDIO_EQ_HIGH_CUT_FREQUENCY_HZ, - ) - } - if (normalized.audioEqBand6GainDb !== undefined) { - normalized.audioEqBand6GainDb = clampAudioEqGainDb(normalized.audioEqBand6GainDb) - } - if (normalized.audioEqBand6Q !== undefined) { - normalized.audioEqBand6Q = clampAudioEqQ(normalized.audioEqBand6Q) - } - if (normalized.audioEqBand6SlopeDbPerOct !== undefined) { - normalized.audioEqBand6SlopeDbPerOct = clampAudioEqCutSlopeDbPerOct( - normalized.audioEqBand6SlopeDbPerOct, - ) - } - if (normalized.audioEqHighCutEnabled !== undefined) { - normalized.audioEqHighCutEnabled = !!normalized.audioEqHighCutEnabled - } - if (normalized.audioEqHighCutFrequencyHz !== undefined) { - normalized.audioEqHighCutFrequencyHz = clampAudioEqFrequencyHz( - normalized.audioEqHighCutFrequencyHz, - AUDIO_EQ_HIGH_CUT_MIN_FREQUENCY_HZ, - AUDIO_EQ_HIGH_CUT_MAX_FREQUENCY_HZ, - AUDIO_EQ_HIGH_CUT_FREQUENCY_HZ, - ) - } - if (normalized.audioEqHighCutSlopeDbPerOct !== undefined) { - normalized.audioEqHighCutSlopeDbPerOct = clampAudioEqCutSlopeDbPerOct( - normalized.audioEqHighCutSlopeDbPerOct, - ) - } // Normalize transform if present if (normalized.transform) { diff --git a/src/shared/timeline/item-clamps.ts b/src/shared/timeline/item-clamps.ts new file mode 100644 index 000000000..b5b304a7e --- /dev/null +++ b/src/shared/timeline/item-clamps.ts @@ -0,0 +1,225 @@ +// Single source of truth for "if-defined-then-clamp" timeline-item field +// normalization. Used by both the runtime items-store normalizer and the +// project-load migration normalizer so a new audio/frame field only needs to +// be registered once. + +import { clampAudioFadeCurve, clampAudioFadeCurveX } from '@/shared/utils/audio-fade-curve' +import { + AUDIO_EQ_HIGH_CUT_FREQUENCY_HZ, + AUDIO_EQ_HIGH_CUT_MAX_FREQUENCY_HZ, + AUDIO_EQ_HIGH_CUT_MIN_FREQUENCY_HZ, + AUDIO_EQ_HIGH_FREQUENCY_HZ, + AUDIO_EQ_HIGH_MAX_FREQUENCY_HZ, + AUDIO_EQ_HIGH_MID_FREQUENCY_HZ, + AUDIO_EQ_HIGH_MID_MAX_FREQUENCY_HZ, + AUDIO_EQ_HIGH_MID_MIN_FREQUENCY_HZ, + AUDIO_EQ_HIGH_MID_Q, + AUDIO_EQ_HIGH_MIN_FREQUENCY_HZ, + AUDIO_EQ_LOW_CUT_FREQUENCY_HZ, + AUDIO_EQ_LOW_CUT_MAX_FREQUENCY_HZ, + AUDIO_EQ_LOW_CUT_MIN_FREQUENCY_HZ, + AUDIO_EQ_LOW_FREQUENCY_HZ, + AUDIO_EQ_LOW_MAX_FREQUENCY_HZ, + AUDIO_EQ_LOW_MID_FREQUENCY_HZ, + AUDIO_EQ_LOW_MID_MAX_FREQUENCY_HZ, + AUDIO_EQ_LOW_MID_MIN_FREQUENCY_HZ, + AUDIO_EQ_LOW_MID_Q, + AUDIO_EQ_LOW_MIN_FREQUENCY_HZ, + clampAudioEqCutSlopeDbPerOct, + clampAudioEqFrequencyHz, + clampAudioEqGainDb, + clampAudioEqQ, +} from '@/shared/utils/audio-eq' +import { clampAudioPitchCents, clampAudioPitchSemitones } from '@/shared/utils/audio-pitch' +import { normalizeCropSettings } from '@/shared/utils/media-crop' +import type { CropSettings } from '@/types/transform' + +export function roundFrame(value: number, fallback = 0): number { + if (!Number.isFinite(value)) return fallback + return Math.max(0, Math.round(value)) +} + +export function roundDuration(value: number, fallback = 1): number { + if (!Number.isFinite(value)) return fallback + return Math.max(1, Math.round(value)) +} + +export function roundOptionalFrame(value: number | undefined): number | undefined { + if (value === undefined) return undefined + return roundFrame(value) +} + +export function normalizeOptionalFps(value: number | undefined): number | undefined { + if (value === undefined || !Number.isFinite(value) || value <= 0) return undefined + return Math.round(value * 1000) / 1000 +} + +interface EqBandSpec { + prefix: string + freq?: { min: number; max: number; def: number } + qDefault?: number + hasEnabled?: boolean + hasGain?: boolean + hasSlope?: boolean +} + +// EQ bands ordered low → high. Band1 and Band6 are full-featured aliases +// for the cut bands kept for legacy projects; LowCut and HighCut carry the +// enabled/freq/slope subset used by the simplified UI. +const EQ_BANDS: readonly EqBandSpec[] = [ + { + prefix: 'audioEqBand1', + hasEnabled: true, + hasGain: true, + hasSlope: true, + qDefault: AUDIO_EQ_LOW_MID_Q, + freq: { + min: AUDIO_EQ_LOW_CUT_MIN_FREQUENCY_HZ, + max: AUDIO_EQ_LOW_CUT_MAX_FREQUENCY_HZ, + def: AUDIO_EQ_LOW_CUT_FREQUENCY_HZ, + }, + }, + { + prefix: 'audioEqLowCut', + hasEnabled: true, + hasSlope: true, + freq: { + min: AUDIO_EQ_LOW_CUT_MIN_FREQUENCY_HZ, + max: AUDIO_EQ_LOW_CUT_MAX_FREQUENCY_HZ, + def: AUDIO_EQ_LOW_CUT_FREQUENCY_HZ, + }, + }, + { + prefix: 'audioEqLow', + hasEnabled: true, + hasGain: true, + qDefault: AUDIO_EQ_LOW_MID_Q, + freq: { + min: AUDIO_EQ_LOW_MIN_FREQUENCY_HZ, + max: AUDIO_EQ_LOW_MAX_FREQUENCY_HZ, + def: AUDIO_EQ_LOW_FREQUENCY_HZ, + }, + }, + { + prefix: 'audioEqLowMid', + hasEnabled: true, + hasGain: true, + qDefault: AUDIO_EQ_LOW_MID_Q, + freq: { + min: AUDIO_EQ_LOW_MID_MIN_FREQUENCY_HZ, + max: AUDIO_EQ_LOW_MID_MAX_FREQUENCY_HZ, + def: AUDIO_EQ_LOW_MID_FREQUENCY_HZ, + }, + }, + { prefix: 'audioEqMid', hasGain: true }, + { + prefix: 'audioEqHighMid', + hasEnabled: true, + hasGain: true, + qDefault: AUDIO_EQ_HIGH_MID_Q, + freq: { + min: AUDIO_EQ_HIGH_MID_MIN_FREQUENCY_HZ, + max: AUDIO_EQ_HIGH_MID_MAX_FREQUENCY_HZ, + def: AUDIO_EQ_HIGH_MID_FREQUENCY_HZ, + }, + }, + { + prefix: 'audioEqHigh', + hasEnabled: true, + hasGain: true, + qDefault: AUDIO_EQ_HIGH_MID_Q, + freq: { + min: AUDIO_EQ_HIGH_MIN_FREQUENCY_HZ, + max: AUDIO_EQ_HIGH_MAX_FREQUENCY_HZ, + def: AUDIO_EQ_HIGH_FREQUENCY_HZ, + }, + }, + { + prefix: 'audioEqBand6', + hasEnabled: true, + hasGain: true, + hasSlope: true, + qDefault: AUDIO_EQ_HIGH_MID_Q, + freq: { + min: AUDIO_EQ_HIGH_CUT_MIN_FREQUENCY_HZ, + max: AUDIO_EQ_HIGH_CUT_MAX_FREQUENCY_HZ, + def: AUDIO_EQ_HIGH_CUT_FREQUENCY_HZ, + }, + }, + { + prefix: 'audioEqHighCut', + hasEnabled: true, + hasSlope: true, + freq: { + min: AUDIO_EQ_HIGH_CUT_MIN_FREQUENCY_HZ, + max: AUDIO_EQ_HIGH_CUT_MAX_FREQUENCY_HZ, + def: AUDIO_EQ_HIGH_CUT_FREQUENCY_HZ, + }, + }, +] + +interface FieldClamp { + key: string + clamp: (value: unknown) => unknown +} + +function buildEqBandClamps(band: EqBandSpec): FieldClamp[] { + const clamps: FieldClamp[] = [] + if (band.hasEnabled) { + clamps.push({ key: `${band.prefix}Enabled`, clamp: (v) => !!v }) + } + if (band.hasGain) { + clamps.push({ + key: `${band.prefix}GainDb`, + clamp: (v) => clampAudioEqGainDb(v as number), + }) + } + if (band.freq) { + const { min, max, def } = band.freq + clamps.push({ + key: `${band.prefix}FrequencyHz`, + clamp: (v) => clampAudioEqFrequencyHz(v as number, min, max, def), + }) + } + if (band.qDefault !== undefined) { + const qDef = band.qDefault + clamps.push({ key: `${band.prefix}Q`, clamp: (v) => clampAudioEqQ(v as number, qDef) }) + } + if (band.hasSlope) { + clamps.push({ + key: `${band.prefix}SlopeDbPerOct`, + clamp: (v) => clampAudioEqCutSlopeDbPerOct(v as number), + }) + } + return clamps +} + +export const OPTIONAL_FIELD_CLAMPS: ReadonlyArray = [ + // Frame fields + { key: 'trimStart', clamp: (v) => roundFrame(v as number) }, + { key: 'trimEnd', clamp: (v) => roundFrame(v as number) }, + { key: 'sourceStart', clamp: (v) => roundFrame(v as number) }, + { key: 'sourceEnd', clamp: (v) => roundFrame(v as number) }, + { key: 'sourceDuration', clamp: (v) => roundFrame(v as number) }, + { key: 'sourceFps', clamp: (v) => normalizeOptionalFps(v as number) }, + { key: 'crop', clamp: (v) => normalizeCropSettings(v as CropSettings) }, + // Audio fades + { key: 'audioFadeInCurve', clamp: (v) => clampAudioFadeCurve(v as number) }, + { key: 'audioFadeOutCurve', clamp: (v) => clampAudioFadeCurve(v as number) }, + { key: 'audioFadeInCurveX', clamp: (v) => clampAudioFadeCurveX(v as number) }, + { key: 'audioFadeOutCurveX', clamp: (v) => clampAudioFadeCurveX(v as number) }, + // Pitch + { key: 'audioPitchSemitones', clamp: (v) => clampAudioPitchSemitones(v as number) }, + { key: 'audioPitchCents', clamp: (v) => clampAudioPitchCents(v as number) }, + // EQ output + bands + { key: 'audioEqOutputGainDb', clamp: (v) => clampAudioEqGainDb(v as number) }, + ...EQ_BANDS.flatMap(buildEqBandClamps), +] + +export function applyOptionalClamps(target: Record): void { + for (const { key, clamp } of OPTIONAL_FIELD_CLAMPS) { + const current = target[key] + if (current === undefined) continue + target[key] = clamp(current) + } +} diff --git a/src/features/editor/components/properties-sidebar/clip-panel/caption-style-presets.ts b/src/shared/typography/caption-style-presets.ts similarity index 100% rename from src/features/editor/components/properties-sidebar/clip-panel/caption-style-presets.ts rename to src/shared/typography/caption-style-presets.ts