Files

188 lines
7.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback } from 'react'
export interface OutpaintPads {
left: number
right: number
top: number
bottom: number
}
interface OutpaintCanvasEditorProps {
// Source video dimensions in pixels (the silhouette, fixed — never resized).
sourceWidth: number
sourceHeight: number
value: OutpaintPads
onChange: (pads: OutpaintPads) => void
}
// Square display box for the editor. The source sits centred; the output frame grows around it.
const STAGE = 260
// Aspect-ratio quick-snaps: expand the deficient axis to hit the ratio, centred. null = reset.
const PRESETS: { id: string; label: string; ratio: number | null }[] = [
{ id: 'reset', label: 'Reset', ratio: null },
{ id: '16:9', label: '16:9', ratio: 16 / 9 },
{ id: '1:1', label: '1:1', ratio: 1 },
{ id: '9:16', label: '9:16', ratio: 9 / 16 },
{ id: '21:9', label: '21:9', ratio: 21 / 9 },
]
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
const toEven = (v: number) => v + (v % 2)
// Handles: each names the edge(s) it moves ('l'/'r'/'t'/'b'). Corners move two.
const HANDLES = ['tl', 't', 'tr', 'l', 'r', 'bl', 'b', 'br'] as const
type Handle = (typeof HANDLES)[number]
export function OutpaintCanvasEditor({ sourceWidth, sourceHeight, value, onChange }: OutpaintCanvasEditorProps) {
// Cap = +100% per axis, so the max canvas is 3× the source on each side. Fit that into the stage.
const scale = Math.min(STAGE / (3 * sourceWidth), STAGE / (3 * sourceHeight))
const stageW = 3 * sourceWidth * scale
const stageH = 3 * sourceHeight * scale
const srcLeft = stageW / 2 - (sourceWidth * scale) / 2
const srcTop = stageH / 2 - (sourceHeight * scale) / 2
const frameLeft = srcLeft - value.left * scale
const frameTop = srcTop - value.top * scale
const frameW = (sourceWidth + value.left + value.right) * scale
const frameH = (sourceHeight + value.top + value.bottom) * scale
const outW = toEven(sourceWidth + value.left + value.right)
const outH = toEven(sourceHeight + value.top + value.bottom)
const startDrag = useCallback(
(e: React.PointerEvent, handle: Handle) => {
e.preventDefault()
e.stopPropagation()
const startX = e.clientX
const startY = e.clientY
const start = { ...value }
const move = (ev: PointerEvent) => {
const dx = (ev.clientX - startX) / scale
const dy = (ev.clientY - startY) / scale
const next = { ...start }
// Dragging the left/top edge outward (negative delta) grows that pad; clamp to [0, source axis].
if (handle.includes('l')) next.left = clamp(Math.round(start.left - dx), 0, sourceWidth)
if (handle.includes('r')) next.right = clamp(Math.round(start.right + dx), 0, sourceWidth)
if (handle.includes('t')) next.top = clamp(Math.round(start.top - dy), 0, sourceHeight)
if (handle.includes('b')) next.bottom = clamp(Math.round(start.bottom + dy), 0, sourceHeight)
onChange(next)
}
const up = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
},
[value, scale, sourceWidth, sourceHeight, onChange],
)
const applyPreset = useCallback(
(ratio: number | null) => {
if (ratio === null) {
onChange({ left: 0, right: 0, top: 0, bottom: 0 })
return
}
const current = sourceWidth / sourceHeight
if (current < ratio) {
// Too tall for the ratio → widen. Cap each side at +100% (total extra ≤ 2× width).
const extra = clamp(Math.round(sourceHeight * ratio) - sourceWidth, 0, 2 * sourceWidth)
const half = Math.round(extra / 2)
onChange({ left: half, right: extra - half, top: 0, bottom: 0 })
} else {
// Too wide → heighten.
const extra = clamp(Math.round(sourceWidth / ratio) - sourceHeight, 0, 2 * sourceHeight)
const half = Math.round(extra / 2)
onChange({ left: 0, right: 0, top: half, bottom: extra - half })
}
},
[sourceWidth, sourceHeight, onChange],
)
// A ratio is reachable only if the +100%-per-axis cap can grow the deficient axis far enough.
const isPresetReachable = (ratio: number | null): boolean => {
if (ratio === null) return true
const current = sourceWidth / sourceHeight
return current < ratio
? Math.round(sourceHeight * ratio) - sourceWidth <= 2 * sourceWidth
: Math.round(sourceWidth / ratio) - sourceHeight <= 2 * sourceHeight
}
// Handle display position (centre point) on the frame border, in stage coordinates.
const handlePos = (h: Handle): { x: number; y: number } => {
const midX = frameLeft + frameW / 2
const midY = frameTop + frameH / 2
const left = frameLeft
const right = frameLeft + frameW
const top = frameTop
const bottom = frameTop + frameH
switch (h) {
case 'tl': return { x: left, y: top }
case 't': return { x: midX, y: top }
case 'tr': return { x: right, y: top }
case 'l': return { x: left, y: midY }
case 'r': return { x: right, y: midY }
case 'bl': return { x: left, y: bottom }
case 'b': return { x: midX, y: bottom }
case 'br': return { x: right, y: bottom }
}
}
const handleCursor: Record<Handle, string> = {
tl: 'nwse-resize', t: 'ns-resize', tr: 'nesw-resize', l: 'ew-resize',
r: 'ew-resize', bl: 'nesw-resize', b: 'ns-resize', br: 'nwse-resize',
}
return (
<div className="flex-1 flex flex-col min-h-0 bg-black">
<div className="flex-1 flex items-center justify-center min-h-0 p-3">
<div className="relative" style={{ width: stageW, height: stageH }}>
{/* Output frame: the green-filled generate region around the source. */}
<div
className="absolute border border-emerald-500/60 bg-emerald-500/10"
style={{ left: frameLeft, top: frameTop, width: frameW, height: frameH }}
/>
{/* Source silhouette (kept region) — fixed size, centred. */}
<div
className="absolute bg-zinc-400/80 border border-zinc-300"
style={{ left: srcLeft, top: srcTop, width: sourceWidth * scale, height: sourceHeight * scale }}
/>
{HANDLES.map((h) => {
const { x, y } = handlePos(h)
return (
<div
key={h}
onPointerDown={(e) => startDrag(e, h)}
className="absolute w-2.5 h-2.5 -ml-1.5 -mt-1.5 rounded-sm bg-white border border-zinc-500 shadow"
style={{ left: x, top: y, cursor: handleCursor[h], touchAction: 'none' }}
/>
)
})}
</div>
</div>
<div className="px-3 py-2 border-t border-zinc-800 flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-1">
{PRESETS.map((p) => {
// Unreachable presets stay clickable but cap-to-closest (applyPreset clamps to the
// +100% extend cap) — marked with a · and a tooltip so it's not a silent wrong ratio.
const capped = !isPresetReachable(p.ratio)
return (
<button
key={p.id}
onClick={() => applyPreset(p.ratio)}
title={capped ? 'Limited by the +100% expand cap — lands at the closest ratio' : undefined}
className="px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-white hover:bg-zinc-800 border border-zinc-700 transition-colors"
>
{p.label}
{capped && <span className="text-amber-500/80">·</span>}
</button>
)
})}
</div>
<span className="text-[10px] text-zinc-500 tabular-nums">
{sourceWidth}×{sourceHeight} → {outW}×{outH}
</span>
</div>
</div>
)
}