เคยไหม? ประชุมเสร็จแล้วต้องมานั่งเปิดวิดีโอย้อนหลังเพื่อสรุปรายงานการประชุม (Minutes of Meeting) วันนี้เราจะมาสร้าง Automated Transcription Pipeline บนเครื่อง Mac ที่จะคอย “เฝ้า” โฟลเดอร์เก็บไฟล์ประชุม เมื่อมีคลิปใหม่เข้ามา ระบบจะดึงเสียงไปแปลงเป็นข้อความภาษาไทยให้อัตโนมัติ แยกย่อยให้ทุก ๆ 5 นาที แล้วส่งตรงเข้า Obsidian ทันที!
🛠️ โครงสร้างระบบ (Directory Setup)
เพื่อให้ระบบทำงานได้ถูกต้อง Script นี้จะอ้างอิง Path บน Mac ของคุณดังนี้ครับ
- 📂 โฟลเดอร์เก็บคลิปประชุม:
/Users/common/Meetings(ที่เก็บไฟล์.mp4,.mov,.mkv) - 📂 โฟลเดอร์เก็บ AI Model:
/Users/common/models/ggml-large-v3.bin(Model ภาษาไทยความแม่นยำสูง) - 📂 โฟลเดอร์ปลายทาง Obsidian:
/Users/common/Obsidian(โฟลเดอร์สำหรับเก็บไฟล์.mdเพื่อเปิดอ่านใน Obsidian)
💡 เจาะลึกการทำงานของ Script
Script นี้ถูกออกแบบมาให้รันแบบ Persistent Loop (ทำงานตลอดเวลา) โดยมี Logic สำคัญดังนี้
- Batch Scan (กวาดรวบจบในรอบเดียว): เมื่อถูกเรียกใช้งาน Script จะเข้าไป Scan หาไฟล์วิดีโอทั้งหมดในโฟลเดอร์แหล่งข้อมูลแบบ Recursive (ค้นหาลงไปในโฟลเดอร์ย่อยด้วย) เพื่อนำมาจัดคิวประมวลผลเป็นชุด ๆ จนครบทั้งหมดในคราวเดียว ก่อนจะทำการปิดโปรแกรมลงอย่างปลอดภัยเพื่อให้ระบบสามารถ Sleep หรือ Shutdown เครื่องต่อได้ทันที
- Smart Skip (ทำเฉพาะไฟล์ใหม่): ระบบจะเช็กก่อนว่าวิดีโอนั้น ๆ เคยถูกถอดความหรือยัง ถ้าตรวจพบไฟล์คู่หู
_mom.mdและ_summaries.mdใน Obsidian แล้ว มันจะข้ามทันที ไม่ทำซ้ำให้เปลืองแรงเครื่อง - High-Performance Audio Extraction & Transcription: * ใช้
ffmpegแปลงเสียงจากวิดีโอให้เป็นคลื่นเสียงแบบ16kHz Mono PCM 16-bitซึ่งเป็น Spec ที่ดีที่สุดสำหรับ Whisper- ส่งต่อให้
whisper-cliทำงานร่วมกับ Apple Silicon (Metal Stack) ดึงพลังของ GPU บน Mac มาประมวลผลภาษาไทยอย่างรวดเร็ว
- ส่งต่อให้
- 5-Minute Time Block Grouping: แทนที่จะพ่นข้อความเป็นก้อนยาว ๆ อ่านยาก Script มี RegEx ช่วยดักจับ Timecode แล้วหั่นเนื้อหาแยกเป็นหัวข้อ
### minute 00:00,### minute 05:00ทำให้คุณกด Jump ย้อนไปฟังในวิดีโอจริงได้ง่ายมาก - Obsidian Double-File Output: ระบบจะสร้างไฟล์ให้ 2 ไฟล์เสมอ:
[ชื่อไฟล์]_mom.md: เก็บ Metadata และ Raw Transcription แยกตามเวลา (เป็นก้อนความรู้หลักที่ห้ามแก้ไข)[ชื่อไฟล์]_summaries.md: ไฟล์ว่างที่ลิงก์กลับไปหาไฟล์หลัก เพื่อให้คุณเขียนสรุปประเด็นสำคัญ หรือจดโน้ตเพิ่มเติมได้โดยไม่กระทบกับตัวถอดความเดิม
batch_transcribe_whisper_cpp.py
import os
import sys
import time
import subprocess
import warnings
import re
import argparse
import threading
from queue import Queue, Empty
from datetime import datetime
from pathlib import Path
# Suppress warnings noise
warnings.filterwarnings("ignore", category=RuntimeWarning)
warnings.filterwarnings("ignore", message=".*NotOpenSSLWarning.*")
# ==========================================
# CONFIGURATION
# ==========================================
SOURCE_DIR = Path("/Users/common/Meetings").resolve()
OBSIDIAN_DIR = Path("/Users/common/Obsidian").resolve()
USER_GROUP = "staff"
# Whisper.cpp Settings
WHISPER_LANGUAGE = "th"
WHISPER_MODEL_PATH = "/Users/common/models/ggml-large-v3.bin"
WHISPER_CLI_PATH = "whisper-cli"
# ค่ากำหนดความเสถียร
REPETITION_THRESHOLD = 5
SILENCE_THRESHOLD_SEC = 30
WHISPER_TIMEOUT_SEC = 45
# คำหลอนยอดฮิตที่จะสั่งให้ข้ามเดดแอร์ (กรณีไม่ได้ Guide เวลามา)
HALLUCINATION_KEYWORDS = ["โปรดติดตามตอนต่อไป", "โปรดติดตามต่อไป", "ขอบคุณครับ", "ขอบคุณสำหรับการรับชม", "thank you"]
def get_readable_file_size(file_path):
try:
size_bytes = os.path.getsize(file_path)
for unit in ['Bytes', 'KB', 'MB', 'GB']:
if size_bytes < 1024.0:
return f"{size_bytes:.2f} {unit}"
size_bytes /= 1024.0
except Exception:
return "Unknown"
def get_video_duration_ffprobe(file_path):
try:
cmd = [
"ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nocikey=1", str(file_path)
]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
return float(result.stdout.strip())
except Exception:
return 0.0
def format_duration_hh_mm_ssss(duration_sec):
hours = int(duration_sec // 3600)
minutes = int((duration_sec % 3600) // 60)
seconds = duration_sec % 60
sec_str = f"{seconds:07.4f}".replace('.', ':')
return f"{hours:02d}:{minutes:02d}:{sec_str}"
def format_seconds_to_hh_mm_ss(seconds_int):
h = int(seconds_int // 3600)
m = int((seconds_int % 3600) // 60)
s = int(seconds_int % 60)
return f"{h:02d}:{m:02d}:{s:02d}"
def format_seconds_to_hh_mm_ss_ms(seconds_float):
total_sec = int(seconds_float)
ms = int((seconds_float - total_sec) * 1000)
h = total_sec // 3600
m = (total_sec % 3600) // 60
s = total_sec % 60
return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
def clean_hallucination_inline(text):
words = text.split()
if not words:
return text
match_pairs = re.findall(r'(.+?)(?:\s+\1){4,}', text)
if match_pairs:
for repeated_word in match_pairs:
repeated_word_clean = repeated_word.strip()
text = re.sub(rf'({re.escape(repeated_word_clean)})(?:\s+\1)+', r'\1', text)
return text.strip()
def parse_start_time_to_seconds(time_arg):
if not time_arg:
return 0
time_arg = str(time_arg).strip()
if ":" in time_arg:
parts = time_arg.split(":")
if len(parts) == 2:
hours = int(parts[0])
minutes = int(parts[1])
return (hours * 3600) + (minutes * 60)
elif len(parts) == 3:
return (int(parts[0]) * 3600) + (int(parts[1]) * 60) + int(parts[2])
else:
try:
return int(time_arg) * 60
except ValueError:
return 0
def enqueue_output(out, queue):
for line in iter(out.readline, ''):
queue.put(line)
out.close()
def flush_block_to_file(file_path, interval_idx, text_list):
"""ฟังก์ชันช่วยเขียนบล็อก 5 นาทีที่เสร็จสมบูรณ์ลงไฟล์ Obsidian ทันที"""
if not text_list:
return
block_start_sec = interval_idx * 300
block_end_sec = block_start_sec + 300
time_tag = f"[{format_seconds_to_hh_mm_ss(block_start_sec)}] - [{format_seconds_to_hh_mm_ss(block_end_sec)}]"
combined_chunk = " ".join(text_list)
with open(file_path, "a", encoding="utf-8") as f_append:
f_append.write(f"### 🕒 {time_tag}\n{combined_chunk}\n\n")
f_append.flush()
# ==========================================
# MAIN EXECUTION
# ==========================================
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="PTT Meeting Minutes Transcriber Suite")
parser.add_argument("-f", "--file", default=None, help="ชื่อไฟล์วิดีโอบางส่วนเพื่อเจาะจงรันเดี่ยว")
parser.add_argument("-t", "--time", default=None, help="เวลาเริ่มต้นที่จะฟังต่อ")
args = parser.parse_args()
print("🚀 Starting Persistent Video Processing Pipeline...")
video_files = []
is_partial_mode = False
global_offset_seconds = parse_start_time_to_seconds(args.time)
if args.file:
print(f"[Target Mode] Searching for file containing: '{args.file}'")
found_file = next(SOURCE_DIR.rglob(f"*{args.file}*"), None)
if found_file and found_file.suffix.lower() in (".avi", ".mkv", ".mov", ".mp4"):
video_files.append(found_file.resolve())
if global_offset_seconds > 0:
is_partial_mode = True
print(f"[Voice Seeking Mode] Aligned target timeline offset: {format_seconds_to_hh_mm_ss(global_offset_seconds)} ({global_offset_seconds} sec)")
else:
print(f"❌ ไม่พบไฟล์วิดีโอที่ระบุด้วยคำค้น '{args.file}' ใน {SOURCE_DIR}")
sys.exit(1)
else:
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Scanning source directory (Batch Mode)...")
video_files = sorted([f.resolve() for f in SOURCE_DIR.rglob("*") if f.suffix.lower() in (".avi", ".mkv", ".mov", ".mp4")])
print(f"Total video files to check: {len(video_files)}")
for video_path in video_files:
relative_dir = video_path.parent.relative_to(SOURCE_DIR)
target_folder = OBSIDIAN_DIR / relative_dir
target_folder.mkdir(parents=True, exist_ok=True)
video_stem = video_path.stem
output_path_md = target_folder / f"{video_stem}_mom.md"
output_path_summaries = target_folder / f"{video_stem}_summaries.md"
if not is_partial_mode and output_path_md.exists() and output_path_summaries.exists():
print(f"[SKIP] Already processed in {relative_dir if str(relative_dir) != '.' else 'root'}: {video_path.name}")
continue
start_process_time_raw = time.time()
raw_duration_sec = get_video_duration_ffprobe(video_path)
video_duration_str = format_duration_hh_mm_ssss(raw_duration_sec)
now = datetime.now()
start_process_time = now.strftime("%Y-%m-%d %H:%M:%S")
sec_with_micro = f"{now.second}.{now.microsecond:04d}".replace('.', ':')
formatted_meta_date = f"{now.strftime('%Y-%m')}-{now.strftime('%H:%M')}:{sec_with_micro}"
file_size_str = get_readable_file_size(video_path)
metadata_block = f'''> [!info] **📋 Session Metadata & Attendance**
> - 📅 **Original File Date:** `{formatted_meta_date}`
> - 🎬 **Reference Video Path:** `{video_path}`
> - 📦 **Video File Size:** `{file_size_str}`
> - ⏳ **Total Video Duration:** `{video_duration_str}`
> - ⚙️ **AI Processing Engine:** `whisper.cpp + Safe Block Flush Strategy`
> - ⏱️ **Transcription Start Time:** `{start_process_time}`'''
print(f"\n==================================================")
print(f"[Processing] Starting execution pipeline for: {video_path.name}")
print(f"==================================================")
# วางโครงหัวไฟล์ก่อนเริ่มสตรีมข้อมูล
if not is_partial_mode or not output_path_md.exists():
with open(output_path_md, "w", encoding="utf-8") as f_init:
f_init.write(f"# 📂 Transcribed Knowledge Base: {video_path.name}\n\n{metadata_block}\n\n## 🕒 Timeline Log (สเปกช่วงละ 5 นาทีสากล)\n\n")
with open(output_path_summaries, "w", encoding="utf-8") as f_sum:
f_sum.write(f"# 📝 Additional Summaries & Personal Notes: {video_path.name}\n\n> [!info] **🔗 Linked Assets**\n> - 📑 **Primary MoM Note:** [[{output_path_md.name}]]\n> - 🎬 **Source Video:** `{video_path.name}`\n\n---\n\n## ✒️ Custom Notes & Takeaways\n")
current_seek_offset = global_offset_seconds
timestamp_regex = re.compile(r"^\[(\d{2}):(\d{2}):(\d{2})\.(\d{3})\s+-->\s+(\d{2}):(\d{2}):(\d{2})\.(\d{3})\](.*)")
is_first_loop_segment = True
last_sentence_end_total_sec = global_offset_seconds
# โครงสร้างหน่วยความจำสำหรับบัฟเฟอร์ข้อมูลช่วงละ 5 นาที
active_interval_block = -1
buffered_text_list = []
while True:
if raw_duration_sec > 0 and current_seek_offset >= int(raw_duration_sec):
break
print(f"\n--- [Loop Segment] Scanning from point: {format_seconds_to_hh_mm_ss(current_seek_offset)} ---")
temp_wav_path = target_folder / f"current_processing_audio_{int(time.time())}.wav"
ffmpeg_cmd = ["ffmpeg", "-y", "-ss", format_seconds_to_hh_mm_ss(current_seek_offset), "-i", str(video_path), "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", str(temp_wav_path)]
try:
subprocess.run(ffmpeg_cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception:
print("❌ Audio extraction failed")
break
whisper_cmd = [WHISPER_CLI_PATH, "-m", str(WHISPER_MODEL_PATH), "-l", str(WHISPER_LANGUAGE), "-f", str(temp_wav_path)]
process = subprocess.Popen(whisper_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
q = Queue()
t = threading.Thread(target=enqueue_output, args=(process.stdout, q))
t.daemon = True
t.start()
last_activity_time = time.time()
segment_has_real_speech = False
highest_segment_end_sec = 0
previous_line_text = ""
consecutive_repetition_count = 0
should_skip_due_to_hallucination = False
while True:
try:
line = q.get(timeout=1.0)
except Empty:
if process.poll() is not None:
break
if time.time() - last_activity_time > WHISPER_TIMEOUT_SEC:
print(f"⚠️ [Timeout] Whisper frozen in silence for {WHISPER_TIMEOUT_SEC}s. Auto-resuming...")
process.terminate()
process.wait()
break
continue
clean_line = line.strip()
match = timestamp_regex.match(clean_line)
if match:
last_activity_time = time.time()
start_sec_raw = (int(match.group(1)) * 3600) + (int(match.group(2)) * 60) + int(match.group(3)) + (int(match.group(4)) / 1000.0)
end_sec_raw = (int(match.group(5)) * 3600) + (int(match.group(6)) * 60) + int(match.group(7)) + (int(match.group(8)) / 1000.0)
current_text = match.group(9).strip()
current_text_cleaned = clean_hallucination_inline(current_text)
is_hallucination = any(kw in current_text_cleaned for kw in HALLUCINATION_KEYWORDS)
if is_hallucination and not (is_partial_mode and is_first_loop_segment):
print(f"🛑 [DeadAir Detector] Hallucination token caught ('{current_text_cleaned}'). Skipping ahead safely...")
should_skip_due_to_hallucination = True
process.terminate()
process.wait()
break
if current_text_cleaned == previous_line_text and current_text_cleaned != "":
consecutive_repetition_count += 1
else:
previous_line_text = current_text_cleaned
consecutive_repetition_count = 0
if consecutive_repetition_count >= REPETITION_THRESHOLD:
continue
segment_has_real_speech = True
highest_segment_end_sec = max(highest_segment_end_sec, end_sec_raw)
sync_start_sec = start_sec_raw + current_seek_offset
sync_end_sec = end_sec_raw + current_seek_offset
# คำนวณบล็อกเวลาหลัก (กล่องละ 5 นาที)
interval_block = int(sync_start_sec // 300)
# [กลไก Auto-Flush] หากข้อความวิ่งทะลุข้ามกล่อง 5 นาทีเดิม ให้ทำการ Flush ของเก่าลงแผ่นดิสก์ทันที
if active_interval_block != -1 and interval_block != active_interval_block:
flush_block_to_file(output_path_md, active_interval_block, buffered_text_list)
buffered_text_list = []
active_interval_block = interval_block
# ตรวจหา Gap ช่วงเงียบภายในบล็อก
if sync_start_sec - last_sentence_end_total_sec >= SILENCE_THRESHOLD_SEC:
silence_notice = f"*[ไม่มีเสียง ช่วง {format_seconds_to_hh_mm_ss(last_sentence_end_total_sec)} - {format_seconds_to_hh_mm_ss(sync_start_sec)}]*"
buffered_text_list.append(silence_notice)
if current_text_cleaned:
buffered_text_list.append(current_text_cleaned)
last_sentence_end_total_sec = sync_end_sec
print(f"Buffered -> [{format_seconds_to_hh_mm_ss(sync_start_sec)}] {current_text_cleaned}")
if temp_wav_path.exists():
os.remove(temp_wav_path)
is_first_loop_segment = False
# ป้องกันข้อมูลค้างกรณีลูปสะดุดขาดตอน: สั่งเขียนบล็อกปัจจุบันเก็บลงดิสก์ก่อนปิดเซกเมนต์ย่อย
if buffered_text_list and should_skip_due_to_hallucination:
flush_block_to_file(output_path_md, active_interval_block, buffered_text_list)
buffered_text_list = []
if should_skip_due_to_hallucination:
current_seek_offset += 30
elif segment_has_real_speech and highest_segment_end_sec > 0:
current_seek_offset += int(highest_segment_end_sec) + 1
else:
current_seek_offset += 60
if process.returncode == 0 and not segment_has_real_speech and not should_skip_due_to_hallucination:
break
# เก็บตก Flush บล็อกเวลาสุดท้ายที่ค้างอยู่ในความจำหลัก
if buffered_text_list:
flush_block_to_file(output_path_md, active_interval_block, buffered_text_list)
# ตรวจสอบความเงียบยาวปิดท้ายคลิป
if raw_duration_sec > 0 and raw_duration_sec - last_sentence_end_total_sec >= SILENCE_THRESHOLD_SEC:
with open(output_path_md, "a", encoding="utf-8") as f_append:
f_append.write(f"### 🕒 Final Boundary\n*[ไม่มีเสียง ช่วง {format_seconds_to_hh_mm_ss(last_sentence_end_total_sec)} - {format_seconds_to_hh_mm_ss(int(raw_duration_sec))}]*\n\n")
# เขียนบันทึกปิดท้าย Pipeline
with open(output_path_md, "a", encoding="utf-8") as f_append:
f_append.write(f"---\n🏁 **Processing End Time:** `{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}`\nStatus: Successfully transcribed\n")
elapsed_total_min = (time.time() - start_process_time_raw) / 60
print(f"\n Pipeline finalized cleanly. (Execution time: {elapsed_total_min:.2f} mins)")
print("=" * 60)
sys.exit(0)
⏱️ เคล็ดลับสำคัญ: ป้องกัน Mac หลับด้วย caffeinate
เนื่องจากคลิปการประชุมมักจะมีความยาว และการประมวลผล AI ขนาดใหญ่ (Large-v3 Model) ต้องใช้เวลา หากปล่อย Mac ทิ้งไว้ หน้าจออาจจะดับหรือเครื่องเข้าสู่ Sleep Mode ทำให้กระบวนการทั้งหมดหยุดชะงัก
เราจึงต้องใช้คำสั่ง caffeinate ซึ่งเป็น Command Line พื้นฐานของ macOS ในการสั่งให้เครื่องตื่นตัวตลอดเวลาตราบใดที่ Script ยังทำงานอยู่
วิธีการรัน Script ร่วมกับ caffeinate
เปิด Terminal แล้วใช้คำสั่งนี้ในการเริ่มระบบcaffeinate -i python3 batch_transcribe_whisper_cpp.py && sudo shutdown -h now
คำสั่งนี้สามารถแยกโครงสร้างออกเป็น 3 ส่วนหลัก ที่ทำงานร่วมกันดังนี้ครับ
คำสั่งนี้สามารถแยกโครงสร้างออกเป็น 3 ส่วนหลัก ที่ทำงานร่วมกันดังนี้ครับ:
1. caffeinate -i python3 batch_transcribe_whisper_cpp.py (ตื่นไว้จนกว่าจะเสร็จ)
caffeinate: เป็นคำสั่ง built-in ของ macOS เปรียบเสมือนการป้อนกาแฟให้ Mac เพื่อสั่งห้ามไม่ให้เครื่องแอบหลับ (Sleep) ระหว่างที่ทำงานหนัก-i: ย่อมาจาก Idle sleep prevention เป็นการเจาะจงว่า “ตราบใดที่โปรแกรมที่พ่วงท้ายยังรันอยู่ ห้ามเครื่องหลับเด็ดขาด” แม้ว่าหน้าจอจะดับไปแล้วก็ตามpython3 batch_transcribe_whisper_cpp.py: คือโปรแกรม Python ที่ทำหน้าที่แปลงไฟล์เสียงของคุณ
สรุปส่วนที่ 1: Mac จะตื่นตัวและจ่ายไฟเต็มกำลังให้โปรแกรม Whisper.cpp แปลงไฟล์วิดีโอจนกว่าจะเสร็จสิ้นทุกไฟล์
2. && (ตัวเชื่อมเงื่อนไข “ถ้าสำเร็จ…ให้ทำต่อ”)
- ในทาง Command Line เครื่องหมาย
&&คือการเชื่อมคำสั่งแบบมีเงื่อนไข หมายความว่า “ให้คำสั่งแรก (ส่วนที่ 1) ทำงานให้เสร็จสมบูรณ์และไม่มีฟ้อง Error ก่อน ถึงจะยอมให้ทำคำสั่งถัดไป (ส่วนที่ 3)” - หากโปรแกรม Python เกิด Crash หรือคุณกด
Ctrl + Cเพื่อสั่งหยุดกลางคัน ระบบจะไม่ทำคำสั่งปิดเครื่องหลังจากนั้น เพื่อความปลอดภัยของข้อมูลครับ
3. sudo shutdown -h now (ปิดเครื่องทันที)
sudo: ย่อมาจาก SuperUser DO เป็นการขอสิทธิ์ผู้ดูแลระบบ (Admin) เนื่องจากคำสั่งปิดเครื่องเป็นคำสั่งระดับโครงสร้างระบบshutdown: คำสั่งสั่งปิดการทำงานของระบบปฏิบัติการ-h: ย่อมาจาก Halt หมายถึงให้ตัดการทำงานของฮาร์ดแวร์และดับไฟเครื่องทั้งหมด (ปิดเครื่องสนิท ไม่ใช่แค่รีสตาร์ท)now: สั่งให้ทำทันที ณ วินาทีนั้น โดยไม่ต้องตั้งเวลานับถอยหลัง
🔄 ลำดับเหตุการณ์จริงเมื่อคุณกดรันคำสั่งนี้:
- Terminal จะถามรหัสผ่านเครื่อง Mac ทันที (จาก
sudo): คุณต้องพิมพ์รหัสผ่านของเครื่องแล้วกด Enter (ตอนพิมพ์รหัสจะไม่ขึ้นตัวอักษรใด ๆ เป็นเรื่องปกติของความปลอดภัยบน Mac) - Python เริ่มทำงาน สแกนและถอดความเสียงการประชุมไปเรื่อย ๆ โดยมี
caffeinateคอยค้ำไม่ให้เครื่องหลับ - เมื่อ Python แปลงไฟล์จนหมดโฟลเดอร์ และจบโปรแกรมลงอย่างสมบูรณ์
- เงื่อนไข
&&ทำงาน ส่งไม้ต่อให้sudo shutdown -h now - Mac ของคุณจะทำกระบวนการปิดเครื่อง (Shutdown) ตัวเองโดยอัตโนมัติทันทีครับ เหมาะมากสำหรับการเปิดรันทิ้งไว้ก่อนนอน หรือก่อนเลิกงานครับ
อ่านเพิ่มเติม