EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Web / พรีวิวตัด MV ในหน้าเว็บเดียว ใช้เสียงเป็นนาฬิกา วิดีโอสองตัวสลับกัน
Web

พรีวิวตัด MV ในหน้าเว็บเดียว ใช้เสียงเป็นนาฬิกา วิดีโอสองตัวสลับกัน

โดย Ton-Or ·6 พ.ย. 2026·อ่าน 10 นาที
พรีวิวตัด 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 บล็อก พอเล่นเพลง หน้าเว็บต้องรู้ว่าตอนนี้อยู่บล็อกไหน แล้วให้วิดีโอแสดงช็อตที่ถูกต้อง ณ ตำแหน่งที่ถูกต้องของคลิป

จอวิดีโอเล็กสองจอวางคู่กัน จอหนึ่งสว่างเป็น on air อีกจอมืดและรอคิวอยู่ มีลำโพงอยู่เหนือทั้งสองจอ
ลำโพงตัวเดียวเป็นนาฬิกา จอที่สว่างคือช็อตที่กำลังออกอากาศ จอที่มืดคือช็อตถัดไปที่เตรียมรอไว้ที่จุดเริ่มแล้ว

ทำไมเลือกเสียงเป็นนาฬิกา

ในหน้าหนึ่งมีของสามอย่างที่เดินตามเวลา คือเสียง วิดีโอ และเส้นตำแหน่งบนแถบ ถ้าต่างคนต่างเดินก็ต้องมีใครสักคนเป็นหลัก ผมให้ <audio> เป็นนาฬิกาหลักด้วยเหตุผลเชิงหลักการข้อเดียว คือเสียงสะดุดแล้วหูรับรู้ได้ทันที ส่วนภาพช้าไปเฟรมสองเฟรมแทบไม่มีใครสังเกต ผมไม่ได้เอามาวัดเทียบ เป็นเหตุผลจากการออกแบบเฉย ๆ ผลคือทุกอย่างอื่นต้องวิ่งตามเสียง ไม่ใช่กลับกัน

ใครเป็นนาฬิกาaudio songนาฬิกาหลักเรือนเดียว->blockAt(t)หาคัตที่ครอบ t->video activeเล่นที่ in + (t - start)->แก้เวลาเมื่อคลาดเกิน 0.22 s เท่านั้น

ส่วนเสียงเองในรอบหลัง ผมเปลี่ยนจากไฟล์เสียงรวมไฟล์เดียวเป็นการเล่น 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 ของวิดีโอยังจำเป็นด้วยอีกเรื่อง คือเบราว์เซอร์ยอมให้วิดีโอที่ปิดเสียงเล่นอัตโนมัติได้ตามนโยบาย

เสียงเป็นนาฬิกา วิดีโอสองตัวตามหลังaudio #songตำแหน่งเพลง tblockAt(t) = iคัตที่ครอบ t จาก 33 คัตvideo A (active)เล่นช็อต i ที่ in + (t - start)คลาดเกิน 0.22 s ค่อยกำหนดเวลาใหม่video B (standby)โหลดช็อต i+1 รอที่ in-pointpause ไว้ ยังไม่ขึ้นจอคัต iตรงขอบคัตสลับบทบาท A กับ B ทันที ไม่ต้องโหลดใหม่ เลยไม่มีจอดำเกณฑ์แก้เวลา 0.22 s ปกติ · 0.1 s ในจังหวะที่เพิ่งสลับช็อต33 คัต · 9 ส่วนของเพลง · ข้อมูลทั้งหมดมาจากแผน JSON
เสียงบอกเวลา t บล็อก i ถูกหาจาก t แล้ว A เล่นช็อตนั้น ขณะเดียวกัน B ถูกเตรียมไว้ที่จุดเริ่มของช็อตถัดไป

ลอจิก: 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.22 ไม่ใช่ 0

ถ้าตั้งเกณฑ์เล็กมาก เช่น 0.02 ตัวแก้เวลาจะทำงานเกือบทุกรอบ แล้ว seek ซ้ำ ๆ จนภาพกระตุกเอง 0.22 วินาทีคือค่าที่ผมเลือกให้ยอมรับความคลาดเล็กน้อยได้ แลกกับภาพที่ลื่น ค่านี้ผมเลือกเองจากการใช้งาน ไม่ใช่ค่าจากมาตรฐาน เครื่องหรือเบราว์เซอร์อื่นอาจต้องปรับ

สลับตรงขอบคัต: ลำดับที่เกิดขึ้น

ตอน t ข้ามขอบคัตจากบล็อก i ไป i+1ก่อนข้ามขอบA activeช็อต iB standbyช็อต i+1 รอ in-pointt ข้ามขอบหลังข้ามขอบB activeช็อต i+1A standbyช็อต i+2สลับ class active ไม่โหลดใหม่ ไม่มีเฟรมดำA.pause(); cue(A, i + 2)A ถูกพัก แล้วถูกกำหนดให้เป็นตัวสำรองของช็อตถัดไปผังเดียวกันนี้วนซ้ำ 33 รอบตลอดเพลง 192.8 วินาที
สลับบทบาทสองตัวที่ขอบคัต ตัวที่หมดหน้าที่ถูกพักและรับช็อตถัดไปไปรอทันที

แถบคลื่นเสียงจาก 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 อยู่บนแถบ
ใต้คลื่นเสียงมีบล็อกคัต 33 ช่องที่ความกว้างเป็นสัดส่วนกับความยาวของคัต คลิกตรงไหนของแถบ เสียงกระโดดไปตรงนั้นแล้ววิดีโอตามมา

พิสูจน์ว่าตามเสียงทัน: ตรวจจากเบราว์เซอร์จริง

ผมไม่ไว้ใจการดูด้วยตาอย่างเดียว เลยทำสคริปต์ที่ขับเบราว์เซอร์จริงเปิดหน้าพรีวิว สั่ง seek ไปที่เวลาหลังขอบแต่ละคัตเล็กน้อย แล้วอ่านค่าเวลาของเสียงกับเวลาของวิดีโอ active มาเทียบกับค่าที่คำนวณจากแผน ผลบันทึกเป็นไฟล์ JSON ครอบคลุมทั้ง 33 คัต ตัวอย่างที่ดูแล้วชัดมีสองแบบ

33 คัตตรวจอัตโนมัติครบ
0.28 sวิดีโอ ณ เพลง 0.08 s
0.28 sค่าที่คาดจากแผน
47 msส่วนต่างช็อตแรกที่เล่นเอง
0.22 sเกณฑ์ที่ยอมรับ
10 msความละเอียดแถบคลื่น

คัตที่ 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 แล้วค่อยใช้ด่านที่เข้มกว่าทีหลัง

ลำดับที่ผมใช้ตั้งพรีวิวแบบนี้

  1. สร้างแผนเป็น JSON จากสคริปต์เสมอ อย่าแก้ HTML ด้วยมือ ข้อมูลบล็อกและส่วนของเพลงฝังจากแผน
  2. ใช้ audio เป็นนาฬิกาหลักเรือนเดียว ตำแหน่งอื่นทุกอย่างคำนวณจาก song.currentTime
  3. ตั้งวิดีโอสองตัว A/B ทั้งคู่ muted playsinline preload auto ให้ตัวสำรองรอที่ in-point ของช็อตถัดไป
  4. แก้เวลาวิดีโอเฉพาะเมื่อคลาดเกิน 0.22 วินาที (0.1 วินาทีในจังหวะเพิ่งสลับ) ไม่ใช่ทุกเฟรม
  5. วาดคลื่นเสียงจาก peak ทุก 10 มิลลิวินาทีบน canvas ใส่ role slider และรองรับลูกศร
  6. ขับเบราว์เซอร์จริง seek ไปทุกคัต เทียบเวลาวิดีโอกับค่าจากแผน บันทึกเป็น JSON

ถ้าจะทำตามเอง ค่า 0.22 กับ 0.1 ให้ถือเป็นจุดเริ่มต้นที่ต้องปรับกับเครื่องและคลิปของตัวเอง คลิปที่เข้ารหัสแบบ keyframe ห่างมากจะ seek ได้แม่นน้อยกว่าคลิปที่เข้ารหัสถี่ ผมไม่ได้ทดลองเปรียบเทียบ เลยไม่ขออ้างตัวเลข ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

เคยเจองานแบบนี้ไหม? ติดตรงไหน?

มาแลกวิธีกันต่อในบอร์ด LinuxThai

→ คุยต่อในบอร์ด

ความคิดเห็น 0

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