"Building Collage: A Small Adventure"
Friday, July 17, 2026

Started building a photo collage maker a few weeks ago. It turned into one of those projects where every step forward reveals a new thing you didn't know you didn't know.

The Quest

Make a tool that takes a bunch of photos and arranges them into a nice collage. Shareable on LINE. Something people would actually use, not just a tech demo.

The mechanic is simple: pick photos in LINE → hit create → get back a nicely formatted collage with a gradient header, your name, and the date. Ready to share back into the chat. No app install, no signup — just the LINE LIFF page.

Stack: Express + sharp on the backend, vanilla HTML on the frontend, LINE LIFF for the chat integration. Simple on paper — but image processing and precise layout rendering are two things that always look easier than they are.

The Boss Fights

Layout. The first version looked like someone spilled photos on the floor. Masonry layout sounds easy until you have to code it yourself. The naive approach is to just dump images into rows, but that means tall photos waste horizontal space and the overall collage becomes an awkward tower.

Tried a row-wrapping grid first — images laid out in fixed rows, wrapping to the next. Looked worse. Things didn't align, gaps were uneven, short images left holes.

Went back to masonry with max 3 columns. The algorithm tracks the height of each column and places the next image in the shortest one. Sounds textbook, but the devils are in the details: what happens when an image has no dimensions? When the last row has only 1 image? When two columns tie at the same height? Started with smart centering for the last row, then scrapped it — just let it fill left to right. Also added a hard rule: never collapse into fewer columns. Even if only 2 photos come in, keep 3 columns. The human eye notices inconsistencies fast, so consistency beats cleverness.

The guard against NaN — photos without metadata returning NaN for dimensions — was the trickiest part. Node's sharp library reads EXIF data but not all photos have it (especially ones from screenshots or weird phone cameras). If width or height is NaN, every calculation downstream breaks: column height accumulates NaN, layout coordinates become NaN, canvas output is a blank JPEG. Took some debugging to trace it back. The fix: if width or height is missing, fallback to a square (1:1 ratio) so the layout math always has valid numbers.

Sharp. Image processing is black magic. The sharp library does JPEG decoding, resizing, compositing, and encoding — all in libvips C under the hood. But the default settings aren't great for collage work. Photos looked soft and slightly blurry when composited onto the canvas. The cause: sharp was upscaling small photos to fit the card slots, which introduces interpolation artifacts.

The fix chain: withoutEnlargement (never upscale beyond original size → small photos stay sharp), resize kernel lanczos3 (sharper downscale than default bilinear), and mozjpeg compression at quality 90 (better JPEG encoding than libjpeg). Small photos get centered inside their card slot with padding instead of being stretched. The difference was night and day — detail preserved, no blur, file size still reasonable.

Fonts. Thai text needs Thai fonts. NotoSansThai is the safest bet — supports all Thai characters, readable at small sizes. But loading a .ttf into sharp's compositing pipeline isn't straightforward. Sharp doesn't handle text rendering natively.

The solution: opentype.js to parse the font file and get glyph paths, then fontkit for advanced layout (glyph positioning, kerning for Thai tone marks). Render each character as a path, composite onto the canvas as shapes, not text. This means the text becomes part of the image — no dependency on system fonts, no rendering differences between platforms. The header gradient has the day name in Thai ("วันอาทิตย์", "วันจันทร์" ฯลฯ) rendered at precise positions on the 1080px-wide canvas. Getting the baseline right for Thai vowels and tone marks (which sit above the consonant) needed some trial and error.

R2. Cloudflare R2 works like S3 but without egress fees — important because collages are shared via LINE and viewed on mobile, meaning lots of GET requests. Auto-cleanup runs every time the API starts: check all objects older than 30 days and delete them. Old collages don't pile up, storage stays near zero cost.

A manual cleanup endpoint (POST /api/cleanup) exists too, for when I want to free up space without restarting the server.

LINE Integration. LIFF (LINE Front-end Framework) is LINE's SDK for running web apps inside the LINE app. It provides the user's LINE ID and profile picture, so the collage can show the creator's name without asking them to type it. The flow: user opens LIFF page → scrolls through their phone gallery via <input type="file" multiple> → selects photos → presses "สร้าง collage" → frontend sends images to Express API → API processes and returns the rendered JPEG → LIFF opens a share dialog to post it in any chat.

The tricky part: LIFF on mobile has a strict 10MB limit on POST bodies. Multiple photos from a phone gallery easily exceed that. So the frontend compresses images with canvas (toDataURL at quality 0.8) before uploading. This was necessary because LINE's in-app browser throttles large uploads differently than Chrome/Safari.

The Party

  • Backend API: Render.com free tier — Express 4 server with sharp for image processing. Cold starts are slow (~10s) but it works.
  • Frontend: Vercel serverless — serves the LIFF single-page app. Vanilla HTML/CSS/JS, no framework overhead.
  • Webhook: Vercel serverless function — handles LINE bot commands (!ส่งรูป, !เมนู, !ลูกค้า). Routes messages to the right action.
  • Storage: Cloudflare R2 — stores rendered collages. Public URL format: pub-737d...r2.dev/collage/<filename>.jpg. No CDN config needed, R2 serves directly.
  • Bot: LINE Messaging API — connects the webhook to the LINE app. Has a main menu with links to collage, truck, and data projects.

The Side Quests

7-day color themes. Each day of the week gets its own palette for the collage header — Sunday red, Monday beige, Tuesday pink, Wednesday green, Thursday orange, Friday blue, Saturday purple. The JavaScript auto-detects the current day and applies the matching palette. Implemented as a simple array of 7 objects with hex colors, gradient stops, and text color. Zero configuration needed from the user. Makes every collage feel like it belongs to that day without trying too hard.

Dark mode. The LIFF page respects prefers-color-scheme. The collage output itself doesn't change (it's a JPEG), but the UI around it adapts — buttons, backgrounds, text. CSS variables swap between light and dark. Nothing fancy, just a @media query.

Name persistence. The LIFF profile gives us a display name, but users can change it. Stored in localStorage so it survives page refreshes. If the user clears their browser data, it falls back to the LINE display name.

What I Learned

Every project has a thing that takes way longer than expected. For collage, it was the layout engine. Masonry with NaN guards and non-collapsing columns sounds like a footnote in the README but it's the core of the whole thing — if the layout looks wrong, nothing else matters. I spent more time on the 3-column layout than on the sharp integration, the font rendering, and the R2 setup combined.

The other lesson: user feedback is fast when the tool lives in a chat app. People see the collage, share it in a group, someone else tries it. Within minutes I know if something breaks. That's a much tighter feedback loop than a traditional web app where users might not bother reporting issues.

Also learned that mozjpeg at Q90 is a surprisingly good compression sweet spot — files are ~200-400KB for a full collage, which loads fast on mobile data. And that you can never assume every photo has valid EXIF data.

Where It Lives

Type !ส่งรูป in any LINE chat, or open the LIFF page directly from the bot menu. The webhook routes to the Vercel function, which returns the LIFF URL. User picks photos, gets a collage, shares it back. Takes about 20 seconds from command to result (Render cold start included).

It works, it's live, and a handful of people use it regularly. That's enough for now.

Started building a photo collage maker เมื่อสองสามสัปดาห์ก่อน กลายเป็นโปรเจกต์ที่ทุกก้าวเดินเจอของใหม่ที่เราไม่รู้ว่าเราไม่รู้

The Quest

เป้าหมาย: เครื่องมือที่เอาหลายๆ รูปมาจัดวางเป็น collage สวยๆ แชร์บน LINE ได้ มีคนใช้จริง ไม่ใช่แค่ tech demo

กลไกง่ายมาก — เลือกรูปใน LINE → กดสร้าง → ได้ collage ที่มี gradient header, ชื่อเรา, และวันที่ พร้อมแชร์กลับเข้าแชท ไม่ต้องติดตั้ง app ไม่ต้องสมัคร แค่เปิด LIFF page

Stack: Express + sharp ฝั่ง backend, vanilla HTML ฝั่ง frontend, LINE LIFF สำหรับ chat integration ฟังดูง่ายบนกระดาษ — แต่ image processing กับ precise layout rendering สองอย่างนี้ดูง่ายกว่าความเป็นจริงเสมอ

The Boss Fights

Layout. version แรกเหมือนเอาภาพมาเทใส่พื้น Masonry layout ฟังดูง่ายจนกว่าจะต้องเขียนเอง วิธี naive คือ dump รูปเป็นแถวๆ แต่รูปสูงๆ กินที่แนวนอนแถวเดียว แล้ว collage ยาวเป็นหอคอย

ลอง row-wrapping grid — รูปเรียงเป็น fixed rows เว้นที่ว่างไม่เท่ากัน รูปสั้นมีรูโหว่ ดูแย่กว่าเดิม

กลับมา masonry max 3 columns อัลกอริทึม track height แต่ละ column แล้ววางรูปถัดไปใน column ที่สั้นที่สุด ฟังดู standard แต่ปีศาจอยู่ตรง detail: เกิดอะไรขึ้นถ้ารูปไม่มี dimensions? แถวสุดท้ายมีรูปเดียว? สอง column สูงเท่ากัน? ลอง smart centering สำหรับ last row แล้วยกเลิก — แค่ fill left to right ก็พอ แถม hard rule: never collapse columns ถึงจะมีแค่ 2 รูปก็ต้อง 3 columns ตาคนจับ inconsistency ไว ดังนั้น consistency ชนะ cleverness

Guard ค่า NaN — รูปที่ไม่มี metadata คืนค่า NaN เป็น dimensions — คือส่วนที่ปวดหัวที่สุด Sharp อ่าน EXIF ได้แต่ไม่ใช่ทุกรูป (screenshots, กล้องแปลกๆ). ถ้า width หรือ height เป็น NaN ทุกอย่างพัง: column height สะสม NaN, layout coordinates เป็น NaN, canvas output เป็น JPEG เปล่า ใช้เวลาดีบั๊กพอสมควรกว่าจะเจอต้นตอ วิธีแก้: ถ้า width หรือ height หาย ให้ fallback เป็น square (1:1 ratio) เพื่อให้ layout math มีตัวเลขใช้เสมอ

Sharp. Image processing คือ black magic Sharp ทำ JPEG decoding, resizing, compositing, encoding — ทั้งหมดด้วย libvips C ข้างใต้ แต่ default settings ไม่เหมาะกับ collage ภาพดูนุ่มๆ เบลอๆ ตอน composite บน canvas สาเหตุ: sharp upscale รูปเล็กให้ fit card slots ทำให้เกิด interpolation artifacts

วิธีแก้: withoutEnlargement (ไม่ upscale เกินต้นทาง → รูปเล็กยังคม), resize kernel lanczos3 (downscale คมกว่า default bilinear), และ mozjpeg compression ที่ quality 90 (JPEG encoding ดีกว่า libjpeg) รูปเล็กจัดกึ่งกลางการ์ดด้วย padding แทนการ stretch ผลลัพธ์ต่างกันชัด — detail คง, ไม่เบลอ, file size ยัง reasonable

Fonts. ภาษาไทยต้องใช้ฟอนต์ไทย NotoSansThai คือ safest bet — support ครบ, readable ที่ขนาดเล็ก แต่การโหลด .ttf เข้า sharp pipeline ไม่ตรงไปตรงมา Sharp ไม่มี native text rendering

วิธีแก้: opentype.js สำหรับ parse ฟอนต์และได้ glyph paths + fontkit สำหรับ advanced layout (glyph positioning, kerning สำหรับวรรณยุกต์) Render ตัวอักษรแต่ละตัวเป็น path, composite ลง canvas เป็น shapes, ไม่ใช่ text ทำให้ text กลายเป็นส่วนหนึ่งของภาพ — ไม่พึ่ง system fonts, ไม่มี rendering ต่างกันระหว่าง platform Header gradient มีชื่อวันภาษาไทย ("วันอาทิตย์", "วันจันทร์" ฯลฯ) render ที่ตำแหน่ง precise บน canvas กว้าง 1080px การปรับ baseline สำหรับสระและวรรณยุกต์ (ที่อยู่เหนือพยัญชนะ) ต้องลองผิดลองถูกหลายรอบ

R2. Cloudflare R2 ทำงานเหมือน S3 แต่ไม่มี egress fees — important เพราะ collage แชร์ผ่าน LINE แล้วเปิดบนมือถือ GET requests เยอะ Auto-cleanup ทุกครั้งที่ API start: เช็ควัตถุที่อายุเกิน 30 วันลบทิ้ง Collage เก่าไม่กอง, storage แทบเป็นศูนย์

มี manual cleanup endpoint (POST /api/cleanup) เผื่ออยากเพิ่มที่ว่างโดยไม่ต้อง restart server

LINE Integration. LIFF (LINE Front-end Framework) คือ SDK ของ LINE สำหรับรัน web app ในแอป LINE LIFF ให้ LINE ID และ profile picture ของผู้ใช้ ทำให้ collage แสดงชื่อคนสร้างโดยไม่ต้องให้พิมพ์เอง Flow: user เปิด LIFF → เลือกรูปจาก gallery ผ่าน <input type="file" multiple> → กด "สร้าง collage" → frontend ส่งรูปไป Express API → API ประมวลผลและคืน JPEG → LIFF เปิด share dialog ให้โพสต์ในแชท

จุดที่ปวดหัว: LIFF บนมือถือมี limit 10MB สำหรับ POST bodies รูปหลายรูปจาก gallery เกินได้ง่าย ดังนั้น frontend บีบอัดรูปด้วย canvas (toDataURL ที่ quality 0.8) ก่อน upload จำเป็นเพราะ in-app browser ของ LINE throttle large uploads ต่างจาก Chrome/Safari

The Party

  • Backend API: Render.com free tier — Express 4 + sharp สำหรับ image processing Cold start ช้า (~10s) แต่ใช้ได้
  • Frontend: Vercel serverless — serve LIFF single-page app Vanilla HTML/CSS/JS ไม่มี framework overhead
  • Webhook: Vercel serverless function — handle LINE bot commands (!ส่งรูป, !เมนู, !ลูกค้า)
  • Storage: Cloudflare R2 — เก็บ rendered collages Public URL: pub-737d...r2.dev/collage/<filename>.jpg
  • Bot: LINE Messaging API — เชื่อม webhook กับ LINE app มี main menu ลิงก์ไป collage, truck, data projects

The Side Quests

7-day color themes. แต่ละวันมี palette ของตัวเองสำหรับ header collage — อาทิตย์แดง, จันทร์เบจ, อังคารชมพู, พุธเขียว, พฤหัสส้ม, ศุกร์น้ำเงิน, เสาร์ม่วง JavaScript auto-detect วันปัจจุบันแล้วเลือก palette ที่ตรง ใช้ array 7 object มี hex colors, gradient stops, text color user ไม่ต้องตั้งค่าอะไร ทำให้ collage รู้สึกเป็นของวันนั้นๆ โดยไม่ต้องพยายามเกินไป

Dark mode. LIFF page respect prefers-color-scheme ตัว collage output ไม่เปลี่ยน (เป็น JPEG) แต่ UI รอบข้างปรับ — ปุ่ม, พื้นหลัง, ข้อความ CSS variables สลับระหว่าง light/dark แค่ @media query ไม่มีอะไรซับซ้อน

Name persistence. LIFF profile ให้ display name แต่ user เปลี่ยนได้ เก็บใน localStorage เพื่อคงค่าข้าม refresh ถ้า user ล้าง browser data ก็ fallback ไปใช้ LINE display name

What I Learned

ทุกโปรเจกต์มีอะไรสักอย่างที่ใช้เวลานานกว่าที่คิด สำหรับ collage มันคือ layout engine Masonry + NaN guards + non-collapsing columns ฟังดูเป็น footnote ใน README แต่คือแก่นของทั้งโปรเจกต์ — ถ้า layout ดูผิด ที่เหลือก็ไม่สำคัญ ใช้เวลากับ 3-column layout มากกว่า sharp integration, font rendering, และ R2 setup รวมกัน

อีก lesson: user feedback ไวมากเมื่อเครื่องมืออยู่ใน chat app คนเห็น collage, แชร์ในกลุ่ม, คนอื่นลองใช้ ภายในไม่กี่นาทีรู้เลยว่าอะไรพัง นั่นคือ feedback loop ที่เร็วกว่า web app ทั่วไปที่ user อาจไม่รายงานอะไรเลย

และได้รู้ว่า mozjpeg ที่ Q90 คือ compression sweet spot ที่ดี — file ~200-400KB สำหรับ collage เต็ม โหลดเร็วบน mobile data และห้ามเด็ดขาดว่าทุกรูปมี EXIF data ครบ

Where It Lives

พิมพ์ !ส่งรูป ใน LINE chat หรือเปิด LIFF page จาก bot menu webhook ส่งไป Vercel function คืน LIFF URL user เลือกรูปได้ collage แชร์กลับ ใช้เวลาประมาณ 20 วินาทีจากคำสั่งถึงผลลัพธ์ (รวม Render cold start)

มันทำงานได้ มีคนใช้ประจำไม่กี่คน แค่นี้ก็พอแล้วตอนนี้

← prev