-
Notifications
You must be signed in to change notification settings - Fork 40
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Correction ms_to_frames/frames_to_ms/ms_to_str #57
Comments
Hi @moi15moi, thanks for reaching out! I see that Aegisub does proper rounding for the millisecond to centisecond conversion: https://github.com/Aegisub/Aegisub/blob/6f546951b4f004da16ce19ba638bf3eedefb9f31/libaegisub/include/libaegisub/ass/time.h#L32 While pysubs2 currently always rounds down: https://github.com/tkarabela/pysubs2/blob/master/pysubs2/substation.py#L147 I agree that we should round like Aegisub does when writing SSA/ASS timestapms, which is responsibility of As for the general routines in |
If you are talking about ms_to_frames/frames_to_ms, here is an example. import bisect
from enum import Enum
from fractions import Fraction
import sys
from typing import Tuple, Union, List
def pysubs2_frames_to_ms(frames: int, fps: float) -> int:
return int(round(frames * (1000 / fps)))
def pysubs2_ms_to_frames(ms: int, fps: float) -> int:
return int(round((ms / 1000) * fps))
def create_timestamps_from_fps(
fps: Union[int, float, Fraction], n_frames: int
) -> List[int]:
timestamps = [round(frame * 1000 / fps) for frame in range(n_frames)]
return timestamps
class TimeType(Enum):
EXACT = "EXACT"
START = "START"
END = "END"
def ms_to_frames(
timestamps: List[int], ms: int, time_type: TimeType = TimeType.EXACT
) -> int:
if not isinstance(time_type, TimeType):
raise TypeError(f'Unknown time_type "{time_type}" provided.')
if time_type == TimeType.START:
return ms_to_frames(timestamps, ms - 1) + 1
elif time_type == TimeType.END:
return ms_to_frames(timestamps, ms - 1)
denominator, numerator, last = get_den_num_last(timestamps)
if ms < 0:
return int(int(ms * numerator / denominator - 999) / 1000)
elif ms > timestamps[-1]:
return int(
int((ms * numerator - last + denominator - 1) / denominator) / 1000
) + (len(timestamps) - 1)
return bisect.bisect_right(timestamps, ms) - 1
@staticmethod
def frames_to_ms(
timestamps: List[int], frame: int, time_type: TimeType = TimeType.EXACT
) -> int:
if not isinstance(time_type, TimeType):
raise TypeError(f'Unknown time_type "{time_type}" provided.')
if time_type == TimeType.START:
prev_frame = frames_to_ms(timestamps, frame - 1)
curr_frame = frames_to_ms(timestamps, frame)
return prev_frame + int((curr_frame - prev_frame + 1) / 2)
elif time_type == TimeType.END:
curr_frame = frames_to_ms(timestamps, frame)
next_frame = frames_to_ms(timestamps, frame + 1)
return curr_frame + int((next_frame - curr_frame + 1) / 2)
denominator, numerator, last = get_den_num_last(timestamps)
if frame < 0:
return int(frame * denominator * 1000 / numerator)
elif frame > (len(timestamps) - 1):
frames_past_end = frame - len(timestamps) + 1
return int(
(frames_past_end * 1000 * denominator + last + int(numerator / 2))
/ numerator
)
return timestamps[frame]
def get_den_num_last(timestamps: List[int]) -> Tuple[int, int, int]:
denominator = 1000000000
numerator = int((len(timestamps) - 1) * denominator * 1000 / timestamps[-1])
last = (len(timestamps) - 1) * denominator * 1000
return denominator, numerator, last
def main():
fps = Fraction(24000, 1001)
timestamps = create_timestamps_from_fps(fps, 1000)
print(f"TimeType.START: {frames_to_ms(timestamps, 500, TimeType.START)}")
print(f"TimeType.EXACT: {frames_to_ms(timestamps, 500, TimeType.EXACT)}")
print(f"TimeType.END: {frames_to_ms(timestamps, 500, TimeType.END)}")
print(f"pysubs2_frames_to_ms: {pysubs2_frames_to_ms(500, fps)}")
if __name__ == "__main__":
sys.exit(main()) It print
Like you can see, pysubs2_frames_to_ms return the same value as TimeType.EXACT. But, there is 3 type of timing. Start, Exact, End. The exact is the timestamps of the frame. Example: Like any line, you need to set an start time and a end time. If you set the start_time and a end_time with the TimeType.EXACT, the subs won't appear since it would have a duration of 0ms. Edit: Ass
Srt
From what I understand, here is how it works with srt def ms_to_frames_for_srt(
timestamps: List[int], ms: int, time_type: TimeType = TimeType.EXACT
) -> int:
if not isinstance(time_type, TimeType):
raise TypeError(f'Unknown time_type "{time_type}" provided.')
if ms < 0:
return ValueError("You cannot specify an time under 0")
if time_type in (TimeType.START, TimeType.EXACT):
return bisect.bisect_right(timestamps, ms) - 1
elif time_type == TimeType.END:
return bisect.bisect_right(timestamps, ms - 1) - 1
def frames_to_ms_for_srt(
timestamps: List[int], frame: int, time_type: TimeType = TimeType.EXACT
) -> int:
if not isinstance(time_type, TimeType):
raise TypeError(f'Unknown time_type "{time_type}" provided.')
if frame < 0:
return ValueError("You cannot specify a frame under 0")
elif frame > (len(timestamps) - 1):
return ValueError("You cannot specify an image above what the video contains")
if time_type == TimeType.START:
return timestamps[frame]
elif time_type == TimeType.END:
return timestamps[frame + 1]
return timestamps[frame] |
I think I understand better what you mean now! To be honest, I've only ever used the library for batch shifting, format conversion, copying styles around, etc. - never for frame-perfect typesetting or karaoke where exact interpretation of timestamps would be relevant. On the other hand, this seems to be the main focus of the https://github.com/CoffeeStraw/PyonFX library you mentioned. In my experience, subtitle formats (except perhaps OpenVTT and similar "official" stuff) are very poorly defined, so it ends up being dependent on the exact player/rendering library used for playback. You noted there is difference between ASS and SRT in mpv. While I can see the use for a thing like |
Yes, there are a lot of subtitle format where the spec aren't well defined, but if pysubs2 respect what mpv does, i think it is better than nothing. |
@tkarabela Here is what I have done: https://gist.github.com/moi15moi/9b68589e6df0a76efbd01693c6c6f2ff
There was something I had misunderstood. In reality, any format use the same logic. |
@tkarabela What do you think about my previous message? |
Hi @moi15moi , thank you for the code :) I'm somewhat busy at the moment, I'll probably get to it over the weekend. |
@moi15moi I've looked at the code and it looks good to me (thanks for including the docstrings and tests!). As the next step I suggest that I create a new branch in this repo, put your code in there and integrate it with the rest of the library, and then we can make a review and decide if it's ready for release or more work is needed. If all goes well I think it could be released before end of the year :) |
I don't really know where to add it properly. I think think they are only used here:
Problem/Question
|
I was thinking about something like this: import pysubs2
import os.path
def snap_to_frames(subs: pysubs2.SSAFile, format_: str, ts: pysubs2.Timestamps):
for e in subs:
old_start_ms = e.start
old_end_ms = e.end
start_frames = ts.ms_to_frames(old_start_ms, format_, pysubs2.TimeType.START) # not sure if we want START or END in these
end_frames = ts.ms_to_frames(old_end_ms, format_, pysubs2.TimeType.END)
new_start_ms = ts.frames_to_ms(start_frames, pysubs2.TimeType.START)
new_end_ms = ts.frames_to_ms(end_frames, pysubs2.TimeType.END)
e.start = new_start_ms
e.end = new_end_ms
# this code would be incorporated into pysubs2.SSAFile.save()
def new_save(subs: pysubs2.SSAFile, path: str, ts: Optional[pysubs2.Timestapms]):
subs = subs.copy()
# when saving, we already know the target format
ext = os.path.splitext(path)[1].lower()
format_ = pysubs2.formats.get_format_identifier(ext)
# new part: snap subtitles to frames according to timestamps
if ts:
snap_to_frames(subs, format_, ts)
# save subtitles as usual
subs.save(path)
ts = pysubs2.Timestamps.from_video_file("video.mkv")
subs = pysubs2.load("subs.srt")
new_save(subs, "subs.ass", timestamps=ts) This would make sure that timestamps are properly "snapped" to timestamps in the output file, while requiring little modification to other code. Would that be useful? I see that going further and allowing something like: ts = pysubs2.Timestamps.from_video_file("video.mkv")
subs = pysubs2.load("subs.srt")
subs.shift(frames=5, timestamps=ts) # <--- ...is problematic if the frames/ms conversion needs to know output format, since we don't know that in pysubs2. The In the command line interface ( On a more general note, what are the use-cases for what
I'm thinking about how to best support it, given that the library wasn't designed for it: for the karaoke/fx use-case which is going to involve a lot of custom scripting anyway, I think even providing the P.S. Sorry for the late reply, I didn't get around to do it during the holidays. |
Not really, the timing does not need to be "snapped" to the frame when saving it, so I don't see the utility to
The timestamps class has no use if the subtitle timing are already in frame!
Exactly, but it isn't necessarily only usefull for karaoke/effect.
This method allow to have a perfect timing when converting an .srt to an .ass file.
Not necessarily a VFR video. It is also usefull with CFR video since the current algorithm does not always work (if you need example, ask it and I will provide you some). There may be some other use case, but these are clearly the most important one. |
Thanks, I see. If you have any examples, that would be helpful :) Feel free to link them here or send me an e-mail to tkarabela at seznam dot cz. The conversion from SRT -> ASS should be improved as of version 1.6.0 of the library after this commit: 2725c0a |
This isn't really a magic method that make the conversion working. Before explaining why it does not, here is an reminder of possible value an time can have for an particular frame:
Let's suppose we have those timestamps (these are the timestamps for an 24000/1001 fps video with 6 images).
Possible start time for the image 3: ]83, 125] = [84, 125]
So, this is a proof that we can't only use a rounding method to convert srt to ass. This is why we need to have the timestamps information to know how to properly convert the milliseconds into centiseconds while conversing the exact frame timing. Why is the current shifting method with frame does not work well?I think the best is to give you an example: Example bad shift frame.zip Even if you floor or ceil the result, there will always be edge case that will cause problem. Example why floor does not work. Example why rounding does not work. Example why ceil does not work. So, how to make the shifting works? Why frames_to_ms need to know the output format?I just realised we don't need it since I take an average of the 2 extreme value (for start time average of So, I removed the parameter "format" in the method frames_to_ms in my last commit. Remaining problemI would need to have the |
I agree, we need an additional parameter with default value
I think I finally get it ^^; I've made a test .mp4 file with frame information both in the video and in SRT subtitles, which match perfectly. Just converting this SRT file to ASS using the current version of the library messes up the timing and many subtitles are 1 frame off, as you explain. This undesirable behaviour should be fixed by the The test data is here: pysubs2_timestamps_test.zip
Let's have a look what may be happening:
As I understand it, doing the
Yes, this is a good trick. One consequence is that this kind of "rounding" will in practice subtly shift most timestamps (eg. a subtitle that used to start at 0:00:00.00 will now be something like 0:00:00.02), which may be somewhat confusing. The previous format-aware method would effectively just round the last digit up or down for timestamps where it is relevant. |
The file But, if it helped you understand the current issues, that's great.
This will cause the same problem has the current shifting since the Example where the fps = 93, the end_time correspond to the frame 37 and we want to shift the end_time by 1 frame. from pysubs2 import Timestamps, TimeType, make_time
def round_timing(ass_ms):
"""
From https://github.com/Aegisub/Aegisub/blob/6f546951b4f004da16ce19ba638bf3eedefb9f31/libaegisub/include/libaegisub/ass/time.h#L32
"""
return (ass_ms + 5) - (ass_ms + 5) % 10
fps = 93
timestamps = Timestamps.from_fps(fps)
end_frame = 37
# end_ms is snapped to the frame
end_ms = timestamps.frames_to_ms(end_frame, TimeType.END)
# I do like you said
end_ms += make_time(frames=1, fps=fps)
# Since .ass does not handle milliseconds, I "round" it
end_ms = round_timing(end_ms)
print(f"Expected result: {end_frame + 1}")
print(f"Result: {timestamps.ms_to_frames(end_ms, TimeType.END)}") Output (Like you can see, it doesn't work)
In my opinion, this is why the method
I am 100% agree. We should not "invoke" anything relied with Timestamps.
I don't see why it should raise en exception.
I am 100% agree. We should not "invoke" anything relied with Timestamps.
If you don't consider the VFR video and a possibility an user shifted the subtitle by few ms, then I think you are right, but I am not 100$ sure. |
Sorry, by "snapping to frame" I didn't mean the simple rounding as you do in def snap_to_frames(subs: pysubs2.SSAFile, format_: str, ts: pysubs2.Timestamps):
for e in subs:
old_start_ms = e.start
old_end_ms = e.end
start_frames = ts.ms_to_frames(old_start_ms, format_, pysubs2.TimeType.START) # not sure if we want START or END in these
end_frames = ts.ms_to_frames(old_end_ms, format_, pysubs2.TimeType.END)
new_start_ms = ts.frames_to_ms(start_frames, pysubs2.TimeType.START)
new_end_ms = ts.frames_to_ms(end_frames, pysubs2.TimeType.END)
e.start = new_start_ms
e.end = new_end_ms Curiously, in your example:
I actually do get both results as 38, this is with moi15moi@aab9981 and this change: # TODO Correct this line
return timestamps.frames_to_ms(frames, TimeType.START)
I meant that it's a tricky operation: let's say your video has 30fps and 60fps segments, then by shifting subtitles by 30 frames, you shift by 1 second in the 30fps parts, but only by 1/2 second in the 60fps parts.
Yeah, I'm also not sure, that's mainly why I actually generated a video with matching subtitles, to demonstrate the issues and see if the code changes resolve them. I did another one for your 93fps example (this time I rounded the ms, as you suggest) and... it just doesn't seem to match, at least in ffmpeg and VLC. The SRT and ASS subtitles are both 2-3 frames ahead of the video, even though the SRT timestamps should be perfect (rounding the ms or not doesn't fix this). pysubs2_timestamps_test2.zip |
I understood that. I don't know what make you think I misunderstood it.
This is simply a coincidence. It make no sense to do that.
The video is broken. There are a lot of frame that are duplicated. |
Ah, you're right, ffmpeg messes up the .mp4 output somehow. With .mkv it's good. I've tried with the first 23.976 video example: import subprocess
import pysubs2
subs = pysubs2.load("test_video.srt")
ts = pysubs2.timestamps.Timestamps.from_video_file("test_video.mp4")
def snap_to_frames(subs: pysubs2.SSAFile, ts: pysubs2.Timestamps):
for e in subs:
old_start_ms = e.start
old_end_ms = e.end
start_frames = ts.ms_to_frames(old_start_ms, pysubs2.TimeType.START) # not sure if we want START or END in these
end_frames = ts.ms_to_frames(old_end_ms, pysubs2.TimeType.END)
new_start_ms = ts.frames_to_ms(start_frames, pysubs2.TimeType.START)
new_end_ms = ts.frames_to_ms(end_frames, pysubs2.TimeType.END)
e.start = new_start_ms
e.end = new_end_ms
subs.shift(frames=99, fps=23.976)
snap_to_frames(subs, ts)
subs.save("test_video-shift.ass")
subprocess.check_call(["ffmpeg", "-y", "-i", "test_video.mp4", "-vf", "subtitles=test_video-shift.ass", "test_video_with_ass-shift.mp4"]) With no shift, Can you implement what you think is needed inside |
See the message where I presented the example where the fps = 93, the end_time correspond to the frame 37 and we want to shift the end_time by 1 frame. I "proved" by showing an counterexample that even if you snap the subs to the frame, it does not always works.
Sure, see my last commit |
Is there any problem? |
Hi @moi15moi , I'm ill at the moment, I will continue when I'm able to. Your code looks good. |
Currently ms_to_frames and frames_to_ms does not works correctly. They can be imprecise.
I did a pullrequest on the PyonFx repos and I think it could be a good idea to do something similar with pysubs2: CoffeeStraw/PyonFX#46
I recommand you to look at these two file of my PR. These are the only one that matters for pysubs2.
In brief, here is all the method I propose to change:
ms_to_str, should add this little part for the .ass and .saa format: ass_ms = (ass_ms + 5) - (ass_ms + 5) % 10
ms_to_frames(Current version) should be something like this: ms_to_frames
frames_to_ms (Current version) should be something like this: frames_to_ms- Corrected version
The text was updated successfully, but these errors were encountered: