คลิปเงียบกับ WAV ยาวเท่ากันทุกเฟรม แต่ MP4 ปัดความยาวเป็นมิลลิวินาที
ความยาวที่เท่ากันทุกเฟรมทุกซัมเปิลยังไม่แปลว่าไฟล์สองไฟล์รายงานความยาวเท่ากัน เพราะคอนเทนเนอร์เก็บความยาวเป็นจำนวนเต็มในหน่วยของมันเอง และหน่วยเริ่มต้นหยาบกว่าซัมเปิลเสียงอยู่สี่สิบแปดเท่า
สรุปใน 1 นาที
- อาการ/ปัญหา
- คลิปเงียบ 203 เฟรมที่ 24 fps กับ WAV 406,000 ซัมเปิลที่ 48 kHz ยาวเท่ากัน แต่ MP4 รายงานความยาวต่างกัน
- ใครควรอ่าน
- คนที่ประกอบวิดีโอกับเสียงด้วย ffmpeg แล้วต้องตรวจความยาว
- ทางแก้แบบสั้น
- ตั้ง movie timescale เป็น 48000 แทนค่า 1000 ที่ปัดเป็นมิลลิวินาที แล้วเทียบความยาวด้วย Fraction ก่อนส่งต่อทุกครั้ง
พอดีผมเตรียมคลิปวิดีโอเงียบ (MP4 ไม่มีแทร็กเสียง) คู่กับไฟล์เสียงร้อง WAV 48 kHz stereo เพื่อส่งต่อให้ระบบอื่นนำไปใช้ต่อ ชุดแรกที่ยาว 5.5 วินาทีผมตรวจในเครื่องแล้วยาวเท่ากัน แต่มีระบบปลายทางตัวหนึ่งแจ้งมาว่าความยาวของสองไฟล์ไม่ตรงกัน ผมไม่รู้ว่าระบบนั้นเทียบตัวเลขอะไร และต้องบอกก่อนเลยว่าเรื่องที่ผมเจอต่อไปนี้ไม่ได้พิสูจน์ว่าเป็นสาเหตุของคำแจ้งนั้น ผมเลยถือเป็นการป้องกันไว้ก่อน ไล่ดูว่าคู่ไฟล์ที่ผมทำเองมีจุดไหนที่ "เท่ากันในเครื่อง แต่เทียบกันแบบเข้มงวดแล้วไม่เท่า" ได้บ้าง ซึ่งเจอจริงหนึ่งจุด และเป็นจุดที่คนตัดต่อด้วยสคริปต์ผ่านไปโดยไม่รู้ตัวบ่อยมาก
ชื่อไฟล์ ชื่อโปรเจกต์ และเนื้อเสียงในบทความนี้ผมตัดออกหมด เหลือแต่ตัวเลขกับคำสั่งที่ผมลองซ้ำบนไฟล์ทดลองที่สร้างขึ้นใหม่
ตั้งต้น: ความยาวมาจากการนับเฟรม ไม่ใช่จากไฟล์
แผนตัดของผมกำหนดความยาวคลิปเป็นจำนวนเฟรมหารด้วย fps คลิปที่ยาวที่สุดในชุดคือ 203 เฟรมที่ 24 fps ซึ่งเท่ากับ 8.458333 วินาที ฝั่งเสียงตัดจากสเต็มด้วยดัชนีซัมเปิลโดยตรง ได้ 406,000 ซัมเปิลที่ 48 kHz ซึ่งก็คือ 8.458333 วินาทีเช่นกัน ตัวเลขสองฝั่งหารลงตัวพอดีโดยไม่มีเศษ เพราะ 203 x 2000 = 406,000
ถึงตรงนี้ผมคิดว่าจบแล้ว แต่ปัญหาอยู่ที่ว่า "ความยาวจริง" ไม่ได้ถูกเก็บในไฟล์เป็นเลขทศนิยมแบบที่ผมคำนวณ MP4 เก็บเป็นจำนวนเต็มคู่หนึ่งคือ จำนวน tick กับ timescale (กี่ tick ต่อหนึ่งวินาที) แล้วใครอ่านไฟล์ก็เอาสองตัวนี้มาหารกันเอง
MP4 เก็บเวลาเป็นจำนวนเต็มในหน่วย timescale
ในไฟล์ MP4 มีที่เก็บความยาวหลายชั้น ชั้นที่เกี่ยวกับเรื่องนี้คือสองชั้น
| กล่องในไฟล์ | เก็บอะไร | เก็บเป็นหน่วยอะไร |
|---|---|---|
| mvhd (movie header) | ความยาวของทั้งไฟล์ | movie timescale |
| mdhd (media header) | ความยาวของแต่ละแทร็ก | timescale ของแทร็กนั้น |
ทั้งสองชั้นเป็นจำนวนเต็ม ถ้าความยาวจริงไม่ใช่จำนวนเต็มของ tick ก็ต้องปัดเศษทิ้งหรือปัดขึ้นตอนเขียนไฟล์ ซึ่งถ้า timescale ละเอียดพอจะไม่มีเศษเลย

ผมสร้างไฟล์ทดลองด้วย ffmpeg ที่มีเฉพาะวิดีโอ 203 เฟรม 24 fps แล้วเขียนสคริปต์สั้น ๆ ไปอ่านค่าใน mvhd กับ mdhd ตรง ๆ เพราะ ffprobe ที่ผมใช้ไม่ได้แสดงความต่างนี้ให้เห็น (เดี๋ยวจะกลับไปที่จุดนี้)
import struct, sys
def read_box_times(path):
d = open(path, 'rb').read()
i = d.find(b'mvhd')
ver = d[i + 4]
if ver == 0:
ts, du = struct.unpack('>II', d[i + 16:i + 24])
else:
ts, du = struct.unpack('>IQ', d[i + 24:i + 36])
j = d.find(b'mdhd')
mts, mdu = struct.unpack('>II', d[j + 16:j + 24])
return (ts, du), (mts, mdu)
movie, media = read_box_times(sys.argv[1])
print('mvhd timescale', movie[0], 'duration', movie[1], '=', movie[1] / movie[0])
print('mdhd timescale', media[0], 'duration', media[1])
สคริปต์นี้หา box ด้วยการค้นสตริงอย่างหยาบ ใช้ได้กับไฟล์ทดลองที่มีวิดีโอแทร็กเดียว งานจริงควรใช้ parser ที่เดิน box ตามโครงสร้างให้ถูก
ตัวเลขที่ผมวัดได้ และสิ่งที่ผมเดาผิด
ผมเดาไว้ก่อนว่า ffmpeg ที่ใช้ movie timescale 1000 จะตัดเศษทิ้งเป็น 8458 tick คือสั้นกว่าจริงประมาณ 0.33 มิลลิวินาที พอรันจริงบนไฟล์ทดลองกลับได้ 8459 tick ซึ่งยาวเกินจริงประมาณ 0.67 มิลลิวินาที สรุปว่าเดาทิศทางผิด แต่หลักการถูก คือไม่เท่ากับความยาวจริงทั้งสองทาง
จุดที่ทำให้ผมงงอยู่พักหนึ่งคือ ffprobe ที่ผมใช้ (รุ่นที่ติดตั้งในเครื่องผม) พิมพ์ความยาวระดับ format ของไฟล์ทดลองทั้งสองแบบเป็น 8.458333 เหมือนกันหมด ดูเหมือนมันเอาความยาวจากแทร็กมาโชว์ ไม่ได้เอาค่าจาก mvhd ตรง ๆ (ผมเดาจากผลที่เห็น ไม่ได้ไล่อ่านซอร์ส) ความต่างเลยโผล่เฉพาะตอนที่ใครสักคนเอา mvhd ไปหารกันเองเท่านั้น ซึ่งก็คือเครื่องมืออื่นที่ไม่ได้ใช้ ffprobe ผมเลยไม่ได้บอกว่าเครื่องมือตัวไหนทำแบบนี้ บอกได้แค่ว่าข้อมูลในไฟล์ต่างกันจริงตามที่อ่านจาก box
ผมไม่ได้วัดค่า timescale เริ่มต้นของแทร็กวิดีโอในไฟล์จริงก่อนแก้ ไฟล์ทดลองที่สร้างจากแหล่งสัญญาณสังเคราะห์ของ ffmpeg ได้ mdhd timescale 12288 ซึ่งอาจไม่เท่ากับไฟล์ที่ได้จากต้นทางอื่น บทความนี้เลยอธิบายเฉพาะส่วน movie timescale ที่มีหลักฐานจากการอ่าน box ตรง ๆ
วิธีแก้: ตั้ง timescale ให้ตรงอัตราซัมเปิลเสียง
คำสั่ง encode ที่ผมใช้กับคู่ที่เหลือ (ชุดที่สองเป็นต้นไป) เป็นแบบนี้ ความยาวบังคับด้วย -frames:v ตามจำนวนเฟรมที่คำนวณไว้ ไม่ใช้ -t กับตัวเลขทศนิยม
ffmpeg -ss 12.5 -i src.mp4 -an -frames:v 203 -r 24 -enc_time_base 1:24 \
-c:v h264_nvenc -preset p4 -cq 19 -pix_fmt yuv420p \
-video_track_timescale 48000 -movie_timescale 48000 \
-movflags +faststart out.mp4
# ถ้า NVENC ล้ม ให้ถอยไป libx264
# -c:v libx264 -crf 18 -pix_fmt yuv420p
ผมทดลองแยกตัวแปรบนไฟล์ทดลอง 3 แบบ เพื่อดูว่าต้องตั้งกี่ตัวถึงจะได้ผล
| ไฟล์ทดลอง | ธง timescale ที่ใส่ | mvhd | mdhd (วิดีโอ) |
|---|---|---|---|
| a | ไม่ใส่ | 1000 / 8459 | 12288 / 103936 |
| b | ทั้ง video_track และ movie ที่ 48000 | 48000 / 406000 | 48000 / 406000 |
| c | video_track_timescale 48000 อย่างเดียว | 1000 / 8459 | 48000 / 406000 |
ไฟล์ c คือจุดที่น่าสนใจ แทร็กวิดีโอได้ 406,000 tick เท่าที่ต้องการ แต่ความยาวของทั้งไฟล์ใน mvhd ยังเป็น 8459 เหมือนเดิม ต้องใส่ -movie_timescale ด้วยค่านี้ถึงจะเปลี่ยน ไม่ใส่ตัวใดตัวหนึ่งก็ยังมีเลขหยาบเหลืออยู่ในไฟล์
พอ timescale เป็น 48000 ที่ 24 fps หนึ่งเฟรมจะยาว 48000 / 24 = 2000 tick เป็นจำนวนเต็มพอดี ถ้าเอา 1000 มาหาร 24 จะได้ 41.667 tick ต่อเฟรม ซึ่งไม่เป็นจำนวนเต็ม
ตรวจด้วย Fraction ก่อนส่งทุกครั้ง
ผมไม่ยอมเทียบความยาวด้วย float เพราะ ffprobe พิมพ์ความยาวเป็นทศนิยม 6 ตำแหน่ง 8.458333 ไม่ใช่ 8.4583333... เลขตัวนี้เทียบเท่ากับ 406000/48000 ไม่ได้แบบเป๊ะ ผมเลยให้สคริปต์ดึง duration_ts กับ time_base ที่เป็นจำนวนเต็มออกมา แล้วคูณกันด้วย Fraction ของ Python
import json, subprocess
from fractions import Fraction
def probe(path):
out = subprocess.run(
['ffprobe', '-v', 'error',
'-show_entries', 'stream=codec_type,nb_frames,duration_ts,time_base',
'-of', 'json', path],
capture_output=True, text=True, check=True).stdout
return json.loads(out)['streams']
def video_seconds(path):
s = probe(path)
assert len(s) == 1 and s[0]['codec_type'] == 'video', 'ต้องมีแต่ video stream'
num, den = s[0]['time_base'].split('/')
return Fraction(int(s[0]['duration_ts'])) * Fraction(int(num), int(den)), int(s[0]['nb_frames'])
FPS, RATE = 24, 48000
frames, samples = 203, 406000
want = Fraction(samples, RATE)
assert want == Fraction(frames, FPS) # แผนตัดสอดคล้องกันเอง
got, n = video_seconds('out.mp4')
assert n == frames, ('จำนวนเฟรมไม่ตรง', n)
assert got == want, ('ความยาวแทร็กไม่ตรง', got, want)
print('ผ่าน', got, float(got))
ผมรันสคริปต์นี้กับไฟล์ b ได้ผ่าน (ผลคือ 203/24 ซึ่งเท่ากับ 406000/48000 พอดี) ส่วนฝั่ง WAV ผมนับซัมเปิลจากไฟล์ แล้วเทียบกับ frames * RATE / FPS ด้วย Fraction แบบเดียวกัน เรื่องไบต์ PCM ที่ตัดจากสเต็มต้องตรงกับต้นฉบับทุกไบต์ผมตรวจแยกอีกขั้นด้วยการเทียบช่วงดัชนีซัมเปิลตรง ๆ

กฎความยาวที่ทำให้ปัญหานี้ไม่เกิดตั้งแต่ต้น
ระหว่างแก้ ผมสังเกตว่ามีวิธีที่ตัดปัญหาได้ตั้งแต่ต้นทางโดยไม่ต้องพึ่ง timescale เลย คือบังคับความยาวคลิปเป็นวินาทีเต็ม ถ้าความยาวเป็นจำนวนเต็มวินาที จำนวน tick ที่ timescale 1000 ก็เป็นจำนวนเต็มเสมอ ไม่มีอะไรให้ปัด ผมเลยกำหนดกติกาของชุดใหม่ 27 คู่ ดังนี้
- ความยาวคลิป duration = ceil(จุดจบ - จุดเริ่ม) ให้เป็นวินาทีเต็ม อยู่ในช่วง 6 ถึง 9 วินาที
- จำนวนเฟรม = round(duration x 24) และจำนวนซัมเปิล = duration x 48000 ทั้งสองเป็นจำนวนเต็มโดยนิยาม
- ตัดเผื่อหัวท้ายด้านละ 0.25 วินาที แต่ความยาวรวมต้องไม่เกิน 10 วินาที
- encode ด้วยคำสั่งด้านบนที่ตั้ง timescale เป็น 48000 ไว้เป็นชั้นป้องกันที่สอง
- รันสคริปต์ตรวจ assert ทุกข้อ ไฟล์ไหนไม่ผ่านไม่ถูกส่งต่อ
คลิปที่ยาว 8.458333 วินาทีซึ่งเป็นเศษของเฟรมคือสิ่งที่พาผมมาเจอเรื่องนี้ ความยาวที่เป็นวินาทีเต็มเลยตัดปัญหาออกไปได้ทั้งก้อน
ที่ 30 fps ผมใช้ timescale 30000 ซึ่งให้ 1000 tick ต่อเฟรม ใช้ตอนต่อคลิป (เรื่องนั้นผมเขียนแยกไว้ใน ต่อคลิปหลายสิบช่วงให้ได้จำนวนเฟรมพอดี) ส่วนอัตราเฟรมแบบ 30000/1001 ถ้าใช้ timescale 30000 จะได้ 1001 tick ต่อเฟรม ก็ยังเป็นจำนวนเต็มเหมือนกัน นี่เป็นการคำนวณ ผมไม่ได้ทดลองกับไฟล์จริง
ผมไม่ได้บอกว่าเรื่อง timescale คือสาเหตุที่ระบบปลายทางแจ้งว่าความยาวไม่ตรง ผมแค่พบว่า MP4 ที่ ffmpeg เขียนด้วยค่าเริ่มต้นเก็บความยาวหยาบกว่า WAV ที่คู่กัน และปิดช่องนั้นได้ด้วยต้นทุนต่ำ ถ้าระบบรับไฟล์ของใครเทียบความยาวคอนเทนเนอร์แบบเข้มงวด สิ่งนี้อาจเกี่ยวหรือไม่เกี่ยวก็ได้ ต้องวัดไฟล์ของตัวเองเอง
ข้อสรุปและสิ่งที่ผมทำเป็นประจำ
สรุปสิ่งที่ผมเก็บไปใช้ มีสามข้อ ข้อแรก ความยาวของไฟล์สื่อคือเศษส่วนของจำนวนเต็มสองตัว ไม่ใช่ทศนิยม ข้อสอง เวลาสร้างคู่ไฟล์ที่ต้องเท่ากัน ให้ตั้ง movie timescale และ video track timescale เป็นอัตราซัมเปิลเสียงทั้งคู่ ข้อสาม ก่อนส่งต่อให้ใครให้รัน assert ด้วย Fraction จำนวนเฟรมกับจำนวนซัมเปิล ไม่ใช่เปิดดูว่า "ดูยาวเท่ากัน"
ผมยังไม่ได้ไล่ดูพฤติกรรมของตัว muxer รุ่นอื่น ๆ ว่าปัดขึ้นหรือปัดลงเหมือนกันไหม ตัวเลข 8459 ที่ผมวัดมาจาก ffmpeg รุ่นที่ติดตั้งในเครื่องผมเท่านั้น ใครจะเอาไปอ้างต้องวัดซ้ำกับรุ่นของตัวเอง เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย