থাম্বনেইল, প্রিভিউ ও ক্যাপশন
এনকোডের পাশাপাশি যে কাজগুলো একটা ভিডিওকে প্রোডাক্ট বানায়: ভালো পোস্টার বাছাই, স্টোরিবোর্ড স্প্রাইট, ছোট প্রিভিউ, অটো ক্যাপশন, চ্যাপ্টার আর অডিওর লাউডনেস, সবগুলো ব্যাকগ্রাউন্ড জব হিসেবে।
গল্পে বুঝি
নীলক্ষেতে ফারাবির একটা ছোট প্রকাশনী। মরিয়মের লেখা নতুন উপন্যাসের পাণ্ডুলিপি ছাপাখানায় চলে গেছে, কিন্তু ফারাবি জানেন, শুধু ছাপা পাতা দিয়ে বই বিক্রি হয় না। দোকানের তাকে কেউ বই হাতে নেয় প্রচ্ছদ দেখে, তারপর উল্টে পেছনের ব্লার্ব পড়ে, তারপর সূচিপত্রে চোখ বুলিয়ে ঠিক করে কিনবে কিনা। তাই ছাপার পাশাপাশি তাঁর দলের চারজন চারটা কাজে বসে যায়।
শিল্পী হাইসাম প্রচ্ছদ বানান। প্রথমবার তিনি উপন্যাসের প্রথম পাতার একটা লাইন তুলে দিয়েছিলেন, আর সেই পাতায় ছিল শুধু উৎসর্গপত্র। ফারাবি বলেছিলেন, প্রথম পাতা দেখে বইয়ের প্রচ্ছদ হয় না; পুরো বই থেকে সবচেয়ে জীবন্ত দৃশ্যটা বেছে নাও, আর তিনটা খসড়া এনো, লেখক নিজে একটা বাছবেন। রুশদ বানান সূচিপত্র আর প্রতি অধ্যায়ের ছোট একটা ছবি, যাতে পাঠক এক নজরে বুঝতে পারেন কোন অধ্যায় কোথায়। কিন্দি লেখেন পেছনের তিন লাইনের ব্লার্ব, যা বইটার একটা ঝলক দেয় কিন্তু পুরো গল্প বলে দেয় না।
চতুর্থ কাজটা সবচেয়ে সাবধানে করতে হয়। প্রকাশনী একজন টাইপিস্টকে দিয়ে লেখকের রেকর্ড করা পাঠের একটা লিখিত রূপ বানায়, যাতে যাঁরা শুনতে পান না তাঁরাও অডিও সংস্করণ অনুসরণ করতে পারেন। টাইপিস্ট দ্রুত, কিন্তু সিলেটি টানের কয়েকটা শব্দ ভুল লেখেন, আর নামগুলো প্রায়ই উল্টাপাল্টা করেন। তাই ফারাবির নিয়ম: টাইপিস্টের খসড়া সরাসরি ছাপা হবে না, মরিয়ম নিজে পড়ে ঠিক করবেন, তারপর। আর অডিও রেকর্ডিংয়ে একটা অধ্যায় খুব জোরে আর পরেরটা ফিসফিসিয়ে পড়া হয়েছিল বলে শ্রোতারা বারবার ভলিউম বাড়াচ্ছিলেন আর কমাচ্ছিলেন; তাই এখন সব অধ্যায় একই শব্দমাত্রায় এনে তারপর প্রকাশ করা হয়।
মিলিয়ে নিই: প্রচ্ছদ হলো poster থাম্বনেইল, আর প্রথম পাতার উৎসর্গপত্র হলো ভিডিওর শুরুর কালো ফ্রেম, যেটা স্বয়ংক্রিয় বাছাই এড়াতে হয়। “পুরো বই থেকে সবচেয়ে জীবন্ত দৃশ্য” হলো scene detection আর থাম্বনেইল ফিল্টার, আর তিনটা খসড়া থেকে লেখকের বাছাই হলো ক্রিয়েটরকে কয়েকটা candidate দেখানো। প্রতি অধ্যায়ের ছোট ছবি আর সূচিপত্র হলো storyboard স্প্রাইট ও WebVTT আর চ্যাপ্টার মার্কার। পেছনের ব্লার্ব হলো ছোট অ্যানিমেটেড প্রিভিউ। টাইপিস্টের খসড়া হলো Whisper-এর অটো ক্যাপশন, আর লেখকের নিজে পড়ে ঠিক করা হলো মানুষের রিভিউ, যা Bangla-র মতো ভাষায় বাদ দেওয়া যায় না। অধ্যায়গুলো একই শব্দমাত্রায় আনা হলো লাউডনেস নরমালাইজেশন। আর চারজনের একসাথে কাজ করা হলো এনকোডের পর একসাথে চলা কয়েকটা ব্যাকগ্রাউন্ড জব। বাস্তবে এই “পার্শ্ব” কাজগুলোই ঠিক করে একটা ভিডিও লিস্টে ক্লিক পায় কিনা, আর সবাই সেটা দেখতে পারেন কিনা।
এগুলো এনকোডের অংশ নয়, তবু সমান জরুরি
এনকোড পাইপলাইন (চ্যাপ্টার ৪) ভিডিওকে প্লেযোগ্য বানায়। এই চ্যাপ্টারের কাজগুলো ভিডিওকে খুঁজে পাওয়া যায় এমন, ব্যবহারযোগ্য আর সবার জন্য দেখার মতো বানায়। এরা আলাদা আলাদা জব, কারণ:
- একটা ব্যর্থ হলে অন্যগুলো আটকানো উচিত না। ক্যাপশন তৈরি ফেইল করলেও ভিডিও প্লে হওয়া উচিত।
- এদের সময় খুব আলাদা। পোস্টার এক সেকেন্ডের কাজ, এক ঘণ্টার ভিডিওর ক্যাপশন CPU-তে অনেকক্ষণ।
- এদের কিছু আবার চালাতে হয়: ক্রিয়েটর নতুন পোস্টার চাইলে, বা ক্যাপশন মডেল ভালো হলে।
লক্ষ্য করুন “asset ready” কোন জবের জন্য অপেক্ষা করে আর কোনটার জন্য করে না। ক্যাপশন রেডির শর্তে নেই। ভিডিও আগে চালু হয়, ক্যাপশন পরে যোগ হয়।
পোস্টার: প্রথম ফ্রেম প্রায় কখনো ঠিক নয়
সবচেয়ে সহজ পোস্টার হলো প্রথম ফ্রেম, আর প্রায় সবসময় সেটা ভুল: কালো স্ক্রিন, ফেড-ইন, লোগো, বা ক্যামেরা চালু হওয়ার সময়ের ঝাপসা ছবি। ffmpeg-এর তিনটা টুল এখানে কাজে লাগে। নিচের সব আউটপুট একটা টেস্ট ফাইলে আসলে চালানো, যার প্রথম ৩ সেকেন্ড ইচ্ছা করে কালো রাখা হয়েছে।
কালো অংশ খুঁজে বের করা:
ffmpeg -i src.mp4 -vf "blackdetect=d=0.5:pix_th=0.10" -an -f null - 2>&1 | grep blackdetect [Parsed_blackdetect_0 @ 0x7b14c45bc0] black_start:0 black_end:3 black_duration:3 thumbnail ফিল্টার: প্রতি N ফ্রেমের একটা ব্যাচ থেকে সবচেয়ে “প্রতিনিধিত্বমূলক” ফ্রেমটা বাছে (ব্যাচের গড় হিস্টোগ্রামের সবচেয়ে কাছেরটা), যা স্বাভাবিকভাবেই কালো আর অদ্ভুত ফ্রেম এড়ায়:
ffmpeg -i src.mp4 -vf "thumbnail=300,scale=640:-2" -frames:v 1 poster.jpg [Parsed_thumbnail_0 @ 0x7b1ac45c80] frame id #191 (pts_time=6.366667) selected from a set of 300 images ৩০০ ফ্রেম মানে ৩০ fps-এ প্রথম ১০ সেকেন্ড; ফিল্টার কালো অংশ পার হয়ে ৬.৩৭ সেকেন্ডের ফ্রেম বেছেছে।
Scene change: যেখানে দৃশ্য বদলায়, সেই মুহূর্তগুলো বের করে আনে। এগুলো ভালো candidate হয়:
ffmpeg -i src.mp4 -vf "select='gt(scene,0.4)',showinfo" -an -f null - 2>&1 | grep -o "pts_time:[0-9.]*" pts_time:3 টেস্ট ফাইলে একটাই দৃশ্য বদল আছে, কালো থেকে ছবিতে, ঠিক ৩ সেকেন্ডে। আসল ভিডিওতে কয়েক ডজন আসবে।
ব্যবহারিক রেসিপি: পুরো ভিডিও জুড়ে সমান দূরত্বে কয়েকটা অংশে thumbnail চালান, কালো অংশের candidate বাদ দিন, আর ক্রিয়েটরকে তিন থেকে পাঁচটা দেখান। নিজের ছবি আপলোডের অপশনও রাখুন। স্বয়ংক্রিয় বাছাই একটা ভালো ডিফল্ট, কিন্তু চূড়ান্ত সিদ্ধান্ত মানুষের। একটা নির্দিষ্ট সময়ের ফ্রেম দ্রুত বের করতে -ss দিন -i-এর আগে, তাহলে ffmpeg সেই জায়গা পর্যন্ত ডিকোড না করে লাফ দেয়:
ffmpeg -ss 00:12:30 -i src.mp4 -frames:v 1 -vf "scale=1280:-2" -q:v 3 cand_0750.jpg Storyboard: সিক বারে ছোট ছবি
প্লেয়ারের সিক বারে মাউস রাখলে যে ছোট ছবি দেখা যায়, সেটা সাধারণত আলাদা আলাদা ছবি নয়, একটা বড় স্প্রাইট শিট যার ভেতর একটা গ্রিডে অনেকগুলো ছোট ফ্রেম, আর একটা WebVTT ফাইল যা বলে কোন সময়ে গ্রিডের কোন অংশ দেখাতে হবে। প্লেয়ার কীভাবে এটা পড়ে আর দেখায়, তা প্লেয়ার ট্র্যাকে; এখানে শুধু বানানো।
প্রতি ৫ সেকেন্ডে একটা ফ্রেম, ১৬০×৯০ সাইজে, ৫×৫ গ্রিডে:
ffmpeg -i src.mp4 -vf "fps=1/5,scale=160:90,tile=5x5" -frames:v 1 sprite.jpg
ffprobe -v error -show_entries stream=width,height -of csv=p=0 sprite.jpg 800,450 ৬০ সেকেন্ডের ভিডিওতে ১২টা ফ্রেম আসে, তাই ২৫ ঘরের গ্রিডের বাকি ১৩টা খালি থাকে। লম্বা ভিডিওতে উল্টো সমস্যা: ঘর ফুরিয়ে যায়, তখন -frames:v সরিয়ে দিলে ffmpeg একাধিক শিট লেখে (sprite_%03d.jpg)।
তারপর VTT, যেখানে প্রতিটা cue-তে #xywh=x,y,w,h দিয়ে গ্রিডের ঘরটা বলে দেওয়া হয়। এই গণিতটা ছোট কিন্তু ভুল করা সহজ, তাই একটা চেক সহ:
package main
import (
"fmt"
"strings"
"time"
)
// storyboardVTT maps each tile of a cols x rows sprite to its time range.
func storyboardVTT(sprite string, duration, every time.Duration, cols, rows, w, h int) string {
var b strings.Builder
b.WriteString("WEBVTT\n\n")
for i := 0; time.Duration(i)*every < duration; i++ {
if i >= cols*rows {
break // ponytail: one sprite sheet; split into sheets when tiles overflow
}
start := time.Duration(i) * every
end := min(start+every, duration)
x, y := (i%cols)*w, (i/cols)*h
fmt.Fprintf(&b, "%s --> %s\n%s#xywh=%d,%d,%d,%d\n\n", ts(start), ts(end), sprite, x, y, w, h)
}
return b.String()
}
func ts(d time.Duration) string {
return fmt.Sprintf("%02d:%02d:%02d.%03d", int(d.Hours()), int(d.Minutes())%60, int(d.Seconds())%60, d.Milliseconds()%1000)
}
func main() {
out := storyboardVTT("sprite.jpg", 60*time.Second, 5*time.Second, 5, 5, 160, 90)
if !strings.Contains(out, "00:00:55.000 --> 00:01:00.000\nsprite.jpg#xywh=160,180,160,90") {
panic("tile math broken")
}
fmt.Print(strings.Join(strings.Split(out, "\n")[:11], "\n"), "\n")
} $ go run main.go
WEBVTT
00:00:00.000 --> 00:00:05.000
sprite.jpg#xywh=0,0,160,90
00:00:05.000 --> 00:00:10.000
sprite.jpg#xywh=160,0,160,90
00:00:10.000 --> 00:00:15.000
sprite.jpg#xywh=320,0,160,90 কত ঘন ঘন ফ্রেম নেবেন: ছোট ভিডিওতে প্রতি ২ থেকে ৫ সেকেন্ড, এক ঘণ্টার বেশি হলে প্রতি ১০ সেকেন্ড বা বেশি। নয়তো স্প্রাইট শিট মেগাবাইটে পৌঁছায়, আর প্লেয়ার সিক বারে প্রথমবার হাত দিতেই সেটা নামাতে বসে যায়। স্প্রাইট আর VTT দুটোই asset-এর revision prefix-এ রাখুন আর লম্বা TTL দিয়ে CDN-এ ক্যাশ করুন।
প্রিভিউ: GIF নয়, ছোট MP4
লিস্টে হোভার করলে যে কয়েক সেকেন্ডের ঝলক চলে, সেটা অনেকে GIF বানান। GIF-এর কম্প্রেশন ভিডিওর জন্য বানানো নয়, তাই একই ক্লিপ অনেক ভারী হয়। একই ৩ সেকেন্ড, একই ৩২০ পিক্সেল চওড়া, দুইভাবে:
ffmpeg -ss 20 -t 3 -i src.mp4 -vf "fps=10,scale=320:-2" prev.gif
ffmpeg -ss 20 -t 3 -i src.mp4 -an -vf "scale=320:-2" -c:v libx264 -crf 28 -preset veryfast -movflags +faststart prev.mp4 142475 prev.gif
37851 prev.mp4 এই টেস্ট ফাইলে GIF প্রায় চার গুণ ভারী, আর তাতে মাত্র ১০ fps; আসল ভিডিওতে ব্যবধান সাধারণত আরো বেশি হয়। MP4 প্রিভিউ পেজে muted, loop, playsinline দিয়ে বসান (playsinline না দিলে কিছু মোবাইল ব্রাউজার ফুলস্ক্রিনে চলে যায়)। অডিও ট্র্যাক বাদ দিন (-an), কারণ প্রিভিউতে শব্দ লাগে না আর মিউটেড অটোপ্লের জন্য ব্রাউজার অডিও ট্র্যাক ছাড়া ফাইলকে বেশি সহজে অনুমতি দেয়।
অটো ক্যাপশন: নিজের সার্ভারে Whisper
ক্যাপশন অ্যাক্সেসিবিলিটির মূল জিনিস (যাঁরা শুনতে পান না), আর অনেক দর্শক শব্দ বন্ধ রেখেই ভিডিও দেখেন। হাতে লেখা ক্যাপশন সবচেয়ে ভালো কিন্তু দামি ও ধীর। OpenAI-এর Whisper মডেল ওপেন ওয়েট, আর whisper.cpp সেটা CPU বা GPU-তে কোনো ক্লাউড সার্ভিস ছাড়াই চালায়, তাই অডিও কখনো নিজের সার্ভারের বাইরে যায় না।
ধাপ ১, অডিও বের করা। whisper.cpp-এর README অনুযায়ী CLI ১৬-বিট WAV চায়, আর রূপান্তরের যে উদাহরণ দেওয়া, সেটা ১৬ kHz মোনো:
ffmpeg -i src.mp4 -vn -ar 16000 -ac 1 -c:a pcm_s16le audio.wav ধাপ ২, ট্রান্সক্রাইব। ২০২৬ সালের অক্টোবরে whisper-cli-এর হেল্প অনুযায়ী -l ভাষা ঠিক করে (ডিফল্ট en, auto দিলে নিজে বোঝার চেষ্টা করে), আর -ovtt বা -osrt ফাইল লেখে:
./build/bin/whisper-cli \
-m models/ggml-large-v3.bin \
-l bn \
-f audio.wav \
-ovtt -of captions/bn.draft এটা লিখবে captions/bn.draft.vtt। .en দিয়ে শেষ হওয়া মডেলগুলো শুধু ইংরেজির জন্য; অন্য ভাষার জন্য multilingual মডেল লাগে।
Bangla-য় অটো ক্যাপশন সরাসরি প্রকাশ করবেন না। Whisper Bangla বোঝে, কিন্তু ইংরেজির তুলনায় এর ভুলের হার অনেক বেশি, বিশেষ করে আঞ্চলিক উচ্চারণ, নাম, Bangla আর ইংরেজি মেশানো কথা (একজন ডেভেলপার যেভাবে “deploy করে দাও” বলেন), আর নিচু শব্দের রেকর্ডিংয়ে। কত ভালো চলবে সেটা আপনার কনটেন্টের উপর নির্ভর করে, তাই নিজের কয়েকটা আসল ভিডিওতে চালিয়ে মেপে দেখুন; কোনো সাধারণ সংখ্যার উপর ভরসা করবেন না। ভুল ক্যাপশন অনেক সময় ক্যাপশন না থাকার চেয়েও খারাপ, কারণ যে দর্শক শুধু ক্যাপশনের উপর নির্ভর করেন, তিনি ভুলটা ধরতে পারেন না।
ক্যাপশনের অবস্থা: খসড়া থেকে প্রকাশ
ক্যাপশনকে ডেটাবেসে একটা অবস্থাসহ রাখুন, যাতে কোনটা মেশিনের খসড়া আর কোনটা মানুষের দেখা, সেটা কখনো গুলিয়ে না যায়:
CREATE TABLE captions (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
asset_id text NOT NULL REFERENCES assets(id),
lang text NOT NULL, -- BCP 47: 'bn', 'en'
source text NOT NULL CHECK (source IN ('auto', 'human', 'uploaded')),
status text NOT NULL CHECK (status IN ('draft', 'in_review', 'published')),
storage_key text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (asset_id, lang, status)
); নিয়মগুলো সহজ:
- অটো ক্যাপশন সবসময়
draftহিসেবে ঢোকে। - ক্রিয়েটর একটা সাধারণ এডিটরে খসড়া ঠিক করেন (সময় ঠিক রেখে শুধু লেখা বদলানো যথেষ্ট), তারপর প্রকাশ করেন।
- ক্রিয়েটর চাইলে রিভিউ ছাড়াই প্রকাশ করতে পারেন, তবে প্লেয়ারে ভাষার নামের পাশে স্পষ্ট লেখা থাকবে “স্বয়ংক্রিয়”। দর্শকের জানার অধিকার আছে লেখাটা কে বানিয়েছে।
- ইংরেজি বা অন্য ভাষায় অনুবাদ করা ক্যাপশন আলাদা রো, আলাদা
lang।
ক্যাপশন ফাইল প্লেয়ারের কাছে কীভাবে যায় (HLS-এ আলাদা subtitle রেন্ডিশন, বা সরাসরি track এলিমেন্ট), তা ভিডিও ট্র্যাক আর প্লেয়ার ট্র্যাকের ক্যাপশন চ্যাপ্টারে।
চ্যাপ্টার মার্কার
লম্বা ভিডিওতে (লেকচার, পডকাস্ট, টিউটোরিয়াল) চ্যাপ্টার দর্শকের সময় বাঁচায়। সবচেয়ে বহনযোগ্য ফরম্যাট আবার WebVTT, kind="chapters" হিসেবে:
WEBVTT
00:00:00.000 --> 00:04:10.000
ভূমিকা
00:04:10.000 --> 00:17:45.000
ক্যাশিং কেন দরকার
00:17:45.000 --> 00:31:02.000
হাতে-কলমে: Redis দিয়ে cache-aside চ্যাপ্টারের উৎস সাধারণত ক্রিয়েটর নিজে: বর্ণনায় টাইমস্ট্যাম্প লিখলে সেটা পার্স করে VTT বানানো যায়। স্বয়ংক্রিয় চ্যাপ্টার (ক্যাপশনের লেখা থেকে বিষয় বদল খোঁজা) সম্ভব, কিন্তু সেটাও খসড়া হিসেবে দেখান, ক্যাপশনের মতোই।
লাউডনেস: সব ভিডিও একই জোরে
একটা ভিডিও খুব জোরে, পরেরটা ফিসফিসে হলে দর্শক বারবার ভলিউম বদলান, যেটা প্লেলিস্ট বা কোর্স প্ল্যাটফর্মে খুব বিরক্তিকর। শব্দের “জোর” পিক দিয়ে মাপা হয় না, মাপা হয় লাউডনেস দিয়ে, যা মানুষের কান কতটা জোরে শোনে সেটা অনুকরণ করে। মাপকাঠি LUFS, আর ব্রডকাস্টের জন্য EBU R128 স্ট্যান্ডার্ড −২৩ LUFS লক্ষ্য রাখে। স্ট্রিমিং প্ল্যাটফর্মগুলো সাধারণত এর চেয়ে একটু জোরে লক্ষ্য রাখে, আর প্রতিটার সংখ্যা আলাদা ও বদলায়; একটা লক্ষ্য বেছে নিয়ে পুরো ক্যাটালগে সেটাই রাখাটা আসল কথা।
ffmpeg-এর loudnorm ফিল্টার দুই পাসে সবচেয়ে ভালো কাজ করে। প্রথম পাসে শুধু মাপে:
ffmpeg -i src.mp4 -af loudnorm=I=-16:TP=-1.5:LRA=11:print_format=json -vn -f null - {
"input_i" : "-21.76",
"input_tp" : "-11.74",
"input_lra" : "0.00",
"input_thresh" : "-31.76",
"output_i" : "-16.03",
"output_tp" : "-5.96",
"output_lra" : "0.10",
"output_thresh" : "-26.03",
"normalization_type" : "dynamic",
"target_offset" : "0.03"
} দ্বিতীয় পাসে মাপা মানগুলো ফেরত দেওয়া হয়, যাতে ফিল্টার পুরো ফাইল জুড়ে একই গেইন দিতে পারে (linear=true):
ffmpeg -i src.mp4 -af loudnorm=I=-16:TP=-1.5:LRA=11:\
measured_I=-21.76:measured_TP=-11.74:measured_LRA=0.00:measured_thresh=-31.76:\
offset=0.03:linear=true:print_format=summary -c:v copy -c:a aac out.mp4 Input Integrated: -21.8 LUFS
Output Integrated: -16.0 LUFS
Output True Peak: -5.9 dBTP
Normalization Type: Dynamic শেষ লাইনটা খেয়াল করুন। linear=true চাওয়া সত্ত্বেও ফিল্টার Dynamic-এ চলে গেছে, কারণ ffmpeg নিজে যাচাই করে linear গেইন দিয়ে লক্ষ্যগুলো মেলানো সম্ভব কিনা, আর না পারলে চুপচাপ dynamic মোডে চলে যায়, যেটা শব্দকে চেপে বা ফুলিয়ে মেলায় আর গানের মতো কনটেন্টে শোনা যায়। (এখানে টেস্ট সিগন্যালটা একটা স্থির সুর, যার LRA শূন্য।) প্রোডাকশন জবে এই লাইনটা পার্স করুন আর লগ করুন; কতগুলো ফাইল dynamic-এ যাচ্ছে সেটা জানা থাকলে লক্ষ্যমাত্রা ঠিক আছে কিনা বোঝা যায়।
লাউডনেস কোথায় বসাবেন: এনকোডের আগে, একবার। প্রথম পাসটা ইনজেস্টের পরেই চালান (উপরের ডায়াগ্রামে “loudness measure”), আর মাপা মানগুলো ladder-এর অডিও এনকোড ধাপে দিয়ে দিন। তাহলে প্রতিটা অডিও রেন্ডিশন আলাদা করে নরমালাইজ করতে হয় না, আর source ফাইল কখনো বদলায় না।
সব একসাথে: River জব
প্রতিটা কাজ একটা আলাদা জব টাইপ, এনকোড শেষ হলে একটা ট্রানজ্যাকশনে সবগুলো কিউতে ঢোকানো হয়। River-এ একটা জব হলো একটা আর্গুমেন্ট টাইপ (যার একটা Kind) আর একটা ওয়ার্কার:
package media
import (
"context"
"fmt"
"github.com/riverqueue/river"
)
type PosterArgs struct {
AssetID string `json:"asset_id"`
Revision int `json:"revision"`
}
func (PosterArgs) Kind() string { return "poster_candidates" }
type PosterWorker struct {
river.WorkerDefaults[PosterArgs]
Media *Toolkit // wraps ffmpeg/ffprobe and storage
}
func (w *PosterWorker) Work(ctx context.Context, job *river.Job[PosterArgs]) error {
cands, err := w.Media.PosterCandidates(ctx, job.Args.AssetID, job.Args.Revision, 5)
if err != nil {
return fmt.Errorf("poster candidates for %s: %w", job.Args.AssetID, err)
}
return w.Media.SaveCandidates(ctx, job.Args.AssetID, cands) // idempotent: upsert by (asset, revision, index)
} এনকোড জব সফল হওয়ার একই ট্রানজ্যাকশনে বাকিগুলো ঢোকে, তাই এনকোডের “সফল” অবস্থা আর পরের জবগুলো কখনো আলাদা হয়ে যায় না:
_, err = client.InsertManyTx(ctx, tx, []river.InsertManyParams{
{Args: PosterArgs{AssetID: id, Revision: rev}},
{Args: StoryboardArgs{AssetID: id, Revision: rev}},
{Args: PreviewArgs{AssetID: id, Revision: rev}},
{Args: CaptionDraftArgs{AssetID: id, Lang: "bn"}, InsertOpts: &river.InsertOpts{Queue: "asr", Priority: 3}},
}) ক্যাপশনের জব আলাদা কিউতে (asr) আর কম অগ্রাধিকারে, কারণ সেটা ভারী আর ধীর, আর সেটার জন্য আলাদা (হয়তো GPU-ওয়ালা) মেশিন রাখা যায়, যাতে ক্যাপশনের লম্বা লাইন পোস্টার বা স্টোরিবোর্ডকে আটকে না রাখে। River-এ priority 1 সবচেয়ে বেশি আর 4 সবচেয়ে কম। প্রতিটা ওয়ার্কার আইডেম্পোটেন্ট হতে হবে (আইডেম্পোটেন্সি): একই asset আর revision-এ আবার চললে একই key-তে একই ফাইল লিখবে, নতুন কিছু জমাবে না।
ডিবাগিং: এই চ্যাপ্টারের সমস্যাগুলো
| লক্ষণ | কীভাবে নিশ্চিত হবেন | সমাধান |
|---|---|---|
| পোস্টার কালো বা ঝাপসা | blackdetect চালিয়ে দেখুন পোস্টারের সময়টা কালো অংশে কিনা | কালো অংশ বাদ দিয়ে thumbnail ফিল্টার; কয়েকটা candidate, ক্রিয়েটর বাছবেন |
| সিক বারের ছবি ভুল জায়গার ফ্রেম দেখায় | VTT-র cue সময় আর #xywh মান হাতে মিলিয়ে দেখুন; স্প্রাইটের সাইজ ffprobe দিয়ে | ffmpeg-এর fps আর VTT-র every একই মান থেকে আসুক; গ্রিডের গণিতে চেক রাখুন |
| শেষের দিকে সিক বারে কোনো ছবি নেই | ভিডিওর দৈর্ঘ্য ÷ ইন্টারভাল, গ্রিডের ঘরের চেয়ে বেশি কিনা | একাধিক স্প্রাইট শিট, বা লম্বা ভিডিওতে বড় ইন্টারভাল |
| মোবাইলে প্রিভিউ ফুলস্ক্রিনে চলে যায় বা চলেই না | এলিমেন্টে playsinline আর muted আছে কিনা; ফাইলে অডিও ট্র্যাক আছে কিনা (ffprobe) | muted loop playsinline; প্রিভিউ থেকে অডিও বাদ |
| অটো ক্যাপশন ইংরেজি অক্ষরে Bangla লিখছে বা অন্য ভাষায় অনুবাদ করছে | কমান্ডে -l কী দেওয়া; মডেল ফাইলের নাম .en-এ শেষ কিনা | -l bn আর multilingual মডেল |
| ক্যাপশনের লেখা কথার আগে বা পরে আসে | প্লেয়ারে পাশাপাশি চালিয়ে দেখুন অফসেট স্থির কিনা; source-এ শুরুতে ফাঁকা অডিও বা edit list আছে কিনা | অডিও বের করার সময় ভিডিওর সাথে একই শুরুর সময়; এডিটরে অফসেট ঠিক করার অপশন |
| ক্যাপশন জব ঘণ্টার পর ঘণ্টা কিউতে, পোস্টারও আটকে আছে | River-এর কিউ অনুযায়ী জমে থাকা জব | ASR আলাদা কিউ আর আলাদা ওয়ার্কার পুলে |
| কিছু ভিডিও অন্যগুলোর চেয়ে অনেক জোরে বা আস্তে | আউটপুটে loudnorm প্রথম পাস চালিয়ে input_i দেখুন; লগে Normalization Type | দুই পাস নরমালাইজেশন এনকোডের আগে; dynamic-এ যাওয়া ফাইলগুলো আলাদা করে দেখুন |
| একই জব দুইবার চলে দুই সেট পোস্টার জমেছে | স্টোরেজে asset prefix লিস্ট করুন; ডেটাবেসে ডুপ্লিকেট রো | (asset, revision, index) দিয়ে নির্দিষ্ট key আর upsert |
মূল শিক্ষা
- এই কাজগুলো আলাদা আলাদা জব। একটার ব্যর্থতা ভিডিও প্রকাশ আটকাবে না; ক্যাপশন “ready”-র শর্ত নয়।
- প্রথম ফ্রেম পোস্টার নয়। কালো অংশ বাদ,
thumbnailআর scene detection দিয়ে candidate, শেষ সিদ্ধান্ত ক্রিয়েটরের। - Storyboard মানে স্প্রাইট শিট আর WebVTT। ফ্রেমের ইন্টারভাল আর VTT-র গণিত এক জায়গা থেকে আসুক, আর তার একটা চেক থাকুক।
- প্রিভিউ ছোট MP4, GIF নয়।
muted loop playsinline, অডিও ছাড়া। - Whisper নিজের সার্ভারে চলে, তাই অডিও বাইরে যায় না। কিন্তু Bangla-য় অটো ক্যাপশন খসড়া, প্রকাশের আগে মানুষ দেখবে, আর না দেখলে “স্বয়ংক্রিয়” লেবেল থাকবে।
- লাউডনেস এনকোডের আগে, দুই পাসে, পুরো ক্যাটালগে একই লক্ষ্যে। ffmpeg চুপচাপ dynamic মোডে গেলে সেটা লগ করুন।
- এনকোড সফল হওয়ার একই ট্রানজ্যাকশনে পরের জবগুলো ঢোকান, ভারী ASR আলাদা কিউতে, আর প্রতিটা ওয়ার্কার আইডেম্পোটেন্ট।