พรีวิวตัด MV ในหน้าเว็บเดียว ใช้เสียงเป็นนาฬิกา วิดีโอสองตัวสลับกัน
การดูแผนตัดต่อเป็นตารางตัวเลขไม่ได้บอกว่าเนื้อหนังจะดูลื่นหรือเปล่า ผมเลยทำหน้าเว็บที่เล่นแผนตามเสียงให้เห็นเป็นหนังจริงก่อนจะเสียเวลา render และถือว่าเสียงคือนาฬิกาเดียวของทั้งหน้า
สรุปใน 1 นาที
- อาการ/ปัญหา
- ดูแผนตัดต่อ 33 คัตเป็นตารางตัวเลข บอกไม่ได้ว่าเล่นแล้วลื่นไหม
- ใครควรอ่าน
- คนทำแผนตัดต่อวิดีโอตามเพลงที่อยากดูผลก่อน render
- ทางแก้แบบสั้น
- ทำหน้า HTML เล่นแผนโดยใช้ audio เป็นนาฬิกาหลัก วิดีโอ A/B สองตัวสลับที่ขอบคัต และแก้เวลาวิดีโอเมื่อคลาดเกิน 0.22 วินาที โดยไม่เรียก ffmpeg
พอดีผมมีแผนตัดต่อของเพลงทดลองหนึ่งเพลง 33 คัต แบ่งเป็น 9 ส่วนของเพลง ตอนแรกผมทำหน้าบอร์ดให้เลือกเวลาคัตเองด้วยมือ ใช้ไปสักพักก็รู้ว่าไม่ตรงกับที่ต้องการ สิ่งที่อยากได้คือ "ล็อกคัตไว้แล้วให้ภาพเปลี่ยนมุมเองตามเพลง" ผมอยากเห็นว่ามันดูเป็นหนังจริงหรือเปล่าตอนเพลงเล่น โดยไม่ต้องรอ render ด้วย ffmpeg ทุกรอบ บทความนี้เล่าโครงของหน้าพรีวิวที่ทำออกมา
เนื้อหาของช็อตแต่ละอันและที่มาของคลิปผมไม่เล่า ในบทความนี้คลิปคือไฟล์ mp4 สั้น ๆ ที่ตั้งชื่อกลาง ๆ อย่างเดียว
โจทย์: เปลี่ยนมุมกล้องตามเสียงโดยไม่เรียก ffmpeg
แผนตัดต่อของผมเป็นไฟล์ JSON ที่สคริปต์ Python สร้างขึ้นและมีสคริปต์ตรวจเทียบ ไม่มีใครแก้ HTML ด้วยมือ ข้อมูลที่หน้าเว็บอ่านมีสองก้อน ก้อนแรกคือบล็อกคัต ทุกบล็อกมี id, เวลาเริ่มและจบบนแกนเวลาของเพลง, ไฟล์คลิป และ in-point คือตำแหน่งที่ต้องเริ่มอ่านในคลิปนั้น ก้อนที่สองคือส่วนของเพลง 9 ส่วน
{
"duration": 192.8,
"blocks": [
{ "id": 1, "start": 0.0, "end": 6.62, "clip": "clips/shot-a.mp4", "in": 0.2 },
{ "id": 2, "start": 6.62, "end": 9.40, "clip": "clips/shot-b.mp4", "in": 1.1 }
],
"sections": [
{ "name": "s1", "start": 0.0, "end": 21.4 }
]
}
ตัวอย่างข้างบนย่อมาให้อ่านง่ายและใช้ชื่อกลาง ๆ ของจริงมี 33 บล็อก พอเล่นเพลง หน้าเว็บต้องรู้ว่าตอนนี้อยู่บล็อกไหน แล้วให้วิดีโอแสดงช็อตที่ถูกต้อง ณ ตำแหน่งที่ถูกต้องของคลิป

ทำไมเลือกเสียงเป็นนาฬิกา
ในหน้าหนึ่งมีของสามอย่างที่เดินตามเวลา คือเสียง วิดีโอ และเส้นตำแหน่งบนแถบ ถ้าต่างคนต่างเดินก็ต้องมีใครสักคนเป็นหลัก ผมให้ <audio> เป็นนาฬิกาหลักด้วยเหตุผลเชิงหลักการข้อเดียว คือเสียงสะดุดแล้วหูรับรู้ได้ทันที ส่วนภาพช้าไปเฟรมสองเฟรมแทบไม่มีใครสังเกต ผมไม่ได้เอามาวัดเทียบ เป็นเหตุผลจากการออกแบบเฉย ๆ ผลคือทุกอย่างอื่นต้องวิ่งตามเสียง ไม่ใช่กลับกัน
ส่วนเสียงเองในรอบหลัง ผมเปลี่ยนจากไฟล์เสียงรวมไฟล์เดียวเป็นการเล่น 9 สเตมพร้อมกันบน AudioContext ตัวเดียวตามที่เล่าไว้ใน เล่น 9 stem พร้อมกันในเบราว์เซอร์ นาฬิกาเดียว ตัวพรีวิวใช้อินเทอร์เฟซเดิมคือ currentTime, play, pause กับเหตุการณ์ timeupdate และ seeked โค้ดฝั่งวิดีโอเลยไม่ต้องรู้ว่าข้างหลังเสียงมาจากไฟล์เดียวหรือเก้าไฟล์
วิดีโอสองตัว A/B ไม่ใช่ตัวเดียวที่เปลี่ยน src
แนวคิดแรกของใครก็คงเป็นวิดีโอตัวเดียว พอถึงขอบคัตก็เปลี่ยน src ปัญหาคือการเปลี่ยน src คือการเริ่มโหลดใหม่ ต้องรอ metadata รอ seek ไปถึงจุดเริ่ม ตรงขอบคัตเลยมีโอกาสเห็นเฟรมดำหรือภาพค้าง นี่เป็นเหตุผลเชิงหลักการ ผมไม่ได้วัดความหน่วงจริง วิธีที่เลือกคือมีวิดีโอสองตัว
- ตัว active กำลังแสดงช็อตปัจจุบัน
- ตัว standby โหลดช็อตถัดไปและรออยู่ที่ in-point ของมันแล้ว
- ถึงขอบคัต แค่สลับบทบาท ไม่ต้องรอโหลดอะไร
ทั้งสองตัวตั้ง muted playsinline preload="auto" เสียงออกจาก <audio> ตัวเดียว ส่วน muted ของวิดีโอยังจำเป็นด้วยอีกเรื่อง คือเบราว์เซอร์ยอมให้วิดีโอที่ปิดเสียงเล่นอัตโนมัติได้ตามนโยบาย
ลอจิก: showBlock กับ sync
หัวใจมีสองฟังก์ชัน ฟังก์ชันแรกสลับบทบาท อีกฟังก์ชันแก้เวลาตามเสียง โค้ดนี้เขียนใหม่จากของจริงให้ชื่อเป็นกลาง ใช้ได้กับ HTML ที่มี <audio id="song">, <video id="video-a"> และ <video id="video-b">
// cut-preview.js — ข้อมูลอยู่ในตัวแปร P ที่สคริปต์ Python ฝังมา
const song = document.getElementById('song');
const A = document.getElementById('video-a');
const B = document.getElementById('video-b');
let active = A, standby = B, current = -1;
function blockAt(t) { // binary search หาบล็อกที่ start <= t
let lo = 0, hi = P.blocks.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (P.blocks[mid].start <= t) lo = mid; else hi = mid - 1;
}
return lo;
}
function targetTime(b, t) { return b.in + (t - b.start); }
function cue(v, i) { // เตรียมวิดีโอให้รอที่ in-point ของบล็อก i
const b = P.blocks[i];
if (!b) return;
v.dataset.block = String(i);
if (v.dataset.clip !== b.clip) { v.dataset.clip = b.clip; v.src = b.clip; }
const seek = () => { v.currentTime = b.in; };
v.readyState >= 1 ? seek() : v.addEventListener('loadedmetadata', seek, { once: true });
v.pause();
}
function showBlock(i) {
if (standby.dataset.block !== String(i)) cue(standby, i); // ปกติเตรียมไว้แล้ว
[active, standby] = [standby, active]; // สลับบทบาท ไม่โหลดใหม่
active.classList.add('active');
standby.classList.remove('active');
standby.pause();
cue(standby, i + 1); // ตัวสำรองรับช็อตถัดไป
current = i;
}
function sync() {
const t = Math.min(song.currentTime, P.duration);
const i = blockAt(t);
const switched = i !== current;
if (switched) showBlock(i);
const want = targetTime(P.blocks[i], t);
const limit = switched ? 0.1 : 0.22; // เกณฑ์แก้เวลา ไม่ใช่ทุกเฟรม
if (active.readyState >= 1 && Math.abs(active.currentTime - want) > limit) {
active.currentTime = want;
}
if (!song.paused && active.paused) active.play().catch(() => {});
}
['timeupdate', 'seeked', 'play'].forEach(ev => song.addEventListener(ev, sync));
song.addEventListener('pause', () => { A.pause(); B.pause(); });
มีรายละเอียดสามจุดที่ผมคิดมาแล้ว จุดแรก ไม่แก้เวลาทุกเฟรม วิดีโอที่ถูก seek บ่อย ๆ จะกระตุก ผมปล่อยให้วิดีโอเดินเองตามปกติ และเข้าไปแก้ก็ต่อเมื่อห่างจากเป้ามากกว่า 0.22 วินาที จุดที่สอง ตอนเพิ่งสลับช็อตใช้เกณฑ์ 0.1 วินาทีแคบกว่า เพราะจังหวะนั้นควรแม่นกว่าปกติ จุดที่สามคือ play().catch(() => {}) กลืน error ของนโยบาย autoplay ไว้ ถ้าเบราว์เซอร์ปฏิเสธ รอบ sync ถัดไปจะลองใหม่เอง
ถ้าตั้งเกณฑ์เล็กมาก เช่น 0.02 ตัวแก้เวลาจะทำงานเกือบทุกรอบ แล้ว seek ซ้ำ ๆ จนภาพกระตุกเอง 0.22 วินาทีคือค่าที่ผมเลือกให้ยอมรับความคลาดเล็กน้อยได้ แลกกับภาพที่ลื่น ค่านี้ผมเลือกเองจากการใช้งาน ไม่ใช่ค่าจากมาตรฐาน เครื่องหรือเบราว์เซอร์อื่นอาจต้องปรับ
สลับตรงขอบคัต: ลำดับที่เกิดขึ้น
แถบคลื่นเสียงจาก canvas ที่เลื่อนตำแหน่งได้
แถบตำแหน่งธรรมดา <input type="range"> บอกได้แค่ตำแหน่ง แต่เวลาตัดต่อผมอยากเห็นรูปคลื่น เห็นว่าตรงไหนเสียงหนัก ตรงไหนเงียบ ผมเลยวาดคลื่นเสียงบน <canvas> ข้อมูลคลื่นเตรียมจากฝั่ง Python ตอนสร้างพรีวิว ลดเสียงเป็น mono 8 kHz แล้วเก็บค่า peak ทุก 80 ซัมเปิล ซึ่งเท่ากับ 10 มิลลิวินาที
# build_peaks.py — peak ทุก 10 ms ฝังลงแผนพรีวิว
import subprocess
import numpy as np
raw = subprocess.run(
['ffmpeg', '-v', 'error', '-i', 'mix.wav', '-ac', '1', '-ar', '8000', '-f', 'f32le', '-'],
capture_output=True, check=True).stdout
y = np.frombuffer(raw, dtype=np.float32)
hop = 80 # 8000 Hz x 0.010 s = 80 ซัมเปิลต่อช่อง
peaks = np.abs(y[: len(y) // hop * hop]).reshape(-1, hop).max(axis=1)
peaks = (peaks / (peaks.max() + 1e-9) * 255).astype(int).tolist()
plan = {'duration': len(y) / 8000, 'peakHop': 0.01, 'peaks': peaks}
print(f'peaks {len(peaks)} ช่อง ยาว {plan["duration"]:.3f} วินาที')
ฝั่งเบราว์เซอร์วาดทีละคอลัมน์ ถ้าความกว้างจอเล็กกว่าจำนวนช่อง ก็ยุบหลายช่องเป็นหนึ่งคอลัมน์โดยเอาค่าสูงสุด และ canvas ตัวเดียวกันทำหน้าที่เป็นแถบเลื่อนด้วย
function drawWave() {
const c = document.getElementById('wave');
const w = c.clientWidth, d = devicePixelRatio || 1;
c.width = w * d; c.height = 58 * d;
const g = c.getContext('2d'); g.scale(d, d);
const n = Math.ceil(w / 3), hop = P.peaks.length / n; // คอลัมน์ละ 3 พิกเซล
for (let i = 0; i < n; i++) {
let m = 0;
for (let j = Math.floor(i * hop); j < Math.floor((i + 1) * hop); j++) m = Math.max(m, P.peaks[j]);
const h = (m / 255) * 54;
g.fillRect(i * 3, 29 - h / 2, 2, Math.max(1, h));
}
}
const wave = document.getElementById('wave');
wave.onclick = e => {
const r = wave.getBoundingClientRect();
song.currentTime = Math.max(0, Math.min(P.duration - 0.01, (e.clientX - r.left) / r.width * P.duration));
};
wave.onkeydown = e => { // role="slider" tabindex="0" ใช้คีย์บอร์ดได้
const step = { ArrowLeft: -5, ArrowRight: 5 }[e.key];
if (step) { song.currentTime = Math.max(0, Math.min(P.duration, song.currentTime + step)); e.preventDefault(); }
};
แถบนี้ใส่ role="slider" และ tabindex="0" ให้กดลูกศรเลื่อนทีละ 5 วินาทีได้ ซึ่งช่วยทั้งคนใช้คีย์บอร์ดและตัวทดสอบอัตโนมัติ

พิสูจน์ว่าตามเสียงทัน: ตรวจจากเบราว์เซอร์จริง
ผมไม่ไว้ใจการดูด้วยตาอย่างเดียว เลยทำสคริปต์ที่ขับเบราว์เซอร์จริงเปิดหน้าพรีวิว สั่ง seek ไปที่เวลาหลังขอบแต่ละคัตเล็กน้อย แล้วอ่านค่าเวลาของเสียงกับเวลาของวิดีโอ active มาเทียบกับค่าที่คำนวณจากแผน ผลบันทึกเป็นไฟล์ JSON ครอบคลุมทั้ง 33 คัต ตัวอย่างที่ดูแล้วชัดมีสองแบบ
คัตที่ 1 ที่เวลาเพลง 0.08 วินาที วิดีโออยู่ที่ 0.28 วินาที ตรงกับที่แผนคาดคือ 0.28 วินาที ส่วนอีกกรณีคือช็อตแรกที่เล่นต่อเองหลังกดเล่น เสียงอยู่ที่ 6.7156 วินาที วิดีโออยู่ที่ 1.2208 วินาที ขณะที่แผนคาด 1.2676 วินาที ต่างกัน 47 มิลลิวินาที ซึ่งอยู่ใต้เกณฑ์ 0.22 วินาทีเลยไม่ถูกแก้ ตัวเลขทั้งสองมาจากไฟล์ผลตรวจจริง ภาพหน้าจอทั้งเดสก์ท็อปและมือถือผมเก็บไว้เป็นหลักฐานคู่กันด้วย
47 มิลลิวินาทีไม่ใช่ความผิดพลาด แต่คือสิ่งที่ออกแบบให้ยอมรับ เพราะถ้าบังคับให้เป็นศูนย์ ผมก็ต้อง seek ตลอดเวลา ซึ่งทำให้ภาพกระตุก เลยเลือกยอมคลาดเล็กน้อยแลกกับความลื่น การพิสูจน์ที่แท้จริงของตำแหน่งเฟรมต้องไปดูตอน render ด้วย ffmpeg ไม่ใช่ที่พรีวิว
ของที่ค้างจากแผนเดิม และขอบเขตของงานนี้
ตอนเสียงเปลี่ยนจากไฟล์เก่าเป็นชุดสเตมที่ยาว 192.8 วินาที ผมพบว่าแอตทริบิวต์ aria-valuemax ในเทมเพลตเดิมยังเป็นเลข 194 ซึ่งมาจากแผนเก่า ค่านี้ต้องมาจากความยาวเพลงในแผนปัจจุบัน ไม่ควรฝังตัวเลขไว้ในเทมเพลต ผมเก็บเป็นข้อสังเกตว่าตัวเลขที่ฝังตายในเทมเพลตคือจุดที่จะผิดเงียบ ๆ เมื่อข้อมูลเปลี่ยน
ส่วนของที่ผมไม่ได้พิสูจน์และไม่ขออ้าง คือเรื่องการเก็บการแก้ของผู้ใช้ลง localStorage เทมเพลตเล่นอัตโนมัติตัวนี้ไม่พบโค้ดเรื่องนั้น (ผมเห็นแต่ในบอร์ดเลือกเวลาเอง) และตัวเอดิเตอร์ที่เป็นของโปรเจกต์เดียวกันก็เฉพาะงานนั้น ไม่ได้ออกแบบให้เอาไปใช้ซ้ำตรง ๆ
บอร์ดเลือกเวลาคัตเอง
- ต้องกดเลือกเวลาด้วยมือ
- ไม่เห็นภาพต่อเนื่องตามเพลง
- ใช้ไม่ได้ตามที่ผมต้องการ
เทมเพลตเล่นตามเสียง
- คัตถูกล็อกในแผน JSON
- ภาพเปลี่ยนมุมเองขณะเพลงเล่น
- เห็นเป็นหนังก่อน render
ข้อจำกัดที่ต้องรู้ก่อนเชื่อพรีวิว
พรีวิวนี้ตอบคำถามเดียวคือ "คัตตามเพลงทันไหมและดูต่อเนื่องไหม" มันไม่ได้พิสูจน์ว่าไฟล์ที่ render ออกมาจะตรงเฟรมต่อเฟรม เพราะวิดีโอในเบราว์เซอร์เล่นผ่านตัวถอดรหัสและการ seek ที่ไม่ได้แม่นระดับเฟรมเสมอไป ส่วนการล็อกเฟรมให้แม่นต้องไปทำและตรวจตอน render ด้วย ffmpeg ผมเลยใช้พรีวิวเป็นด่านแรกที่เร็ว เห็นปัญหาของแผนได้ภายในไม่กี่วินาทีโดยไม่ต้องรอ render แล้วค่อยใช้ด่านที่เข้มกว่าทีหลัง
ลำดับที่ผมใช้ตั้งพรีวิวแบบนี้
- สร้างแผนเป็น JSON จากสคริปต์เสมอ อย่าแก้ HTML ด้วยมือ ข้อมูลบล็อกและส่วนของเพลงฝังจากแผน
- ใช้ audio เป็นนาฬิกาหลักเรือนเดียว ตำแหน่งอื่นทุกอย่างคำนวณจาก song.currentTime
- ตั้งวิดีโอสองตัว A/B ทั้งคู่ muted playsinline preload auto ให้ตัวสำรองรอที่ in-point ของช็อตถัดไป
- แก้เวลาวิดีโอเฉพาะเมื่อคลาดเกิน 0.22 วินาที (0.1 วินาทีในจังหวะเพิ่งสลับ) ไม่ใช่ทุกเฟรม
- วาดคลื่นเสียงจาก peak ทุก 10 มิลลิวินาทีบน canvas ใส่ role slider และรองรับลูกศร
- ขับเบราว์เซอร์จริง seek ไปทุกคัต เทียบเวลาวิดีโอกับค่าจากแผน บันทึกเป็น JSON
ถ้าจะทำตามเอง ค่า 0.22 กับ 0.1 ให้ถือเป็นจุดเริ่มต้นที่ต้องปรับกับเครื่องและคลิปของตัวเอง คลิปที่เข้ารหัสแบบ keyframe ห่างมากจะ seek ได้แม่นน้อยกว่าคลิปที่เข้ารหัสถี่ ผมไม่ได้ทดลองเปรียบเทียบ เลยไม่ขออ้างตัวเลข ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



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