توضیح 20 تا 100 کد برنامۀ جدیدم: با زیرنویس‌ها بازی کنیم!

سلام و علیکم و برحمت ا... و برکاته (باقی‌اش را به یاد ندارم, درغیراینصورت, ادامه می‌دادمش)به هر حال, چندی پیش (یا کمی بیشتر از کمی بیشتر از چندی پیش) تعدادی (حدود 100 تا) فیلم انگلیسی دانلود کردم و زیرنویسشان را جدا از خود فیلم دانلود نموده.پس از چندی فهمیدم که بعضی از زیرنویس‌هایی که دانلود کردم با زمان واقعی فیلم کمی اختلاف دارند, بنابراین دست به کیبورد(!) شده و برنامه‌ای نوشتم که این مشکل را برطرف کند. (تا به آن موقع متوجه نشده بودم چقدر برنامه‌نویس بودن کاربردی هست!)در این پست می‌خواهم کد برنامه را شرح دهم. البته, اگر در مورد زیرنویس‌ها (در این مورد بخصوص, .srt) اطلاعاتی ندارید, نیاز نیست "وحشت و فرار" کنید. توضیح خواهم داد!زیرنویس چیست و چگونه کار می‌کند؟دقت کنید کنید که من در حال توضیح SubRip هستم و به بقیه formatهای زیرنویس کاری ندارم.در حقیقت, مشکل از جایی شروع می‌شود که یک فیلم به زبان خارجه می‌خواهید ببینید و خیلی هم در آن زبان خوب نیستید. در این صورت بدون زیرنویس, شما هیچی نخواهید فهمید, اما با حضور زیرنویس, نه تنها می‌تواند سیر تا پیاز داستان را بفهمید, بلکه اگر واژه‌ای را نمی‌دانستید هم می‌توانید در دیکشنری (لغتنامه) جستجو کنید.اما این فایل بسیار مهم, چگونه کار می‌کند؟تعریف بالا, برای هر فرمت زیرنویس قابل قبول بود اما وقتی که بحث به لایه‌های پایین‌تر کشیده می‌شوید, خیر! (برنامه‌نویس‌های low-level این را خوب درک می‌کنند).من اینجا طرز کار (و البته نوشتنِ) یک زیرنویس با فرمت .srt را توضیح می‌دهم. اکثر زیرنویس‌هایی که در طول عمرتان خواهید دید (و استفاده نمود) هم از همین فرمت استفاده می‌کنند.کاملاً کامل و جامع(!)در صورتی که انگلیسی خوبی دارید, یا اینکه این‌ها را می‌دانید می‌توانید به بخش بعدی بپرید!در غیر این صورت: Just go with the flowتا به حال دیدید که متن زیرنویس, بعد از یک یا چند ثانیه از بین می‌رود و با یک متن جدید تعویض می‌شوند؟به این فاصله, frame می‌گویند, یا به زبان دیگر: فریم, اساسی‌ترین ذرۀ تشکیل دهندۀ زیرنویس هست.هر فریم شامل 4 بخش هست:1. شمارۀ فریم sequence number2. مهر(های) زمانی timecode(s)3. متن caption4. خط خالی 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ها از فرمت خاصی استفاده می‌کنند که در زیر توضیح داده شده:دو رقم سمت چپ‌تر برای نمایش ساعت استفاده می‌شوند و توسط : از دو رقمی که راست hh قرار دارند و برای نمایش دقیقه استفاده می‌شوند جدا شده.دو رقمی که راست ساعت قرار دارند برای نمایش دقیقه استفاده می‌شوند و توسط : از دو رقمی که راست mm قرار دارند و برای نمایش ثانیه استفاده می‌شوند جدا شده.دو رقمی که سمت راست دقیقه قرار دارند برای نمایش ثانیه استفاده می‌شوند و توسط , از سه رقمی که راست ss قرار دارند و برای نمایش میلی‌ثانیه (یک هزارم ثانیه) استفاده می‌شوند جدا شده.پس از شمارۀ فریم و 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) کنید. (و خواهید فهمید که مطلب ادامه نداشت!)&quot&quot&quot All rights are reserved for the Author.Author: Pooia Ferdowsi <pooia.ferdowsi.is.developer@gmail.com>You can find LICENSE in the README.mdEnsure that you always have one and only one blankline between framesEnsure that the first line of the file is frame numberEnsure that last frame has at least two blanklines after its captionNOTE: datetime.time.microsecond stands for millisecond&quot&quot&quotfrom datetime import time, timedeltaimport reFROM = &quotD:/srt.srt&quot # the file to read the subtitle fromOFFSET = 0000 # time to add to timecode in milisecondsTO = &quotD:&quot # the file to write the subtitle to############### UTILITY ################def read_file_splitted(path): &quot&quot&quotRead 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) &quot&quot&quot return open(path, encoding='utf-8-sig').read().splitlines()def parse_timecode(timecode): &quotreturn datetime.time from ' 00:00:00,000 ' pattern/format&quot 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 wishdef getInfo(): &quot&quot&quotRead and assign the desired data The function assign the value acquired by the implemented method in this function to FROM, TO, and OFFSET variables &quot&quot&quot # TODO: check answer more than now global FROM, TO, OFFSET FROM = input(&quotFile to read the data from: &quot) TO = input(&quotFile to write the data to: &quot) OFFSET = int(input(&quotTime to delay the captions (millisecs): &quot))# CUSTOMIZABLE: customize 'do' func as you wishdef do(fr): &quotThis function tells the program to do what&quot global OFFSET fr.setTimecodes\ (time_sum(fr.start, OFFSET), time_sum(fr.end, OFFSET),) return fr.__str__()def file2write(filename: str): &quot&quot&quotCreate and return the file 'filename' Create if doesn't exist and open (in append mode) if exists the file 'filename' and return it &quot&quot&quot try: f = open(filename, 'x', encoding='utf-8-sig') except FileExistsError: f = open(filename, 'a', encoding='utf-8-sig') finally: return fdef time2msec(hour, minute, second, millisec): &quotconverts (h, m, s, ms) to milliseconds&quot # millisec acts as millisecond return millisec + ((hour * 60 + minute) * 60 + second) * 1000def msec2time(millisecond): &quotconverts millisecond to (h, m, s, ms)&quot # 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): &quot&quot&quotAdd offset (in milliseconds) to tm (datetime.time object) &quot&quot&quot # 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(&quotdatetime.time object expected&quot) result = time2msec(tm.hour, tm.minute , tm.second, tm.microsecond) + offset result = 0 if result < 0 else result return time(*msec2time(result))class Frame: &quot&quot&quotThis 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. &quot&quot&quot 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\ (&quotFrame number cannot be negative or zero&quot) self.number = number self.start, self.end = timecodes self.setTimecodes(self.start, self.end) if type(caption) != str: raise Exception(&quotCaption must be string&quot) self.caption = caption def __str__(self): &quotRenders the frame as if it was in a SRT file&quot return \ f&quot{self.number}\n&quot\ f&quot{self.start.hour}:{self.start.minute}:&quot\ f&quot{self.start.second},{self.start.microsecond}&quot\ f&quot --> {self.end.hour}:{self.end.minute}:&quot\ f&quot{self.end.second},{self.end.microsecond}&quot\ f&quot\n{self.caption}\n\t\n&quot 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(&quotTimecodes must be datetime.time objects&quot)### functions to extract desired data from the argument $line ###def frame_number(line: str, *, timeout=100): &quot&quot&quotTry to extract frame number from the given line To do it, this function tries to convert the 'line' parameter to a positive integer. &quot23&quot --> 23 --> # it's frame number &quot&quot&quot frame = int(line) if frame < 0: raise Exception(&quotFrame number can't be zero or negative&quot) return framedef frame_timecode(line: str): &quot&quot&quotRead $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 &quot&quot&quot 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(&quotTwo timecodes in a single frame/No timetimecodes&quot)def frame_caption(line: str, *, blank=False): &quot&quot&quotRead 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 &quot&quot&quot if isBlank(line): return blank # blankline found instead of caption else: return line# get FROM, TO, OFFSETgetInfo()# $frame saves each frame as a dictionary til it's valued newlyframe = {} #{'number': int, 'timecodes': (,), 'caption': ''}&quot&quot&quotVariable 'STATE' contain the current (previous) state of the line.Variable 'EXPECTED' contain the state which is expected to be onthe line at the point. it must be the current state of the line.Integer variable start with 'STATE_' are used to define line states.&quot&quot&quotSTATE_BEGIN = -1 # it's first time to do any process on the fileSTATE_FRAME = 0 # it's frame numberSTATE_TIME = 1 # it's hh:mm:ss,ms --> hh:mm:ss,msSTATE_CAPTION = 2 # it's captionSTATE_BLANK = 3 # it's blank lineSTATE = STATE_BEGIN # default statusEXPECTED = STATE_FRAMESUBTITLE = 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(&quotCaption expected. Blankline found&quot) 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'] += &quot\n%s&quot % captionSUBTITLE.write(&quot\n\t\n\t\n&quot)SUBTITLE.close()