Skip to content

Commit eab173d

Browse files
Add GIF rounded corners tool (#105)
- add a standalone GIF Rounded Corners tool with upload, radius selection, and download - process GIF frames to apply a transparent rounded mask while keeping animation timing - document the new tool for the site index ------ [Codex Task](https://chatgpt.com/codex/tasks/task_e_69524477cf648325b47456862b98eb7d)
1 parent 0cc0bac commit eab173d

2 files changed

Lines changed: 281 additions & 0 deletions

File tree

gif-rounded-corners.docs.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Upload a square GIF, set a corner radius, and download the animation with transparent rounded corners. The tool masks each frame so anything outside the radius becomes fully transparent while preserving the GIF's timing.

gif-rounded-corners.html

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
4+
<head>
5+
<meta charset="UTF-8">
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
7+
<title>GIF Rounded Corners</title>
8+
<link rel="stylesheet" href="styles.css">
9+
<style>
10+
body {
11+
max-width: 960px;
12+
margin: 0 auto;
13+
padding: clamp(1.5rem, 3vw, 2.5rem) clamp(1.25rem, 3vw, 2.75rem) clamp(3rem, 4vw, 4rem);
14+
}
15+
16+
header {
17+
margin-bottom: clamp(1.25rem, 3vw, 2.25rem);
18+
}
19+
20+
.grid {
21+
display: grid;
22+
gap: 1rem;
23+
}
24+
25+
.tool-card {
26+
padding: clamp(1.25rem, 3vw, 1.75rem);
27+
}
28+
29+
.preview-grid {
30+
display: grid;
31+
grid-template-columns: 1fr;
32+
gap: 1rem;
33+
}
34+
35+
.preview {
36+
border: 1px dashed var(--ui-2);
37+
border-radius: 12px;
38+
padding: 0.75rem;
39+
background: var(--bg-2);
40+
min-height: 220px;
41+
display: grid;
42+
place-items: center;
43+
text-align: center;
44+
}
45+
46+
.preview img {
47+
max-width: 100%;
48+
height: auto;
49+
}
50+
51+
.controls {
52+
display: grid;
53+
gap: 1rem;
54+
grid-template-columns: 1fr;
55+
}
56+
57+
.controls .inline {
58+
display: grid;
59+
gap: 0.75rem;
60+
grid-template-columns: 1fr;
61+
}
62+
63+
.actions {
64+
display: flex;
65+
flex-wrap: wrap;
66+
gap: 0.75rem;
67+
align-items: center;
68+
}
69+
70+
.status {
71+
margin: 0.25rem 0 0;
72+
color: var(--tx-3);
73+
min-height: 1.25rem;
74+
}
75+
76+
.download-link[aria-disabled="true"] {
77+
pointer-events: none;
78+
color: var(--tx-3);
79+
}
80+
81+
@media (min-width: 720px) {
82+
.preview-grid {
83+
grid-template-columns: repeat(2, minmax(0, 1fr));
84+
}
85+
86+
.controls .inline {
87+
grid-template-columns: 2fr 1fr;
88+
align-items: end;
89+
}
90+
}
91+
</style>
92+
</head>
93+
94+
<body>
95+
<header class="page-header">
96+
<a class="site-link" href="https://tools.mathspp.com/" aria-label="Back to tools.mathspp.com">← tools.mathspp.com</a>
97+
<h1>GIF Rounded Corners</h1>
98+
<p class="lead">Upload a square GIF, choose a corner radius, and download the animation with transparent rounded corners.</p>
99+
</header>
100+
101+
<main class="grid">
102+
<section class="surface tool-card">
103+
<div class="controls">
104+
<div class="form-group">
105+
<label for="gif-input">GIF file</label>
106+
<input type="file" id="gif-input" accept="image/gif">
107+
<p class="status" id="file-status">Select a square GIF to begin.</p>
108+
</div>
109+
110+
<div class="inline">
111+
<div class="form-group">
112+
<label for="radius-input">Corner radius (pixels)</label>
113+
<input type="number" id="radius-input" min="0" step="1" value="24">
114+
<p class="status">Pixels to keep inside each rounded corner.</p>
115+
</div>
116+
<div class="actions">
117+
<button id="process-button" type="button" disabled>Round corners</button>
118+
<a id="download-link" class="download-link" href="#" aria-disabled="true">Download rounded GIF</a>
119+
</div>
120+
</div>
121+
122+
<p class="status" id="process-status" aria-live="polite"></p>
123+
</div>
124+
</section>
125+
126+
<section class="surface tool-card">
127+
<div class="preview-grid">
128+
<div class="preview" id="original-preview" aria-label="Original GIF preview">
129+
<p class="status">Original GIF preview appears here.</p>
130+
</div>
131+
<div class="preview" id="result-preview" aria-label="Rounded GIF preview">
132+
<p class="status">Processed GIF preview will appear here.</p>
133+
</div>
134+
</div>
135+
</section>
136+
</main>
137+
138+
<script src="https://unpkg.com/gifuct-js@2.0.5/dist/gifuct.min.js"></script>
139+
<script src="https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.js"></script>
140+
<script>
141+
(() => {
142+
const fileInput = document.getElementById('gif-input');
143+
const radiusInput = document.getElementById('radius-input');
144+
const processButton = document.getElementById('process-button');
145+
const processStatus = document.getElementById('process-status');
146+
const fileStatus = document.getElementById('file-status');
147+
const downloadLink = document.getElementById('download-link');
148+
const originalPreview = document.getElementById('original-preview');
149+
const resultPreview = document.getElementById('result-preview');
150+
151+
let selectedFile = null;
152+
153+
function resetResult() {
154+
resultPreview.innerHTML = '<p class="status">Processed GIF preview will appear here.</p>';
155+
downloadLink.setAttribute('aria-disabled', 'true');
156+
downloadLink.removeAttribute('download');
157+
downloadLink.href = '#';
158+
}
159+
160+
function roundedRectPath(ctx, width, height, radius) {
161+
const r = Math.max(0, Math.min(radius, Math.min(width, height) / 2));
162+
ctx.beginPath();
163+
ctx.moveTo(r, 0);
164+
ctx.lineTo(width - r, 0);
165+
ctx.quadraticCurveTo(width, 0, width, r);
166+
ctx.lineTo(width, height - r);
167+
ctx.quadraticCurveTo(width, height, width - r, height);
168+
ctx.lineTo(r, height);
169+
ctx.quadraticCurveTo(0, height, 0, height - r);
170+
ctx.lineTo(0, r);
171+
ctx.quadraticCurveTo(0, 0, r, 0);
172+
ctx.closePath();
173+
}
174+
175+
fileInput.addEventListener('change', () => {
176+
resetResult();
177+
processStatus.textContent = '';
178+
const [file] = fileInput.files;
179+
if (!file) {
180+
selectedFile = null;
181+
fileStatus.textContent = 'Select a square GIF to begin.';
182+
processButton.disabled = true;
183+
originalPreview.innerHTML = '<p class="status">Original GIF preview appears here.</p>';
184+
return;
185+
}
186+
187+
if (file.type !== 'image/gif') {
188+
selectedFile = null;
189+
fileStatus.textContent = 'Please choose a GIF file.';
190+
processButton.disabled = true;
191+
originalPreview.innerHTML = '<p class="status">Original GIF preview appears here.</p>';
192+
return;
193+
}
194+
195+
selectedFile = file;
196+
fileStatus.textContent = `${file.name} selected (${(file.size / 1024).toFixed(1)} KB).`;
197+
processButton.disabled = false;
198+
199+
const previewImg = document.createElement('img');
200+
previewImg.alt = 'Original GIF';
201+
previewImg.src = URL.createObjectURL(file);
202+
originalPreview.innerHTML = '';
203+
originalPreview.appendChild(previewImg);
204+
});
205+
206+
async function processGif() {
207+
if (!selectedFile) return;
208+
209+
const radius = Number(radiusInput.value) || 0;
210+
processStatus.textContent = 'Processing GIF...';
211+
processButton.disabled = true;
212+
downloadLink.setAttribute('aria-disabled', 'true');
213+
214+
try {
215+
const arrayBuffer = await selectedFile.arrayBuffer();
216+
const gif = window.gifuct.parseGIF(arrayBuffer);
217+
const frames = window.gifuct.decompressFrames(gif, true);
218+
if (!frames.length) {
219+
throw new Error('No frames found in the GIF.');
220+
}
221+
222+
const { width, height } = frames[0].dims;
223+
const canvas = document.createElement('canvas');
224+
canvas.width = width;
225+
canvas.height = height;
226+
const ctx = canvas.getContext('2d');
227+
228+
const gifEncoder = new GIF({
229+
workers: 2,
230+
quality: 10,
231+
width,
232+
height,
233+
transparent: 'rgba(0,0,0,0)'
234+
});
235+
236+
const minDelay = 20;
237+
238+
frames.forEach(frame => {
239+
ctx.clearRect(0, 0, width, height);
240+
const imageData = new ImageData(new Uint8ClampedArray(frame.patch), width, height);
241+
ctx.putImageData(imageData, 0, 0);
242+
ctx.save();
243+
ctx.globalCompositeOperation = 'destination-in';
244+
roundedRectPath(ctx, width, height, radius);
245+
ctx.fill();
246+
ctx.restore();
247+
248+
const delayMs = Math.max((frame.delay || 2) * 10, minDelay);
249+
gifEncoder.addFrame(ctx, { copy: true, delay: delayMs });
250+
});
251+
252+
gifEncoder.on('finished', blob => {
253+
const objectUrl = URL.createObjectURL(blob);
254+
const resultImg = document.createElement('img');
255+
resultImg.alt = 'Rounded GIF preview';
256+
resultImg.src = objectUrl;
257+
resultPreview.innerHTML = '';
258+
resultPreview.appendChild(resultImg);
259+
260+
downloadLink.href = objectUrl;
261+
downloadLink.download = `rounded-${selectedFile.name}`;
262+
downloadLink.setAttribute('aria-disabled', 'false');
263+
processStatus.textContent = 'Done! Download your rounded GIF below.';
264+
processButton.disabled = false;
265+
});
266+
267+
gifEncoder.render();
268+
} catch (error) {
269+
console.error(error);
270+
processStatus.textContent = 'Something went wrong while rounding the GIF. Please try another file.';
271+
processButton.disabled = false;
272+
}
273+
}
274+
275+
processButton.addEventListener('click', processGif);
276+
})();
277+
</script>
278+
</body>
279+
280+
</html>

0 commit comments

Comments
 (0)