วิดีโอ 4K เล่นไม่ขึ้น เพราะซับภาพบังคับให้เซิร์ฟเวอร์แปลงทั้งเรื่อง
เซิร์ฟเวอร์วิดีโอตัวเล็กไม่ได้ตายเพราะไฟล์ใหญ่ มันตายเพราะถูกขอให้ถอดรหัสแล้วเข้ารหัสใหม่ทั้งเรื่อง และคนที่ขอมักไม่ใช่ภาพ แต่เป็นแทร็กซับไตเติลแบบภาพที่ player ปลายทางวาดเองไม่ได้
สรุปใน 1 นาที
- อาการ/ปัญหา
- Jellyfin บน LXC 2 core เล่นไฟล์ 4K ไม่ขึ้น เพราะซับแบบภาพบังคับฝังลงภาพได้แค่ 8 fps
- ใครควรอ่าน
- คนตั้ง media server เล็กที่บ้านบน LXC หรือเครื่องสเปกต่ำ
- ทางแก้แบบสั้น
- อ่านเหตุผลจาก log ปิดการแปลงวิดีโอ แล้วเปลี่ยนซับภาพเป็น SRT และซ่อมสระลอยจาก OCR
พอดีผมตั้งคลังวิดีโอในบ้านไว้บนเครื่อง Proxmox ที่มี VM งานอื่นรันอยู่แล้ว ใช้ Jellyfin ใน container ตัวเล็ก ๆ ไฟล์ทั่วไปเล่นได้ปกติ จนวันหนึ่งคนในบ้านกดเล่นไฟล์ 4K แล้วจอหมุนค้าง ไม่ขึ้นภาพ ผมไล่โทษ Wi-Fi โทษทีวี โทษพอร์ต LAN ไปทีละอย่าง สุดท้ายคำตอบอยู่ใน log ของเซิร์ฟเวอร์บรรทัดเดียว คือมันกำลังฝังซับไตเติลลงบนภาพ 4K ด้วย CPU 2 core ได้ความเร็ว 8 fps เลยเอาลำดับการไล่กับทางแก้มาเขียนเก็บไว้
ชื่อไฟล์ ชื่อบัญชี และเลข IP ในบทความนี้ผมเปลี่ยนเป็นค่าตัวอย่างทั้งหมด ส่วนตัวเลขที่วัดได้กับข้อความใน log เป็นของจริง
คลังวิดีโอบน LXC ที่ตั้งใจให้กวนเครื่องน้อยที่สุด
โฮสต์ตัวนี้งานหลักคือ VM อื่น ผมเลยตั้ง Jellyfin เป็น LXC แบบ unprivileged บน Debian แยกต่างหาก แล้วบีบทรัพยากรไว้ตั้งแต่แรก โฟลเดอร์วิดีโอบน HDD ของโฮสต์ mount เข้า container แบบอ่านอย่างเดียว ตัว container เสียบเข้า bridge เดิม ไม่แตะ network ของโฮสต์เลย
# /etc/pve/lxc/200.conf (เลข container เป็นค่าตัวอย่าง)
arch: amd64
cores: 2
cpuunits: 50
memory: 1536
swap: 512
rootfs: local:200/vm-200-disk-0.raw,size=16G
mp0: /mnt/data/video,mp=/media/video,ro=1
startup: order=99,up=60
unprivileged: 1
cpuunits: 50 คือครึ่งหนึ่งของ guest ปกติ เวลา CPU แย่งกัน container ตัวนี้จะได้คิวทีหลัง rootfs 16G วางบนดิสก์ OS ไม่ไปปนกับ pool ของ VM และ startup: order=99 ให้มันขึ้นเป็นตัวท้าย ๆ หลังงานหลักขึ้นครบ
ใน container ผมวาง nftables เปิดพอร์ต 8096 ให้เฉพาะ IPv4 ในวง LAN ส่วน IPv6 ปิดทั้งหมด เพราะ container ได้ IPv6 สาธารณะมาเองจาก router ถ้าไม่ปิดก็เท่ากับเปิดหน้าล็อกอินออกอินเทอร์เน็ต พร้อมกันนั้นปิด DLNA กับ auto-discovery (UDP 7359) ไปด้วย
# /etc/nftables.conf ใน container
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
iif "lo" accept
ct state established,related accept
meta nfproto ipv6 drop
ip saddr 192.168.10.0/24 tcp dport 8096 accept
}
}
เกร็ดเรื่อง firewall ที่ผมพลาดเองคือรอบแรกผมล็อกให้เข้าได้จาก IP ของทีวีเครื่องเดียว วันรุ่งขึ้นคนในบ้านแจ้งว่า "เซิร์ฟเวอร์ดับ ไม่ auto start" ผมเข้าไปดู เซิร์ฟเวอร์ไม่เคยดับเลย ที่เปลี่ยนคือ IP ของทีวี DHCP แจกใหม่จาก 192.168.10.190 เป็น 192.168.10.134 กฎเดิมเลยกันทีวีออก ผมเปลี่ยนเป็นเปิดทั้งวง LAN แล้วพึ่งการล็อกอินของ Jellyfin แทน
อาการแรก: ไฟล์ 4K กดเล่นแล้วไม่ขึ้น
ไฟล์ที่มีปัญหาเป็น 4K HEVC 10-bit HDR bitrate ราว 80 Mbps กดเล่นแล้วหมุนค้าง สิ่งที่ผมเดาไล่ไปตามลำดับคือ Wi-Fi ไม่พอ ทีวีไม่แรงพอจะถอดรหัส และพอร์ต LAN ที่เสียบอยู่วิ่งแค่ 100 Mbps ซึ่งข้อหลังนี่เป็นจริง ผมเลยย้ายไปพอร์ต 1 Gbps แต่ไฟล์นั้นก็ยังเล่นไม่ขึ้นเหมือนเดิม แปลว่าไม่ใช่สาเหตุของเคสนี้
พอเปิด log ของ Jellyfin ถึงเห็นว่าเซิร์ฟเวอร์ไม่ได้ส่งไฟล์ต้นฉบับ มันสั่ง ffmpeg ให้ฝังซับไทยแบบ PGS ซึ่งเป็นซับแบบภาพ (แทร็กที่ติดธง Forced) ลงไปบนภาพ 4K เพราะ player ตัวนั้นรับซับภาพแยกไปวาดเองไม่ได้ บรรทัดความคืบหน้าของ ffmpeg บอกตัวเลขตรง ๆ
# ดู log การแปลงล่าสุดใน container (ตัดให้เหลือเฉพาะค่าที่ใช้)
$ ls -t /var/log/jellyfin/FFmpeg.Transcode-*.log | head -1 | xargs tail -n 3
frame= ... fps=8.0 ... speed=0.33x
วิดีโอ 24 fps ที่แปลงได้ 8 fps คือได้ภาพ 1 วินาทีจากการทำงาน 3 วินาที player รอ buffer เท่าไรก็ไม่มีวันทัน ระหว่างนั้น ffmpeg ที่ค้างอยู่ทำให้ทั้งโฮสต์อืดไปราว 12 นาที ทั้งที่ผมตั้ง cpuunits ไว้ต่ำแล้ว
ซับ PGS เก็บเป็นรูปภาพทีละเฟรม ไม่มีตัวอักษรให้ส่งไปแสดงผล ถ้า player ปลายทางวาดภาพซับซ้อนทับเองไม่ได้ ทางเดียวที่เซิร์ฟเวอร์ทำได้คือวาดซับลงบนภาพแล้วเข้ารหัสวิดีโอใหม่ทั้งเรื่อง แทร็กซับที่เปิดอยู่แทร็กเดียวเลยเปลี่ยนงาน "ส่งไฟล์" ให้กลายเป็นงาน "เข้ารหัส 4K" ได้ทันที
เซิร์ฟเวอร์ตัดสินใจอย่างไรว่าจะส่งแบบไหน
Jellyfin มีวิธีส่งวิดีโอสามชั้น เรียงจากเบาไปหนัก มันจะพยายามใช้ชั้นบนสุดก่อน แล้วถูกผลักลงมาทีละชั้นเมื่อ player บอกว่ารับไม่ได้
Direct play คือส่งไฟล์ต้นฉบับไปทั้งอย่างนั้น เซิร์ฟเวอร์ทำหน้าที่เป็นแค่ที่เก็บไฟล์ Remux (ในหน้าจอของ Jellyfin เรียก direct stream) คือแกะ stream ภาพกับเสียงออกจาก container เดิมแล้วห่อใส่ container ที่ player เปิดได้ ไม่มีการถอดรหัสภาพ ใช้ CPU น้อยมาก ส่วน transcode คือถอดรหัสภาพทุกเฟรมแล้วเข้ารหัสใหม่ ซึ่งเป็นงานที่ container 2 core ตัวนี้ไม่ได้ถูกออกแบบมาให้ทำ
เหตุผลที่ผลักงานลงชั้นล่างมีสามกลุ่มที่ผมเจอ คือ container ที่ player ไม่เปิด codec ซับที่ player ไม่วาด และเพดาน bitrate หรือความละเอียดที่ตั้งไว้ในแอป ทุกครั้งที่เซิร์ฟเวอร์ตัดสินใจ มันเขียนวิธีที่เลือกกับเหตุผลไว้ใน log ให้อ่านได้เลย ไม่ต้องเดา
อาการที่สอง: ไฟล์ 1080p ธรรมดาค้างกลางเรื่อง
อีกสัปดาห์ถัดมา ไฟล์ 1080p H.264 ธรรมดาเล่นไปแล้วค้างกลางเรื่อง เดาแรกของผมคือไฟล์เสียตรงจุดนั้น ผมเลยให้ ffmpeg ถอดรหัสเฉพาะช่วงที่ค้างแล้วทิ้งผลลัพธ์ ถ้าไฟล์เสียจะมี error ออกมา
# ถอดรหัสช่วงที่ค้าง 2 นาทีแล้วทิ้งผล ถ้าไม่มีอะไรพิมพ์ออกมา = ไฟล์ไม่เสีย
ffmpeg -v error -ss 00:41:00 -t 120 -i /media/video/sample.mkv -map 0:v:0 -f null -
# ดูว่า cgroup ของ container ถูกบีบหน่วยความจำกี่ครั้ง (รันบนโฮสต์)
grep -E '^(high|oom_kill) ' /sys/fs/cgroup/lxc/200/memory.events
high 10996
oom_kill 0
ไฟล์ไม่มี error สักบรรทัด แต่ตัวเลขฝั่งหน่วยความจำบอกเรื่องอื่น ตอนเล่น RAM ของ container ขึ้นไป 1467 จาก 1536 MB ตัวนับ high ใน memory.events ขึ้นไป 10,996 ครั้ง ส่วน oom_kill เป็น 0 แปลว่า container ไม่ได้ถูกฆ่า แต่ถูก kernel ชะลอทุกครั้งที่ชนเพดาน memory.high อาการที่เห็นจากหน้าจอเลยเป็นค้างเป็นช่วง ไม่ใช่ดับ
คนในบ้านเจอเองข้อหนึ่งว่าในแอปตั้ง max resolution ไว้ที่ 4K ทำให้ไฟล์ 1080p ถูกขยายแล้วเข้ารหัสใหม่ ซึ่งก็เป็นภาระเพิ่มจริง แต่พอผมไปอ่าน log ของ session นั้น เหตุผลที่เซิร์ฟเวอร์เขียนไว้คืออีกเรื่อง
# เหตุผลของ session ที่ค้าง (รูปแบบย่อจาก log ของเซิร์ฟเวอร์)
method = transcode, reasons = ContainerNotSupported, SubtitleCodecNotSupported
แอปตัวนั้นใช้ player ของระบบบนกล่องทีวี ซึ่งไม่เปิดไฟล์ .mkv ตรง ๆ และไม่รับ codec ซับของไฟล์ เลยขอให้เซิร์ฟเวอร์แปลง ที่ผมไม่คาดคือ แม้ policy ของบัญชีจะตั้งว่าห้ามแปลงวิดีโอ แอปก็ยังเรียกปลายทาง transcode ของเซิร์ฟเวอร์ตรง ๆ ได้ เพราะงั้นการตั้ง policy อย่างเดียวไม่พอ ต้องกลับไปอ่าน log ยืนยันทุกครั้งว่า session จริงใช้วิธีไหน
เวลาเล่นไม่ขึ้นหรือค้าง ให้ดูก่อนว่า session นั้นเป็น direct play, remux หรือ transcode และเหตุผลคืออะไร ถ้าเป็น transcode บนเครื่องที่ไม่มีตัวเร่ง ปัญหาอยู่ที่ CPU กับหน่วยความจำของเซิร์ฟเวอร์ ไม่ใช่ที่สาย LAN ผมเสียเวลาเปลี่ยนพอร์ตไปหนึ่งรอบเพราะข้ามขั้นนี้
GPU ในเครื่องช่วยไม่ได้
คำถามถัดมาคือให้ GPU ช่วยแปลงได้ไหม เครื่องนี้ใช้ CPU เดสก์ท็อปรุ่นเก่าซึ่งไม่มี iGPU การ์ดจอที่เสียบอยู่เป็นรุ่นเก่าระดับล่างซึ่งไม่มี NVENC และโฮสต์ใช้ driver nouveau ใน container ไม่มี /dev/dri ให้ใช้เลย
จุดที่หลอกคือรายชื่อ encoder ที่ ffmpeg พิมพ์ออกมา มี h264_nvenc, h264_qsv, h264_vaapi ครบ ซึ่งแปลว่า ffmpeg ถูก compile ให้รู้จัก encoder พวกนี้ ไม่ได้แปลว่าเครื่องมีฮาร์ดแวร์ให้มันใช้
# รายชื่อนี้คือ compile-in ไม่ใช่ของที่ใช้ได้จริง
/usr/lib/jellyfin-ffmpeg/ffmpeg -hide_banner -encoders | grep -E 'nvenc|qsv|vaapi'
# ของจริงต้องมีอุปกรณ์ให้เห็นใน container
ls -l /dev/dri
ls: cannot access '/dev/dri': No such file or directory
ffmpeg มี encoder ฮาร์ดแวร์ครบ
รายชื่อ nvenc, qsv, vaapi โผล่ในผลลัพธ์ของ -encoders ดูเหมือนเปิดใช้ตัวเร่งได้ทันที
ไม่มีอุปกรณ์ให้ใช้สักตัว
CPU ไม่มี iGPU การ์ดจอไม่มี NVENC และไม่มี /dev/dri ใน container เปิดตัวเร่งไปก็ได้แค่ error ทางที่เหลือคือทำให้เซิร์ฟเวอร์ไม่ต้องแปลงภาพเลย
ทางแก้: ปิดการแปลงวิดีโอ แล้วเลิกเปิดซับเป็นค่าเริ่มต้น
สรุปคือเครื่องนี้ต้องทำงานได้สองชั้นบนเท่านั้น ผมปิดการแปลงวิดีโอทุกบัญชี เปิดไว้แค่แปลงเสียงกับ remux ต้นทางของผมไม่ได้จดชื่อ field ไว้ ชื่อข้างล่างนี้ผมเอามาจากเอกสาร API สาธารณะของ Jellyfin ใครจะใช้ให้เทียบกับเวอร์ชันที่ตัวเองลงก่อน หรือจะติ๊กในหน้า Dashboard ของแต่ละบัญชีก็ได้ผลเท่ากัน
import json, os, urllib.request
BASE = "http://192.168.10.20:8096"
HDR = {
"Authorization": 'MediaBrowser Token="%s"' % os.environ["JF_API_KEY"],
"Content-Type": "application/json",
}
def call(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, headers=HDR, method=method)
with urllib.request.urlopen(req, timeout=15) as r:
raw = r.read()
return json.loads(raw) if raw else None
for u in call("GET", "/Users"):
pol = u["Policy"]
pol["EnableVideoPlaybackTranscoding"] = False # ห้ามเข้ารหัสภาพใหม่
pol["EnableAudioPlaybackTranscoding"] = True # แปลงเสียงได้ งานเบา
pol["EnablePlaybackRemuxing"] = True # ห่อ container ใหม่ได้
call("POST", "/Users/%s/Policy" % u["Id"], pol)
for n, u in enumerate(call("GET", "/Users"), 1):
p = u["Policy"]
print("user %d direct play %s / remux %s / transcoding %s" % (
n, p["EnableMediaPlayback"], p["EnablePlaybackRemuxing"],
p["EnableVideoPlaybackTranscoding"]))
ผลตรวจที่ต้องได้ต่อบัญชีคือ direct play True, remux True, transcoding False จากนั้นผมแก้ค่าเริ่มต้นฝั่งการเล่นของทุกบัญชีอีกสามข้อ
| ค่าที่ตั้ง | ตั้งเป็น | เหตุผล |
|---|---|---|
โหมดซับเริ่มต้น (SubtitleMode) |
ปิด (None) |
ไม่ให้แทร็กซับภาพถูกเปิดเองแล้วลากงานลงไปชั้น transcode |
ภาษาเสียงที่เลือกก่อน (AudioLanguagePreference) |
ไทย ถ้ามี | ลดโอกาสที่ต้องพึ่งซับตั้งแต่ต้น |
จำแทร็กที่เคยเลือก (RememberAudioSelections, RememberSubtitleSelections) |
ปิด | แทร็กซับภาพที่เคยกดไว้ครั้งเดียวจะไม่ตามมาเปิดเองในเรื่องถัดไป |
การตั้งแบบนี้มีผลข้างเคียงที่ผมไม่ได้คิดไว้ก่อน วิดีโอบางเรื่องมีบทพูดบางช่วงเป็นภาษาอื่นที่ต้องพึ่งซับแทร็ก Forced พอปิดซับเริ่มต้นและห้ามฝังซับลงภาพ บน player ที่วาดซับภาพเองไม่ได้ ซับช่วงนั้นหายไปเงียบ ๆ ไม่มี error ไม่มีคำเตือน คนดูแค่ไม่เข้าใจว่าตัวละครพูดอะไร
ทางออกคือเปลี่ยนซับภาพให้เป็นซับข้อความเสียครั้งเดียว ซับข้อความ player แทบทุกตัววาดเองได้ เซิร์ฟเวอร์ไม่ต้องแตะภาพ ผมดึงแทร็ก PGS ออกมา ส่งเข้าโปรแกรม OCR ซับไตเติล (ต้นทางผมไม่ได้จดว่าใช้ตัวไหน เลยไม่ระบุชื่อ ใช้ตัวที่ถนัดได้) แล้ววางผลเป็นไฟล์ซับภายนอกข้างไฟล์วิดีโอ ไฟล์วิดีโอเองไม่ถูกแก้สักไบต์ ได้มาสองแทร็กคือ Forced 230 บรรทัด กับซับเต็ม 540 บรรทัด
# ดูว่าแทร็กซับไหนเป็น PGS และแทร็กไหนติดธง forced
ffprobe -v error -select_streams s \
-show_entries stream=index,codec_name:stream_tags=language:stream_disposition=forced \
-of csv=p=0 /media/video/sample.mkv
# ดึงแทร็กซับลำดับที่ 2 ออกมาเป็น .sup โดยไม่เข้ารหัสใหม่
ffmpeg -i /media/video/sample.mkv -map 0:s:1 -c copy sample.th.forced.sup
# หลัง OCR แล้ว วางชื่อไฟล์ให้เซิร์ฟเวอร์จับคู่ได้เอง
# sample.th.forced.srt sample.th.srt
ซ่อมสระลอยจาก OCR
วางซับเสร็จ คนในบ้านแจ้งกลับมาทันทีว่า "สระลอยหมด" เปิดไฟล์ดูถึงเห็นว่า OCR แทรกช่องว่างไว้หน้าสระบน สระล่าง และวรรณยุกต์ ตัวอักษรถูกทุกตัว แต่สระไปลอยอยู่บนช่องว่างแทนที่จะเกาะพยัญชนะ ผมนับได้ 876 จุดในไฟล์ Forced และ 2,229 จุดในไฟล์เต็ม บวกสระอำที่ประกอบผิดอีก 1 จุดซึ่งแก้แยกต่างหาก
อักขระกลุ่มนี้ในภาษาไทยไม่มีทางอยู่หลังช่องว่างได้เลย กฎเลยเขียนได้ตรง ๆ คือลบช่องว่างที่อยู่หน้าอักขระช่วง U+0E31, U+0E34 ถึง U+0E3A และ U+0E47 ถึง U+0E4E เรื่องสระเพี้ยนจาก OCR ในบริบทนำเข้าข้อมูล ผมเคยเขียนไว้ใน บทความเรื่องย้ายข้อมูลไทย อันนี้เป็นกรณีของซับไตเติล ซึ่งมีข้อบังคับเพิ่มคือห้ามให้จำนวนบรรทัดกับ timestamp ขยับ
import re, shutil, sys
# สระบน สระล่าง วรรณยุกต์ ที่ต้องเกาะพยัญชนะตัวหน้าเสมอ
MARKS = "\u0e31\u0e34-\u0e3a\u0e47-\u0e4e"
GAP = re.compile("[ \u00a0]+(?=[" + MARKS + "])")
STAMP = re.compile(r"\d\d:\d\d:\d\d,\d{3}")
def fix(path, apply=False):
with open(path, encoding="utf-8-sig", newline="") as f:
text = f.read()
n = len(GAP.findall(text))
print("%s: ช่องว่างหน้าสระ/วรรณยุกต์ %d จุด" % (path, n))
if not apply or n == 0:
return n
fixed = GAP.sub("", text)
# ซับต้องยังตรงเวลาเดิม: จำนวนบรรทัดและ timestamp ห้ามเปลี่ยน
assert fixed.count("\n") == text.count("\n")
assert STAMP.findall(fixed) == STAMP.findall(text)
shutil.copy2(path, path + ".orig")
with open(path, "w", encoding="utf-8", newline="") as f:
f.write(fixed)
return len(GAP.findall(fixed))
if __name__ == "__main__":
do_apply = "--apply" in sys.argv
for p in sys.argv[1:]:
if p != "--apply":
print(" เหลือ", fix(p, do_apply))
รันแบบไม่ใส่ --apply ก่อนเพื่อดูจำนวน แล้วค่อยใส่จริง ผลคือทั้งสองไฟล์เหลือ 0 จุด จำนวนบรรทัดกับ timestamp เท่าเดิม และมีต้นฉบับเก็บไว้เป็น .srt.orig เผื่อต้องย้อน
ลำดับที่ผมใช้ตรวจตอนนี้
- เล่นไม่ขึ้นหรือค้าง ให้เปิด log ของเซิร์ฟเวอร์ก่อน ดูว่า session เป็น direct play, remux หรือ transcode และเหตุผลคืออะไร
- ถ้าเป็น transcode ดู fps กับ speed ของ ffmpeg ถ้าต่ำกว่า 1.00x ไม่มีทางเล่นลื่น ไม่ต้องไปไล่เครือข่าย
- ดู
memory.eventsของ cgroup ถ้าhighขึ้นเป็นหมื่นแต่oom_killเป็น 0 คือถูกชะลอ ไม่ได้ถูกฆ่า - ตรวจว่ามีตัวเร่งจริงไหมจาก
/dev/driไม่ใช่จากรายชื่อ encoder ของ ffmpeg - ถ้าไม่มีตัวเร่ง ปิดการแปลงวิดีโอทุกบัญชี ตั้งซับเริ่มต้นเป็นปิด แล้วยืนยันจาก log อีกรอบว่า session จริงไม่ได้ transcode
- แทร็กซับภาพที่จำเป็นต้องใช้ ให้ OCR เป็น SRT วางเป็นไฟล์นอก แล้วตรวจช่องว่างหน้าสระก่อนส่งให้คนดู
อีกทางที่ไม่ต้อง OCR เลยคือเลือก player ที่วาดซับภาพเองได้ ตัวแบบนั้นจะ direct play ไฟล์เดิมพร้อมซับ PGS ได้ทันที ใครมีอุปกรณ์ที่ลง player แบบนั้นได้ก็ตามสะดวกครับ ส่วนตัวผมเลือก OCR เพราะในบ้านมีอุปกรณ์หลายแบบ ซับข้อความใช้ได้กับทุกตัว
ข้อสุดท้ายที่มีคนถามคือเปิดไฟล์ผ่าน SMB จะเร็วกว่าไหม คำตอบคือไม่ต่างกัน direct play ก็คือการดึงไฟล์ต้นฉบับไปตรง ๆ อยู่แล้ว สิ่งที่ทำให้ช้าคือการแปลง ไม่ใช่โปรโตคอลที่ใช้ขนไฟล์ ตัวเลขทั้งหมดนี้เป็นของเครื่องผมเครื่องเดียว เครื่องที่มีตัวเร่งจริงจะได้คำตอบอีกแบบ ตรงไหนผมอธิบายตกหล่นหรือชื่อ field เปลี่ยนไปในเวอร์ชันใหม่ เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



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