diff --git a/src/app/tools/wigglegram/AutoAlignTool.tsx b/src/app/tools/wigglegram/AutoAlignTool.tsx new file mode 100644 index 000000000..5dc06cb43 --- /dev/null +++ b/src/app/tools/wigglegram/AutoAlignTool.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { ExtractedFrame, AlignmentOffsets, BoundingBox } from "./types"; +import { BoundingBoxSelector } from "./BoundingBoxSelector"; +import { autoAlign } from "./autoAlign"; +import { Button } from "@/components/ui/button"; + +interface AutoAlignToolProps { + canvasRef: React.RefObject; + extractedFrames: ExtractedFrame[]; + baseFrameIndex: number; + onAlignment: (offsets: AlignmentOffsets) => void; +} + +export const AutoAlignTool = ({ + canvasRef, + extractedFrames, + baseFrameIndex, + onAlignment, +}: AutoAlignToolProps) => { + const [box, setBox] = useState(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (canvas && !box) { + const rect = canvas.getBoundingClientRect(); + const size = Math.min(rect.width, rect.height) * 0.25; + setBox({ + x: (rect.width - size) / 2, + y: (rect.height - size) / 2, + width: size, + height: size, + }); + } + }, [canvasRef, box]); + + if (!box) return null; + + const handleAlign = async () => { + const canvasEl = canvasRef.current; + const base = extractedFrames[baseFrameIndex]; + if (!canvasEl || !base) return; + + const rect = canvasEl.getBoundingClientRect(); + const scaleX = base.canvas.width / rect.width; + const scaleY = base.canvas.height / rect.height; + const scaledBox: BoundingBox = { + x: Math.round(box.x * scaleX), + y: Math.round(box.y * scaleY), + width: Math.round(box.width * scaleX), + height: Math.round(box.height * scaleY), + }; + + const newOffsets: AlignmentOffsets = { + left: { x: 0, y: 0 }, + right: { x: 0, y: 0 }, + }; + const leftFrame = extractedFrames[baseFrameIndex - 1]; + if (leftFrame) { + newOffsets.left = await autoAlign(base.canvas, leftFrame.canvas, scaledBox); + } + const rightFrame = extractedFrames[baseFrameIndex + 1]; + if (rightFrame) { + newOffsets.right = await autoAlign(base.canvas, rightFrame.canvas, scaledBox); + } + onAlignment(newOffsets); + }; + + const canvas = canvasRef.current; + const width = canvas?.clientWidth || 0; + const height = canvas?.clientHeight || 0; + + return ( + <> + +
+ +
+ + ); +}; diff --git a/src/app/tools/wigglegram/BoundingBoxSelector.tsx b/src/app/tools/wigglegram/BoundingBoxSelector.tsx new file mode 100644 index 000000000..1e4abb126 --- /dev/null +++ b/src/app/tools/wigglegram/BoundingBoxSelector.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { BoundingBox } from "./types"; + +interface BoundingBoxSelectorProps { + box: BoundingBox; + onChange: (box: BoundingBox) => void; + containerWidth: number; + containerHeight: number; +} + +export const BoundingBoxSelector = ({ + box, + onChange, + containerWidth, + containerHeight, +}: BoundingBoxSelectorProps) => { + const [action, setAction] = useState< + | null + | { + type: "move" | "resize"; + corner?: "tl" | "tr" | "bl" | "br"; + startX: number; + startY: number; + startBox: BoundingBox; + } + >(null); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!action) return; + const dx = e.clientX - action.startX; + const dy = e.clientY - action.startY; + let newBox = { ...action.startBox }; + if (action.type === "move") { + newBox.x = Math.min( + Math.max(0, action.startBox.x + dx), + containerWidth - action.startBox.width, + ); + newBox.y = Math.min( + Math.max(0, action.startBox.y + dy), + containerHeight - action.startBox.height, + ); + } else if (action.type === "resize") { + switch (action.corner) { + case "tl": + newBox.x = Math.min( + action.startBox.x + dx, + action.startBox.x + action.startBox.width - 20, + ); + newBox.y = Math.min( + action.startBox.y + dy, + action.startBox.y + action.startBox.height - 20, + ); + newBox.width = action.startBox.width - dx; + newBox.height = action.startBox.height - dy; + break; + case "tr": + newBox.y = Math.min( + action.startBox.y + dy, + action.startBox.y + action.startBox.height - 20, + ); + newBox.width = action.startBox.width + dx; + newBox.height = action.startBox.height - dy; + break; + case "bl": + newBox.x = Math.min( + action.startBox.x + dx, + action.startBox.x + action.startBox.width - 20, + ); + newBox.width = action.startBox.width - dx; + newBox.height = action.startBox.height + dy; + break; + case "br": + newBox.width = action.startBox.width + dx; + newBox.height = action.startBox.height + dy; + break; + } + if (newBox.width < 20) newBox.width = 20; + if (newBox.height < 20) newBox.height = 20; + if (newBox.x < 0) { + newBox.width += newBox.x; + newBox.x = 0; + } + if (newBox.y < 0) { + newBox.height += newBox.y; + newBox.y = 0; + } + if (newBox.x + newBox.width > containerWidth) + newBox.width = containerWidth - newBox.x; + if (newBox.y + newBox.height > containerHeight) + newBox.height = containerHeight - newBox.y; + } + onChange(newBox); + }; + const handleMouseUp = () => setAction(null); + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [action, onChange, containerWidth, containerHeight]); + + const startMove = ( + e: React.MouseEvent, + type: "move" | "resize", + corner?: "tl" | "tr" | "bl" | "br", + ) => { + e.preventDefault(); + e.stopPropagation(); + setAction({ + type, + corner, + startX: e.clientX, + startY: e.clientY, + startBox: { ...box }, + }); + }; + + return ( +
+
startMove(e, "move")} + > + {(["tl", "tr", "bl", "br"] as const).map((corner) => ( +
startMove(e, "resize", corner)} + /> + ))} +
+
+ ); +}; + +export type { BoundingBox }; diff --git a/src/app/tools/wigglegram/ImageAlignmentEditor.tsx b/src/app/tools/wigglegram/ImageAlignmentEditor.tsx index d1bf53a69..8ada54392 100644 --- a/src/app/tools/wigglegram/ImageAlignmentEditor.tsx +++ b/src/app/tools/wigglegram/ImageAlignmentEditor.tsx @@ -6,6 +6,7 @@ import { AlignmentOffsets, DragLayer, } from "./types"; +import { AutoAlignTool } from "./AutoAlignTool"; export const ImageAlignmentEditor = ({ extractedFrames, @@ -351,17 +352,25 @@ export const ImageAlignmentEditor = ({
- +
+ + +

diff --git a/src/app/tools/wigglegram/autoAlign.ts b/src/app/tools/wigglegram/autoAlign.ts new file mode 100644 index 000000000..196e04b68 --- /dev/null +++ b/src/app/tools/wigglegram/autoAlign.ts @@ -0,0 +1,99 @@ +import { BoundingBox } from "./types"; + +function downscaleCanvas( + canvas: HTMLCanvasElement, + scale: number, +): HTMLCanvasElement { + const scaled = document.createElement("canvas"); + scaled.width = Math.round(canvas.width * scale); + scaled.height = Math.round(canvas.height * scale); + const ctx = scaled.getContext("2d")!; + ctx.drawImage(canvas, 0, 0, scaled.width, scaled.height); + return scaled; +} + +function computeEdges(canvas: HTMLCanvasElement) { + const ctx = canvas.getContext("2d")!; + const { data, width, height } = ctx.getImageData(0, 0, canvas.width, canvas.height); + const gray = new Float32Array(width * height); + for (let i = 0; i < width * height; i++) { + const idx = i * 4; + gray[i] = data[idx] * 0.299 + data[idx + 1] * 0.587 + data[idx + 2] * 0.114; + } + const edges = new Float32Array(width * height); + for (let y = 1; y < height - 1; y++) { + for (let x = 1; x < width - 1; x++) { + const idx = y * width + x; + const gx = gray[idx + 1] - gray[idx - 1]; + const gy = gray[idx + width] - gray[idx - width]; + edges[idx] = Math.sqrt(gx * gx + gy * gy); + } + } + return { data: edges, width, height }; +} + +function edgeDifference( + base: { data: Float32Array; width: number; height: number }, + target: { data: Float32Array; width: number; height: number }, + box: BoundingBox, + dx: number, + dy: number, +) { + let score = 0; + let count = 0; + for (let y = 0; y < box.height; y++) { + const by = box.y + y; + const ty = by + dy; + if (ty < 0 || ty >= target.height) continue; + for (let x = 0; x < box.width; x++) { + const bx = box.x + x; + const tx = bx + dx; + if (tx < 0 || tx >= target.width) continue; + const bIdx = by * base.width + bx; + const tIdx = ty * target.width + tx; + const diff = base.data[bIdx] - target.data[tIdx]; + score += diff * diff; + count++; + } + } + return count === 0 ? Number.POSITIVE_INFINITY : score / count; +} + +export async function autoAlign( + baseCanvas: HTMLCanvasElement, + targetCanvas: HTMLCanvasElement, + box: BoundingBox, +) { + const maxDim = 200; + const scale = Math.min( + maxDim / baseCanvas.width, + maxDim / baseCanvas.height, + 1, + ); + const baseScaled = downscaleCanvas(baseCanvas, scale); + const targetScaled = downscaleCanvas(targetCanvas, scale); + const scaledBox: BoundingBox = { + x: Math.round(box.x * scale), + y: Math.round(box.y * scale), + width: Math.round(box.width * scale), + height: Math.round(box.height * scale), + }; + const baseEdges = computeEdges(baseScaled); + const targetEdges = computeEdges(targetScaled); + const maxRadius = 20; + let bestScore = Number.POSITIVE_INFINITY; + let bestOffset = { x: 0, y: 0 }; + for (let r = 0; r <= maxRadius; r++) { + for (let dx = -r; dx <= r; dx++) { + for (let dy = -r; dy <= r; dy++) { + if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue; + const score = edgeDifference(baseEdges, targetEdges, scaledBox, dx, dy); + if (score < bestScore) { + bestScore = score; + bestOffset = { x: dx, y: dy }; + } + } + } + } + return { x: bestOffset.x / scale, y: bestOffset.y / scale }; +} diff --git a/src/app/tools/wigglegram/types.ts b/src/app/tools/wigglegram/types.ts index c16b0e876..8fce855c8 100644 --- a/src/app/tools/wigglegram/types.ts +++ b/src/app/tools/wigglegram/types.ts @@ -10,6 +10,13 @@ export interface AlignmentOffsets { right: { x: number; y: number }; } +export interface BoundingBox { + x: number; + y: number; + width: number; + height: number; +} + export interface LayerState { left: boolean; right: boolean;