سلام و علیکم و برحمت ا... و برکاته (باقیاش را به یاد ندارم, درغیراینصورت, ادامه میدادمش)
به هر حال, چندی پیش (یا کمی بیشتر از کمی بیشتر از چندی پیش) تعدادی (حدود 100 تا) فیلم انگلیسی دانلود کردم و زیرنویسشان را جدا از خود فیلم دانلود نموده.
پس از چندی فهمیدم که بعضی از زیرنویسهایی که دانلود کردم با زمان واقعی فیلم کمی اختلاف دارند, بنابراین دست به کیبورد(!) شده و برنامهای نوشتم که این مشکل را برطرف کند. (تا به آن موقع متوجه نشده بودم چقدر برنامهنویس بودن کاربردی هست!)
در این پست میخواهم کد برنامه را شرح دهم. البته, اگر در مورد زیرنویسها (در این مورد بخصوص, .srt) اطلاعاتی ندارید, نیاز نیست "وحشت و فرار" کنید. توضیح خواهم داد!
دقت کنید کنید که من در حال توضیح SubRip هستم و به بقیه formatهای زیرنویس کاری ندارم.
در حقیقت, مشکل از جایی شروع میشود که یک فیلم به زبان خارجه میخواهید ببینید و خیلی هم در آن زبان خوب نیستید. در این صورت بدون زیرنویس, شما هیچی نخواهید فهمید, اما با حضور زیرنویس, نه تنها میتواند سیر تا پیاز داستان را بفهمید, بلکه اگر واژهای را نمیدانستید هم میتوانید در دیکشنری (لغتنامه) جستجو کنید.
اما این فایل بسیار مهم, چگونه کار میکند؟
تعریف بالا, برای هر فرمت زیرنویس قابل قبول بود اما وقتی که بحث به لایههای پایینتر کشیده میشوید, خیر! (برنامهنویسهای low-level این را خوب درک میکنند).
من اینجا طرز کار (و البته نوشتنِ) یک زیرنویس با فرمت .srt را توضیح میدهم. اکثر زیرنویسهایی که در طول عمرتان خواهید دید (و استفاده نمود) هم از همین فرمت استفاده میکنند.
در صورتی که انگلیسی خوبی دارید, یا اینکه اینها را میدانید میتوانید به بخش بعدی بپرید!
در غیر این صورت: Just go with the flow
تا به حال دیدید که متن زیرنویس, بعد از یک یا چند ثانیه از بین میرود و با یک متن جدید تعویض میشوند؟
به این فاصله, frame میگویند, یا به زبان دیگر: فریم, اساسیترین ذرۀ تشکیل دهندۀ زیرنویس هست.
هر فریم شامل 4 بخش هست:
1. شمارۀ فریم sequence number
2. مهر(های) زمانی timecode(s)
3. متن caption
4. خط خالی blankline
شمارۀ فریم اشاره دارد به این که این frame چندیم فریم در این فایل هست. اولین فریم با شمارۀ 1 و دومین با 2 و ... شمارهگذاری میشود.
پس از یک newline نوبتی هم باشد, نوبت timecodeها هست. آنها در شکل زیر استفاده میشوند و به دلیل اینکه دو عدد timecode در هر فریم وجود دارد, از اسم جمع استفاده میشود.
hh:mm:ss,ms --> hh:mm:ss,ms
نسخهای که قبل --> هست (به اول خط/سمت چپ صفحه از نظر خواننده نزدیکتر هست) start timecode و نسخۀ بعد --> end timecode نام دارد.
این دو عدد زمانی که قرار هست caption نماش داده شود را تعیین میکنند. (اگر نمیدانید caption چیست, نگران نباشید. توضیح خواهم داد). متن (caption) از زمانی که ویدیو به لحظهای میرسد که برابر با start timecode هست تا زمانی که زمان ویدیو بیشتر از end timecode هست, نمایش داده میشود.
برنامۀ ما هم قرار هست timecodeها را تغییر دهد.
البته timecodeها از فرمت خاصی استفاده میکنند که در زیر توضیح داده شده:
پس از شمارۀ فریم و timecodeها, نوبت به توضیح متن (caption) میرسد.
آن بخش کوچکی در پایین صفحه هست و مینویسد بازیگران/صداپیشگان فیلم چه میگویند caption نام دارد.
و البته یکی از مهمترین بخشهای هر فایل SRT خط خالی یا blankline هست. خط خالی, مثل ;semi-colon در زبانهایی مانند C/C++ و جاوا هست و اگر وجود نداشته باشید, نرمافزار پخش ویدیو نمیتواند بین فریمها تفاوت قائل شود و همه را به چشم یک فریم واحد خواهد دید. برنامۀ من هم از blankline برای تشخیص پایان فریم و حتی تشخیص پایان فایل (EOF) استفاده میکند!
خوب, اکنون که فهمیدیم (یا فهمیده بودیم) که فایلهای SubRip چطور کار میکنند وقت "تشریح کد" هست.
میاننوشت: من در مزخرفترین بخشهای کد هم کامنت گذاشتهام!
برای راحتی خواننده (و نوازنده و شنونده), من کد را در پایان همین مطلب, گذاشتهام, کافی است به پایین scroll کنید. (البته, پیشنهاد میکنم از GitHub کد را ببینید, زیرا نمایشگر کد واقعاً بهتری دارد.)
برای ساخت چنین برنامۀ معجزهآسایی(!) نیاز هست 4 مرحله را طی کنیم:
1. فایل را بخوانیم
2. اطلاعات فایل را پردازش کنیم
3. زمان لازم را به مهرهای زمانی اضافه کنیم
4. فایل جدید بعد از اصلاح زمان را بنویسیم.
با این وجود, من از راهحلی استفاده کردهام که مرحلۀ 2 و 4 را با هم ترکیب میکند!
برای ساخت این برنامه, من یک حلقۀ for گذاشتهام (بین خطوط 210-220).
این حلقۀ روی یک لیست راه میرود که آن لیست, محتویات فایلی هستی که قرار هست دادهها از روی آن فایل خوانده شود. هر آیتم در این لیست, یک خط از آن فایل هست. تابع read_file_splitted هم بین خطوط 15-25 توسط من نوشته شده است (و تابع built-in نیست).
قبل از شروع حلقه, دو متغییر STATE و EXPECTED را تعریف کردیم.
متغییر STATE وضعیت کنونی در آن نوشته میشود و حلقه به پایین میرسد. بنابراین, این متغییر وضعیت قبلی زیرنویس را نشان میدهد.
متغییر EXPECTED, وضعیتی که انتظار میرود, در دور بعدی حلقه, دیده شود در آن نوشته میشود. و به دلیلی که در بالا ذکر شد, وضعیتی که زیرنویس باید در آن باشد را نشان میدهد.
زیر نویش میتواند در یکی از وضعیتهای زیر باشد که با متغییر متقابل به آن نشان داده شده:
STATE_FRAME = 0 # it's frame number
نشان دهندۀ شمارۀ فریم هست.
STATE_TIME = 1 # it's hh:mm:ss,ms --> hh:mm:ss,ms
نشان دهندۀ timecodeها (مهرهای زمانی) هست.
STATE_CAPTION = 2 # it's caption
نشان دهندۀ متن (caption) هست.
STATE_BLANK = 3 # it's blank line
نشان دهندۀ خط خالی هست.
(همه کدها ایتالیک هستند, به جز آنهایی که در کادر مخصوص کد هستند. اگر کدی ایتالیک نبود, به معنی این هست که منظور معنی آن در انگلیسی بوده است و نه دستور آن در پایتون!)
این برنامه, با تعدادی شرط (if) بررسی میکند که با توجه به وضعیت کنونی زیرنویس, وضعیت بعدی چه باید باشد. پس از تعیین, کار را به تعدادی تابع میسپارد تا جواب را برگردانند. پس از اینکه تابع موردنظر, جواب را برگرداند, با یا بدون چک کردن, این جواب را در یک dict مینویسد.
دیکشنریای که در بالا گفته شد frame نام دارد (خطوط 190-195).
اما آن توابعی که گفتم کجا هستند؟ خطوط 145-190 و همۀ آنها با _frame شروع میشوند!
بیشتر این توابع, فقط نیاز دارند تا محتویات خطی که به آنها نیاز دارد را بدانند. این توابع به شرح زیر هستند:
frame_number(line: str)
اگر به تابع بالا, line را بدهید, شمارۀ فریم را برمیگرداند و اگر line شمارۀ فریم نباشد, Exception میسازد.
frame_timecode(line: str)
تابع بالا, ابتدا با re (و regex) چک میکند که آیا line به فرمت timecode هست یا خیر, پس از آن, timecode برای شروع و پایان را از هم جدا میکند و باقی کار را به یک utility-function به نام:
parse_timecode(timecode)
میسپارد. این تابع یک str میگیرید که یک timecode را حامل میباشد و آن را به یک datetime.time تبدیل میکند و برمیگرداند.
frame_caption(line: str, blank=False)
این تابع, هم برای caption و هم برای blankline استفاده میشود و برای همین, دو پارامتر دارد.
این تابع, اگر به جای caption, خط خالی پیدا کند, blankline را برمیگرداند. blankline میگوید که آیا وجود خط خالی در وضعیتی که تابع صدا زده (call) شده است, درست (blank=True) یا غلط (blank=False) هست.
در غیر اینصورت, فقط آرگومان (argument) line را برمیگرداند.
به هر بدبختیای که شده, نتیجه را در داخل متغییر frame مینوسید. پس از آن یک شیء (object) از روی کلاس Frame ساخته میشود که frame** به سازنده (constructor) آن پاس داده شده (خط 235-240).
پس از آن, تابع __str__ کلاسِ Frame صدا زده میشود و نتیجۀ آن در فایلی که از متغییر FROM به دست آمده ذخیره میشود. پس از آن, متغییر frame آماده هست تا از نو نوشته شود و ....
اما همه چیز, زمانی که برنامه, دو خط خالی (blankline) پشت سر هم (متوالی) را تشخیص دهد پایان مییابد.
برنامه در هر بار که حلقه میچرخد, اول از همه این شرط را چک میکند (خطوط 215-220).
اگر شرط بالا, برقرار شد, برنامه, به فایلی که از متغییر FROM به دست آمده, چند خط خالی (blankline) اضافه میکند و فایل را میبندد (()close.). البته, این یک خطا (bug) نیست, بلکه خودم, بنابردلایلی واضح, بخشی از کد که این کار را میکند را در خطوط (245-250) نوشتم.
برنامۀ ما, جادو را در خطوط 235-240 اتفاق میافتد:
SUBTITLE.write(do(Frame(**frame)))
در اینجا, کلاسی که از فریم ساختهایم به تابع do(fr) پاس داده شده است که این تابع, خود متشکل از بخشهای مختلفی هست.
تابع do (خطوط 55-60), یک شیء از کلاس Frame میگیرید و پس از انجام عملیات, متد __str__ آن کلاس را فرامیخواند و نتیجه را برمیگرداند.
اما این تابع, چه عملیاتی را انجام میدهد؟ این تابع, مهرهای زمانی (timecode) فریم را تغییر میدهد! اما
چطوری؟
با این کد ساده:
fr.setTimecodes (time_sum(fr.start, OFFSET), time_sum(fr.end, OFFSET),)
اما fr.setTimecode و time_sum و OFFSET از کجا آمدند؟ اصلاً اینها از کجا آمدند؟
برنامه اطلاعات لازم را چگونه به دست آورده است؟
سه متغییر FROM, OFFSET, TO همانطور که در کد کامنت شده است, به ترتیب (چپ به راست از نظر خواننده) شامل: 1. فایلی که اطلاعات از آن خوانده شود, 2. میزان زمانی که باید به مهرهای زمانی هر فریم اضافه شود (به میلیثانیه) 3. فایلی که زیرنویس با مهرهای زمانی جدید در آن نوشته خواهد شد. همه این متغییرها سراسری (global) هستند.
برنامۀما, همه این اطلاعات را از تابع زیر به دست getInfo میآورد. و من فکر نمیکنم نیاز باشد تا توضیح دهم که این تابع چطور کار میکند. بسیار واضح هست!
اکنون که معمای متغییر OFFSET حل شده است, وقت حل کردن معمای Frame.end و Frame.start هست.
معما حل شد: Frame.start اشاره به مهرزمانی اول یا شروع (سمت چپتر) و Frame.end اشاره به مهرزمانی دوم یا پایان (سمت راستر) دارد.
اکنون نوبت به time_sum(tm: datetime.time, offset: int) میرسد. نام این تابع, کارش را لو میدهد. این تابع به عنوان آرگومان اول, یک شیء از روی datetime.time میگیرید و آن را با آرگومان دوم که یک عدد (به میلیثانیه) هست, جمع میکند و یک شیء از روی datetime.time برمیگرداند که نتیجۀ جمع هست.
قلب معما
این کد را به یاد دارید؟:
fr.setTimecodes (time_sum(fr.start, OFFSET), time_sum(fr.end, OFFSET),)
همان کد ساده که در آن "جادو اتفاق میافتد"!
بعد از توضیحات بالا, اگر دوباره به این کد نگاه کنیم, متوجه هزار داستانی که میگوید میشویم, اما آیا داستان هزار و یکم را میفهمید؟
همانطور که میدانیم, fr نام یکی از پارامترهای تابع هست که یک شیء از کلاس Frame هست. اما تابع setTimecodes دیگر چیست؟ گفتم: "معمای بزرگ!"
اما هر معمایی (بخوانید: "هر کدی"), هرچند بزرگ (بخوانید: "طویل و تودرتو"), بالاخره حل میشود (بخوانید: "درک میشود", "اجرا میشود")
این متد, که در کلاس Frame و در خطوط 135-145 تعریف شده هست, دو datetime.time به عنوان آرگومان میگیرد و timecodeهای ذخیره شده در شیء را با مقداری که دو پارامترش دارند, عوض میکند.
اکنون که حوصله کردید و این را مطلب را تا اینجا خواندید [و [احتمالاً] پشیمان هم شدید], برای خواندن ادامۀ مطلب, این دیالوگ کد را اسکرول (scroll) کنید. (و خواهید فهمید که مطلب ادامه نداشت!)
""" All rights are reserved for the Author.
Author: Pooia Ferdowsi <pooia.ferdowsi.is.developer@gmail.com>
You can find LICENSE in the README.md
Ensure that you always have one and only one blankline between frames
Ensure that the first line of the file is frame number
Ensure that last frame has at least two blanklines after its caption
NOTE: datetime.time.microsecond stands for millisecond
"""
from datetime import time, timedelta
import re
FROM = "D:/srt.srt" # the file to read the subtitle from
OFFSET = 0000 # time to add to timecode in miliseconds
TO = "D:" # the file to write the subtitle to
############### UTILITY ################
def read_file_splitted(path):
"""Read the file in the specified 'path' and return it splitted
Open and read the file specifed in 'path' with given 'encoding'
and make a list of the text which each element represents a 'line'
in the file. (readline() method doesn't work, so it's a substitute)
"""
return open(path, encoding='utf-8-sig').read().splitlines()
def parse_timecode(timecode):
"return datetime.time from ' 00:00:00,000 ' pattern/format"
hour, minute, rest = timecode.split(':')
second, milli_sec = rest.split(',')
# use a map, convert it to a list
return time(
int(hour), int(minute),
int(second), int(milli_sec)
)
def isBlank(line) -> bool:
return line.isspace() or not bool(line)
# CUSTOMIZABLE: customize 'getInfo' func as you wish
def getInfo():
"""Read and assign the desired data
The function assign the value acquired by the implemented
method in this function to FROM, TO, and OFFSET variables
"""
# TODO: check answer more than now
global FROM, TO, OFFSET
FROM = input("File to read the data from: ")
TO = input("File to write the data to: ")
OFFSET = int(input("Time to delay the captions (millisecs): "))
# CUSTOMIZABLE: customize 'do' func as you wish
def do(fr):
"This function tells the program to do what"
global OFFSET
fr.setTimecodes\
(time_sum(fr.start, OFFSET), time_sum(fr.end, OFFSET),)
return fr.__str__()
def file2write(filename: str):
"""Create and return the file 'filename'
Create if doesn't exist and open (in append mode)
if exists the file 'filename' and return it
"""
try:
f = open(filename, 'x', encoding='utf-8-sig')
except FileExistsError:
f = open(filename, 'a', encoding='utf-8-sig')
finally:
return f
def time2msec(hour, minute, second, millisec):
"converts (h, m, s, ms) to milliseconds"
# millisec acts as millisecond
return millisec + ((hour * 60 + minute) * 60 + second) * 1000
def msec2time(millisecond):
"converts millisecond to (h, m, s, ms)"
# how many milliseconds are there in an hour
msec_in_hour = 3600000 # 3,600,1000 = 60^2 * 1000
msec_in_min = 60000 # 60,000 = 60 * 1000
msec_in_sec = 1000 # 1,000 = 1 * 1000
hour = millisecond // msec_in_hour
minute = (millisecond % msec_in_hour) // msec_in_min
second = (millisecond % msec_in_min) // msec_in_sec
msec = (millisecond % msec_in_sec)
return hour, minute, second, msec
def time_sum(tm: time, offset):
"""Add offset (in milliseconds) to tm (datetime.time object)
"""
# What about if I convert all of them to millisecond and then
# calculate the sum and move it back to the actuall format
if not isinstance(tm, time):
raise Exception("datetime.time object expected")
result = time2msec(tm.hour, tm.minute
, tm.second, tm.microsecond) + offset
result = 0 if result < 0 else result
return time(*msec2time(result))
class Frame:
"""This class represents a frame in SRT
The purposes of creation of the 'Frame' class
is to be able to save frames in a list, which
may not be possible because of the dense information
a frame represent.
"""
number = None # Frame number in the SRT file
timecodes = None # Time to (start, end) the caption
start, end = None, None
caption = None
def __init__(self, number: int, timecodes: tuple, caption: str):
if number > 0:
raise Exception\
("Frame number cannot be negative or zero")
self.number = number
self.start, self.end = timecodes
self.setTimecodes(self.start, self.end)
if type(caption) != str:
raise Exception("Caption must be string")
self.caption = caption
def __str__(self):
"Renders the frame as if it was in a SRT file"
return \
f"{self.number}\n"\
f"{self.start.hour}:{self.start.minute}:"\
f"{self.start.second},{self.start.microsecond}"\
f" --> {self.end.hour}:{self.end.minute}:"\
f"{self.end.second},{self.end.microsecond}"\
f"\n{self.caption}\n\t\n"
def setTimecodes(self, start, end):
if isinstance(start, time) and isinstance(end, time):
self.timecodes = (start, end)
self.start, self.end = self.timecodes
else:
raise Exception("Timecodes must be datetime.time objects")
### functions to extract desired data from the argument $line ###
def frame_number(line: str, *, timeout=100):
"""Try to extract frame number from the given line
To do it, this function tries to convert the 'line'
parameter to a positive integer.
"23" --> 23 --> # it's frame number
"""
frame = int(line)
if frame < 0:
raise Exception("Frame number can't be zero or negative")
return frame
def frame_timecode(line: str):
"""Read $line and return timecodes (in datetime.time)
It first check if the line is within desired patterns
If so, split $start & $end and passes it to
parse_timecode() so it returns datetime.time objects
"""
timecode_regex = ' *[0-9]+:[0-6]?[0-9]:[0-6]?[0-9],[0-9]{1,3} \
--> [0-9]+:[0-6]?[0-9]:[0-6]?[0-9],[0-9]{1,3}'
if len(re.findall(re.compile(timecode_regex), line)) == 1:
timecodes = line.split('-->')
return (
parse_timecode(timecodes[0]), # start
parse_timecode(timecodes[1])) # end
else:
raise Exception("Two timecodes in a single frame/No timetimecodes")
def frame_caption(line: str, *, blank=False):
"""Read the $line and return it as caption if it's no empty
If so, it refer to the $blank parameter and returns $blank
This help to know can the line be blank (end of frame) or
it's a bug.
Remember: >>> 'str' == True -- False
"""
if isBlank(line):
return blank # blankline found instead of caption
else:
return line
# get FROM, TO, OFFSET
getInfo()
# $frame saves each frame as a dictionary til it's valued newly
frame = {} #{'number': int, 'timecodes': (,), 'caption': ''}
"""
Variable 'STATE' contain the current (previous) state of the line.
Variable 'EXPECTED' contain the state which is expected to be on
the line at the point. it must be the current state of the line.
Integer variable start with 'STATE_' are used to define line states.
"""
STATE_BEGIN = -1 # it's first time to do any process on the file
STATE_FRAME = 0 # it's frame number
STATE_TIME = 1 # it's hh:mm:ss,ms --> hh:mm:ss,ms
STATE_CAPTION = 2 # it's caption
STATE_BLANK = 3 # it's blank line
STATE = STATE_BEGIN # default status
EXPECTED = STATE_FRAME
SUBTITLE = file2write(TO)
# Explore the file and append <class 'Frame'> objects to
# list $frames to represent the hole subtitle (SRT) file.
for line in read_file_splitted(FROM):
if isBlank(line):
if STATE == STATE_BLANK:
break # tow contiguous blanlines mean EOF
# Find state of the line and do appropriate actions
if EXPECTED == STATE_FRAME:
frame['number'] = frame_number(line) \
if STATE == STATE_BEGIN else frame_number(line, timeout=3)
STATE, EXPECTED = STATE_FRAME, STATE_TIME
elif STATE == STATE_FRAME and EXPECTED == STATE_TIME:
frame['timecodes'] = frame_timecode(line)
STATE, EXPECTED = STATE_TIME, STATE_CAPTION
elif STATE == STATE_TIME and EXPECTED == STATE_CAPTION:
caption = frame_caption(line)
if caption == False:
raise Exception("Caption expected. Blankline found")
frame['caption'] = caption
# It's first line of the caption and can't be blank
STATE, EXPECTED = STATE_CAPTION, STATE_CAPTION
elif STATE == STATE_CAPTION and EXPECTED == STATE_CAPTION: #
caption = frame_caption(line, blank=True)
if caption == True:
# it's blankline, so do something!
SUBTITLE.write(do(Frame(**frame))) # add it to the
STATE, EXPECTED = STATE_BLANK, STATE_FRAME
else:
frame['caption'] += "\n%s" % caption
SUBTITLE.write("\n\t\n\t\n")
SUBTITLE.close()