diff options
| author | cashmere <cashmere@autistici.org> | 2026-08-02 19:59:32 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-08-08 10:40:12 +0200 |
| commit | 237248321a1a40b9b98e7e0a6df156d0aa247ef0 (patch) | |
| tree | 62f2f46474b08c3b7723e78a2f4dd9cf58d64494 /apps/web/src/effects/components/assets-view.tsx | |
| parent | 465071d8232e74b1690280a38a86d435269fc35d (diff) | |
| download | opencut-237248321a1a40b9b98e7e0a6df156d0aa247ef0.tar.gz | |
feat: pan & zoom motion presets
Ken Burns-style motion effects (zoom in/out, pan left/right/up/down) that
drag onto clips like regular effects. Instead of shader passes they
evaluate a per-frame transform offset in resolveVisualState, so they
stack with keyframed transforms and work in preview and export.
- intensity param (0-100%, keyframable) scales the whole effect
- easing select maps progress through the graph editor's bezier presets
- supportsStandalone: false hides them from effect-track creation
- empty shader pass groups are filtered out so pass-less effects and
zero-intensity blur no longer crash the compositor
Diffstat (limited to 'apps/web/src/effects/components/assets-view.tsx')
| -rw-r--r-- | apps/web/src/effects/components/assets-view.tsx | 106 |
1 files changed, 104 insertions, 2 deletions
diff --git a/apps/web/src/effects/components/assets-view.tsx b/apps/web/src/effects/components/assets-view.tsx index 000af7e6..8c2ec85b 100644 --- a/apps/web/src/effects/components/assets-view.tsx +++ b/apps/web/src/effects/components/assets-view.tsx @@ -53,6 +53,102 @@ function EffectPreviewCanvas({ effectType }: { effectType: string }) { return <canvas ref={canvasRef} className="size-full" />; } +const MOTION_ARROW_DIRECTIONS: Record<string, [number, number]> = { + "pan-left": [-1, 0], + "pan-right": [1, 0], + "pan-up": [0, -1], + "pan-down": [0, 1], +}; + +function drawArrow({ + context, + fromX, + fromY, + toX, + toY, +}: { + context: CanvasRenderingContext2D; + fromX: number; + fromY: number; + toX: number; + toY: number; +}) { + const headLength = 10; + const angle = Math.atan2(toY - fromY, toX - fromX); + context.beginPath(); + context.moveTo(fromX, fromY); + context.lineTo(toX, toY); + context.moveTo(toX, toY); + context.lineTo( + toX - headLength * Math.cos(angle - Math.PI / 6), + toY - headLength * Math.sin(angle - Math.PI / 6), + ); + context.moveTo(toX, toY); + context.lineTo( + toX - headLength * Math.cos(angle + Math.PI / 6), + toY - headLength * Math.sin(angle + Math.PI / 6), + ); + context.stroke(); +} + +function MotionPreviewCanvas({ effectType }: { effectType: string }) { + const canvasRef = useRef<HTMLCanvasElement>(null); + + useEffect(() => { + const canvas = canvasRef.current; + const context = canvas?.getContext("2d"); + if (!canvas || !context) return; + + const size = canvas.width; + const center = size / 2; + context.clearRect(0, 0, size, size); + context.fillStyle = "#17171c"; + context.fillRect(0, 0, size, size); + context.strokeStyle = "rgba(255, 255, 255, 0.35)"; + context.lineWidth = 2; + context.strokeRect(size * 0.2, size * 0.28, size * 0.6, size * 0.44); + context.strokeStyle = "#ffffff"; + context.lineWidth = 4; + context.lineCap = "round"; + + const panDirection = MOTION_ARROW_DIRECTIONS[effectType]; + if (panDirection) { + const [dx, dy] = panDirection; + drawArrow({ + context, + fromX: center - dx * size * 0.18, + fromY: center - dy * size * 0.18, + toX: center + dx * size * 0.18, + toY: center + dy * size * 0.18, + }); + return; + } + + const inward = effectType === "zoom-out"; + const spread = size * 0.16; + const reach = size * 0.3; + for (const [dx, dy] of [ + [1, 1], + [-1, -1], + ] as const) { + const innerX = center + dx * (inward ? reach : spread); + const innerY = center + dy * (inward ? reach : spread) * 0.7; + const outerX = center + dx * (inward ? spread : reach); + const outerY = center + dy * (inward ? spread : reach) * 0.7; + drawArrow({ context, fromX: innerX, fromY: innerY, toX: outerX, toY: outerY }); + } + }, [effectType]); + + return ( + <canvas + ref={canvasRef} + width={effectPreviewService.PREVIEW_SIZE} + height={effectPreviewService.PREVIEW_SIZE} + className="size-full" + /> + ); +} + function EffectItem({ effect }: { effect: EffectDefinition }) { const editor = useEditor(); @@ -69,7 +165,11 @@ function EffectItem({ effect }: { effect: EffectDefinition }) { }); }, [editor, effect.type]); - const preview = <EffectPreviewCanvas effectType={effect.type} />; + const preview = effect.motion ? ( + <MotionPreviewCanvas effectType={effect.type} /> + ) : ( + <EffectPreviewCanvas effectType={effect.type} /> + ); return ( <DraggableItem @@ -82,7 +182,9 @@ function EffectItem({ effect }: { effect: EffectDefinition }) { effectType: effect.type, targetElementTypes: EFFECT_TARGET_ELEMENT_TYPES, }} - onAddToTimeline={handleAddToTimeline} + onAddToTimeline={ + effect.supportsStandalone === false ? undefined : handleAddToTimeline + } aspectRatio={1} isRounded variant="card" |
