ভিডিও মাস্টারি — পর্ব ২: শেখার জন্য একটা ট্রান্সকোডার বানাই
ffmpeg দিয়ে হাতে-কলমে — probe থেকে ল্যাডার, কি-ফ্রেম বাঁধা, HLS প্যাকেজিং, থাম্বনেইল, সাবটাইটেল, আর একটা ওয়ার্কার যেটা পুরো কাজটা নিজে করে।
ffmpeg দিয়ে হাতে-কলমে — probe থেকে ল্যাডার, কি-ফ্রেম বাঁধা, HLS প্যাকেজিং, থাম্বনেইল, সাবটাইটেল, আর একটা ওয়ার্কার যেটা পুরো কাজটা নিজে করে।
গল্পটা যেখান থেকে শুরু
আল-বিরুনি একটা ছোট কোর্স প্ল্যাটফর্ম বানাচ্ছে। প্রথম শিক্ষক হিসেবে ইবনে সিনা একটা লেকচার আপলোড করল — ৪২ মিনিটের একটা ফাইল, ২.৩ গিগাবাইট, ফোন থেকে রেকর্ড করা।
আল-বিরুনি ফাইলটা সার্ভারে রেখে দিল, আর পেজে একটা সাধারণ ভিডিও ট্যাগ বসিয়ে দিল। কাজ শেষ, ভাবল সে।
পরদিন অভিযোগ আসতে শুরু করল:
- সমরকন্দ থেকে: “প্লে দিলে এক মিনিট কিছুই হয় না, তারপর হঠাৎ চলে।”
- মোবাইল ডেটার একজন: “আধা ঘণ্টায় আমার পুরো ডেটা শেষ।”
- আরেকজন: “মাঝখানে টেনে নিতে গেলে সব আটকে যায়।”
- আইফোনের একজন: “আমার কাছে শুধু শব্দ আসে, ছবি কালো।”
চারটাই আলাদা সমস্যা, চারটারই কারণ পর্ব ১-এ ব্যাখ্যা করা আছে। আজ আমরা চারটাই ঠিক করব — হাতে।
আজকের লক্ষ্য: একটা ফাইল নেব, আর বের করব কয়েকটা কোয়ালিটির ছোট ছোট টুকরো, একটা প্লেলিস্ট, থাম্বনেইল আর সাবটাইটেল — যেটা যেকোনো নেটে, যেকোনো ডিভাইসে চলে। তারপর একটা ওয়ার্কার লিখব যেটা কাজটা নিজে থেকে করে।
১. যন্ত্রটার নাম ffmpeg
ভিডিও দুনিয়ায় প্রায় সবকিছুর নিচে একটাই জিনিস চলে: ffmpeg। ইউটিউব থেকে ছোট স্টার্টআপ — সবাই কোনো না কোনোভাবে একে ব্যবহার করে।
ffmpeg-কে ভয় লাগে কারণ তার কমান্ডগুলো দেখতে মন্ত্রের মতো। কিন্তু ভেতরে সে খুব সরল পাঁচটা ধাপে কাজ করে:
- ডিমাক্স — কন্টেইনার খুলে ভেতরের ভিডিও, অডিও, সাবটাইটেল আলাদা করা।
- ডিকোড — সংকুচিত ডেটা থেকে কাঁচা ফ্রেম বানানো।
- ফিল্টার — ফ্রেমের উপর কাজ: ছোট করা, ক্রপ, ঘোরানো, লোগো, শব্দের মাত্রা ঠিক করা।
- এনকোড — আবার চেপে ধরা, হয়তো অন্য কোডেকে।
- মাক্স — সব একসাথে একটা কন্টেইনারে ভরা।
“ট্রান্সকোডিং” শব্দটার মানে এইটুকুই — খুলে, বদলে, আবার বেঁধে দেওয়া।
মনে রাখার মতো একটা কথা: ডিকোড না করে শুধু খাম বদলানোকে বলে রিমাক্স, আর সেটা প্রায় তাৎক্ষণিক। mkv থেকে mp4 বানানো, বা টুকরো জোড়া দেওয়া — এসব রিমাক্স, ট্রান্সকোড না। কোথায় কোনটা লাগবে সেটা বোঝা বড় সাশ্রয়ের জায়গা।
শুরুর আগে দেখে নাও কী কী আছে:
ffmpeg -version
ffmpeg -encoders | grep -E "x264|x265|svt|aac"
ffmpeg -filters | grep -E "scale|loudnorm" ২. আগে probe, তারপর কিছু
ইনপুট নিয়ে অনুমান করা ভিডিও পাইপলাইনের সবচেয়ে বড় পাপ। তাই প্রথম কাজ — ফাইলটাকে জিজ্ঞেস করা সে কী।
ffprobe -v error -print_format json -show_format -show_streams lecture-01.mp4 উত্তরের গুরুত্বপূর্ণ অংশগুলো, আর প্রতিটা কেন দরকার:
{
"streams": [
{
"codec_name": "h264",
"width": 1920,
"height": 1080,
"r_frame_rate": "30000/1001",
"avg_frame_rate": "29917/1000",
"pix_fmt": "yuv420p",
"nb_frames": "75600",
"side_data_list": [{ "rotation": -90 }]
},
{
"codec_name": "aac",
"sample_rate": "44100",
"channels": 1
}
],
"format": { "duration": "2523.456", "bit_rate": "7290000" }
} এখান থেকে যা যা সিদ্ধান্ত হয়:
| দেখো | কেন |
|---|---|
width / height | ল্যাডারে কোন ধাপ পর্যন্ত যাবে — ইনপুটের চেয়ে বড় ধাপ বানানো টাকার অপচয় |
r_frame_rate বনাম avg_frame_rate | দুটো আলাদা মানে ভেরিয়েবল ফ্রেমরেট — অডিও সরে যাওয়ার ঝুঁকি |
pix_fmt | 4:2:0 না হলে অনেক ডিভাইসে চলবে না |
rotation | ফোনের ভিডিও উল্টো হয়ে যাওয়ার কারণ |
অডিও channels | মনো হলে ভুল করে স্টেরিও ধরে নিলে এক পাশে শব্দ যাবে |
duration | অগ্রগতি দেখানোর জন্য, আর অস্বাভাবিক লম্বা ফাইল আটকানোর জন্য |
| ভিডিও স্ট্রিম আছে কিনা | শুধু অডিও ফাইল ট্রান্সকোড করতে গেলে ffmpeg কাঁদবে |
আল-বিরুনির ফাইলে দুটো লাল পতাকা: ফ্রেমরেট পরিবর্তনশীল, আর ঘোরানোর তথ্য আছে। দুটোই এখনই ঠিক করে নেব।
৩. প্রথম ট্রান্সকোড
সবচেয়ে সহজ কাজ — ভিডিওটাকে 720p করো:
ffmpeg -i lecture-01.mp4 \
-vf scale=-2:720 \
-c:v libx264 -crf 23 -preset medium \
-pix_fmt yuv420p \
-c:a aac -b:a 128k \
-movflags +faststart \
lecture-01-720p.mp4 প্রতিটা লাইনের মানে:
-vf scale=-2:720— উচ্চতা ৭২০, চওড়াটা অনুপাত ঠিক রেখে হিসাব করো।-2মানে “জোড় সংখ্যায় নামাও” — কোডেকরা বিজোড় মাপ পছন্দ করে না।-1লিখলে বিজোড় হতে পারে আর তখন এনকোড ব্যর্থ হবে।-c:v libx264— H.264 কোডেক।-crf 23— কোয়ালিটির নব। ছোট মান = ভালো কোয়ালিটি + বড় ফাইল।-preset medium— এনকোডার কত খাটবে।-pix_fmt yuv420p— এটাই আল-বিরুনির “আইফোনে কালো স্ক্রিন” সমস্যার সমাধান। ইনপুট 4:2:2 বা 4:4:4 হলে আউটপুটও তাই হয়ে যেত, আর বহু ডিভাইস সেটা চালাতে পারে না।-movflags +faststart— সূচিপত্রটা ফাইলের শুরুতে সরাও। এটাই “প্লে দিলে এক মিনিট কিছুই হয় না” সমস্যার সমাধান।
CRF কীভাবে বাছবে
| CRF | ফল | কোথায় |
|---|---|---|
| ১৬–১৮ | কার্যত নিখুঁত, ফাইল বিশাল | মাস্টার/আর্কাইভ কপি |
| ২০–২৩ | দারুণ, চোখে ধরা পড়ে না | ল্যাডারের উপরের ধাপ |
| ২৪–২৬ | ভালো, খুঁটিয়ে দেখলে পার্থক্য | মাঝের ধাপ |
| ২৭–৩০ | চোখে পড়ে, কিন্তু দেখা যায় | নিচের ধাপ |
| ৩১+ | স্পষ্ট ব্লক আর ঘোলা ভাব | জরুরি না হলে না |
মনে রাখো, CRF মান কোডেকভেদে আলাদা। x264-এর ২৩ আর x265-এর ২৩ এক জিনিস না — x265-এ একই মান আরও বেশি কম্প্রেশন করে।
preset কীভাবে বাছবে
preset কোয়ালিটির নব না — এটা “সময় বনাম ফাইল সাইজ”-এর নব। একই CRF-এ সব preset প্রায় একই মান দেয়, শুধু ধীর preset কম বিট খরচ করে সেটা অর্জন করে।
| preset | গতি | একই মানে ফাইল |
|---|---|---|
| ultrafast | ১০× দ্রুত | ~৫০% বড় |
| veryfast | ৪× দ্রুত | ~২০% বড় |
| medium | ভিত্তি | ভিত্তি |
| slow | ২× ধীর | ~৫% ছোট |
| veryslow | ৬× ধীর | ~৮% ছোট |
হিসাবটা সোজা: যে ভিডিও লাখবার দেখা হবে, তার জন্য ধীর preset-এ এনকোড করা লাভজনক — একবার খরচ, বারবার সাশ্রয়। যে ভিডিও দশজন দেখবে, তার জন্য veryfast-ই যথেষ্ট।
আল-বিরুনির ২.৩ গিগার ফাইল এই এক কমান্ডেই নেমে এলো ৩৮০ মেগাবাইটে। কিন্তু কাজ শেষ না — এটা এখনো একটাই ফাইল, একটাই কোয়ালিটি।
৪. ইনপুটের সমস্যাগুলো ঠিক করা
ল্যাডার বানানোর আগে ইনপুটটাকে শৃঙ্খলায় আনতে হবে। এগুলোই সেই ছোট ছোট লাইন যেগুলো না দিলে পরে দিনভর বাগ খুঁজতে হয়।
ভেরিয়েবল ফ্রেমরেট ঠিক করা — নির্দিষ্ট ফ্রেমরেটে জোর করে আনো:
-vsync cfr -r 30 এতে ffmpeg দরকার হলে ফ্রেম নকল করবে বা ফেলে দেবে, কিন্তু সময়ের হিসাব ঠিক থাকবে। এই এক লাইন “অডিও ধীরে ধীরে সরে যায়” বাগটার প্রায় সব ক্ষেত্রেই যথেষ্ট।
ঘোরানো ঠিক করা — বেশিরভাগ ক্ষেত্রে ffmpeg নিজেই ঠিক করে, কিন্তু নিশ্চিত হতে চাইলে:
-vf "transpose=1,scale=-2:720" -metadata:s:v rotate=0 অডিও একরকম করা — স্যাম্পল রেট আর চ্যানেল নির্দিষ্ট করো:
-ar 48000 -ac 2 শব্দের মাত্রা সমান করা — বিভিন্ন শিক্ষকের ভিডিওতে যেন শব্দ একই রকম হয়:
-af loudnorm=I=-16:TP=-1.5:LRA=11 সংখ্যাগুলোর মানে: গড় লাউডনেস -১৬ LUFS (ওয়েব ভিডিওর প্রচলিত মান), সর্বোচ্চ চূড়া -১.৫ dB (যাতে কোথাও কেটে না যায়)। নিখুঁত ফল চাইলে দুই পাস লাগে, কিন্তু এক পাসেই ৯০% কাজ হয়।
সাবটাইটেল সরিয়ে রাখা — mp4-এ সব সাবটাইটেল ফরম্যাট ঢোকে না, তাই ভুল এড়াতে ভিডিও-অডিও স্পষ্ট করে বেছে নাও:
-map 0:v:0 -map 0:a:0 ৫. কি-ফ্রেম বেঁধে ফেলা
এটাই সেই ধাপ যেটা বাদ দিলে সবকিছু “প্রায় কাজ করে”, কিন্তু ধাপ বদলানোর সময় ছবি আটকে যায় বা কালো ঝিলিক দেয়।
মনে করো: প্রতিটা সেগমেন্ট কি-ফ্রেম দিয়ে শুরু হতে হবে, আর সব ধাপে সেগমেন্টের সীমানা একই সময়ে পড়তে হবে। তাই কি-ফ্রেম নির্দিষ্ট ছন্দে বসাতে হবে, আর এনকোডারকে নিজের ইচ্ছেমতো বাড়তি কি-ফ্রেম বসাতে দেওয়া যাবে না।
-g 60 -keyint_min 60 -sc_threshold 0 হিসাবটা সহজ: GOP দৈর্ঘ্য = ফ্রেমরেট × সেগমেন্টের সেকেন্ড।
৩০ fps, ২ সেকেন্ড সেগমেন্ট → -g 60
৩০ fps, ৪ সেকেন্ড সেগমেন্ট → -g 120
২৫ fps, ৪ সেকেন্ড সেগমেন্ট → -g 100
৬০ fps, ২ সেকেন্ড সেগমেন্ট → -g 120 -sc_threshold 0 মানে “দৃশ্য বদলালেও নিজে থেকে বাড়তি কি-ফ্রেম বসিও না”। না দিলে দৃশ্য বদলের জায়গায় এনকোডার বাড়তি কি-ফ্রেম বসাবে, আর ধাপগুলোর সীমানা এলোমেলো হয়ে যাবে।
আরও কড়াকড়ি চাইলে সময় ধরে বলা যায়:
-force_key_frames "expr:gte(t,n_forced*2)" মানে “প্রতি ২ সেকেন্ডে একটা কি-ফ্রেম, ফ্রেমরেট যা-ই হোক”। চাংকড এনকোডিংয়ে (পর্ব ৩) এই রূপটাই বেশি নিরাপদ।
যাচাই করে নাও কি-ফ্রেম সত্যিই ঠিক জায়গায় পড়েছে কিনা:
ffprobe -v error -select_streams v \
-show_entries frame=pkt_pts_time,key_frame \
-of csv=p=0 out/720p.mp4 | grep ",1" | head সময়গুলো যদি ০, ২, ৪, ৬… হয়, তুমি ঠিক পথে।
৬. ল্যাডার ঠিক করা
এবার ধাপগুলো। আল-বিরুনির কনটেন্ট মূলত লেকচার — স্লাইড আর কথা বলা মানুষ — তাই বিটরেট তুলনামূলক কম রাখা যায়।
| ধাপ | রেজলিউশন | CRF | maxrate | অডিও | কার জন্য |
|---|---|---|---|---|---|
| ১ | 426×240 | ২৮ | ৩০০k | ৬৪k | খুব দুর্বল নেট |
| ২ | 640×360 | ২৬ | ৭০০k | ৯৬k | মোবাইল ডেটা |
| ৩ | 854×480 | ২৪ | ১৪০০k | ১২৮k | সাধারণ মোবাইল |
| ৪ | 1280×720 | ২৩ | ৩০০০k | ১২৮k | ব্রডব্যান্ড |
| ৫ | 1920×1080 | ২২ | ৬০০০k | ১২৮k | ভালো ব্রডব্যান্ড |
ধাপগুলোর মধ্যে অনুপাত মোটামুটি ২ গুণ — পর্ব ১-এ বলা “১.৫ গুণের বেশি ফাঁক” নিয়মটা মানা হচ্ছে।
একটা ধাপের কমান্ড, সব শিক্ষা একসাথে:
ffmpeg -i lecture-01.mp4 \
-map 0:v:0 -map 0:a:0 \
-vf "scale=-2:720" \
-vsync cfr -r 30 \
-c:v libx264 -preset medium -crf 23 \
-maxrate 3000k -bufsize 6000k \
-profile:v main -level 4.0 -pix_fmt yuv420p \
-g 60 -keyint_min 60 -sc_threshold 0 \
-c:a aac -b:a 128k -ar 48000 -ac 2 \
-af loudnorm=I=-16:TP=-1.5:LRA=11 \
-movflags +faststart \
out/720p.mp4 -profile:v main -level 4.0 কেন? পুরনো ডিভাইসের কথা ভেবে। নিচের ধাপগুলোয় আরও রক্ষণশীল হওয়া যায় (baseline, level 3.0), যাতে দশ বছরের পুরনো ফোনও অন্তত ২৪০p পায়।
৭. এক কমান্ডে পুরো ল্যাডার + HLS
পাঁচবার ফাইলটা পড়া আর পাঁচবার ডিকোড করা অপচয়। ffmpeg একবার পড়ে সবগুলো আউটপুট দিতে পারে — আর সরাসরি HLS-এ লিখতে পারে।
ffmpeg -i lecture-01.mp4 \
-filter_complex "\
[0:v]split=4[v1][v2][v3][v4]; \
[v1]scale=-2:360[v360]; \
[v2]scale=-2:480[v480]; \
[v3]scale=-2:720[v720]; \
[v4]scale=-2:1080[v1080]" \
-map "[v360]" -c:v:0 libx264 -crf 26 -maxrate 700k -bufsize 1400k \
-map "[v480]" -c:v:1 libx264 -crf 24 -maxrate 1400k -bufsize 2800k \
-map "[v720]" -c:v:2 libx264 -crf 23 -maxrate 3000k -bufsize 6000k \
-map "[v1080]" -c:v:3 libx264 -crf 22 -maxrate 6000k -bufsize 12000k \
-map a:0 -map a:0 -map a:0 -map a:0 \
-c:a aac -b:a 128k -ar 48000 -ac 2 \
-preset medium -pix_fmt yuv420p \
-vsync cfr -r 30 -g 60 -keyint_min 60 -sc_threshold 0 \
-f hls \
-hls_time 2 \
-hls_playlist_type vod \
-hls_segment_type fmp4 \
-hls_flags independent_segments \
-hls_segment_filename "out/%v/seg-%03d.m4s" \
-hls_fmp4_init_filename "init.mp4" \
-master_pl_name master.m3u8 \
-var_stream_map "v:0,a:0,name:360p v:1,a:1,name:480p v:2,a:2,name:720p v:3,a:3,name:1080p" \
"out/%v/index.m3u8" দেখতে ভয়ংকর, কিন্তু ভেতরে চেনা জিনিসই — একবার ডিকোড, চারবার এনকোড:
split=4— ডিকোড করা ফ্রেমগুলো চার ভাগে বিলি করো।- প্রতিটা ভাগ আলাদা মাপে রিসাইজ করো।
- প্রতিটা আলাদা সেটিংসে এনকোড করো।
- আউটপুট mp4 না, HLS — ২ সেকেন্ডের fMP4 টুকরো (CMAF), সাথে প্লেলিস্ট।
নতুন কয়েকটা অপশনের মানে:
-hls_segment_type fmp4— পুরনো .ts-এর বদলে খণ্ডিত mp4। এটাই CMAF, আর এর ফলে একই ফাইল পরে DASH-এও ব্যবহার করা যাবে।-hls_flags independent_segments— প্রতিটা সেগমেন্ট নিজে নিজে সম্পূর্ণ, প্লেয়ার যেকোনোটা থেকে শুরু করতে পারে।-var_stream_map ... name:720p— ফোল্ডারের নাম সংখ্যার বদলে অর্থপূর্ণ হবে।
চালানোর পর ফোল্ডারটা দাঁড়াবে:
out/
master.m3u8
360p/ init.mp4 index.m3u8 seg-000.m4s seg-001.m4s ...
480p/ init.mp4 index.m3u8 seg-000.m4s ...
720p/ init.mp4 index.m3u8 seg-000.m4s ...
1080p/ init.mp4 index.m3u8 seg-000.m4s ... পর্ব ১-এ যে “ফাইলের তাক”-এর কথা বলেছিলাম — এইটাই সেই তাক। কোনো বিশেষ সার্ভার নেই।
অডিওকে আলাদা করা (আরও ভালো উপায়)
উপরের কমান্ডে অডিও চারবার এনকোড হয়েছে — চারটা ধাপের সাথে চারবার একই জিনিস। অপচয়, আর ধাপ বদলের সময় ছোট ছোট শব্দের ফাঁকও তৈরি করতে পারে।
আসল প্রোডাকশনে অডিওকে আলাদা রেন্ডিশন গ্রুপ হিসেবে রাখা হয় — এক সেট অডিও, সব ভিডিও ধাপ সেটাকেই ব্যবহার করে। মাস্টার প্লেলিস্টে তখন এরকম লাইন থাকে:
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud",NAME="Bangla",DEFAULT=YES,URI="audio/index.m3u8"
#EXT-X-STREAM-INF:BANDWIDTH=3128000,RESOLUTION=1280x720,AUDIO="aud"
720p/index.m3u8 এতে স্টোরেজ কমে, ধাপ বদল মসৃণ হয়, আর একাধিক ভাষার অডিও যোগ করা সহজ হয়।
৮. চালিয়ে দেখা
একটা সাধারণ স্ট্যাটিক সার্ভার চালাও:
npx serve out --cors সাফারিতে master.m3u8 সরাসরি চলে যাবে। ক্রোম বা ফায়ারফক্সে HLS বোঝানোর জন্য hls.js লাগে:
<video id="player" controls width="720" playsinline></video>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
<script>
const video = document.getElementById('player');
if (Hls.isSupported()) {
const hls = new Hls({ debug: false });
hls.loadSource('/master.m3u8');
hls.attachMedia(video);
hls.on(Hls.Events.LEVEL_SWITCHED, (_e, data) => {
console.log('ধাপ বদলাল →', hls.levels[data.level].height + 'p');
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = '/master.m3u8';
}
</script> এবার মজার অংশ। ব্রাউজারের ডেভটুল খুলে নেটওয়ার্ক থ্রটল করে “Slow 3G” করে দাও, তারপর কনসোল দেখো:
ধাপ বদলাল → 720p
ধাপ বদলাল → 480p
ধাপ বদলাল → 360p পর্ব ১-এর ABR তত্ত্বটা চোখের সামনে ঘটছে। থ্রটল সরিয়ে দিলে ধাপে ধাপে আবার উপরে উঠবে — খেয়াল করো, নিচে নামে দ্রুত, উপরে ওঠে ধীরে। ওটা ইচ্ছাকৃত।
যাচাইয়ের ছোট তালিকা
# সেগমেন্টের আসল দৈর্ঘ্য মিলছে কিনা
ffprobe -v error -show_entries format=duration -of csv=p=0 out/720p/seg-005.m4s
# সব ধাপে সেগমেন্টের সংখ্যা এক কিনা (না হলে কি-ফ্রেম মেলেনি)
for d in out/*/; do echo -n "$d "; grep -c "seg-" "$d/index.m3u8"; done
# প্লেলিস্টে ঘোষিত ব্যান্ডউইথ বাস্তবের কাছাকাছি কিনা
grep BANDWIDTH out/master.m3u8 তৃতীয়টা গুরুত্বপূর্ণ: BANDWIDTH যদি বাস্তবের চেয়ে কম ঘোষণা করা থাকে, প্লেয়ার এমন ধাপ বেছে নেবে যেটা তার নেট সামলাতে পারবে না — আর দর্শক বাফারিং দেখবে।
৯. থাম্বনেইল, স্প্রাইট, পোস্টার
লিস্ট পেজে ছবি লাগবে, আর টাইমলাইনে হোভার করলে প্রিভিউ লাগবে।
পোস্টার — একটা নির্দিষ্ট মুহূর্ত থেকে:
ffmpeg -ss 10 -i lecture-01.mp4 -frames:v 1 -vf scale=-2:720 -q:v 3 out/poster.jpg -ss ইনপুটের আগে দিলে ffmpeg দ্রুত ওই জায়গায় লাফ দেয়; পরে দিলে সে শুরু থেকে ডিকোড করতে করতে যায় (নিখুঁত, কিন্তু ধীর)। লম্বা ভিডিওতে এই পার্থক্যটা মিনিটের হিসাবে।
কালো ফ্রেম এড়ানো — ভিডিওর শুরুতে প্রায়ই কালো ফ্রেম থাকে। সবচেয়ে “আকর্ষণীয়” ফ্রেম বেছে নিতে:
ffmpeg -i lecture-01.mp4 -vf "thumbnail=300,scale=-2:720" -frames:v 1 out/poster.jpg স্প্রাইট শিট — প্রতি ১০ সেকেন্ডে একটা ছোট ছবি, একটা গ্রিডে বসানো:
ffmpeg -i lecture-01.mp4 \
-vf "fps=1/10,scale=160:-2,tile=10x10" \
-q:v 4 out/sprite-%02d.jpg একটা ছবি নামালেই ১০০টা প্রিভিউ পাওয়া যায়। প্লেয়ারকে বলতে হয় কোন সময়ে ছবির কোন অংশ দেখাবে — সেটার জন্য একটা WebVTT ফাইল:
WEBVTT
00:00:00.000 --> 00:00:10.000
sprite-01.jpg#xywh=0,0,160,90
00:00:10.000 --> 00:00:20.000
sprite-01.jpg#xywh=160,0,160,90 এই ফাইলটা হাতে লেখার দরকার নেই — স্প্রাইট বানানোর সময় একই লুপে জেনারেট করে ফেলা যায়।
১০. সাবটাইটেল
পাশে আলাদা ফাইল হিসেবে দেওয়াই সাধারণত ঠিক। SRT থেকে WebVTT:
ffmpeg -i lecture-01.srt out/subs/bn.vtt মাস্টার প্লেলিস্টে যোগ করো:
#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="বাংলা",LANGUAGE="bn",DEFAULT=YES,URI="subs/index.m3u8" আর যদি ছবির ভেতরে পুড়িয়ে দিতেই হয় (সোশ্যাল ক্লিপ ইত্যাদি):
ffmpeg -i lecture-01.mp4 -vf "subtitles=lecture-01.srt:force_style='FontSize=22'" ... মনে রাখো, পুড়িয়ে দেওয়া মানে প্রতিটা ধাপ আলাদা করে আবার এনকোড করা, আর দর্শক সেটা বন্ধ করতে পারবে না।
১১. এবার একটা ওয়ার্কার
হাতে কমান্ড চালানো শেখার জন্য ভালো, কিন্তু আল-বিরুনির দরকার এমন কিছু যেটা আপলোড হলেই নিজে কাজটা করে।
কাজের মডেল
প্রথমে ঠিক করি একটা “কাজ” জিনিসটা কী:
const job = {
id: 'job_01H...',
input: 'inbox/lecture-01.mp4',
output: 'out/lecture-01',
status: 'queued', // queued | probing | encoding | packaging | done | failed
progress: 0,
duration: null, // probe থেকে আসবে
error: null,
attempts: 0
}; এই সামান্য কাঠামোটাই পর্ব ৩-এর পূর্ণ স্টেট মেশিনের বীজ।
সারি আর একটা করে কাজ
import { spawn } from 'node:child_process';
import { watch, mkdirSync, rmSync } from 'node:fs';
import { basename, extname, join } from 'node:path';
const INBOX = './inbox';
const OUTBOX = './out';
const TMP = './tmp';
const CONCURRENCY = 1;
const queue = [];
let running = 0;
watch(INBOX, (_event, file) => {
if (file && ['.mp4', '.mov', '.mkv'].includes(extname(file))) {
queue.push(file);
pump();
}
});
function pump() {
while (running < CONCURRENCY && queue.length) {
const file = queue.shift();
running++;
handle(file)
.catch((err) => console.error('ব্যর্থ:', file, err.message))
.finally(() => {
running--;
pump();
});
}
} এখানেই প্রথম প্রোডাকশন-শিক্ষা: একসাথে একটার বেশি ভারী কাজ চালিও না। ট্রান্সকোডিং সব সিপিইউ খেয়ে ফেলে। দশটা ভিডিও একসাথে চালালে দশটাই ধীরে চলবে, আর মেশিন বসে যাবে। ধাপে ধাপে বাড়াতে চাইলে CONCURRENCY বাড়াও, কিন্তু কোর সংখ্যার হিসাব করে — একটা এনকোডই সাধারণত সব কোর ব্যবহার করে।
probe ধাপ
function probe(path) {
return new Promise((resolve, reject) => {
const args = ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', path];
const ff = spawn('ffprobe', args);
let out = '';
ff.stdout.on('data', (d) => (out += d));
ff.on('close', (code) => {
if (code !== 0) return reject(new Error('probe ব্যর্থ'));
const data = JSON.parse(out);
const v = data.streams.find((s) => s.codec_type === 'video');
const a = data.streams.find((s) => s.codec_type === 'audio');
if (!v) return reject(new Error('ভিডিও স্ট্রিম নেই'));
resolve({
width: v.width,
height: v.height,
fps: eval(v.r_frame_rate), // "30000/1001" → 29.97
duration: Number(data.format.duration),
hasAudio: Boolean(a)
});
});
});
} ইনপুট অনুযায়ী ল্যাডার ঠিক করা
const ALL_RUNGS = [
{ name: '240p', h: 240, crf: 28, maxrate: '300k', bufsize: '600k', ab: '64k' },
{ name: '360p', h: 360, crf: 26, maxrate: '700k', bufsize: '1400k', ab: '96k' },
{ name: '480p', h: 480, crf: 24, maxrate: '1400k', bufsize: '2800k', ab: '128k' },
{ name: '720p', h: 720, crf: 23, maxrate: '3000k', bufsize: '6000k', ab: '128k' },
{ name: '1080p', h: 1080, crf: 22, maxrate: '6000k', bufsize: '12000k', ab: '128k' }
];
// ইনপুটের চেয়ে বড় ধাপ বানিয়ে টাকা পোড়ানোর মানে নেই
function ladderFor(meta) {
const rungs = ALL_RUNGS.filter((r) => r.h <= meta.height);
return rungs.length ? rungs : [ALL_RUNGS[0]];
} এই ছোট ফাংশনটা বাস্তবে বিশাল সাশ্রয় করে। ফোনে তোলা ৭২০p ভিডিও থেকে 1080p ধাপ বানালে ফাইল বড় হয়, কোয়ালিটি বাড়ে না।
এনকোড আর অগ্রগতি
function transcode(job, meta) {
const rungs = ladderFor(meta);
const gop = Math.round(meta.fps) * 2; // ২ সেকেন্ড সেগমেন্ট
const split =
`[0:v]split=${rungs.length}` +
rungs.map((_, i) => `[s${i}]`).join('') +
';' +
rungs.map((r, i) => `[s${i}]scale=-2:${r.h}[v${i}]`).join(';');
const args = ['-i', job.input, '-filter_complex', split];
rungs.forEach((r, i) => {
args.push(
'-map',
`[v${i}]`,
`-c:v:${i}`,
'libx264',
`-crf:v:${i}`,
String(r.crf),
`-maxrate:v:${i}`,
r.maxrate,
`-bufsize:v:${i}`,
r.bufsize
);
});
rungs.forEach(() => args.push('-map', 'a:0'));
args.push(
'-c:a',
'aac',
'-b:a',
'128k',
'-ar',
'48000',
'-ac',
'2',
'-af',
'loudnorm=I=-16:TP=-1.5:LRA=11',
'-preset',
'medium',
'-pix_fmt',
'yuv420p',
'-vsync',
'cfr',
'-r',
String(Math.round(meta.fps)),
'-g',
String(gop),
'-keyint_min',
String(gop),
'-sc_threshold',
'0',
'-f',
'hls',
'-hls_time',
'2',
'-hls_playlist_type',
'vod',
'-hls_segment_type',
'fmp4',
'-hls_flags',
'independent_segments',
'-hls_segment_filename',
join(job.output, '%v/seg-%03d.m4s'),
'-master_pl_name',
'master.m3u8',
'-var_stream_map',
rungs.map((r, i) => `v:${i},a:${i},name:${r.name}`).join(' '),
join(job.output, '%v/index.m3u8')
);
return run('ffmpeg', ['-nostdin', '-progress', 'pipe:1', '-nostats', ...args], job, meta);
} অগ্রগতি পড়া — ffmpeg নিয়মিত জানায় সে কোথায় আছে:
function run(cmd, args, job, meta) {
return new Promise((resolve, reject) => {
const ff = spawn(cmd, args);
let stderrTail = [];
ff.stdout.on('data', (chunk) => {
const m = String(chunk).match(/out_time_ms=(\d+)/);
if (m && meta.duration) {
const seconds = Number(m[1]) / 1_000_000;
job.progress = Math.min(99, Math.round((seconds / meta.duration) * 100));
console.log(job.id, job.progress + '%');
}
});
// ভুল হলে শেষ কয়েক লাইনই আসল সূত্র — পুরোটা রাখার দরকার নেই
ff.stderr.on('data', (d) => {
stderrTail.push(String(d));
if (stderrTail.length > 20) stderrTail.shift();
});
ff.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error('ffmpeg কোড ' + code + '\n' + stderrTail.join('')));
});
});
} অস্থায়ী ফাইল আর পরিষ্কার করা
async function handle(file) {
const id = basename(file, extname(file));
const job = {
id,
input: join(INBOX, file),
output: join(OUTBOX, id),
tmp: join(TMP, id),
status: 'probing',
progress: 0
};
mkdirSync(job.tmp, { recursive: true });
try {
const meta = await probe(job.input);
job.status = 'encoding';
mkdirSync(job.output, { recursive: true });
await transcode(job, meta);
await makeThumbnails(job, meta);
job.status = 'done';
job.progress = 100;
} catch (err) {
job.status = 'failed';
job.error = err.message;
throw err;
} finally {
rmSync(job.tmp, { recursive: true, force: true });
}
} finally ব্লকটা সবচেয়ে গুরুত্বপূর্ণ লাইন। ব্যর্থ কাজ যদি অস্থায়ী ফাইল রেখে যায়, কয়েকশো ব্যর্থতার পর ডিস্ক ভরে যাবে — আর তখন সব কাজ ব্যর্থ হতে শুরু করবে। এটাই সেই ধরনের সমস্যা যেটা আগে থেকে না ভাবলে রাত তিনটায় শিখতে হয়।
আর একটা ছোট কিন্তু জরুরি জিনিস: আউটপুট পুরো শেষ না হওয়া পর্যন্ত সেটা “রেডি” দেখিও না। সবচেয়ে সহজ উপায় — অস্থায়ী ফোল্ডারে কাজ করো, শেষে নাম বদলে আসল জায়গায় সরাও। ফোল্ডারের অস্তিত্ব যেন সাফল্যের প্রমাণ না হয়।
১২. এখন এটা যেখানে ভেঙে পড়বে
আল-বিরুনির জিনিসটা এখন কাজ করে। ইবনে সিনা আপলোড করে, কিছুক্ষণ পর সব ডিভাইসে ভিডিও চলে — এমনকি সমরকন্দের দুর্বল নেটেও।
তারপর বাস্তবতা আসে। যেসব জায়গায় এটা ভাঙবে:
১. গতি। ৪২ মিনিটের ভিডিও পাঁচ ধাপে এনকোড হতে ২৫–৪০ মিনিট। বিশ জন একসাথে আপলোড করলে সারিতে অপেক্ষা কয়েক ঘণ্টা, আর কেউ জানে না কতক্ষণ।
২. রিস্টার্ট। সারিটা মেমোরিতে। ডিপ্লয় করলে বা প্রসেস মরলে চলমান কাজগুলো চিরতরে “encoding” অবস্থায় আটকে থাকে।
৩. একটা ভাঙা ফাইল পুরো লাইন থামায়। আধা-আপলোড হওয়া ফাইল এলো, ffmpeg কাঁদল — আর কেউ হাতে ঠিক না করা পর্যন্ত কিছু এগোয় না।
৪. ডিস্ক। কাঁচা ফাইল + পাঁচ ধাপের হাজার হাজার টুকরো। কয়েকশো ভিডিওতেই জায়গা শেষ।
৫. একই মেশিন এনকোড করছে আর সার্ভ করছে। CPU ১০০%, আর তখনই দর্শকরা বাফারিং দেখছে।
৬. একই ভিডিও দুবার আপলোড হলে দুবার এনকোড। একই কাজ, দ্বিগুণ বিল।
৭. পুনরাবৃত্তি নেই। নেটের সাময়িক সমস্যায় ব্যর্থ কাজ আর কখনো চেষ্টা করা হয় না।
৮. কোনো দৃশ্যমানতা নেই। কয়টা কাজ জমে আছে, কোনটা কতক্ষণ ধরে চলছে, গড়ে কত সময় লাগছে — কিছুই জানা যায় না।
এগুলো কোডের বাগ না — আর্কিটেকচারের সীমা। শেখার ট্রান্সকোডার এখানেই তার কাজ শেষ করে: সে তোমাকে প্রতিটা ধাপ চিনিয়ে দিল, আর দেখিয়ে দিল আসল কঠিন প্রশ্নগুলো কোথায়।
এই পর্বের চেকলিস্ট
- সবসময় আগে probe, তারপর সিদ্ধান্ত
-
-pix_fmt yuv420p— না হলে অনেক ডিভাইসে কালো স্ক্রিন - mp4-এ
-movflags +faststart -
-vsync cfr -r N— ভেরিয়েবল ফ্রেমরেটের অডিও ড্রিফট ঠেকাতে -
-g,-keyint_min,-sc_threshold 0— সেগমেন্ট সীমানা মেলাতে - ক্যাপড CRF (
-crf+-maxrate+-bufsize) স্ট্রিমিংয়ের জন্য - ইনপুটের চেয়ে বড় ধাপ বানিও না
- অডিও একরকম করো, লাউডনেস সমান করো
-
independent_segments, আর fMP4 (CMAF) দিয়ে প্যাকেজিং - প্লেলিস্টের BANDWIDTH বাস্তবের কাছাকাছি
- অস্থায়ী ফাইল সবসময় মুছে ফেলা
- কাজ শেষ না হলে “রেডি” দেখিও না
পর্ব ৩-এ আমরা উপরের আটটা সমস্যার উত্তর সাজাব — সারি আর ওয়ার্কার আলাদা করা, বড় ভিডিও টুকরো করে সমান্তরালে এনকোড করা, স্টেট মেশিন আর আইডেম্পোটেন্সি, স্টোরেজ আর CDN, খরচের হিসাব, আর কীভাবে বুঝবে দর্শকের কাছে ভিডিওটা সত্যিই ভালো চলছে।