กลับไปที่บล็อก
13 สิงหาคม 2569Sergei Solod14 นาทีในการอ่าน

ผมแปลง Animated WebP, GIF และ APNG เป็น H.264 MP4 ขนาดเล็กที่ปลอดภัยสำหรับ browser อย่างไร

Production pipeline ของผม reconstruct displayed canvas state และ source timing จริง เลือก CFR ต่อ source ไม่ upscale encode เป็น H.264 segment แยกกัน concat โดยไม่ lossy encode รอบสอง และ validate exact packet timeline ก่อน publish

H.264FFmpegMP4Animated WebPGIFAPNGการบีบอัดวิดีโอMedia pipeline

ผมไม่ได้สร้าง pipeline นี้เพราะอยากทดลอง codec ใหม่ ปัญหาจริงเป็นเรื่องการส่งไฟล์: Animated WebP, GIF และ APNG หลายไฟล์ทำหน้าที่เหมือนวิดีโอสั้นแบบไม่มีเสียงอยู่แล้ว แต่ไม่ได้หมายความว่าจะเป็น representation ที่ประหยัดที่สุดเมื่อ asset เดิมถูกโหลดซ้ำ ๆ บนเว็บ

workload ของผมส่วนใหญ่เป็น animation ไม่กี่วินาที มี displayed frame หลักสิบ และมี temporal redundancy สูงมาก Encoding ทำครั้งเดียว แต่ delivery เกิดซ้ำหลายครั้ง ดังนั้นขนาดไฟล์สุดท้ายและ playback ที่คาดเดาได้ใน browser สำคัญกว่า encode speed

ประโยคว่า “แปลง animation เป็น MP4” ฟังง่าย แต่ production version ไม่ง่าย Animated image ไม่จำเป็นต้องเป็น sequence ของ full-size frame ที่มี FPS เดียวอย่างสวยงาม มันอาจมี partial rectangle, blend/disposal rule, alpha, delay ไม่สม่ำเสมอ, zero-duration frame และ timing metadata ที่ tool ทั่วไปสรุปเป็น FPS แบบชวนเข้าใจผิด

สุดท้าย pipeline ของผมจึงกลายเป็นชุด invariant แบบนี้:

animated WebP / GIF / APNG
        ↓
decode + reconstruct displayed canvas states
        ↓
recover source timeline
        ↓
select per-source CFR
        ↓
resize / pad without upscaling
        ↓
encode one H.264 segment
        ↓
repeat independently for every source
        ↓
concat with stream copy
        ↓
normalize final packet timeline
        ↓
ffprobe + packet checks + full decode
        ↓
atomic publish

codec เป็นเพียงส่วนหนึ่ง งานยากจริงคือเปลี่ยน representation โดยไม่เปลี่ยนความหมายของ animation

กฎข้อแรก: extract สิ่งที่แสดงจริง ไม่ใช่ rectangle ที่เก็บไว้

shortcut ที่อันตรายที่สุดคือสมมติว่า stored frame ทุกอันเป็นภาพเต็มที่แทน frame ก่อนหน้า

Animated WebP มี position, rectangle size, blend mode และ disposal mode ต่อ frame ได้ APNG มี offsets, dimensions, dispose operation และ blend operation ส่วน GIF มี Graphic Control Extension ที่กำหนด disposal behavior

ดังนั้น stored frame อาจเป็นเพียง patch เล็ก ๆ ที่ต้องอาศัย canvas state ก่อนหน้า ถ้าเอา patch เหล่านี้ไป encode เหมือนเป็น full frame ผลลัพธ์ไม่ใช่ animation เดิมที่เล็กลง แต่เป็น animation ที่ผิด

boundary ของ extraction สำหรับผมคือ displayed canvas state: ภาพ composited เต็มที่ decoder ถูกต้องจะแสดงหลังจากใช้ disposal ของ frame ก่อนหน้าและ blending ของ frame ปัจจุบัน

นี่คือ correctness guarantee แรกของทั้ง pipeline ถ้า pixel ผิดตั้งแต่จุดนี้ H.264 setting ภายหลังก็แก้ไม่ได้

Frame timing คือข้อมูลจาก source ไม่ใช่ FPS ที่เดา

  • Animated WebP เก็บ duration ต่อ frame หน่วย 1 ms
  • GIF เก็บ delay เป็นหน่วยหนึ่งในร้อยวินาที
  • APNG เก็บ delay numerator/denominator; ถ้า denominator เป็น 0 สเปก PNG ให้ถือเป็น 100

delay ต่อ frame เหล่านี้คือ timeline จริง Average FPS เป็นเพียง summary

WebP จริงไฟล์หนึ่งมีขนาด 1264×720, มี 49 displayed frames, delay สลับ 62 และ 63 ms และ duration รวม 3.063 s ซึ่งใกล้กับ cadence 16 fps มาก เพราะ 16 fps เท่ากับ 62.5 ms ต่อ frame

แต่ probe หนึ่งรายงาน source เดียวกันว่า 25 fps

ถ้าผมเชื่อตัวเลขนั้นแล้ว encode 25 หรือ 30 fps ผมอาจเปลี่ยน timing หรือสร้าง duplicate frames ที่ไม่จำเป็น Source frame durations ต่างหากคือข้อมูล authoritative

ผม normalize timing ที่ไม่ดี ก่อนเลือก CFR

zero delay หรือ delay ที่เล็กมากอาจถูก implementation ต่าง ๆ เล่นไม่เหมือนกัน Production pipeline จึงต้องมี policy ที่ชัดเจน ผมเก็บ timeline ด้วย millisecond precision, ใช้ minimum frame delay 10 ms และใช้ 100 ms เป็น fallback เฉพาะเมื่อไม่มี timing ที่มีประโยชน์

สองตัวเลขนี้ไม่ใช่กฎสากล หลักสำคัญคือรักษา valid source timing และทำ normalization ก่อน frame-rate quantization

ทำไมผมไม่บังคับทุก animation เป็น 30 fps

ถ้า animation เปลี่ยน state จริงประมาณ 12 หรือ 16 ครั้งต่อวินาที การเก็บเป็น 30 fps ไม่ได้สร้าง motion ใหม่ ส่วนใหญ่แค่เพิ่ม temporal samples ที่ซ้ำกัน H.264 บีบอัด duplicate frame ได้ดี แต่ข้อมูลที่ไม่จำเป็นก็ยังไม่ควรถูกสร้างตั้งแต่แรก

ผมเลือก CFR ต่อ source จาก candidate เหล่านี้:

10, 12, 15, 16, 18, 20, 24, 25, 30 fps

selector เลือก candidate ต่ำสุดที่ยัง represent source timeline ได้สะอาด หากค่าต่ำกว่าใช้ไม่ได้ fallback คือ 30 fps สำหรับตัวอย่าง 62/63 ms ค่า 16 fps fit โดยธรรมชาติ

Final MP4 ไม่จำเป็นต้องมี global CFR ค่าเดียว

แต่ละ animation ถูก encode เป็น segment แยกกัน หนึ่ง source อาจเป็น 12 fps อีก source 16 และอีก source 24 หรือ 30 ผมไม่เปลี่ยน cadence ของ source เพียงเพื่อให้ collection มีตัวเลขเดียว

แต่ละ segment เป็น CFR ภายในตัวเอง แต่ใน final concatenated timeline packet duration สามารถเปลี่ยนที่ segment boundary ได้ นี่เป็น design ที่ตั้งใจไว้

ทำไมใช้ time base 90,000 Hz

90,000 ทำให้ FPS ที่อนุญาตทั้งหมดได้ integer ticks/frame พอดี:

FPSticks/frame
109000
127500
156000
165625
185000
204500
243750
253600
303000

grid ที่ exact แบบนี้ช่วยทั้ง concat และ packet validation

Geometry: downscale เมื่อจำเป็น และไม่ upscale

  • landscape: สูงสุด 1280×720
  • portrait: สูงสุด 720×1280
  • mixed/square: ประมาณ 960×960

source ที่เล็กกว่านี้ยังคงเล็ก การเปลี่ยน 900×600 เป็น 1280×720 ไม่ได้สร้าง detail ใหม่ มีแต่เพิ่มจำนวน pixel ที่ encoder ต้องจัดการ

ผมไม่ stretch aspect ratio ถ้าจำเป็นใช้ proportional scaling และ padding Alpha จะถูก flatten ลง background ที่กำหนด เพราะ H.264/yuv420p output ใน pipeline นี้ไม่ได้เก็บ transparency

ทำไม H.264 MP4 มักเล็กกว่ามากสำหรับ workload แบบนี้

GIF, APNG และ Animated WebP เองก็มี partial updates และ disposal จึงไม่ถูกต้องที่จะบอกว่า MP4 เล็กกว่าเสมอ

แต่ H.264 ถูกออกแบบมาสำหรับ temporal video compression โดยตรง ใน illustrated scene ที่ background ส่วนใหญ่คงที่ inter prediction, reference pictures, P-frames และ B-frames ใช้ความคล้ายกันระหว่าง frames ได้ดีมาก

Apple แนะนำ H.264 MP4 สำหรับ static web video และแนะนำ MP4 แทน animated GIF เอกสาร Safari ระบุว่า GIF อาจใช้ bandwidth สูงสุด 12× และ energy ราว 2× เมื่อเทียบกับ modern video codec 12× นี้เป็นตัวอย่างจาก Apple ไม่ใช่ benchmark ของผม

Animated WebP ต้องระวังกว่า ถ้า WebP ต้นฉบับเป็น lossy การแปลงเป็น H.264 คือ lossy generation ที่สอง ขนาดอาจลดมากได้ แต่ quality ต้อง validate

H.264 profile ตั้งใจให้ conservative

SettingValue
ContainerMP4
CodecH.264 / libx264
Sample entryavc1
Profile / levelMain @ 3.1
Pixel format8-bit yuv420p
Rate controlCRF 28
Presetveryslow
Tuneanimation
Reference frames4
Maximum B-frames5
Open GOPoff
B-pyramidnormal
Maximum GOPประมาณ 5 s
VBV4M maxrate / 8M bufsize
ColorBT.709, limited range
Track/movie timescale90,000
Audio/subtitles/datanone

Main@3.1 ไม่ใช่ขีดจำกัดของ device สมัยใหม่ แต่เป็น conservative one-file baseline ของผม Android บังคับ Main Profile decoder ตั้งแต่ Android 6.0 และยก 1280×720@30 fps เป็น H.264 HD recommendation ส่วน Apple ก็แนะนำ H.264 MP4 สำหรับ static web video

ใช้ CPU หนักครั้งเดียว เพื่อลด bandwidth ซ้ำ ๆ

-preset veryslow

Encoding เป็น offline one-time cost ส่วน delivery เกิดซ้ำ การให้ x264 ใช้ CPU มากเพื่อหา representation ที่มีประสิทธิภาพกว่าจึงสมเหตุผล Decoder complexity ถูกจำกัดแยกด้วย profile, level, refs และ B-frame structure

CRF 28 เป็น baseline สำหรับ illustrated, bandwidth-first workload ของผม ไม่ใช่ universal setting

แต่ละ animation encode เป็น H.264 segment อิสระ

ffmpeg -framerate "$SELECTED_FPS" -i frame-%06d.png \
  -c:v libx264 \
  -preset veryslow \
  -tune animation \
  -crf 28 \
  -profile:v main \
  -level:v 3.1 \
  -pix_fmt yuv420p \
  -tag:v avc1 \
  -refs 4 -bf 5 \
  -g "$GOP_FRAMES" \
  -maxrate:v 4M -bufsize:v 8M \
  -x264-params "stitchable=1:open-gop=0:b-pyramid=normal:nal-hrd=none" \
  -color_range tv -color_primaries bt709 -color_trc bt709 -colorspace bt709 \
  -fps_mode passthrough \
  -map_metadata -1 -map_chapters -1 \
  -an -sn -dn \
  -video_track_timescale 90000 -movie_timescale 90000 \
  -t "$EXPECTED_DURATION" segment.mp4

ก่อน command นี้ timeline-aware frame sequence และ geometry สุดท้ายพร้อมแล้ว GOP ประมาณ selectedFps * 5

Concat ปลอดภัยเพราะ segment contract เข้ม

FFmpeg concat demuxer ต้องการ streams ที่ compatible และใช้ duration ของแต่ละ file เพื่อวาง timestamp ของ file ถัดไป ดังนั้น mismatch ต้องถูก reject ก่อน concat ไม่ใช่หวังแก้ภายหลัง

ffmpeg -f concat -safe 0 -i segments.ffconcat \
  -c:v copy -an -sn -dn -movflags +faststart final.mp4

-c:v copy ทำให้ไม่เกิด H.264 lossy encode รอบที่สอง

Stream copy ไม่ได้แปลว่า timestamp จะ perfect

90000 / 24 = 3750 ticks

ใน output จริงหนึ่งไฟล์ validator พบ packet 3751 ticks แทนที่จะเป็น 3750 สำหรับ 24 fps ภาพอาจดูปกติ แต่ exact-grid invariant แตกแล้ว

ดังนั้นการ copy compressed H.264 payload ไม่ได้พิสูจน์ timing correctness Final concat ตอนนี้ใช้ setts bitstream filter เพื่อ normalize PTS, DTS และ duration กลับไปยัง 90-kHz grid ที่รู้จัก โดยยังคง -c:v copy Exact expression สร้างจาก segment timeline ไม่ใช่สูตร generic

Validation เป็นส่วนหนึ่งของ encoder

FFmpeg exit code 0 ยังไม่พอ ผมตรวจ streams, profile/level, pixel format, dimensions, time base, frame/packet counts, exact durations, PTS/DTS และ final duration แล้ว decode ทั้งไฟล์

ffprobe -v error -select_streams v:0 -show_streams -show_packets -of json final.mp4
ffmpeg -v error -xerror -err_detect explode -i final.mp4 -f null -

atomic publish จะเกิดหลังทุก check ผ่านเท่านั้น

ข้อผิดพลาดที่ pipeline นี้ป้องกัน

  • encode raw subframe แทน visible canvas
  • เชื่อ guessed FPS
  • บังคับทุก source เป็น 30 fps
  • upscale source เล็ก
  • stretch aspect ratio
  • lossy encode อีกครั้งหลัง concat
  • คิดว่า -c copy พิสูจน์ timing
  • ตรวจแค่ header โดยไม่ full decode

สิ่งที่ผมตั้งใจไม่ preserve

Alpha ถูก flatten, arbitrary VFR ถูกประมาณด้วย source-specific CFR, resolution ใหญ่ถูก downscale, lossy WebP ผ่าน lossy generation เพิ่ม และ audio อยู่นอก scope ทั้งหมดเป็น explicit trade-off

เมื่อไรผมจะไม่ใช้วิธีนี้

ถ้าต้องรักษา transparency, exact irregular timing มีความหมายที่ห้าม approximate, ต้องการ archival master หรือมี adaptive multi-codec stack อยู่แล้ว ผมจะไม่ใช้ single-file pipeline นี้ตรง ๆ

สำหรับ Animated WebP ที่ optimize จนเล็กมากอยู่แล้ว ผมจะ benchmark ก่อน ไม่สมมติว่า MP4 ต้องชนะ

Practical pipeline ที่ใช้ตอนนี้

  1. อ่าน animation metadata จริง
  2. reconstruct visible canvas states ด้วย blend/disposal
  3. normalize delays และสร้าง millisecond timeline
  4. เลือก CFR ต่อ source จาก 10/12/15/16/18/20/24/25/30
  5. ไม่ upscale; proportional resize/pad; alpha flatten
  6. H.264 Main@3.1/yuv420p/avc1, veryslow, CRF 28
  7. 90-kHz time base
  8. validate ทุก segment
  9. concat ด้วย -c:v copy
  10. normalize final packet timeline ไป exact grid
  11. packet inspection + full decode
  12. ผ่านทั้งหมดแล้วค่อย publish

บทเรียนใหญ่ที่สุด: asset ที่แท้จริงคือ timeline

Animated WebP, GIF และ APNG ไม่ใช่แค่ “ภาพหลายภาพ” แต่เป็น sequence ของ canvas state ที่ผูกกับเวลา

เมื่อ timeline เป็น source of truth การตัดสินใจอื่นชัดขึ้น: ลด cadence ได้โดยไม่สร้าง motion ปลอม, ลด resolution ได้โดยไม่สร้าง pixel ปลอม, แต่ละ source มี CFR ของตัวเอง และ concat ไม่ต้องผ่าน lossy encode อีกครั้ง

H.264 ใช้ temporal redundancy ได้ดีและ MP4 ทำ delivery ง่าย แต่ถ้า compositing หรือ timing พังตั้งแต่ extraction ไม่มี codec ไหนช่วยได้

เอกสาร primary หลัก