diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aba304ce..f7f71ae4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,7 @@ Areas where help is especially valuable: - Export optimisations - Native screen recording for Linux - Wallpaper submissions +- Extensions (device frames, click effects, render hooks — see [EXTENSIONS.md](./EXTENSIONS.md)) ## How to Contribute diff --git a/README.md b/README.md index a4c553e6..c77eeaeb 100644 --- a/README.md +++ b/README.md @@ -388,6 +388,14 @@ Recordly is licensed under the **AGPL 3.0**. --- +## Extensions + +Recordly has an extension system for adding device frames, click effects, render hooks, sounds, and settings panels. Built-in extensions ship under `public/builtin-extensions/`. + +See [EXTENSIONS.md](./EXTENSIONS.md) for the full API reference and examples. + +--- + # Credits ## Acknowledgements diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 3a267362..f70e0db8 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -5,6 +5,7 @@ import { Download, FolderOpen, MousePointer2, + Puzzle, Redo2, Save, Sparkles, @@ -15,6 +16,7 @@ import { AnimatePresence, LayoutGroup, motion } from "motion/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Panel, PanelGroup } from "react-resizable-panels"; import { toast } from "sonner"; +import { ExtensionIcon } from "./ExtensionIcon"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -71,6 +73,8 @@ import { validateProjectData, } from "./projectPersistence"; import { type EditorEffectSection, SettingsPanel } from "./SettingsPanel"; +import ExtensionManager from "./ExtensionManager"; +import { extensionHost } from "@/lib/extensions"; import { APP_HEADER_ICON_BUTTON_CLASS, DiscordLinkButton, @@ -80,9 +84,7 @@ import { RECORDLY_ISSUES_URL, } from "./TutorialHelp"; import TimelineEditor from "./timeline/TimelineEditor"; -import { - normalizeCursorTelemetry, -} from "./timeline/zoomSuggestionUtils"; +import { normalizeCursorTelemetry } from "./timeline/zoomSuggestionUtils"; import { type AnnotationRegion, type AudioRegion, @@ -132,7 +134,6 @@ import { type EditorHistorySnapshot = { zoomRegions: ZoomRegion[]; - trimRegions: TrimRegion[]; clipRegions: ClipRegion[]; speedRegions: SpeedRegion[]; annotationRegions: AnnotationRegion[]; @@ -514,6 +515,7 @@ export default function VideoEditor() { const [cursorSway, setCursorSway] = useState(initialEditorPreferences.cursorSway); const [borderRadius, setBorderRadius] = useState(initialEditorPreferences.borderRadius); const [padding, setPadding] = useState(initialEditorPreferences.padding); + const [frame, setFrame] = useState(initialEditorPreferences.frame); const [cropRegion, setCropRegion] = useState(DEFAULT_CROP_REGION); const [webcam, setWebcam] = useState( initialEditorPreferences.webcam ?? DEFAULT_WEBCAM_OVERLAY, @@ -609,6 +611,8 @@ export default function VideoEditor() { const applyingHistoryRef = useRef(false); const pendingExportSaveRef = useRef(null); const pendingTelemetryRetryTimeoutRef = useRef(null); + const pendingFreshRecordingAutoSuggestTimeoutRef = useRef(null); + const pendingFreshRecordingAutoSuggestTelemetryCountRef = useRef(0); const cropSnapshotRef = useRef(null); const mp4SupportRequestRef = useRef(0); const smokeExportStartedRef = useRef(false); @@ -619,6 +623,21 @@ export default function VideoEditor() { setAppPlatform(platform); }); }, []); + + useEffect(() => { + autoSuggestedVideoPathRef.current = null; + pendingFreshRecordingAutoSuggestTelemetryCountRef.current = 0; + if (pendingFreshRecordingAutoSuggestTimeoutRef.current !== null) { + window.clearTimeout(pendingFreshRecordingAutoSuggestTimeoutRef.current); + pendingFreshRecordingAutoSuggestTimeoutRef.current = null; + } + }, [videoPath]); + + // Auto-activate builtin extensions at editor startup (idempotent) + useEffect(() => { + extensionHost.autoActivateBuiltins(); + }, []); + const [supportedMp4SourceDimensions, setSupportedMp4SourceDimensions] = useState({ width: 1920, @@ -845,6 +864,10 @@ export default function VideoEditor() { window.clearTimeout(pendingTelemetryRetryTimeoutRef.current); pendingTelemetryRetryTimeoutRef.current = null; } + if (pendingFreshRecordingAutoSuggestTimeoutRef.current !== null) { + window.clearTimeout(pendingFreshRecordingAutoSuggestTimeoutRef.current); + pendingFreshRecordingAutoSuggestTimeoutRef.current = null; + } }; }, []); @@ -976,6 +999,26 @@ export default function VideoEditor() { mp4FrameRate, ]); + // Extension-contributed standalone section pages (no parentSection) + const [extensionSectionButtons, setExtensionSectionButtons] = useState< + { id: EditorEffectSection; label: string; icon: typeof Puzzle | string }[] + >([]); + useEffect(() => { + const update = () => { + const panels = extensionHost.getSettingsPanels(); + const standalone = panels + .filter(p => !p.panel.parentSection) + .map(p => ({ + id: `ext:${p.extensionId}/${p.panel.id}` as EditorEffectSection, + label: p.panel.label, + icon: p.panel.icon || (Puzzle as typeof Puzzle | string), + })); + setExtensionSectionButtons(standalone); + }; + update(); + return extensionHost.onChange(update); + }, []); + const editorSectionButtons = useMemo( () => [ { id: "scene" as const, label: t("settings.sections.scene", "Scene"), icon: Sparkles }, @@ -990,8 +1033,14 @@ export default function VideoEditor() { label: t("settings.sections.captions", "Captions"), icon: Captions, }, + ...extensionSectionButtons, + { + id: "extensions" as const, + label: t("settings.sections.extensions", "Extensions"), + icon: Puzzle, + }, ], - [t], + [t, extensionSectionButtons], ); useEffect(() => { @@ -1033,6 +1082,7 @@ export default function VideoEditor() { cursorSway: number; borderRadius: number; padding: number; + frame: string | null; cropRegion: CropRegion; webcam: WebcamOverlaySettings; zoomRegions: ZoomRegion[]; @@ -1132,6 +1182,7 @@ export default function VideoEditor() { cursorSway, borderRadius, padding, + frame, webcam, zoomRegions, trimRegions, @@ -1205,7 +1256,6 @@ export default function VideoEditor() { const buildHistorySnapshot = useCallback((): EditorHistorySnapshot => { return { zoomRegions, - trimRegions, clipRegions, speedRegions, annotationRegions, @@ -1220,7 +1270,6 @@ export default function VideoEditor() { }; }, [ zoomRegions, - trimRegions, clipRegions, speedRegions, annotationRegions, @@ -1239,7 +1288,6 @@ export default function VideoEditor() { applyingHistoryRef.current = true; const cloned = cloneSnapshot(snapshot); setZoomRegions(cloned.zoomRegions); - setTrimRegions(cloned.trimRegions); setClipRegions(cloned.clipRegions); setSpeedRegions(cloned.speedRegions); setAnnotationRegions(cloned.annotationRegions); @@ -1256,10 +1304,6 @@ export default function VideoEditor() { "zoom", cloned.zoomRegions.map((region) => region.id), ); - nextTrimIdRef.current = deriveNextId( - "trim", - cloned.trimRegions.map((region) => region.id), - ); nextClipIdRef.current = deriveNextId( "clip", cloned.clipRegions.map((region) => region.id), @@ -1367,6 +1411,7 @@ export default function VideoEditor() { setCursorSway(normalizedEditor.cursorSway); setBorderRadius(normalizedEditor.borderRadius); setPadding(normalizedEditor.padding); + setFrame(normalizedEditor.frame); setCropRegion(DEFAULT_CROP_REGION); setWebcam(normalizedEditor.webcam); setZoomRegions(normalizedEditor.zoomRegions); @@ -1677,6 +1722,7 @@ export default function VideoEditor() { cursorSway, borderRadius, padding, + frame, webcam, aspectRatio, exportEncodingMode, @@ -1718,6 +1764,7 @@ export default function VideoEditor() { cursorSway, borderRadius, padding, + frame, webcam, aspectRatio, exportEncodingMode, @@ -2071,8 +2118,8 @@ export default function VideoEditor() { setCursorTelemetry(samples); const shouldRetryFreshRecordingTelemetry = - samples.length < 2 && pendingFreshRecordingAutoZoomPathRef.current === videoPath && + autoSuggestedVideoPathRef.current !== videoPath && retryAttempts < 12; if (shouldRetryFreshRecordingTelemetry) { @@ -2091,6 +2138,7 @@ export default function VideoEditor() { setCursorTelemetry([]); if ( pendingFreshRecordingAutoZoomPathRef.current === videoPath && + autoSuggestedVideoPathRef.current !== videoPath && retryAttempts < 12 ) { retryAttempts += 1; @@ -2202,33 +2250,6 @@ export default function VideoEditor() { return result; }, [clipRegions, speedRegions]); - useEffect(() => { - if ( - !videoPath || - loading || - !isPreviewReady || - duration <= 0 || - loopCursor || - zoomRegions.length > 0 || - effectiveCursorTelemetry.length < 2 - ) { - return; - } - - if (pendingFreshRecordingAutoZoomPathRef.current !== videoPath) { - return; - } - - if (autoSuggestedVideoPathRef.current === videoPath) { - pendingFreshRecordingAutoZoomPathRef.current = null; - return; - } - - autoSuggestedVideoPathRef.current = videoPath; - pendingFreshRecordingAutoZoomPathRef.current = null; - setAutoSuggestZoomsTrigger((value) => value + 1); - }, [videoPath, loading, isPreviewReady, duration, effectiveCursorTelemetry.length, loopCursor, zoomRegions.length]); - function togglePlayPause() { const playback = videoPlaybackRef.current; const video = playback?.video; @@ -2287,11 +2308,16 @@ export default function VideoEditor() { focus: { cx: 0.5, cy: 0.5 }, mode: "manual", }; + if (videoPath && pendingFreshRecordingAutoZoomPathRef.current === videoPath) { + autoSuggestedVideoPathRef.current = videoPath; + pendingFreshRecordingAutoZoomPathRef.current = null; + } setZoomRegions((prev) => [...prev, newRegion]); setSelectedZoomId(id); setSelectedTrimId(null); setSelectedAnnotationId(null); - }, []); + extensionHost.emitEvent({ type: 'timeline:region-added', data: { id, startMs: newRegion.startMs, endMs: newRegion.endMs } }); + }, [videoPath]); const handleZoomSuggested = useCallback((span: Span, focus: ZoomFocus) => { const id = `zoom-${nextZoomIdRef.current++}`; @@ -2303,11 +2329,75 @@ export default function VideoEditor() { focus: clampFocusToDepth(focus, DEFAULT_AUTO_ZOOM_DEPTH), mode: "auto", }; + if (videoPath && pendingFreshRecordingAutoZoomPathRef.current === videoPath) { + autoSuggestedVideoPathRef.current = videoPath; + pendingFreshRecordingAutoZoomPathRef.current = null; + } setZoomRegions((prev) => [...prev, newRegion]); setSelectedZoomId(id); setSelectedTrimId(null); setSelectedAnnotationId(null); - }, []); + extensionHost.emitEvent({ type: 'timeline:region-added', data: { id, startMs: newRegion.startMs, endMs: newRegion.endMs } }); + }, [videoPath]); + + useEffect(() => { + if ( + !videoPath || + loading || + !isPreviewReady || + duration <= 0 || + zoomRegions.length > 0 || + normalizedCursorTelemetry.length < 2 + ) { + if (pendingFreshRecordingAutoSuggestTimeoutRef.current !== null) { + window.clearTimeout(pendingFreshRecordingAutoSuggestTimeoutRef.current); + pendingFreshRecordingAutoSuggestTimeoutRef.current = null; + } + return; + } + + if (pendingFreshRecordingAutoZoomPathRef.current !== videoPath) { + return; + } + + if (autoSuggestedVideoPathRef.current === videoPath) { + pendingFreshRecordingAutoZoomPathRef.current = null; + return; + } + + const telemetryPointCount = cursorTelemetry.length; + if (pendingFreshRecordingAutoSuggestTelemetryCountRef.current === telemetryPointCount) { + return; + } + + pendingFreshRecordingAutoSuggestTelemetryCountRef.current = telemetryPointCount; + + if (pendingFreshRecordingAutoSuggestTimeoutRef.current !== null) { + window.clearTimeout(pendingFreshRecordingAutoSuggestTimeoutRef.current); + pendingFreshRecordingAutoSuggestTimeoutRef.current = null; + } + + pendingFreshRecordingAutoSuggestTimeoutRef.current = window.setTimeout(() => { + pendingFreshRecordingAutoSuggestTimeoutRef.current = null; + if ( + pendingFreshRecordingAutoZoomPathRef.current !== videoPath || + autoSuggestedVideoPathRef.current === videoPath || + zoomRegions.length > 0 + ) { + return; + } + + setAutoSuggestZoomsTrigger((value) => value + 1); + }, 500); + }, [ + videoPath, + loading, + isPreviewReady, + duration, + cursorTelemetry.length, + normalizedCursorTelemetry, + zoomRegions, + ]); const handleTrimAdded = useCallback((span: Span) => { const id = `trim-${nextTrimIdRef.current++}`; @@ -2401,6 +2491,7 @@ export default function VideoEditor() { if (selectedZoomId === id) { setSelectedZoomId(null); } + extensionHost.emitEvent({ type: 'timeline:region-removed', data: { id } }); }, [selectedZoomId], ); @@ -3130,6 +3221,7 @@ export default function VideoEditor() { setExportProgress(null); setExportError(null); clearPendingExportSave(); + extensionHost.emitEvent({ type: 'export:start' }); const smokeExportStartedAt = smokeExportConfig.enabled ? performance.now() : null; let keepExportDialogOpen = false; @@ -3232,6 +3324,7 @@ export default function VideoEditor() { cursorClickBounce, cursorClickBounceDuration, cursorSway, + frame, previewWidth, previewHeight, maxDecodeQueue: smokeExportConfig.maxDecodeQueue, @@ -3398,6 +3491,7 @@ export default function VideoEditor() { cursorClickBounce, cursorClickBounceDuration, cursorSway, + frame, audioRegions, sourceAudioFallbackPaths, previewWidth, @@ -3561,6 +3655,7 @@ export default function VideoEditor() { window.close(); } } finally { + extensionHost.emitEvent({ type: 'export:complete' }); setIsExporting(false); exporterRef.current = null; setShowExportDropdown(keepExportDialogOpen); @@ -4226,7 +4321,6 @@ export default function VideoEditor() {
{editorSectionButtons.map((section) => { - const Icon = section.icon; const isActive = activeEffectSection === section.id; return ( - + {typeof section.icon === "string" ? ( + + ) : ( + + )} {isActive ? ( @@ -4315,6 +4413,7 @@ export default function VideoEditor() { connectedZoomEasing={connectedZoomEasing} borderRadius={borderRadius} padding={padding} + frame={frame} cropRegion={cropRegion} webcam={webcam} webcamVideoPath={webcam.sourcePath ? toFileUrl(webcam.sourcePath) : null} @@ -4427,6 +4526,9 @@ export default function VideoEditor() { {/* Left section: settings panel */}
+ {activeEffectSection === "extensions" ? ( + + ) : ( + )}