diff --git a/apps/desktop/src-tauri/src/captions.rs b/apps/desktop/src-tauri/src/captions.rs index f4fb8ac49b..624606cb7c 100644 --- a/apps/desktop/src-tauri/src/captions.rs +++ b/apps/desktop/src-tauri/src/captions.rs @@ -5,6 +5,7 @@ use ffmpeg::{ format::{self as avformat}, software::resampling, }; +use futures::StreamExt; use serde::{Deserialize, Serialize}; use specta::Type; use std::fs::File; @@ -12,7 +13,8 @@ use std::io::Read; use std::path::PathBuf; use std::process::Command; use std::sync::Arc; -use tauri::{AppHandle, Emitter, Manager, Window}; +use tauri::{AppHandle, Manager}; +use tauri_specta::Event; use tempfile::tempdir; use tokio::io::AsyncWriteExt; use tokio::sync::Mutex; @@ -1775,11 +1777,14 @@ pub async fn save_captions( "position".to_string(), serde_json::Value::String(settings.position.clone()), ); - settings_obj.insert("bold".to_string(), serde_json::Value::Bool(settings.bold)); settings_obj.insert( "italic".to_string(), serde_json::Value::Bool(settings.italic), ); + settings_obj.insert( + "fontWeight".to_string(), + serde_json::Value::Number(serde_json::Number::from(settings.font_weight)), + ); settings_obj.insert( "outline".to_string(), serde_json::Value::Bool(settings.outline), @@ -1912,18 +1917,19 @@ pub fn parse_captions_json(json: &str) -> Result Result Result<(), String> { @@ -2128,22 +2129,17 @@ pub async fn download_whisper_model( .await .map_err(|e| format!("Failed to create file: {e}"))?; - let mut downloaded = 0; - let mut bytes = response - .bytes() - .await - .map_err(|e| format!("Failed to get response bytes: {e}"))?; + let mut downloaded: u64 = 0; + let mut stream = response.bytes_stream(); - const CHUNK_SIZE: usize = 1024 * 1024; - while !bytes.is_empty() { - let chunk_size = std::cmp::min(CHUNK_SIZE, bytes.len()); - let chunk = bytes.split_to(chunk_size); + while let Some(chunk_result) = stream.next().await { + let chunk = chunk_result.map_err(|e| format!("Error while downloading: {e}"))?; file.write_all(&chunk) .await .map_err(|e| format!("Error while writing to file: {e}"))?; - downloaded += chunk_size as u64; + downloaded += chunk.len() as u64; let progress = if total_size > 0 { (downloaded as f64 / total_size as f64) * 100.0 @@ -2151,15 +2147,12 @@ pub async fn download_whisper_model( 0.0 }; - window - .emit( - DownloadProgress::EVENT_NAME, - DownloadProgress { - message: format!("Downloading model: {progress:.1}%"), - progress, - }, - ) - .map_err(|e| format!("Failed to emit progress: {e}"))?; + DownloadProgress { + progress, + message: format!("Downloading model: {progress:.1}%"), + } + .emit(&app) + .ok(); } file.flush() diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index d09d54d0dd..b36d554334 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -182,10 +182,10 @@ impl CapWindowId { pub fn min_size(&self) -> Option<(f64, f64)> { Some(match self { Self::Setup => (600.0, 600.0), - Self::Main => (300.0, 360.0), + Self::Main => (310.0, 320.0), Self::Editor { .. } => (1275.0, 800.0), Self::ScreenshotEditor { .. } => (800.0, 600.0), - Self::Settings => (600.0, 450.0), + Self::Settings => (600.0, 465.0), Self::Camera => (200.0, 200.0), Self::Upgrade => (950.0, 850.0), Self::ModeSelect => (580.0, 340.0), diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx index ab95e90f45..88f76d44ca 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx @@ -20,7 +20,7 @@ export default function CameraSelect(props: { ); diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/MicrophoneSelect.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/MicrophoneSelect.tsx index 31b49cb337..38470ab0e1 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/MicrophoneSelect.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/MicrophoneSelect.tsx @@ -27,7 +27,7 @@ export default function MicrophoneSelect(props: { return ( } /> diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/TargetDropdownButton.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/TargetDropdownButton.tsx index a14601d687..ca19538a52 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/TargetDropdownButton.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/TargetDropdownButton.tsx @@ -27,7 +27,7 @@ export default function TargetDropdownButton< aria-expanded={local.expanded ? "true" : "false"} data-expanded={local.expanded ? "true" : "false"} class={cx( - "flex h-[3.75rem] w-5 shrink-0 items-center justify-center rounded-lg bg-gray-4 text-gray-12 transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-9 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-1 hover:bg-gray-5", + "flex h-[4rem] w-5 shrink-0 items-center justify-center rounded-lg bg-gray-4 text-gray-12 transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-9 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-1 hover:bg-gray-5", local.expanded && "bg-gray-5", local.disabled && "pointer-events-none opacity-60", local.class, diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx index 4baa5c2e59..2ebed29d66 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -83,7 +83,7 @@ import TargetDropdownButton from "./TargetDropdownButton"; import TargetMenuGrid from "./TargetMenuGrid"; import TargetTypeButton from "./TargetTypeButton"; -const WINDOW_SIZE = { width: 290, height: 310 } as const; +const WINDOW_SIZE = { width: 310, height: 320 } as const; const findCamera = (cameras: CameraInfo[], id: DeviceOrModelID) => { return cameras.find((c) => { diff --git a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx index bf282d4b5b..23cc588b14 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx @@ -152,11 +152,8 @@ function AppearanceSection(props: { return (
-
-

General

-

- General settings of your Cap application. -

+
+

General Settings

{ - if (model) localStorage.setItem("selectedTranscriptionModel", model); - }), + on( + selectedModel, + (model) => { + if (model) localStorage.setItem("selectedTranscriptionModel", model); + }, + { defer: true }, + ), ); createEffect( - on(selectedLanguage, (language) => { - if (language) - localStorage.setItem("selectedTranscriptionLanguage", language); - }), + on( + selectedLanguage, + (language) => { + if (language) + localStorage.setItem("selectedTranscriptionLanguage", language); + }, + { defer: true }, + ), ); const checkModelExists = async (modelName: string) => { @@ -778,32 +789,71 @@ export function CaptionsTab() {
- }> -
-
- - - updateCaptionSetting("outline", checked) - } - disabled={!hasCaptions()} - /> - -
- - -
- Outline Color - - updateCaptionSetting("outlineColor", value) - } + }> + { + if (!value) return; + updateCaptionSetting("fontWeight", value.value); + }} + disabled={!hasCaptions()} + itemComponent={(selectItemProps) => ( + + as={KSelect.Item} + item={selectItemProps.item} + > + + {selectItemProps.item.rawValue.label} + + + + + + )} + > + + class="truncate"> + {(state) => { + const selected = state.selectedOption(); + if (selected) return selected.label; + const weight = getSetting("fontWeight"); + const option = [ + { label: "Normal", value: 400 }, + { label: "Medium", value: 500 }, + { label: "Bold", value: 700 }, + ].find((o) => o.value === weight); + return option ? option.label : "Bold"; + }} + + + + + + + + as={KSelect.Content} + class={cx(topSlideAnimateClasses, "z-50")} + > + + class="overflow-y-auto max-h-40" + as={KSelect.Listbox} /> -
-
-
+ + +
}> diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx index 7b56751f9a..1e683b149b 100644 --- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx +++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx @@ -2491,14 +2491,6 @@ function CornerStyleSelect(props: { ); } -const TEXT_FONT_OPTIONS = [ - { value: "sans-serif", label: "Sans" }, - { value: "serif", label: "Serif" }, - { value: "monospace", label: "Monospace" }, - { value: "Inter", label: "Inter" }, - { value: "Geist Sans", label: "Geist Sans" }, -]; - const normalizeHexInput = (value: string, fallback: string) => { const trimmed = value.trim(); const withHash = trimmed.startsWith("#") ? trimmed : `#${trimmed}`; @@ -2522,32 +2514,28 @@ function HexColorInput(props: { return (
-
Math.min(Math.max(Number.isFinite(value) ? value : min), max); - const textFontOptions = createMemo(() => { - const font = props.segment.fontFamily; - if (!font) return TEXT_FONT_OPTIONS; - const exists = TEXT_FONT_OPTIONS.some((option) => option.value === font); - return exists - ? TEXT_FONT_OPTIONS - : [...TEXT_FONT_OPTIONS, { value: font, label: font }]; - }); - - const selectedFont = createMemo( - () => - textFontOptions().find( - (option) => option.value === props.segment.fontFamily, - ) ?? textFontOptions()[0], - ); const updateSegment = (fn: (segment: TextSegment) => void) => { setProject( @@ -2632,67 +2605,26 @@ function TextSegmentConfig(props: {
- }> -
- - options={textFontOptions()} - optionValue="value" - optionTextValue="label" - value={selectedFont()} - onChange={(option) => { - if (option) { - updateSegment((segment) => { - segment.fontFamily = option.value; - }); - } - }} - itemComponent={(selectProps) => ( - - as={KSelect.Item} - item={selectProps.item} - > - - {selectProps.item.rawValue.label} - - - )} - > - - class="flex-1 text-sm text-left truncate text-[--gray-500] font-normal"> - {(state) => {state.selectedOption().label}} - - - as={(selectProps) => ( - - )} - /> - - - - as={KSelect.Content} - class={cx(topSlideAnimateClasses, "z-50")} - > - - class="overflow-y-auto max-h-40" - as={KSelect.Listbox} - /> - - - -
-
}> updateSegment((segment) => { - segment.fontSize = clampNumber(value, 8, 200); + const newFontSize = clampNumber(value, 8, 200); + const oldFontSize = segment.fontSize || 48; + const scale = newFontSize / oldFontSize; + + segment.fontSize = newFontSize; + + if ( + segment.size && + segment.size.x > 0.025 && + segment.size.y > 0.025 + ) { + const maxSize = 0.95; + segment.size.x = Math.min(segment.size.x * scale, maxSize); + segment.size.y = Math.min(segment.size.y * scale, maxSize); + } }) } minValue={8} @@ -2700,19 +2632,13 @@ function TextSegmentConfig(props: { step={1} /> - }> + }>
( + itemComponent={(selectItemProps) => ( as={KSelect.Item} - item={props.item} + item={selectItemProps.item} > - {props.item.rawValue.label} + {selectItemProps.item.rawValue.label} @@ -2745,20 +2671,15 @@ function TextSegmentConfig(props: { {(state) => { const selected = state.selectedOption(); if (selected) return selected.label; - // Find label for current weight const weight = props.segment.fontWeight; const option = [ - { label: "Thin", value: 100 }, - { label: "Extra Light", value: 200 }, - { label: "Light", value: 300 }, { label: "Normal", value: 400 }, { label: "Medium", value: 500 }, - { label: "Semi Bold", value: 600 }, { label: "Bold", value: 700 }, - { label: "Extra Bold", value: 800 }, - { label: "Black", value: 900 }, ].find((o) => o.value === weight); - return option ? option.label : weight.toString(); + if (option) return option.label; + if (weight != null) return `Custom (${weight})`; + return "Normal"; }} @@ -2801,6 +2722,20 @@ function TextSegmentConfig(props: { } /> + }> + + updateSegment((segment) => { + segment.fadeDuration = clampNumber(value, 0, 1); + }) + } + minValue={0} + maxValue={1} + step={0.01} + formatTooltip="s" + /> +
); } @@ -2959,6 +2894,20 @@ function MaskSegmentConfig(props: { />
+ }> + + updateSegment((segment) => { + segment.fadeDuration = v; + }) + } + minValue={0} + maxValue={1} + step={0.01} + formatTooltip="s" + /> + ); } diff --git a/apps/desktop/src/routes/editor/MaskOverlay.tsx b/apps/desktop/src/routes/editor/MaskOverlay.tsx index 5f2db0e22c..5e8a4a6cf4 100644 --- a/apps/desktop/src/routes/editor/MaskOverlay.tsx +++ b/apps/desktop/src/routes/editor/MaskOverlay.tsx @@ -1,6 +1,6 @@ import { createEventListenerMap } from "@solid-primitives/event-listener"; import { cx } from "cva"; -import { createMemo, createRoot, Show } from "solid-js"; +import { createMemo, createRoot, For, Show } from "solid-js"; import { produce } from "solid-js/store"; import { useEditorContext } from "./context"; @@ -11,27 +11,34 @@ type MaskOverlayProps = { }; export function MaskOverlay(props: MaskOverlayProps) { - const { project, setProject, editorState, projectHistory } = + const { project, setProject, editorState, setEditorState, projectHistory } = useEditorContext(); - const selectedMask = createMemo(() => { + const currentAbsoluteTime = () => + editorState.previewTime ?? editorState.playbackTime ?? 0; + + const visibleMaskSegments = createMemo(() => { + const segments = project.timeline?.maskSegments ?? []; + const time = currentAbsoluteTime(); + return segments + .map((segment, index) => ({ segment, index })) + .filter(({ segment }) => time >= segment.start && time < segment.end); + }); + + const selectedMaskIndex = createMemo(() => { const selection = editorState.timeline.selection; - if (!selection || selection.type !== "mask") return; - const index = selection.indices[0]; + if (!selection || selection.type !== "mask") return null; + return selection.indices[0] ?? null; + }); + + const selectedMask = createMemo(() => { + const index = selectedMaskIndex(); + if (index === null) return; const segment = project.timeline?.maskSegments?.[index]; if (!segment) return; return { index, segment }; }); - const currentAbsoluteTime = () => - editorState.previewTime ?? editorState.playbackTime ?? 0; - - const maskState = createMemo(() => { - const selected = selectedMask(); - if (!selected) return; - return evaluateMask(selected.segment, currentAbsoluteTime()); - }); - const updateSegment = (fn: (segment: MaskSegment) => void) => { const index = selectedMask()?.index; if (index === undefined) return; @@ -48,24 +55,79 @@ export function MaskOverlay(props: MaskOverlayProps) { ); }; - const currentMaskState = maskState(); - const selected = selectedMask(); + const handleSelectSegment = (index: number, e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setEditorState("timeline", "selection", { + type: "mask", + indices: [index], + }); + }; + + const getMaskRect = (segment: MaskSegment) => { + const state = evaluateMask(segment, currentAbsoluteTime()); + const width = state.size.x * props.size.width; + const height = state.size.y * props.size.height; + const left = state.position.x * props.size.width - width / 2; + const top = state.position.y * props.size.height - height / 2; + return { width, height, left, top }; + }; + + const handleBackgroundClick = (e: MouseEvent) => { + if (e.target === e.currentTarget && selectedMaskIndex() !== null) { + e.preventDefault(); + e.stopPropagation(); + setEditorState("timeline", "selection", null); + } + }; + + const hasMaskSelection = () => selectedMaskIndex() !== null; return ( - - } - updateSegment={updateSegment} - projectHistory={projectHistory} - /> - +
+ + {({ segment, index }) => { + const isSelected = () => selectedMaskIndex() === index; + const rect = () => getMaskRect(segment); + const maskState = () => evaluateMask(segment, currentAbsoluteTime()); + + return ( + handleSelectSegment(index, e)} + /> + } + > + + + ); + }} + +
); } function MaskOverlayContent(props: { size: { width: number; height: number }; - maskState: ReturnType; + maskState: () => ReturnType; updateSegment: (fn: (segment: MaskSegment) => void) => void; projectHistory: ReturnType["projectHistory"]; }) { @@ -108,7 +170,7 @@ function MaskOverlayContent(props: { }; } - const state = () => props.maskState; + const state = () => props.maskState(); const rect = () => { const width = state().size.x * props.size.width; const height = state().size.y * props.size.height; @@ -160,53 +222,51 @@ function MaskOverlayContent(props: { }; return ( -
-
-
- - - - - - - - - - -
+
+
+ + + + + + + + + +
); } @@ -218,7 +278,7 @@ function ResizeHandle(props: { return (
{ + const currentAbsoluteTime = () => + editorState.previewTime ?? editorState.playbackTime ?? 0; + + const visibleTextSegments = createMemo(() => { + const segments = project.timeline?.textSegments ?? []; + const time = currentAbsoluteTime(); + return segments + .map((segment, index) => ({ segment, index })) + .filter(({ segment }) => time >= segment.start && time < segment.end); + }); + + const selectedTextIndex = createMemo(() => { const selection = editorState.timeline.selection; - if (!selection || selection.type !== "text") return; - const index = selection.indices[0]; - const segment = project.timeline?.textSegments?.[index]; - if (!segment) return; - return { index, segment }; + if (!selection || selection.type !== "text") return null; + return selection.indices[0] ?? null; }); - const clamp = (value: number, min: number, max: number) => - Math.min(Math.max(value, min), max); + const clamp = (value: number, min: number, max: number) => { + if (min > max) { + return (min + max) / 2; + } + return Math.min(Math.max(value, min), max); + }; - const updateSegment = (fn: (segment: TextSegment) => void) => { - const index = selectedText()?.index; - if (index === undefined) return; + const updateSegmentByIndex = ( + index: number, + fn: (segment: TextSegment) => void, + ) => { setProject( "timeline", "textSegments", @@ -78,281 +100,432 @@ export function TextOverlay(props: TextOverlayProps) { }; } + const handleSelectSegment = (index: number, e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setEditorState("timeline", "selection", { + type: "text", + indices: [index], + }); + }; + + const handleBackgroundClick = (e: MouseEvent) => { + if (e.target === e.currentTarget && selectedTextIndex() !== null) { + e.preventDefault(); + e.stopPropagation(); + setEditorState("timeline", "selection", null); + } + }; + + const hasTextSelection = () => selectedTextIndex() !== null; + return ( - - {(selected) => { - const segment = () => selected().segment; - - // Measurement Logic - let hiddenMeasureRef: HTMLDivElement | undefined; - - createEffect( - on( - () => ({ - content: segment().content, - fontFamily: segment().fontFamily, - fontSize: segment().fontSize, - fontWeight: segment().fontWeight, - italic: segment().italic, - containerWidth: props.size.width, - containerHeight: props.size.height, - }), - (deps) => { - if (!hiddenMeasureRef) return; - - const { width: naturalWidth, height: naturalHeight } = - hiddenMeasureRef.getBoundingClientRect(); - - if ( - naturalWidth === 0 || - naturalHeight === 0 || - !deps.containerWidth || - !deps.containerHeight - ) - return; - - // Normalize to [0-1] - const normalizedWidth = naturalWidth / deps.containerWidth; - const normalizedHeight = naturalHeight / deps.containerHeight; - - const _newFontSize = deps.fontSize; - const newSizeX = normalizedWidth; - const newSizeY = normalizedHeight; - - // Logic simplified: Trust the measurement. - - // Update if significant difference to avoid loops - const sizeXDiff = Math.abs(newSizeX - segment().size.x); - const sizeYDiff = Math.abs(newSizeY - segment().size.y); - // const fontDiff = Math.abs(newFontSize - segment().fontSize); // We aren't changing font size anymore - - if (sizeXDiff > 0.001 || sizeYDiff > 0.001) { - updateSegment((s) => { - const oldHeight = s.size.y; - s.size.x = newSizeX; - s.size.y = newSizeY; - // s.fontSize = newFontSize; // Don't override font size - - // Adjust Center Y to keep top anchor fixed (growing down) - // If height changes by diff, center moves by diff/2 - const diff = newSizeY - oldHeight; - s.center.y += diff / 2; - - // Frame constraints for center - const halfH = s.size.y / 2; - const halfW = s.size.x / 2; - - if (s.center.y + halfH > 1) { - s.center.y -= s.center.y + halfH - 1; - } - if (s.center.y - halfH < 0) { - s.center.y += 0 - (s.center.y - halfH); - } - if (s.center.x + halfW > 1) { - s.center.x -= s.center.x + halfW - 1; - } - if (s.center.x - halfW < 0) { - s.center.x += 0 - (s.center.x - halfW); - } - }); - } - }, - ), - ); +
+ + {({ segment, index }) => ( + handleSelectSegment(index, e)} + updateSegment={(fn) => updateSegmentByIndex(index, fn)} + createMouseDownDrag={createMouseDownDrag} + clamp={clamp} + /> + )} + +
+ ); +} + +type SegmentWithDefaults = { + start: number; + end: number; + enabled: boolean; + content: string; + center: { x: number; y: number }; + size: { x: number; y: number }; + fontFamily: string; + fontSize: number; + fontWeight: number; + italic: boolean; + color: string; +}; + +function normalizeSegment(segment: TauriTextSegment): SegmentWithDefaults { + return { + start: segment.start, + end: segment.end, + enabled: segment.enabled ?? true, + content: segment.content ?? "Text", + center: segment.center ?? { x: 0.5, y: 0.5 }, + size: segment.size ?? { x: 0.01, y: 0.01 }, + fontFamily: segment.fontFamily ?? "sans-serif", + fontSize: segment.fontSize ?? 48, + fontWeight: segment.fontWeight ?? 700, + italic: segment.italic ?? false, + color: segment.color ?? "#ffffff", + }; +} + +function TextSegmentOverlay(props: { + size: { width: number; height: number }; + segment: TauriTextSegment; + index: number; + isSelected: boolean; + onSelect: (e: MouseEvent) => void; + updateSegment: (fn: (segment: TextSegment) => void) => void; + createMouseDownDrag: ( + setup: () => T, + update: ( + e: MouseEvent, + value: T, + initialMouse: { x: number; y: number }, + ) => void, + ) => (downEvent: MouseEvent) => void; + clamp: (value: number, min: number, max: number) => number; +}) { + const segment = createMemo(() => normalizeSegment(props.segment)); + let hiddenMeasureRef: HTMLDivElement | undefined; + const [mounted, setMounted] = createSignal(false); + const [isResizing, setIsResizing] = createSignal(false); + let pendingResizeCleanup: (() => void) | null = null; + + onMount(() => { + setMounted(true); + }); + + onCleanup(() => { + if (pendingResizeCleanup) { + pendingResizeCleanup(); + pendingResizeCleanup = null; + } + setIsResizing(false); + }); + + const isDefaultSize = (size: { x: number; y: number }) => + size.x <= 0.025 || size.y <= 0.025; + + const [lastContent, setLastContent] = createSignal(segment().content); + const [lastFontSize, setLastFontSize] = createSignal(segment().fontSize); - const rect = () => { - const width = segment().size.x * props.size.width; - const height = segment().size.y * props.size.height; - const left = segment().center.x * props.size.width - width / 2; - const top = segment().center.y * props.size.height - height / 2; - return { width, height, left, top }; + const measureAndUpdateSize = (forceUpdate = false) => { + if (!hiddenMeasureRef) return false; + + const seg = segment(); + if (!forceUpdate && !isDefaultSize(seg.size)) return true; + + const { width: naturalWidth, height: naturalHeight } = + hiddenMeasureRef.getBoundingClientRect(); + + if ( + naturalWidth === 0 || + naturalHeight === 0 || + !props.size.width || + !props.size.height + ) + return false; + + const normalizedWidth = naturalWidth / props.size.width; + const normalizedHeight = naturalHeight / props.size.height; + + props.updateSegment((s) => { + s.size.x = normalizedWidth; + s.size.y = normalizedHeight; + }); + return true; + }; + + createEffect( + on( + () => ({ + mounted: mounted(), + containerWidth: props.size.width, + containerHeight: props.size.height, + }), + () => { + if (!mounted()) return; + const tryMeasure = () => { + if (!measureAndUpdateSize()) { + requestAnimationFrame(tryMeasure); + } }; + queueMicrotask(tryMeasure); + }, + ), + ); - const onMove = createMouseDownDrag( - () => ({ - startPos: { ...segment().center }, - startSize: { ...segment().size }, - }), - (e, { startPos, startSize }, initialMouse) => { - const dx = (e.clientX - initialMouse.x) / props.size.width; - const dy = (e.clientY - initialMouse.y) / props.size.height; - - updateSegment((s) => { - const newX = startPos.x + dx; - const newY = startPos.y + dy; - - // Constrain to frame - const halfW = startSize.x / 2; - const halfH = startSize.y / 2; - - s.center.x = clamp(newX, halfW, 1 - halfW); - s.center.y = clamp(newY, halfH, 1 - halfH); + createEffect( + on( + () => ({ + content: segment().content, + fontSize: segment().fontSize, + fontWeight: segment().fontWeight, + fontFamily: segment().fontFamily, + italic: segment().italic, + }), + (current) => { + if (!mounted()) return; + if (isResizing()) return; + + const contentChanged = current.content !== lastContent(); + const fontSizeChanged = current.fontSize !== lastFontSize(); + + if (contentChanged || fontSizeChanged) { + setLastContent(current.content); + setLastFontSize(current.fontSize); + + queueMicrotask(() => { + requestAnimationFrame(() => { + if (!isResizing()) { + measureAndUpdateSize(true); + } }); - }, + }); + } + }, + ), + ); + + const rect = () => { + const seg = segment(); + const minDimension = 20; + const width = Math.max(seg.size.x * props.size.width, minDimension); + const height = Math.max(seg.size.y * props.size.height, minDimension); + const left = Math.max(0, seg.center.x * props.size.width - width / 2); + const top = Math.max(0, seg.center.y * props.size.height - height / 2); + return { width, height, left, top }; + }; + + const onMove = props.createMouseDownDrag( + () => { + const seg = segment(); + return { + startPos: { ...seg.center }, + startSize: { ...seg.size }, + }; + }, + (e, { startPos, startSize }, initialMouse) => { + const dx = (e.clientX - initialMouse.x) / props.size.width; + const dy = (e.clientY - initialMouse.y) / props.size.height; + + const minPadding = 0.02; + + props.updateSegment((s) => { + const newX = startPos.x + dx; + const newY = startPos.y + dy; + + const halfW = s.size.x / 2; + const halfH = s.size.y / 2; + + s.center.x = props.clamp( + newX, + halfW + minPadding, + 1 - halfW - minPadding, ); + s.center.y = props.clamp( + newY, + halfH + minPadding, + 1 - halfH - minPadding, + ); + }); + }, + ); - const createResizeHandler = (dirX: -1 | 0 | 1, dirY: -1 | 0 | 1) => { - return createMouseDownDrag( - () => ({ - startPos: { ...segment().center }, - startSize: { ...segment().size }, - startFontSize: segment().fontSize, - }), - (e, { startPos, startSize, startFontSize }, initialMouse) => { - const dx = (e.clientX - initialMouse.x) / props.size.width; - const dy = (e.clientY - initialMouse.y) / props.size.height; - - updateSegment((s) => { - // If Corner Drag -> Scale (change fontSize and Width) - // If Side Drag -> Change Width (reflow) - - const isCorner = dirX !== 0 && dirY !== 0; - const isSide = dirX !== 0 && dirY === 0; - - if (isSide) { - // Standard resize logic: updates width, keeps center relative or fixed - const targetWidth = startSize.x + dx * dirX; - const clampedWidth = clamp(targetWidth, 0.05, 1); - const appliedDelta = clampedWidth - startSize.x; - - s.size.x = clampedWidth; - s.center.x = clamp( - startPos.x + (dirX * appliedDelta) / 2, - s.size.x / 2, - 1 - s.size.x / 2, - ); - } else if (isCorner) { - // Scale uniformly - const _currentWidthPx = startSize.x * props.size.width; - const currentHeightPx = startSize.y * props.size.height; - - const _deltaPxX = dx * props.size.width * dirX; - const deltaPxY = dy * props.size.height * dirY; - - const scaleY = (currentHeightPx + deltaPxY) / currentHeightPx; - const scale = scaleY; - - if (scale > 0.1) { - s.fontSize = clamp(startFontSize * scale, 8, 400); - // Also scale width to maintain aspect ratio of the box - s.size.x = clamp(startSize.x * scale, 0.05, 1); - - // Update center - const widthDiff = s.size.x - startSize.x; - const approxHeightDiff = startSize.y * scale - startSize.y; - - s.center.x = clamp( - startPos.x + (widthDiff * dirX) / 2, - s.size.x / 2, - 1 - s.size.x / 2, - ); - s.center.y = clamp( - startPos.y + (approxHeightDiff * dirY) / 2, - s.size.y / 2, // Use calculated height for clamping? Approximation - 1 - s.size.y / 2, - ); - } - } - }); - }, - ); + const createResizeHandler = (dirX: -1 | 0 | 1, dirY: -1 | 0 | 1) => { + const isCorner = dirX !== 0 && dirY !== 0; + + const handler = props.createMouseDownDrag( + () => { + if (isCorner) { + setIsResizing(true); + } + const seg = segment(); + return { + startPos: { ...seg.center }, + startSize: { ...seg.size }, + startFontSize: seg.fontSize, }; + }, + (e, { startPos, startSize, startFontSize }, initialMouse) => { + const dx = (e.clientX - initialMouse.x) / props.size.width; + const dy = (e.clientY - initialMouse.y) / props.size.height; + + const isSide = dirX !== 0 && dirY === 0; + + const minSize = 0.03; + const maxSize = 0.95; + const minPadding = 0.02; + + props.updateSegment((s) => { + if (isSide) { + const targetWidth = startSize.x + dx * dirX; + const clampedWidth = props.clamp(targetWidth, minSize, maxSize); + const appliedDelta = clampedWidth - startSize.x; + + s.size.x = clampedWidth; + + const halfWidth = s.size.x / 2; + const halfHeight = s.size.y / 2; + s.center.x = props.clamp( + startPos.x + (dirX * appliedDelta) / 2, + halfWidth + minPadding, + 1 - halfWidth - minPadding, + ); + s.center.y = props.clamp( + s.center.y, + halfHeight + minPadding, + 1 - halfHeight - minPadding, + ); + } else if (isCorner) { + const currentHeightPx = startSize.y * props.size.height; + const deltaPxY = dy * props.size.height * dirY; + + const scaleY = (currentHeightPx + deltaPxY) / currentHeightPx; + const scale = scaleY; + + if (scale > 0.1 && scale < 10) { + const newFontSize = props.clamp(startFontSize * scale, 8, 400); + const newSizeX = props.clamp( + startSize.x * scale, + minSize, + maxSize, + ); + const newSizeY = props.clamp( + startSize.y * scale, + minSize, + maxSize, + ); + + s.fontSize = newFontSize; + s.size.x = newSizeX; + s.size.y = newSizeY; + + const widthDiff = s.size.x - startSize.x; + const heightDiff = s.size.y - startSize.y; + + const halfWidth = s.size.x / 2; + const halfHeight = s.size.y / 2; + s.center.x = props.clamp( + startPos.x + (widthDiff * dirX) / 2, + halfWidth + minPadding, + 1 - halfWidth - minPadding, + ); + s.center.y = props.clamp( + startPos.y + (heightDiff * dirY) / 2, + halfHeight + minPadding, + 1 - halfHeight - minPadding, + ); + } + } + }); + }, + ); - return ( -
- {/* Hidden Measurement Div */} -
- {segment().content} - {/* Ensure height for empty lines if needed, though pre-wrap usually handles it */} - {segment().content.endsWith("\n") ?
: null} -
- -
- {/* Visual placeholder (not used for measurement anymore) */} -
- {segment().content} -
- -
- - - - - - -
-
- ); - }} - + return (downEvent: MouseEvent) => { + handler(downEvent); + if (isCorner) { + const onMouseUp = () => { + setIsResizing(false); + window.removeEventListener("mouseup", onMouseUp); + pendingResizeCleanup = null; + }; + window.addEventListener("mouseup", onMouseUp); + pendingResizeCleanup = () => { + setIsResizing(false); + window.removeEventListener("mouseup", onMouseUp); + }; + } + }; + }; + + return ( + <> +
+ {segment().content} + {segment().content.endsWith("\n") ?
: null} +
+ +
{ + if (!props.isSelected) { + props.onSelect(e); + } + onMove(e); + }} + > +
+ {props.isSelected && ( + <> + + + + + + + + )} +
+ ); } diff --git a/apps/desktop/src/routes/editor/Timeline/TextTrack.tsx b/apps/desktop/src/routes/editor/Timeline/TextTrack.tsx index 88a4c9fac7..7cf30e7091 100644 --- a/apps/desktop/src/routes/editor/Timeline/TextTrack.tsx +++ b/apps/desktop/src/routes/editor/Timeline/TextTrack.tsx @@ -288,7 +288,7 @@ export function TextTrack(props: { )} /> { @@ -325,10 +325,10 @@ export function TextTrack(props: { }, )} > -
+
Text -
- +
+ {segment.content || "Label"}
diff --git a/apps/desktop/src/routes/editor/Timeline/Track.tsx b/apps/desktop/src/routes/editor/Timeline/Track.tsx index 85c954f327..bd22c98ea7 100644 --- a/apps/desktop/src/routes/editor/Timeline/Track.tsx +++ b/apps/desktop/src/routes/editor/Timeline/Track.tsx @@ -113,16 +113,16 @@ export function SegmentHandle( props: ComponentProps<"div"> & { position: "start" | "end" }, ) { const ctx = useSegmentContext(); - const hidden = () => ctx.width() < 80; + const hidden = () => ctx.width() < 40; return (
+ {(ann) => ( { + el.textContent = ann.text ?? ""; setTimeout(() => { el.focus(); - // Select all text const range = document.createRange(); range.selectNodeContents(el); const sel = window.getSelection(); @@ -568,37 +577,38 @@ export function AnnotationLayer(props: { sel?.addRange(range); }); }} + onInput={(e) => { + const text = e.currentTarget.textContent ?? ""; + setAnnotations((a) => a.id === ann.id, "text", text); + }} onBlur={(e) => { - const text = e.currentTarget.innerText; - const originalText = annotations.find( - (a) => a.id === ann.id, - )?.text; + const text = e.currentTarget.textContent ?? ""; if (!text.trim()) { - // If deleting, use snapshot if (textSnapshot) projectHistory.push(textSnapshot); setAnnotations((prev) => prev.filter((a) => a.id !== ann.id), ); - } else if (text !== originalText) { - // If changed, use snapshot - if (textSnapshot) projectHistory.push(textSnapshot); - setAnnotations((a) => a.id === ann.id, "text", text); + } else if (textSnapshot) { + const originalText = textSnapshot.annotations.find( + (a) => a.id === ann.id, + )?.text; + if (text !== originalText) { + projectHistory.push(textSnapshot); + } } textSnapshot = null; setTextEditingId(null); }} onKeyDown={(e) => { - e.stopPropagation(); // Prevent deleting annotation + e.stopPropagation(); if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); e.currentTarget.blur(); } }} - > - {ann.text} -
+ /> @@ -606,6 +616,30 @@ export function AnnotationLayer(props: { + {/* Text hover overlay - only shown when not selected */} + + + + props.handleSize / 2); + const isText = () => props.annotation.type === "text"; + const isArrow = () => props.annotation.type === "arrow"; + + const padding = createMemo(() => (isText() ? props.handleSize * 0.3 : 0)); + + const selectionRect = createMemo(() => { + const ann = props.annotation; + const p = padding(); + return { + x: Math.min(ann.x, ann.x + ann.width) - p, + y: Math.min(ann.y, ann.y + ann.height) - p, + width: Math.abs(ann.width) + p * 2, + height: Math.abs(ann.height) + p * 2, + }; + }); + + const cornerHandles = () => { + if (isText()) { + return [ + { id: "nw", x: 0, y: 0 }, + { id: "ne", x: 1, y: 0 }, + { id: "sw", x: 0, y: 1 }, + { id: "se", x: 1, y: 1 }, + ]; + } + return [ + { id: "nw", x: 0, y: 0 }, + { id: "n", x: 0.5, y: 0 }, + { id: "ne", x: 1, y: 0 }, + { id: "w", x: 0, y: 0.5 }, + { id: "e", x: 1, y: 0.5 }, + { id: "sw", x: 0, y: 1 }, + { id: "s", x: 0.5, y: 1 }, + { id: "se", x: 1, y: 1 }, + ]; + }; + return ( - - {(handle) => ( - - props.onResizeStart(e, props.annotation.id, handle.id) - } - /> - )} - + + props.onResizeStart(e, props.annotation.id, "start") + } + /> + + props.onResizeStart(e, props.annotation.id, "end") + } + /> } > - - props.onResizeStart(e, props.annotation.id, "start") - } - /> - - props.onResizeStart(e, props.annotation.id, "end") - } - /> + + + + + {(handle) => ( + + props.onResizeStart(e, props.annotation.id, handle.id) + } + /> + )} + ); } function Handle(props: { - x: number; - y: number; - size: number; + cx: number; + cy: number; + r: number; cursor: string; + isText: boolean; onMouseDown: (e: MouseEvent) => void; }) { return ( - ; size: XY; feather?: number; opacity?: number; pixelation?: number; darkness?: number; keyframes?: MaskKeyframes } +export type MaskSegment = { start: number; end: number; enabled?: boolean; maskType: MaskKind; center: XY; size: XY; feather?: number; opacity?: number; pixelation?: number; darkness?: number; fadeDuration?: number; keyframes?: MaskKeyframes } export type MaskType = "blur" | "pixelate" export type MaskVectorKeyframe = { time: number; x: number; y: number } export type ModelIDType = string @@ -495,7 +495,7 @@ export type StereoMode = "stereo" | "monoL" | "monoR" export type StudioRecordingMeta = { segment: SingleSegment } | { inner: MultipleSegments } export type StudioRecordingStatus = { status: "InProgress" } | { status: "NeedsRemux" } | { status: "Failed"; error: string } | { status: "Complete" } export type TargetUnderCursor = { display_id: DisplayId | null; window: WindowUnderCursor | null } -export type TextSegment = { start: number; end: number; enabled?: boolean; content?: string; center?: XY; size?: XY; fontFamily?: string; fontSize?: number; fontWeight?: number; italic?: boolean; color?: string } +export type TextSegment = { start: number; end: number; enabled?: boolean; content?: string; center?: XY; size?: XY; fontFamily?: string; fontSize?: number; fontWeight?: number; italic?: boolean; color?: string; fadeDuration?: number } export type TimelineConfiguration = { segments: TimelineSegment[]; zoomSegments: ZoomSegment[]; sceneSegments?: SceneSegment[]; maskSegments?: MaskSegment[]; textSegments?: TextSegment[] } export type TimelineSegment = { recordingSegment?: number; timescale: number; start: number; end: number } export type UploadMeta = { state: "MultipartUpload"; video_id: string; file_path: string; pre_created_video: VideoUploadInfo; recording_dir: string } | { state: "SinglePartUpload"; video_id: string; recording_dir: string; file_path: string; screenshot_path: string } | { state: "Failed"; error: string } | { state: "Complete" } diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 85b34847ca..ed9b166f70 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -604,6 +604,8 @@ pub struct MaskSegment { pub pixelation: f64, #[serde(default)] pub darkness: f64, + #[serde(default = "MaskSegment::default_fade_duration")] + pub fade_duration: f64, #[serde(default)] pub keyframes: MaskKeyframes, } @@ -616,6 +618,10 @@ impl MaskSegment { fn default_opacity() -> f64 { 1.0 } + + fn default_fade_duration() -> f64 { + 0.15 + } } #[derive(Type, Serialize, Deserialize, Clone, Debug)] @@ -641,6 +647,8 @@ pub struct TextSegment { pub italic: bool, #[serde(default = "TextSegment::default_color")] pub color: String, + #[serde(default = "TextSegment::default_fade_duration")] + pub fade_duration: f64, } impl TextSegment { @@ -675,6 +683,10 @@ impl TextSegment { fn default_color() -> String { "#ffffff".to_string() } + + fn default_fade_duration() -> f64 { + 0.15 + } } #[derive(Type, Serialize, Deserialize, Clone, Copy, Debug, Default)] @@ -776,8 +788,9 @@ pub struct CaptionSettings { pub background_opacity: u32, #[serde(default)] pub position: String, - pub bold: bool, pub italic: bool, + #[serde(alias = "fontWeight", default = "CaptionSettings::default_font_weight")] + pub font_weight: u32, pub outline: bool, #[serde(alias = "outlineColor")] pub outline_color: String, @@ -810,6 +823,10 @@ impl CaptionSettings { "#FFFFFF".to_string() } + fn default_font_weight() -> u32 { + 700 + } + fn default_fade_duration() -> f32 { 0.15 } @@ -833,9 +850,9 @@ impl Default for CaptionSettings { background_color: "#000000".to_string(), background_opacity: 90, position: "bottom-center".to_string(), - bold: false, italic: false, - outline: true, + font_weight: Self::default_font_weight(), + outline: false, outline_color: "#000000".to_string(), export_with_subtitles: false, highlight_color: Self::default_highlight_color(), diff --git a/crates/rendering/src/layers/captions.rs b/crates/rendering/src/layers/captions.rs index b17b451bba..c98bc98af1 100644 --- a/crates/rendering/src/layers/captions.rs +++ b/crates/rendering/src/layers/captions.rs @@ -542,8 +542,10 @@ impl CaptionsLayer { _ => Family::SansSerif, }; - let weight = if caption_data.settings.bold { + let weight = if caption_data.settings.font_weight >= 700 { Weight::BOLD + } else if caption_data.settings.font_weight >= 500 { + Weight::MEDIUM } else { Weight::NORMAL }; diff --git a/crates/rendering/src/layers/text.rs b/crates/rendering/src/layers/text.rs index 59265eeb15..75453cd541 100644 --- a/crates/rendering/src/layers/text.rs +++ b/crates/rendering/src/layers/text.rs @@ -53,11 +53,12 @@ impl TextLayer { let mut text_area_data = Vec::with_capacity(texts.len()); for text in texts { + let alpha = text.color[3].clamp(0.0, 1.0) * text.opacity.clamp(0.0, 1.0); let color = Color::rgba( (text.color[0].clamp(0.0, 1.0) * 255.0) as u8, (text.color[1].clamp(0.0, 1.0) * 255.0) as u8, (text.color[2].clamp(0.0, 1.0) * 255.0) as u8, - (text.color[3].clamp(0.0, 1.0) * 255.0) as u8, + (alpha * 255.0) as u8, ); let width = (text.bounds[2] - text.bounds[0]).max(1.0); diff --git a/crates/rendering/src/mask.rs b/crates/rendering/src/mask.rs index b89e89c1cc..7b6b9a693b 100644 --- a/crates/rendering/src/mask.rs +++ b/crates/rendering/src/mask.rs @@ -83,8 +83,8 @@ pub fn interpolate_masks( let mut intensity = interpolate_scalar(segment.opacity, &segment.keyframes.intensity, relative_time); - if let MaskKind::Highlight = segment.mask_type { - let fade_duration = 0.15; + let fade_duration = segment.fade_duration.max(0.0); + if fade_duration > 0.0 { let time_since_start = (frame_time - segment.start).max(0.0); let time_until_end = (segment.end - frame_time).max(0.0); diff --git a/crates/rendering/src/text.rs b/crates/rendering/src/text.rs index 6c4e508b28..661eabee5f 100644 --- a/crates/rendering/src/text.rs +++ b/crates/rendering/src/text.rs @@ -1,6 +1,7 @@ use cap_project::{TextSegment, XY}; const BASE_TEXT_HEIGHT: f64 = 0.2; +const MAX_FONT_SIZE_PX: f32 = 256.0; #[derive(Debug, Clone)] pub struct PreparedText { @@ -11,6 +12,7 @@ pub struct PreparedText { pub font_size: f32, pub font_weight: f32, pub italic: bool, + pub opacity: f32, } fn parse_color(hex: &str) -> [f32; 4] { @@ -70,14 +72,29 @@ pub fn prepare_texts( let right = (left + width).min(output_size.x as f32); let bottom = (top + height).min(output_size.y as f32); + let fade_duration = segment.fade_duration.max(0.0); + let opacity = if fade_duration > 0.0 { + let time_since_start = (frame_time - segment.start).max(0.0); + let time_until_end = (segment.end - frame_time).max(0.0); + + let fade_in = (time_since_start / fade_duration).min(1.0); + let fade_out = (time_until_end / fade_duration).min(1.0); + + (fade_in * fade_out) as f32 + } else { + 1.0 + }; + prepared.push(PreparedText { content: segment.content.clone(), bounds: [left, top, right, bottom], color: parse_color(&segment.color), font_family: segment.font_family.clone(), - font_size: (segment.font_size * size_scale).max(1.0) * height_scale, + font_size: ((segment.font_size * size_scale).max(1.0) * height_scale) + .min(MAX_FONT_SIZE_PX), font_weight: segment.font_weight, italic: segment.italic, + opacity, }); } diff --git a/crates/video-decode/src/avassetreader.rs b/crates/video-decode/src/avassetreader.rs index d7e4d1e48a..8471701bb9 100644 --- a/crates/video-decode/src/avassetreader.rs +++ b/crates/video-decode/src/avassetreader.rs @@ -171,8 +171,7 @@ pub fn pixel_to_pixel_format(pixel: avformat::Pixel) -> Result