Skip to content

Feature request: deduplication of transcript snippets #608

Description

@mebeim

I have noticed that a few videos on YouTube have now started using rich SRV3/VTT transcripts where text changes color/position sort of like an animation. These "animations" are obtained via duplicated cues shown one right after the other with different colors/positions.

In the "dumb" XML format text animations such as changes in position appear as duplicated text. Thus, sometimes we have consecutive segments that are exactly identical (same text, start and duration), and other times we have consecutive segments that have identical text content but start time equal to the end time of the previous segment.

One good example: https://www.youtube.com/watch?v=5F5MKI_jXJw. Here, it looks like almost every single cue is duplicated. Here's the first few snippets:

FetchedTranscriptSnippet(text='\u200b\u200b\u200b \u200bWe are trapped inside \u200b \u200b\u200b\n\u200b\u200b \u200bthis Minecraft house\u200b \u200b\u200b', start=0.0, duration=1.817)
FetchedTranscriptSnippet(text='\u200b\u200b\u200b \u200bWe are trapped inside \u200b \u200b\u200b\n\u200b\u200b \u200bthis Minecraft house\u200b \u200b\u200b', start=0.0, duration=1.817)
FetchedTranscriptSnippet(text='\u200b\u200b\u200b \u200bfor the next hundred days\u200b \u200b\u200b', start=1.851, duration=1.083)
FetchedTranscriptSnippet(text='\u200b\u200b\u200b \u200bfor the next hundred days\u200b \u200b\u200b', start=1.851, duration=1.083)
FetchedTranscriptSnippet(text='\u200b\u200b\u200b \u200buntil we beat realistic Minecraft.\u200b \u200b\u200b', start=2.968, duration=1.983)
FetchedTranscriptSnippet(text='\u200b\u200b\u200b \u200buntil we beat realistic Minecraft.\u200b \u200b\u200b', start=2.968, duration=1.983)
FetchedTranscriptSnippet(text='\u200b\u200b\u200b \u200bWhoaaa!\u200b \u200b\u200b', start=5.484, duration=1.0330000000000004)
FetchedTranscriptSnippet(text='\u200b\u200b\u200b \u200bWhoaaa!\u200b \u200b\u200b', start=5.484, duration=0.067)
FetchedTranscriptSnippet(text='\u200b\u200b\u200b \u200bWhoaaa!\u200b \u200b\u200b', start=5.551, duration=0.067)
FetchedTranscriptSnippet(text='\u200b\u200b\u200b \u200bWhoaaa!\u200b \u200b\u200b', start=5.551, duration=0.067)
FetchedTranscriptSnippet(text='\u200b\u200b\u200b \u200bWhoaaa!\u200b \u200b\u200b', start=5.618, duration=0.899)
FetchedTranscriptSnippet(text='\u200b\u200b\u200b \u200bWhoaaa!\u200b \u200b\u200b', start=5.618, duration=0.899)
...

It is rather annoying to see a bunch of duplicated segments that are obviously an artifact of VTT/SRV3 animations. I have therefore implemented my own deduplication logic that takes the result of YouTubeTranscriptApi().fetch() and tries to dedup according to the couple of patterns I have noted above.

I feel like one deduplicate boolean argument could be added to .fetch() to apply this kind of filtering.

Sharing my current homemade solution in case it helps:

from youtube_transcript_api import FetchedTranscriptSnippet


def dedup_transcript_snippets(raw: list[FetchedTranscriptSnippet]) -> list[FetchedTranscriptSnippet]:
    '''
    Deduplicate transcript snippets returned by youtube_transcript_api.

    YT XML may show duplicated segments in two ways:

    1. Exact pairs: identical start/dur/text emitted twice for each cue.
    2. Animation frames: same text repeated across consecutive frames as the
       karaoke colour-sweep advances (animation metadata is not present in XML
       format, but only available in the richer vtt/srv3 formats).

    If two consecutive segments share the same text AND either their start/dur
    are identical (exact dupe) OR the current start equals the end of the last
    discarded segment (frame continuation), the second is dropped. For frame
    continuations the first snippet's duration is extended to cover the full
    span of the collapsed segments.
    '''
    snippets: list[FetchedTranscriptSnippet] = []
    prev: FetchedTranscriptSnippet|None = None
    prev_end: float|None = None

    for s in raw:
        if prev is not None and s.text == prev.text:
            if s.start == prev.start and s.duration == prev.duration:
                # Exact dupe
                prev = s
                prev_end = s.start + s.duration
                continue
            elif prev_end is not None and s.start - prev_end < 1e-6:
                # Likely some text animation continuation
                prev = s
                prev_end = s.start + s.duration
                snippets[-1].duration = prev_end - snippets[-1].start
                continue
        else:
            prev_end = None

        snippets.append(s)
        prev = s

    return snippets


if __name__ == '__main__':
    from youtube_transcript_api import YouTubeTranscriptApi
    
    ytt_api = YouTubeTranscriptApi()
    ts = ytt_api.fetch()

    deduped = dedup_transcript_snippets(ts.snippets)
    for s in deduped:
        print(s)

The result is as follows:

FetchedTranscriptSnippet(text='\u200b\u200b\u200b \u200bWe are trapped inside \u200b \u200b\u200b\n\u200b\u200b \u200bthis Minecraft house\u200b \u200b\u200b', start=0.0, duration=1.817)
FetchedTranscriptSnippet(text='\u200b\u200b\u200b \u200bfor the next hundred days\u200b \u200b\u200b', start=1.851, duration=1.083)
FetchedTranscriptSnippet(text='\u200b\u200b\u200b \u200buntil we beat realistic Minecraft.\u200b \u200b\u200b', start=2.968, duration=1.983)
FetchedTranscriptSnippet(text='\u200b\u200b\u200b \u200bWhoaaa!\u200b \u200b\u200b', start=5.484, duration=1.0330000000000004)
...

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions