
上週剪一支直式短片,旁白是一整段話,字幕要照語音的停頓切成一張一張的卡。成片看一遍,有幾句字幕就是沒出來,像「光,」和「就在那個方向」這幾句,聲音有、字沒有。
我先去翻時間軸,不看畫面。71 張字幕卡裡有 6 張長度正好 0.00 秒,另外 5 張不到 0.35 秒。0 秒的卡當然畫不出來,那幾句就是這樣不見的。比較尷尬的是,我原本的驗收是每張卡抽中間那一幀看有沒有字,這一關全過,一張都沒抓到。
為什麼會切出 0 秒的卡
原本的切法很直覺:每個原始切點各自去找離它最近的停頓,貼過去。切點密集、停頓稀疏的時候,好幾個切點最近的停頓是同一個,第 N 個邊界和第 N+1 個邊界就被貼到同一個時間,夾在中間那張卡長度是 0。
驗收沒抓到的原因跟上面無關,但更值得記下來。0 秒的卡,中點就是那個邊界時間,抽到的那一幀其實是隔壁那張卡正在顯示的畫面,上面當然有字。檢查本身沒算錯,它只是在回答另一張卡的問題。
改法:邊界只能往後走,每張卡有下限
核心只有一件事:貼停頓的時候,候選範圍限制在「上一個邊界 + 最短長度」之後。這樣邊界一定嚴格遞增,每張卡至少 0.6 秒。
光這樣還不夠。候選的上界如果只設成「結尾 − 0.6」,前面的邊界還是可能把後面好幾張卡一起擠到結尾去,最後一張被壓扁。所以上界改成「結尾 − 剩下的卡數 × 0.6」,每個邊界都得替後面每一張卡留位子。
範圍裡找不到停頓的時候,就把原切點夾進 [上一邊界 + 0.6, 上界] 這段區間,然後寫一行警告。這時邊界已經離開停頓點,字幕可能切在話講到一半的地方,但每張卡的長度守得住,日誌也看得出來是哪一張。
另外在函式一開始先算總容量,(切點數 + 1) × 0.6 比音軌還長的話直接拋 ValueError。這種情況怎麼切都會被驗收擋掉,不如一開始就講清楚。
切完之後,字數少於 4 的碎片併回前一張,這次 71 張變成 70 張。最後驗收函式直接對時間軸量每張卡的長度,有一張不夠就讓建置失敗。
import logging
MIN_LEN = 0.6 # 每片最短秒數
MIN_CHARS = 4 # 少於這個字數的碎片併回前一片
EPS = 1e-6 # 浮點容差
log = logging.getLogger(__name__)
def snap_monotonic(cuts, pauses, end, min_len=MIN_LEN):
"""cuts: 原始切點(秒);pauses: 停頓時間點(秒),皆已排序;end: 音軌結尾(秒)
回傳嚴格遞增的邊界,保證每一片(含最後一片)至少 min_len 秒;容量不夠直接拋錯"""
n = len(cuts)
need = (n + 1) * min_len
if end + EPS < need:
raise ValueError(f"not enough room: {n + 1} segments need {need:.2f}s, have {end:.2f}s")
out, prev = [], 0.0
for i, c in enumerate(cuts):
lo = prev + min_len # 下界:上一個邊界 + 最短秒數
hi = end - (n - i) * min_len # 上界:替後面 n-i 片(含最後一片)預留容量
cand = [p for p in pauses if lo - EPS <= p <= hi + EPS]
if cand:
b = min(cand, key=lambda p: abs(p - c))
else:
b = min(max(c, lo), hi) # 夾進 [lo, hi];離開停頓點,記一行日誌
log.warning("cut %d: no pause in [%.2f, %.2f], fallback %.2f", i, lo, hi, b)
out.append(b)
prev = b
return out
def merge_short(segs):
"""segs: [(start, end, text)],太短的字併回前一片"""
res = []
for s, e, t in segs:
if res and len(t.strip()) < MIN_CHARS:
ps, _, pt = res[-1]
res[-1] = (ps, e, pt + t)
else:
res.append((s, e, t))
return res
def check(segs, min_len=MIN_LEN):
"""第一層:只量長度。零長、負長、過短一律擋下,之後才輪到抽幀"""
bad = [(i, round(s, 2), round(e, 2)) for i, (s, e, _) in enumerate(segs) if e - s < min_len - EPS]
assert not bad, f"too short: {bad}"
# 第二層抽幀點:1/4 與 3/4。對零長片段這兩點與中點是同一點,所以必須先過上面這關
return [(s + (e - s) * 0.25, s + (e - s) * 0.75) for s, e, _ in segs]
呼叫順序是先用 snap_monotonic 拿到邊界,照邊界切文字,再用 merge_short 併碎片,最後 check。併完要再 check 一次,因為合併會改變片段的起訖。
這段範例我用四組資料跑過:
- 停頓充足的正常情況:結果和舊版一樣。
- 貼近結尾,cuts=[1.7, 1.8]、沒有停頓、end=2.0:舊版給 [1.7, 2.3],三張卡的長度是 1.7、0.6、−0.3,最後一張跑到音軌外面去了。新版給 [0.8, 1.4],三張是 0.8、0.6、0.6。
- 貼近結尾、有停頓但全落在預留區裡:新版退回區間邊界並記日誌,三張是 1.8、0.6、0.6。
- 總長 2.0 秒卻要切 4 張:新版進函式就拋錯。舊版會切出一張 0.2 秒的卡,要到 check 才被擋下。
驗收改成先量長度,再抽幀
現在字幕 QC 分兩步。第一步不碰影像,只對時間軸跑長度檢查,確認沒有任何一張卡短於 0.6 秒。第二步才抽幀,每張卡在 1/4 和 3/4 兩個位置各取一幀,偵測畫面上有沒有字幕。
要說清楚的是,1/4 和 3/4 本身擋不住 0 秒的卡。長度是 0 的時候,1/4、3/4 和中點是同一個瞬間,抽到的還是隔壁的字。零長和過短的卡一定要靠第一步的長度斷言先擋下;第二步抽兩個偏離中心的點,是為了前半段和後半段都看一眼,不是為了抓 0 秒。
修完那次建置的結果是 70 張卡、140 幀全部偵測到白字、缺字幕 0 幀。這組數字來自當天的建置紀錄,寫這篇時沒有重跑;上面的程式範例則另外用那四組資料驗過。後來定版的直式版本,每一張字幕都有出現。
適用範圍
任何照語音停頓自動切字幕卡的流程都適用,跟語言無關。0.6 秒和 4 個字是這支影片的門檻,要看字幕樣式和語速調,字小、講得快的版型可以放寬,慢節奏的長句可能要拉高。停頓怎麼偵測不在這篇的範圍,程式假設停頓清單已經排好序。
找不到合格停頓時的退回路徑會讓字幕切在語音中間,這是資料的問題,程式只負責記一行警告。容量連 (切點數 + 1) × 0.6 都不到的時候直接拋錯,不會悄悄產出壞結果。
這次學到的其實只有一件事:能直接量的東西先量。抽樣檢查的取樣點,要選在問題發生時也會落在問題上的位置,不然檢查過了也不代表什麼。
— 邱柏宇
When Snapped Subtitle Cuts Collapse to Zero
Last week I was cutting a vertical short. The narration is one continuous take, and the subtitles get split into cards at the speech pauses. Watching the render, a few lines simply never showed up. Lines like “光,” and “就在那個方向” were there in the audio but never on screen.
I went to the timeline first instead of the frames. Of the 71 subtitle cards, 6 were exactly 0.00 seconds long and another 5 were under 0.35 seconds. A zero-length card is never drawn, so those lines were gone. The awkward part is that my QC at the time grabbed one frame from the middle of every card and checked for text. Every card passed. It caught none of them.
Why a card ends up at zero
The original approach was the obvious one: take each raw cut point, find the nearest pause, snap to it. When cuts are dense and pauses are sparse, several cuts share the same nearest pause. Boundary N and boundary N+1 land on the same timestamp, and the card between them has no length.
The QC miss has nothing to do with that, and it is the part worth remembering. The midpoint of a zero-length card is the boundary time itself, so the sampled frame is whatever the neighbouring card is showing at that instant, and that frame has text on it. The check was not wrong. It was answering the question for a different card.
The fix: boundaries only move forward, and every card has a floor
The core change is one rule. When snapping, only consider pauses at or after “previous boundary + minimum length”. The boundaries are then strictly increasing and every card is at least 0.6 seconds.
That alone is not enough. If the upper bound for candidates is just “end − 0.6”, an early boundary can still push several later cards up against the end and flatten the last one. So the upper bound became “end − (cards remaining × 0.6)”. Every boundary has to leave room for every card after it.
When no pause falls inside that window, the raw cut gets clamped into [previous boundary + 0.6, upper bound] and a warning goes to the log. The boundary has left the pause grid at that point, so the card may break mid-sentence, but its length holds and the log says which card it was.
There is also a feasibility check at the top of the function. If (number of cuts + 1) × 0.6 is longer than the audio, it raises ValueError right away. No way of cutting would pass QC in that case, so it may as well say so up front.
After slicing, fragments with fewer than 4 characters are merged into the previous card. Here 71 cards became 70. Finally the check function measures every card directly on the timeline and fails the build if any one is too short.
import logging
MIN_LEN = 0.6 # minimum seconds per card
MIN_CHARS = 4 # fragments shorter than this merge into the previous card
EPS = 1e-6 # float tolerance
log = logging.getLogger(__name__)
def snap_monotonic(cuts, pauses, end, min_len=MIN_LEN):
"""cuts: raw cut points (s); pauses: pause timestamps (s), both sorted; end: audio end (s)
returns strictly increasing boundaries; every card (last one included) gets at least min_len seconds; raises if there is not enough room"""
n = len(cuts)
need = (n + 1) * min_len
if end + EPS < need:
raise ValueError(f"not enough room: {n + 1} segments need {need:.2f}s, have {end:.2f}s")
out, prev = [], 0.0
for i, c in enumerate(cuts):
lo = prev + min_len # lower bound: previous boundary + min length
hi = end - (n - i) * min_len # upper bound: reserve room for the n-i cards after this one, last one included
cand = [p for p in pauses if lo - EPS <= p <= hi + EPS]
if cand:
b = min(cand, key=lambda p: abs(p - c))
else:
b = min(max(c, lo), hi) # clamp into [lo, hi]; we left the pause grid, so log it
log.warning("cut %d: no pause in [%.2f, %.2f], fallback %.2f", i, lo, hi, b)
out.append(b)
prev = b
return out
def merge_short(segs):
"""segs: [(start, end, text)]; merge tiny fragments into the previous card"""
res = []
for s, e, t in segs:
if res and len(t.strip()) < MIN_CHARS:
ps, _, pt = res[-1]
res[-1] = (ps, e, pt + t)
else:
res.append((s, e, t))
return res
def check(segs, min_len=MIN_LEN):
"""layer one: measure length only. zero, negative and too-short cards all fail here, before any frame sampling"""
bad = [(i, round(s, 2), round(e, 2)) for i, (s, e, _) in enumerate(segs) if e - s < min_len - EPS]
assert not bad, f"too short: {bad}"
# layer two sample points: 1/4 and 3/4. for a zero-length card these equal the midpoint, which is why the assert above comes first
return [(s + (e - s) * 0.25, s + (e - s) * 0.75) for s, e, _ in segs]
Call order: snap_monotonic for the boundaries, slice the text at those boundaries, merge_short to absorb fragments, then check. Run check again after merging, because merging changes where cards start and end.
I ran this sample against four sets of data:
- Normal, with plenty of pauses: same result as the old version.
- Near the end, cuts=[1.7, 1.8], no pauses, end=2.0: the old version returned [1.7, 2.3], giving card lengths of 1.7, 0.6 and −0.3, with the last card past the end of the audio. The new version returns [0.8, 1.4], giving 0.8, 0.6 and 0.6.
- Near the end, with pauses that all sit inside the reserved zone: the new version falls back to the window bounds, logs it, and gives 1.8, 0.6 and 0.6.
- Four cards in 2.0 seconds: the new version raises on entry. The old one produced a 0.2-second last card that only check would have rejected.
QC now measures length first, then samples frames
Subtitle QC is two steps now. Step one never touches the video. It runs the length check on the timeline and confirms no card is under 0.6 seconds. Step two samples frames, one at 1/4 and one at 3/4 of each card, and detects whether subtitle text is on screen.
To be clear, 1/4 and 3/4 do not catch zero-length cards on their own. At length zero, 1/4, 3/4 and the midpoint are the same instant, and the sampled frame still shows the neighbour’s text. Zero and too-short cards have to be stopped by the length assertion in step one. Step two samples two off-centre points so that both halves of a card get looked at, not to catch zeros.
The build after the fix came out at 70 cards, 140 frames all showing white text, 0 frames missing a subtitle. Those numbers are from that day’s build log and I did not re-run them for this post. The code sample above was verified separately with the four cases. In the finalised vertical cut, every subtitle card appears.
Where this applies
Any pipeline that auto-splits subtitle cards at speech pauses, in any language. 0.6 seconds and 4 characters are the thresholds for this one video. Tune them to your subtitle style and speaking rate: small type and fast speech can go lower, slow long sentences may need more. Pause detection is out of scope here; the code assumes the pause list is already sorted.
The fallback path, when no valid pause is left, can break a card in the middle of speech. That is a data problem, and the code only writes a warning for it. When even (cuts + 1) × 0.6 does not fit in the audio, it raises instead of quietly producing a bad result.
The one thing I took away: measure what can be measured before sampling anything. And when you do sample, pick points that still land on the problem when the problem is there, otherwise a pass means nothing.
— 邱柏宇
延伸閱讀
- 2D 角色換色變體五種做法實測:從 SAM 2 換色到 ControlNet 重上色定案
- invalid_auth 之後 is_connected() 還是 True:Slack bot 靜默近 12 小時
- 白閃轉場讓 iPhone 卡住:同一支 H.264 混了 4:4:4
- mysql2 讀 DATETIME 差 8 小時,一行 timezone: ‘Z’ 解決
- 全天事件的「有空」陷阱:預設值會說謊
https://justfly.idv.tw/s/JPnTsii