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:
parent
bf20155423
commit
bd4f073cac
3 changed files with 200 additions and 0 deletions
7
package-lock.json
generated
7
package-lock.json
generated
|
|
@ -29,6 +29,7 @@
|
||||||
"embla-carousel-autoplay": "^8.5.2",
|
"embla-carousel-autoplay": "^8.5.2",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "^11.18.2",
|
"framer-motion": "^11.18.2",
|
||||||
|
"gifenc": "^1.0.3",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"katex": "^0.16.45",
|
"katex": "^0.16.45",
|
||||||
|
|
@ -12136,6 +12137,12 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/github-slugger": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@
|
||||||
"embla-carousel-autoplay": "^8.5.2",
|
"embla-carousel-autoplay": "^8.5.2",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "^11.18.2",
|
"framer-motion": "^11.18.2",
|
||||||
|
"gifenc": "^1.0.3",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"katex": "^0.16.45",
|
"katex": "^0.16.45",
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,124 @@ function vEqual(a: V | null, b: V | null): boolean {
|
||||||
return a[0] === b[0] && a[1] === b[1];
|
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 ───────────────────────────────────────────────────────
|
// ─── Replay URL codec ───────────────────────────────────────────────────────
|
||||||
// The payload captures just enough to reconstruct a completed tour: which
|
// The payload captures just enough to reconstruct a completed tour: which
|
||||||
// pattern (preset id, or "custom" with its edge list), the start vertex, and
|
// 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');
|
window.open(tweet, '_blank', 'noopener,noreferrer');
|
||||||
}, [buildReplayUrl, patternId, stitches.length, visiblePatterns]);
|
}, [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.
|
// 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.
|
// When the tour is un-completed (undo/reset/pattern change), tear down replay.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1135,6 +1317,8 @@ const KasutiEmbroidery: React.FC = () => {
|
||||||
onSpeedChange={setReplaySpeed}
|
onSpeedChange={setReplaySpeed}
|
||||||
onCopyLink={copyReplayLink}
|
onCopyLink={copyReplayLink}
|
||||||
onShareTweet={shareReplayOnX}
|
onShareTweet={shareReplayOnX}
|
||||||
|
onExportGif={exportGif}
|
||||||
|
gifBusy={gifBusy}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -1211,6 +1395,8 @@ interface ReplayControlsProps {
|
||||||
onSpeedChange: (speed: number) => void;
|
onSpeedChange: (speed: number) => void;
|
||||||
onCopyLink: () => void;
|
onCopyLink: () => void;
|
||||||
onShareTweet: () => void;
|
onShareTweet: () => void;
|
||||||
|
onExportGif: () => void;
|
||||||
|
gifBusy: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const REPLAY_SPEEDS = [0.5, 1, 2] as const;
|
const REPLAY_SPEEDS = [0.5, 1, 2] as const;
|
||||||
|
|
@ -1225,6 +1411,8 @@ const ReplayControls: React.FC<ReplayControlsProps> = ({
|
||||||
onSpeedChange,
|
onSpeedChange,
|
||||||
onCopyLink,
|
onCopyLink,
|
||||||
onShareTweet,
|
onShareTweet,
|
||||||
|
onExportGif,
|
||||||
|
gifBusy,
|
||||||
}) => {
|
}) => {
|
||||||
const atStart = replayIdx <= 0;
|
const atStart = replayIdx <= 0;
|
||||||
const atEnd = replayIdx >= totalStitches;
|
const atEnd = replayIdx >= totalStitches;
|
||||||
|
|
@ -1254,6 +1442,10 @@ const ReplayControls: React.FC<ReplayControlsProps> = ({
|
||||||
<Share2 className="w-4 h-4 mr-1" />
|
<Share2 className="w-4 h-4 mr-1" />
|
||||||
Share
|
Share
|
||||||
</Button>
|
</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">
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
speed
|
speed
|
||||||
{REPLAY_SPEEDS.map((s) => (
|
{REPLAY_SPEEDS.map((s) => (
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue