DEV Community

Cover image for ffmpeg से वीडियो को उसके ट्रांसक्रिप्ट के आधार पर कैसे काटें

ffmpeg से वीडियो को उसके ट्रांसक्रिप्ट के आधार पर कैसे काटें

टेक्स्ट-बेस्ड वीडियो एडिटिंग देखने में वर्ड प्रोसेसर जैसी लगती है, लेकिन अंदर से वह समय की रेंज की एक सूची और एक ffmpeg कमांड है। ट्रांसक्रिप्ट हर शब्द को एक शुरू और एक खत्म होने का समय देता है, जो शब्द आप डिलीट करते हैं वे कट बन जाते हैं, और कट्स के बीच का सब कुछ रखा जाता है। osFoundry डेस्कटॉप ऐप का वीडियो एडिटर यही काम Rust में करता है (canvas_transcript.rs)। नीचे वही पाइपलाइन Python में लिखी है, 37.3 सेकंड की एक असली इंटरव्यू क्लिप पर चलाई हुई, और उस शॉर्टकट से तुलना भी है जो बराबर दिखता है पर है नहीं: हर टुकड़े को -c copy से काटकर जोड़ देना।

शब्दों के टाइमिंग से शुरू करें

कोई भी ट्रांसक्राइबर चलेगा जो हर शब्द का टाइमस्टैम्प देता हो। Whisper देता है, और ज़्यादातर होस्टेड speech-to-text API भी माँगने पर देती हैं। यहाँ की क्लिप अंतरिक्ष यात्री Christina Koch के साथ NASA का एक इंटरव्यू है, 640×360, 30 fps, और उसके ट्रांसक्रिप्ट में 119 शब्द हैं:

{"words": [
  {"index": 0, "text": "Well",  "start_ms": 0,   "end_ms": 500},
  {"index": 1, "text": "I",     "start_ms": 740, "end_ms": 840},
  {"index": 2, "text": "think", "start_ms": 840, "end_ms": 1060},
  ...
], "duration_ms": 37300}
Enter fullscreen mode Exit fullscreen mode

शब्दों को समय से नहीं, इंडेक्स से पहचाना जाता है, क्योंकि यूज़र के चुनने और चुनाव हटाने के दौरान इंडेक्स वही रहता है। नीचे की एडिट शब्द 30 से 81 (16.7 सेकंड का भटकाव) और 111 से 118 (अंत की विदाई) डिलीट करती है।

ऐप ट्रांसक्राइबर को सिर्फ़ ऑडियो भेजता है, और स्क्रिप्ट में भी यह कदम अपनाने लायक है। प्रोवाइडर अवधि के हिसाब से पैसे लेते हैं, पर वीडियो ट्रैक अपलोड करने की कोई वजह नहीं है:

ffmpeg -i clip.mp4 -vn -ac 1 -ar 16000 -f wav clip.wav
Enter fullscreen mode Exit fullscreen mode

हर कट को फ़्रेम पर बैठाएँ

जो शब्द 9,420 ms पर शुरू होता है, वह 30 fps पर किसी फ़्रेम के बीच में शुरू होता है। अगर कट का समय फ़्रेम की सीमा पर नहीं लाया गया, तो यूज़र को दिखने वाली संख्या और ffmpeg जिस फ़्रेम पर असल में काटता है, दोनों अलग हो जाते हैं। ऐप छोड़ने के बजाय सबसे नज़दीकी फ़्रेम तक राउंड करता है, और उसका कमेंट वजह बताता है: छोड़ने से हर कट पहले खिसक जाता है, और दर्जन भर एडिट के बाद शब्दों की शुरुआत कटी हुई सुनाई देती है।

def snap(ms, fps):
    frame = 1000 / fps
    return round(round(ms / frame) * frame)
Enter fullscreen mode Exit fullscreen mode

डिलीट किए शब्दों को रखी जाने वाली रेंज में बदलें

डिलीट किए गए शब्द कट स्पैन बन जाते हैं। जो स्पैन ओवरलैप करते हैं या सटे हैं वे मर्ज हो जाते हैं, और कट्स के बीच का सब कुछ रखा जाता है, जिसमें रखे गए शब्दों के बीच के विराम भी शामिल हैं। यह आख़िरी नियम ही नतीजे को ऑटोमैटिक जंप-कट एडिट जैसा सुनाई देने से बचाता है: सिर्फ़ उतना समय जाता है जितना डिलीट किया शब्द घेरता है, हर खामोशी नहीं।

def keep_ranges(words, deleted, duration, fps):
    cuts = sorted((snap(w["start_ms"], fps), snap(w["end_ms"], fps))
                  for w in words if w["index"] in deleted)
    merged = []
    for s, e in cuts:
        if merged and s <= merged[-1][1]:
            merged[-1][1] = max(merged[-1][1], e)
        else:
            merged.append([s, e])
    keeps, cursor = [], 0
    for s, e in merged:
        if s > cursor:
            keeps.append((cursor, s))
        cursor = max(cursor, e)
    if cursor < duration:
        keeps.append((cursor, duration))
    return [k for k in keeps if k[1] - k[0] >= 1000 / fps]  # a sub-frame keep can't be encoded
Enter fullscreen mode Exit fullscreen mode

इस एडिट के लिए यह उन दो की जगह चार रेंज लौटाता है जिनकी आप उम्मीद करते:

(0, 9433) (16833, 16967) (26133, 34700) (36933, 37300)
Enter fullscreen mode Exit fullscreen mode

दूसरी रेंज 134 ms की है। डिलीट किए गए भटकाव के अंदर वक्ता "road" और "but" के बीच 120 ms रुकता है। कोई डिलीट किया शब्द उस विराम को नहीं ढकता, इसलिए "हर गैप रखो" वाला नियम उसे रख लेता है, और आउटपुट में उस वाक्य के बीच का चार फ़्रेम का एक झलक आ जाती है जिसे आपने डिलीट कर दिया था। आख़िरी रेंज अंत में वही चीज़ है: डिलीट की गई विदाई के बाद 367 ms का कमरे का शोर।

इसका हल यह तय करना है कि विराम किसका है। दो डिलीट किए शब्दों के बीच का गैप, या डिलीट किए शब्द और क्लिप की शुरुआत या अंत के बीच का गैप, उन्हीं के साथ जाता है। रखे गए शब्द के बगल का गैप रहता है:

def cut_spans(words, deleted, duration, fps):
    spans = []
    for i, w in enumerate(words):
        if w["index"] not in deleted:
            continue
        s, e = w["start_ms"], w["end_ms"]
        if i == 0 or words[i - 1]["index"] in deleted:
            s = 0 if i == 0 else words[i - 1]["end_ms"]
        if i == len(words) - 1 or words[i + 1]["index"] in deleted:
            e = duration if i == len(words) - 1 else words[i + 1]["start_ms"]
        spans.append((snap(s, fps), snap(e, fps)))
    return spans
Enter fullscreen mode Exit fullscreen mode

शब्दों के स्पैन की जगह cut_spans लगाने पर वही एडिट दो रेंज देती है, (0, 9433) (26133, 34700), और 18.0 सेकंड का नतीजा।

वीडियो और ऑडियो को एक ही ग्राफ़ में ट्रिम करें

हर रेंज वीडियो के लिए trim और ऑडियो के लिए atrim बनती है, और एक concat सबको, वीडियो और ऑडियो साथ में, जोड़ देता है:

def graph(keeps):
    p = []
    for i, (s, e) in enumerate(keeps):
        p.append(f"[0:v]trim=start={s/1000:.3f}:end={e/1000:.3f},setpts=PTS-STARTPTS[v{i}]")
        p.append(f"[0:a]atrim=start={s/1000:.3f}:end={e/1000:.3f},asetpts=PTS-STARTPTS[a{i}]")
    p.append("".join(f"[v{i}][a{i}]" for i in range(len(keeps)))
             + f"concat=n={len(keeps)}:v=1:a=1[outv][outa]")
    return ";".join(p)

subprocess.run(["ffmpeg", "-y", "-i", "clip.mp4", "-filter_complex", graph(keeps),
                "-map", "[outv]", "-map", "[outa]", "out.mp4"], check=True)
Enter fullscreen mode Exit fullscreen mode

यह पूरे आउटपुट को दोबारा एन्कोड करता है, और यही इसकी कीमत है: Apple M1 पर ffmpeg 7.1 के साथ इस क्लिप के लिए 4.5 से 7.4 सेकंड, इस पर निर्भर करते हुए कि और क्या चल रहा था।

-c copy क्यों नहीं

तेज़ और सबसे सीधा रास्ता यह है कि हर रेंज को बिना दोबारा एन्कोड किए काटें और टुकड़ों को concat demuxer से जोड़ें:

ffmpeg -ss 26.133 -to 34.700 -i clip.mp4 -c copy seg2.mp4
ffmpeg -f concat -safe 0 -i list.txt -c copy out-copy.mp4
Enter fullscreen mode Exit fullscreen mode

दोनों तरीके उन्हीं चार रेंज पर चलाए गए और सोर्स से मिलाए गए। वीडियो फ़्रेम सोर्स से हैश के ज़रिए मिलाए गए (कॉपी में मूल फ़्रेम ही रहते हैं) और सबसे नज़दीकी थंबनेल से भी, और ऑडियो को आउटपुट की छोटी विंडो को सोर्स ऑडियो से कोरिलेट करके।

फ़िल्टर ग्राफ़ -c copy + concat
वीडियो की लंबाई 18.50 s 18.87 s
तीसरा हिस्सा सोर्स में यहाँ से शुरू 26.133 s 26.000 s
डिकोड किए ऑडियो की लंबाई 18.53 s 20.85 s
7 जाँच बिंदुओं पर ऑडियो बनाम तस्वीर 0 से 1 ms दूसरे जोड़ के बाद करीब 1 s पीछे

सोर्स में कीफ़्रेम सिर्फ़ हर 8.33 सेकंड पर है, इसलिए कॉपी किया हुआ कट वहाँ से शुरू नहीं हो सकता जहाँ ट्रांसक्रिप्ट कहता है। कॉपी किया हर टुकड़ा अलग-अलग लंबाई के वीडियो और ऑडियो के साथ निकला (तीसरी रेंज के लिए 8.73 s बनाम 8.58 s), जोड़ने से डिलीट किए शब्द के चार फ़्रेम वापस आ गए, और जुड़े ऑडियो को डिकोड करते समय ffmpeg ने पीछे जाते टाइमस्टैम्प की चेतावनी दी। कॉपी किए ऑडियो पर मैच भी कमज़ोर थे (कोरिलेशन 0.2 से 0.8, जबकि फ़िल्टर ग्राफ़ के लिए 1.00), इसलिए "करीब एक सेकंड" अनुमान है। जो साफ़ है वह यह कि वह सिंक में नहीं था, और दर्शक को ऐसे होंठ दिखेंगे जो शब्दों से मेल नहीं खाते।

एक कीफ़्रेम पर एक कट के लिए स्ट्रीम कॉपी ठीक है। ट्रांसक्रिप्ट से तय हुए कट्स के लिए दोबारा एन्कोड करना वह कीमत है जिससे कट वहीं पड़ते हैं जहाँ शब्द हैं।

पूरा कोड

ऊपर के टुकड़े मिलकर 60 लाइन से कम Python बनते हैं, जिसमें सिर्फ़ स्टैंडर्ड लाइब्रेरी और एक ffmpeg बाइनरी लगती है। osFoundry का canvas वीडियो एडिटर इस तरह की पाइपलाइन को ट्रांसक्रिप्ट पैनल के पीछे रखता है: शब्दों पर ड्रैग करें, Delete दबाएँ, कट्स लागू करें। ऐप osFoundry डाउनलोड पेज से मुफ़्त है।

Top comments (0)