kasuti: stage 3 replay — client-side GIF export

Adds a "Save GIF" button to the replay panel. On click, the component
renders each step of the replay to an offscreen canvas (both fabric
panels side-by-side, FRONT/BACK labels, active-side border, dashed
pattern guides for undrawn edges, drawn stitches in thread colour,
dashed start marker, red current-vertex dot) and feeds the frames
through gifenc — dynamically imported so the ~8 KB chunk only loads
when the user actually exports.

Frame timing: 200 ms between stitches, 900 ms hold on the first and
last frames. Palette is quantised to 64 colours per frame, which keeps
a Snowflake tour (~53 frames at 684×376) around 150–250 KB. The
button flips to "Encoding…" while busy and a sonner toast reports the
file size on success or the error on failure.

https://claude.ai/code/session_01KNdXjQczMCRGMK7K3Qderw
This commit is contained in:
Claude 2026-04-22 11:40:21 +00:00
parent bf20155423
commit bd4f073cac
No known key found for this signature in database
3 changed files with 200 additions and 0 deletions

7
package-lock.json generated
View file

@ -29,6 +29,7 @@
"embla-carousel-autoplay": "^8.5.2",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^11.18.2",
"gifenc": "^1.0.3",
"gray-matter": "^4.0.3",
"input-otp": "^1.4.2",
"katex": "^0.16.45",
@ -12136,6 +12137,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gifenc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/gifenc/-/gifenc-1.0.3.tgz",
"integrity": "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==",
"license": "MIT"
},
"node_modules/github-slugger": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",

View file

@ -38,6 +38,7 @@
"embla-carousel-autoplay": "^8.5.2",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^11.18.2",
"gifenc": "^1.0.3",
"gray-matter": "^4.0.3",
"input-otp": "^1.4.2",
"katex": "^0.16.45",

View file

@ -76,6 +76,124 @@ function vEqual(a: V | null, b: V | null): boolean {
return a[0] === b[0] && a[1] === b[1];
}
// ─── GIF export ─────────────────────────────────────────────────────────────
// Draws both fabric panels to an offscreen canvas and uses gifenc to encode
// one frame per stitch (plus a hold at the start and end). The rendering
// mirrors the on-screen SVG: grid dots, dashed pattern guides for undrawn
// edges, drawn stitches in thread colour, and the current vertex dot. Kept
// self-contained so it doesn't depend on the live DOM.
const GIF_PANEL = 320; // px per fabric panel
const GIF_GAP = 24;
const GIF_LABEL_H = 36;
const GIF_WIDTH = GIF_PANEL * 2 + GIF_GAP + 20;
const GIF_HEIGHT = GIF_PANEL + GIF_LABEL_H + 20;
function drawGifFrame(
ctx: CanvasRenderingContext2D,
placed: PlacedPattern,
stitchesUpTo: Stitch[],
frontColor: string,
backColor: string,
currentVertex: V | null,
startVertex: V | null,
activeSide: Side
) {
const panelPad = 10;
const inner = GIF_PANEL - panelPad * 2;
const cellPx = inner / GRID_SIZE;
ctx.fillStyle = '#fdfcfa';
ctx.fillRect(0, 0, GIF_WIDTH, GIF_HEIGHT);
const drawPanel = (offsetX: number, side: Side) => {
const isActive = side === activeSide;
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = isActive ? '#d04a2b' : '#d8d4cc';
ctx.lineWidth = isActive ? 2 : 1;
ctx.fillRect(offsetX, 10, GIF_PANEL, GIF_PANEL);
ctx.strokeRect(offsetX + 0.5, 10.5, GIF_PANEL - 1, GIF_PANEL - 1);
const toCanvas = (v: V) => ({
x: offsetX + panelPad + v[1] * cellPx,
y: 10 + panelPad + v[0] * cellPx,
});
ctx.fillStyle = 'rgba(100,100,100,0.30)';
for (let r = 0; r <= GRID_SIZE; r++) {
for (let c = 0; c <= GRID_SIZE; c++) {
const { x, y } = toCanvas([r, c] as V);
ctx.beginPath();
ctx.arc(x, y, 0.8, 0, Math.PI * 2);
ctx.fill();
}
}
const drawnHere = new Set<EdgeKey>();
for (const s of stitchesUpTo) {
if (s.side === side) drawnHere.add(edgeKey(s.from, s.to));
}
ctx.strokeStyle = 'rgba(100,100,100,0.55)';
ctx.lineWidth = 1;
ctx.setLineDash([2, 3]);
for (const [a, b] of placed.edges) {
if (drawnHere.has(edgeKey(a, b))) continue;
const p = toCanvas(a);
const q = toCanvas(b);
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(q.x, q.y);
ctx.stroke();
}
ctx.setLineDash([]);
ctx.strokeStyle = side === 'front' ? frontColor : backColor;
ctx.lineWidth = Math.max(2, cellPx * 0.14);
ctx.lineCap = 'round';
for (const s of stitchesUpTo) {
if (s.side !== side) continue;
const p = toCanvas(s.from);
const q = toCanvas(s.to);
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(q.x, q.y);
ctx.stroke();
}
if (startVertex) {
const { x, y } = toCanvas(startVertex);
ctx.strokeStyle = '#171717';
ctx.lineWidth = 1.4;
ctx.setLineDash([2, 2]);
ctx.beginPath();
ctx.arc(x, y, 4.5, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
if (currentVertex) {
const { x, y } = toCanvas(currentVertex);
ctx.fillStyle = isActive ? '#d04a2b' : 'rgba(100,100,100,0.6)';
ctx.beginPath();
ctx.arc(x, y, 4, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = '#5C5C5C';
ctx.font = '600 14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(
side === 'front' ? 'FRONT' : 'BACK',
offsetX + GIF_PANEL / 2,
GIF_PANEL + 30
);
};
drawPanel(10, 'front');
drawPanel(10 + GIF_PANEL + GIF_GAP, 'back');
}
// ─── Replay URL codec ───────────────────────────────────────────────────────
// The payload captures just enough to reconstruct a completed tour: which
// pattern (preset id, or "custom" with its edge list), the start vertex, and
@ -841,6 +959,70 @@ const KasutiEmbroidery: React.FC = () => {
window.open(tweet, '_blank', 'noopener,noreferrer');
}, [buildReplayUrl, patternId, stitches.length, visiblePatterns]);
const [gifBusy, setGifBusy] = useState(false);
const exportGif = useCallback(async () => {
if (gifBusy) return;
if (!completed || !startVertex) return;
setGifBusy(true);
try {
const { GIFEncoder, quantize, applyPalette } = await import('gifenc');
const canvas = document.createElement('canvas');
canvas.width = GIF_WIDTH;
canvas.height = GIF_HEIGHT;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('canvas unavailable');
const encoder = GIFEncoder();
const totalFrames = stitches.length + 1;
// Timings: slightly longer hold on the first and last frames.
const frameDelayMs = 200;
const holdDelayMs = 900;
for (let k = 0; k <= stitches.length; k++) {
const stitchesUpTo = stitches.slice(0, k);
const side: Side =
k === 0 ? 'front' : stitches[k - 1].side === 'front' ? 'back' : 'front';
const cur = k === 0 ? startVertex : stitches[k - 1].to;
drawGifFrame(ctx, placed, stitchesUpTo, frontColor, backColor, cur, startVertex, side);
const { data } = ctx.getImageData(0, 0, GIF_WIDTH, GIF_HEIGHT);
const palette = quantize(data, 64);
const index = applyPalette(data, palette);
const delay = k === 0 || k === stitches.length ? holdDelayMs : frameDelayMs;
encoder.writeFrame(index, GIF_WIDTH, GIF_HEIGHT, { palette, delay });
// Yield once in a while so the UI doesn't lock up on long tours.
if (k % 8 === 0) await new Promise((r) => setTimeout(r, 0));
}
encoder.finish();
const buffer = encoder.bytes();
const blob = new Blob([buffer], { type: 'image/gif' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const name =
visiblePatterns.find((p) => p.id === patternId)?.title?.toLowerCase() ?? 'kasuti';
link.href = url;
link.download = `kasuti-${name}-${stitches.length}.gif`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
toast.success(`Saved ${totalFrames}-frame GIF (${(buffer.length / 1024).toFixed(0)} KB).`);
} catch (err) {
toast.error(`Could not export GIF${err instanceof Error ? `: ${err.message}` : ''}.`);
} finally {
setGifBusy(false);
}
}, [
gifBusy,
completed,
startVertex,
stitches,
placed,
frontColor,
backColor,
visiblePatterns,
patternId,
]);
// On first completion: confetti + kick off an autoplay replay from step 0.
// When the tour is un-completed (undo/reset/pattern change), tear down replay.
useEffect(() => {
@ -1135,6 +1317,8 @@ const KasutiEmbroidery: React.FC = () => {
onSpeedChange={setReplaySpeed}
onCopyLink={copyReplayLink}
onShareTweet={shareReplayOnX}
onExportGif={exportGif}
gifBusy={gifBusy}
/>
)}
@ -1211,6 +1395,8 @@ interface ReplayControlsProps {
onSpeedChange: (speed: number) => void;
onCopyLink: () => void;
onShareTweet: () => void;
onExportGif: () => void;
gifBusy: boolean;
}
const REPLAY_SPEEDS = [0.5, 1, 2] as const;
@ -1225,6 +1411,8 @@ const ReplayControls: React.FC<ReplayControlsProps> = ({
onSpeedChange,
onCopyLink,
onShareTweet,
onExportGif,
gifBusy,
}) => {
const atStart = replayIdx <= 0;
const atEnd = replayIdx >= totalStitches;
@ -1254,6 +1442,10 @@ const ReplayControls: React.FC<ReplayControlsProps> = ({
<Share2 className="w-4 h-4 mr-1" />
Share
</Button>
<Button variant="outline" size="sm" onClick={onExportGif} disabled={gifBusy}>
<Download className="w-4 h-4 mr-1" />
{gifBusy ? 'Encoding…' : 'Save GIF'}
</Button>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
speed
{REPLAY_SPEEDS.map((s) => (