Create Perfect Summaries of Video Transcripts with AI for free

Another update here from OP.

I have come here to draw attention to some quirks I’m seeing and how to tackle them.

1

Lately, I’ve been seeing cases where Gemini jumps over a whole hour in the output of summaries.
Often around the [00:08:00] - [00:11:00] mark.
Even if I tried to draw attention to this in an updated prompt, it will happen.

Solutions

  • My statistics are hardly anything to go by but it seems as if the 1219 model is less prone to this than the others.
  • You can try to keep the output and prompt Gemini in the same chat session to rectify the missing hour problem. Sometimes this works.
  • It’s better to start again in a new chat session it seems.
  • You can try with a different model, like Deepseek if it can incorporate the whole text plus your prompt, and then switch to Google to tell it “You didn’t give me a comprehensive summary last time. I need a more elaborate version.”

2

I kind of promised or rather just alluded to a post processor search and replace solution…to a problem that never really existed at the time but it creeps in more often than not.

Mind you I don’t do Templater scripts anymore, so the following is a Typescript script.
Save it as Transcript-Summary-Cleanup.ts or any name you can remember later:

import * as obsidian from 'obsidian';

// 250325 Update: Script now can be used for subsequent runs to update content based on newly added replacement rules

// Simple string-based extractors to avoid complex regex
const extractVideoId = (content: string): string | null => {
    // Extract source line
    const lines = content.split('\n');
    let sourceLine = '';
    
    for (const line of lines) {
        if (line.trim().startsWith('source:')) {
            sourceLine = line;
            break;
        }
    }
    
    if (!sourceLine) return null;
    
    // Handle YouTube URLs
    if (sourceLine.includes('youtube.com/watch?v=')) {
        const parts = sourceLine.split('youtube.com/watch?v=');
        if (parts.length > 1) {
            const id = parts[1].split(/[&"\s]/)[0];
            console.log("Found YouTube video ID:", id);
            return id;
        }
    }
    
    if (sourceLine.includes('youtu.be/')) {
        const parts = sourceLine.split('youtu.be/');
        if (parts.length > 1) {
            const id = parts[1].split(/[&"\s]/)[0];
            console.log("Found YouTube video ID:", id);
            return id;
        }
    }
    
    // Handle Rumble URLs
    if (sourceLine.includes('rumble.com/')) {
        const parts = sourceLine.split('rumble.com/');
        if (parts.length > 1) {
            // Extract the full path after rumble.com/
            const path = parts[1].split(/["'\s]/)[0];
            console.log("Found Rumble path:", path);
            return path; // Return full path for Rumble
        }
    }
    
    // Handle Videa URLs
    if (sourceLine.includes('videa.hu/videok/')) {
        const parts = sourceLine.split('videa.hu/videok/');
        if (parts.length > 1) {
            const pathParts = parts[1].split('?')[0].split(/["'\s]/)[0];
            console.log("Found Videa path:", pathParts);
            return pathParts;
        }
    }
    
    return null;
};

// Helper to determine video platform
const getVideoPlatform = (content: string): { platform: 'youtube' | 'rumble' | 'videa' | null, id: string | null } => {
    // Extract source line
    const lines = content.split('\n');
    let sourceLine = '';
    
    for (const line of lines) {
        if (line.trim().startsWith('source:')) {
            sourceLine = line;
            break;
        }
    }
    
    // Check for YouTube in source
    if (sourceLine.includes('youtube.com') || sourceLine.includes('youtu.be')) {
        return { platform: 'youtube', id: extractVideoId(content) };
    }
    
    // Check for Rumble in source
    if (sourceLine.includes('rumble.com')) {
        return { platform: 'rumble', id: extractVideoId(content) };
    }

    // Check for Videa in source
    if (sourceLine.includes('videa.hu')) {
        return { platform: 'videa', id: extractVideoId(content) };
    }
    
    return { platform: null, id: null };
};

// Parse timestamp to seconds
const parseTimestamp = (timestamp: string): number => {
    const parts = timestamp.split(':').map(Number);
    if (parts.length === 2) {
        return parts[0] * 60 + parts[1];
    } else if (parts.length === 3) {
        return parts[0] * 3600 + parts[1] * 60 + parts[2];
    }
    return 0;
};

// Convert seconds to timestamp format
const secondsToTimestamp = (seconds: number): string => {
    const hours = Math.floor(seconds / 3600);
    const minutes = Math.floor((seconds % 3600) / 60);
    const secs = seconds % 60;
    
    if (hours > 0) {
        return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
    } else {
        return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
    }
};

// NEW FUNCTION: Fix incorrect timestamps based on URL seconds parameter
const fixIncorrectTimestamps = (content: string): string => {
    let result = content;
    
    // Match timestamp links with pattern [HH:MM:SS](URL with t= or start= parameter)
    const timestampLinkRegex = /\[(\d{1,2}:\d{2}(?::\d{2})?)\]\((https?:\/\/[^)]*?[?&](?:t|start)=(\d+)[^)]*?)\)/g;
    
    let match;
    const replacements = [];
    
    while ((match = timestampLinkRegex.exec(result)) !== null) {
        const [fullMatch, displayedTimestamp, url, secondsParam] = match;
        const seconds = parseInt(secondsParam);
        const correctTimestamp = secondsToTimestamp(seconds);
        
        // Only replace if the displayed timestamp doesn't match the URL seconds
        if (displayedTimestamp !== correctTimestamp) {
            console.log(`Fixing timestamp: [${displayedTimestamp}] -> [${correctTimestamp}] (${seconds} seconds)`);
            replacements.push({
                original: fullMatch,
                fixed: `[${correctTimestamp}](${url})`
            });
        }
    }
    
    // Apply all replacements
    for (const replacement of replacements) {
        result = result.replace(replacement.original, replacement.fixed);
    }
    
    return result;
};

// Fix existing timestamp links with simpler string operations
const fixExistingTimestampLinks = (content: string, platform: 'youtube' | 'rumble' | 'videa' | null, id: string | null): string => {
    if (!id) return content;
    
    let result = content;
    const lines = content.split('\n');
    
    for (let i = 0; i < lines.length; i++) {
        const line = lines[i];
        
        // Look for timestamp patterns like [01:08](any_url)
        if (line.includes('[') && line.includes('](')) {
            
            // Collect all timestamp link matches in this line
            let modifiedLine = line;
            let bracketIndex = modifiedLine.indexOf('[');
            
            while (bracketIndex !== -1) {
                const closeBracketIndex = modifiedLine.indexOf(']', bracketIndex);
                if (closeBracketIndex === -1) break;
                
                const timestampText = modifiedLine.substring(bracketIndex + 1, closeBracketIndex);
                // Check if this is a timestamp format
                if (/^\d{1,2}:\d{2}(:\d{2})?$/.test(timestampText)) {
                    const linkOpenIndex = modifiedLine.indexOf('(', closeBracketIndex);
                    if (linkOpenIndex === -1) break;
                    
                    const linkCloseIndex = modifiedLine.indexOf(')', linkOpenIndex);
                    if (linkCloseIndex === -1) break;
                    
                    // Extract the old URL and seconds
                    const oldUrl = modifiedLine.substring(linkOpenIndex + 1, linkCloseIndex);
                    let seconds = 0;
                    
                    // Extract seconds from any supported platform URL
                    if (oldUrl.includes('youtube.com') || oldUrl.includes('youtu.be')) {
                        const timeMatches = oldUrl.match(/[?&]t=(\d+)/);
                        if (timeMatches && timeMatches[1]) {
                            seconds = parseInt(timeMatches[1]);
                        } else {
                            seconds = parseTimestamp(timestampText);
                        }
                    } else if (oldUrl.includes('rumble.com')) {
                        const timeMatches = oldUrl.match(/[?&]start=(\d+)/);
                        if (timeMatches && timeMatches[1]) {
                            seconds = parseInt(timeMatches[1]);
                        } else {
                            seconds = parseTimestamp(timestampText);
                        }
                    } else if (oldUrl.includes('videa.hu')) {
                        const timeMatches = oldUrl.match(/[?&]start=(\d+)/);
                        if (timeMatches && timeMatches[1]) {
                            seconds = parseInt(timeMatches[1]);
                        } else {
                            seconds = parseTimestamp(timestampText);
                        }
                    } else {
                        seconds = parseTimestamp(timestampText);
                    }
                    
                    let newUrl = '';
                    
                    // Convert to the target platform format
                    if (platform === 'youtube') {
                        newUrl = `https://www.youtube.com/watch?v=${id}&t=${seconds}`;
                    } else if (platform === 'rumble') {
                        newUrl = `https://rumble.com/${id}${seconds > 0 ? `?start=${seconds}` : ''}`;
                    } else if (platform === 'videa') {
                        newUrl = `https://videa.hu/videok/${id}${seconds > 0 ? `?start=${seconds}` : ''}`;
                    }
                    
                    // Replace the entire link if we have a new URL
                    if (newUrl) {
                        const oldLink = modifiedLine.substring(bracketIndex, linkCloseIndex + 1);
                        const newLink = `[${timestampText}](${newUrl})`;
                        modifiedLine = modifiedLine.substring(0, bracketIndex) + 
                                      newLink + 
                                      modifiedLine.substring(linkCloseIndex + 1);
                    }
                }
                
                // Find next bracket
                bracketIndex = modifiedLine.indexOf('[', bracketIndex + 1);
            }
            
            lines[i] = modifiedLine;
        }
    }
    
    return lines.join('\n');
};

// Link unlinked timestamps
const linkUnlinkedTimestamps = (content: string, platform: 'youtube' | 'rumble' | 'videa' | null, id: string | null): string => {
    if (!id) return content;
    
    let result = content;
    const lines = content.split('\n');
    
    for (let i = 0; i < lines.length; i++) {
        const line = lines[i];
        let modifiedLine = line;
        
        // Match timestamp ranges like [01:08-01:45]
        const rangeMatches = [];
        const rangeRegex = /\[(\d{1,2}:\d{2})-(\d{1,2}:\d{2})\](?!\()/g;
        let match;
        
        while ((match = rangeRegex.exec(modifiedLine)) !== null) {
            rangeMatches.push({
                fullMatch: match[0],
                startTime: match[1],
                endTime: match[2],
                index: match.index
            });
        }
        
        // Process matches in reverse to avoid index shifting
        for (let j = rangeMatches.length - 1; j >= 0; j--) {
            const { fullMatch, startTime, endTime, index } = rangeMatches[j];
            const seconds = parseTimestamp(startTime);
            
            let newUrl = '';
            if (platform === 'youtube') {
                newUrl = `https://www.youtube.com/watch?v=${id}&t=${seconds}`;
            } else if (platform === 'rumble') {
                newUrl = `https://rumble.com/${id}${seconds > 0 ? `?start=${seconds}` : ''}`;
            } else if (platform === 'videa') {
                newUrl = `https://videa.hu/videok/${id}${seconds > 0 ? `?start=${seconds}` : ''}`;
            }
            
            if (newUrl) {
                const replacement = `[${startTime}-${endTime}](${newUrl})`;
                modifiedLine = modifiedLine.substring(0, index) + 
                              replacement + 
                              modifiedLine.substring(index + fullMatch.length);
            }
        }
        
        // Match simple timestamps like [01:08]
        const simpleMatches = [];
        const simpleRegex = /\[(\d{1,2}:\d{2})\](?!\()/g;
        
        while ((match = simpleRegex.exec(modifiedLine)) !== null) {
            simpleMatches.push({
                fullMatch: match[0],
                timestamp: match[1],
                index: match.index
            });
        }
        
        // Process matches in reverse to avoid index shifting
        for (let j = simpleMatches.length - 1; j >= 0; j--) {
            const { fullMatch, timestamp, index } = simpleMatches[j];
            const seconds = parseTimestamp(timestamp);
            
            let newUrl = '';
            if (platform === 'youtube') {
                newUrl = `https://www.youtube.com/watch?v=${id}&t=${seconds}`;
            } else if (platform === 'rumble') {
                newUrl = `https://rumble.com/${id}${seconds > 0 ? `?start=${seconds}` : ''}`;
            } else if (platform === 'videa') {
                newUrl = `https://videa.hu/videok/${id}${seconds > 0 ? `?start=${seconds}` : ''}`;
            }
            
            if (newUrl) {
                const replacement = `[${timestamp}](${newUrl})`;
                modifiedLine = modifiedLine.substring(0, index) + 
                              replacement + 
                              modifiedLine.substring(index + fullMatch.length);
            }
        }
        
        lines[i] = modifiedLine;
    }
    
    return lines.join('\n');
};

const applyStringReplacements = (content: string): string => {
    let updatedContent = content;
    const replacements = [
        // Convert smart quotes to regular quotes
        {
            from: /[„”]/g,
            to: '"'
        },
        {
            from: /<sil>/g,
            to: ''
        },
        // Add more mistakes you find along the way
        {
            from: /placeholder1a/gi,
            to: 'placeholder1a'
        },
        // Exchange faulty ending bracket (sometimes when Google is fast, this error happens)
        {
            from: /(\[(?:\d{1,2}:)?\d{2}:\d{2}\]\(https?:\/\/[^)]*?)\]/g,
            to: '$1)'
        },
        // Add newlines before callouts - simplified
        {
            from: /([^\n])\n(>\s*\[!(?:check|important|fail|note|question|example)\])/g,
            to: '$1\n\n$2'
        },
        // Remove any markdown code blocks and trailing backticks
        {
            from: /```(?:markdown)?\n?([\s\S]*?)(?:```|$)/g,
            to: '$1'
        },
        // Deordinalize numbers 1,2
        {
            from: /^(>\s{1}[0-9]{1,5})([.])/gm,
            to: '$1\\$2'
        },
        {
            from: /^([0-9]{1,5})([.])/gm,
            to: '$1\\$2'
        },
        // Add this to the replacements array in applyStringReplacements:
        {
            from: /\[(\d{1,2}(?::\d{2}){1,2})\](?!\()/g,
            to: (match, timestamp) => {
                // Find any existing timestamp link in the content to use as template
                const templateMatch = updatedContent.match(/\[(\d{1,2}(?::\d{2}){1,2})\]\((https?:\/\/[^\s)]+)\)/);
                if (templateMatch) {
                    const [_, templateTime, templateUrl] = templateMatch;
                    // Extract base URL and time parameter
                    const urlBase = templateUrl.split('?')[0];
                    const seconds = parseTimestamp(timestamp);
                    
                    // Determine time parameter format based on URL
                    let timeParam = '';
                    if (templateUrl.includes('youtube.com') || templateUrl.includes('youtu.be')) {
                        timeParam = `?t=${seconds}`;
                    } else if (templateUrl.includes('rumble.com')) {
                        timeParam = `?start=${seconds}`;
                    } else if (templateUrl.includes('videa.hu')) {
                        timeParam = `?start=${seconds}`;
                    }
                    
                    return `[${timestamp}](${urlBase}${timeParam})`;
                }
                return match; // Keep original if no template found
            }
        }
    ];

    for (const {from, to} of replacements) {
        // Convert string or regex to RegExp if needed
        const regex = from instanceof RegExp ? from : new RegExp(from);
        
        // Test if there are any matches before replacing
        if (regex.test(updatedContent)) {
            console.log(`Found matches for pattern: ${regex}`);
            // Reset regex lastIndex
            regex.lastIndex = 0;
            // Apply replacement
            updatedContent = updatedContent.replace(regex, to);
        }
    }
    
    return updatedContent;
};

// Helper to convert SBV time string to seconds
function sbvTimeToSeconds(time: string): number {
    // Handles SS.mmm, M:SS.mmm, MM:SS.mmm, H:MM:SS.mmm
    const parts = time.split(':');
    if (parts.length === 3) {
        return parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseFloat(parts[2].replace(',', '.'));
    } else if (parts.length === 2) {
        return parseInt(parts[0]) * 60 + parseFloat(parts[1].replace(',', '.'));
    } else if (parts.length === 1) {
        return parseFloat(parts[0].replace(',', '.'));
    }
    return 0;
}

// Helper to convert SRT time string to seconds
function srtTimeToSeconds(hh: string, mm: string, ss: string, ms: string): number {
    return parseInt(hh) * 3600 + parseInt(mm) * 60 + parseInt(ss);
}

const transcriptSummaryCleanup = async (app: obsidian.App): Promise<void> => {
    const currentFile = app.workspace.getActiveFile();
    if (!currentFile) return;

    let fileContent = await app.vault.read(currentFile);
    const videoId = extractVideoId(fileContent);

    let transcriptConverted = false;

    // --- SUBTITLE BLOCK CLEANUP (SBV, SRT, VTT) ---
    // SBV handler: [hh:mm:ss](YouTube link) text for each SBV block
    const sbvBlockRegex = /^([\d:]+\.\d{3}),[\d:]+\.\d{3}\r?\n([\s\S]*?)(?=^[\d:]+\.\d{3},[\d:]+\.\d{3}|$)/gm;
    if (sbvBlockRegex.test(fileContent)) {
        transcriptConverted = true;
        fileContent = fileContent.replace(sbvBlockRegex, (match, timestamp, text) => {
            const seconds = Math.floor(sbvTimeToSeconds(timestamp));
            const hhmmss = secondsToTimestamp(seconds);
            const lineText = text.replace(/\r?\n/g, ' ').trim();
            return `[${hhmmss}](https://www.youtube.com/watch?v=${videoId}&t=${seconds}) ${lineText}\n`;
        });
    }

    // SRT handler: [hh:mm:ss](YouTube link) text for each SRT block
    const srtBlockRegex = /^\d+\r?\n(\d{2}:\d{2}:\d{2}),(\d{3}) --> [^\r\n]+\r?\n([\s\S]*?)(?=^\d+\r?\n\d{2}:\d{2}:\d{2},\d{3} --> |$)/gm;
    if (srtBlockRegex.test(fileContent)) {
        transcriptConverted = true;
        fileContent = fileContent.replace(srtBlockRegex, (match, timestamp, ms, text) => {
            const seconds = Math.floor(srtTimeToSeconds(...timestamp.split(':'), ms));
            const hhmmss = secondsToTimestamp(seconds);
            const lineText = text.replace(/\r?\n/g, ' ').trim();
            return `[${hhmmss}](https://www.youtube.com/watch?v=${videoId}&t=${seconds}) ${lineText}\n`;
        });
    }

    // VTT handler: preserve everything above WEBVTT, keep first 3 lines as anchor, process transcript after, then remove anchor lines
    if (/^WEBVTT/m.test(fileContent)) {
        transcriptConverted = true;
        const webvttIdx = fileContent.indexOf('WEBVTT');
        const beforeVtt = fileContent.slice(0, webvttIdx);
        const vttAndAfter = fileContent.slice(webvttIdx);
        const vttLinesArr = vttAndAfter.split(/\r?\n/);
        const transcriptContent = vttLinesArr.slice(3).join('\n');
        const vttBlockRegex = /^(\d{2}:\d{2}:\d{2}\.\d{3}) --> [^\r\n]+\r?\n([\s\S]*?)(?=^\d{2}:\d{2}:\d{2}\.\d{3} --> |$)/gm;
        let vttLines: string[] = [];
        transcriptContent.replace(vttBlockRegex, (match, timestamp, text) => {
            const timeParts = timestamp.split(':');
            const seconds = Math.floor(parseInt(timeParts[0]) * 3600 + parseInt(timeParts[1]) * 60 + parseFloat(timeParts[2]));
            const hhmmss = secondsToTimestamp(seconds);
            let lineText = text.replace(/<[^>]+>/g, '').replace(/\r?\n/g, ' ').trim();
            if (!lineText) return '';
            vttLines.push(`[${hhmmss}](https://www.youtube.com/watch?v=${videoId}&t=${seconds}) ${lineText}`);
            return '';
        });
        vttLines = vttLines.filter(line => !/^[\[]\d{2}:\d{2}(?::\d{2})?\][^)]*\)\s*$/.test(line));
        let deduped: string[] = [];
        let lastText = '';
        for (const line of vttLines) {
            const textPart = line.replace(/^\[\d{2}:\d{2}(?::\d{2})?\]\([^)]*\)\s*/, '');
            if (textPart && textPart !== lastText) {
                deduped.push(line);
                lastText = textPart;
            }
        }
        fileContent = beforeVtt + deduped.join('\n') + '\n';
    }

    // Remove double newlines from the area starting with the first clickable timestamp line to the end
    if (transcriptConverted) {
        const firstClickableIdx = fileContent.search(/^\[/m);
        if (firstClickableIdx !== -1) {
            const before = fileContent.slice(0, firstClickableIdx);
            const after = fileContent.slice(firstClickableIdx).replace(/\n\n+/g, '\n');
            fileContent = before + after;
        }
    }

    // --- AI summary cleaner and string replacements follow as before ---
    let updatedContent = applyStringReplacements(fileContent);
    updatedContent = fixIncorrectTimestamps(updatedContent);
    const { platform, id } = getVideoPlatform(updatedContent);
    console.log("Detected video platform:", platform, "with ID:", id);
    if (platform && id) {
        updatedContent = fixExistingTimestampLinks(updatedContent, platform, id);
        updatedContent = linkUnlinkedTimestamps(updatedContent, platform, id);
    }
    await app.vault.modify(currentFile, updatedContent);
};

export class TranscriptSummaryCleanupPlugin extends obsidian.Plugin {
    async onload() {
        this.addCommand({
            id: 'transcript-summary-cleanup',
            name: 'Transcript Summary Cleanup',
            callback: async () => await transcriptSummaryCleanup(this.app)
        });
    }
}

export async function invoke(app: obsidian.App): Promise<void> {
    return transcriptSummaryCleanup(app);
}

In your File Manager/Explorer save it in the folder you specify in the settings of CodeScript Toolkit you need to install and enable.

  • You can find some info on how to do this in the top part of the guide I share here.

That plugin will handle running the script.


Transcript Summary Cleanup Script – Quick Guide

What does this do?
This script fixes up messy transcript summaries in your Obsidian notes, especially those with time-stamped links to YouTube, Rumble or Videa videos.

1. Fixes Broken Timestamp Links

Ever see stuff like this in your notes?

[01:23](]

or

[01:23]

instead of a proper clickable link?

This script:

  • Fixes broken links like [01:23](] and turns them into real links.
  • Finds orphan timestamps like [01:23] and makes them clickable, using the video link from your source: line at the top of the note.

2. Makes All Timestamps Clickable

If you set up WebClipper in the first post as I showed, you will have a source property value with a URL, which is what this script will use to rebuild the orphaned links. No need to re-prompt AI for this, as this is easy computing.

source: https://www.youtube.com/watch?v=abc123

and a bunch of [01:23] stamps with no actual links, the script will turn them into:

[01:23](https://www.youtube.com/watch?v=abc123&t=83)

It works for YouTube, Rumble and Videa (didn’t test this last one though).


3. Fixes Displayed Timestamps

If you have a link like:

[01:23](https://www.youtube.com/watch?v=abc123&t=99)

but the timestamp and the link don’t match, the script will fix the label to match the actual time in the link.


4. Other Cleanups

  • Converts smart quotes to regular quotes if you can dig that.
  • Removes weird <sil> tags that break formatting and stray code blocks.
  • (If you want more, add your own rules!)

How to Use

  1. Do the steps above (install plugin, save script in folder).
  2. Position your cursor anywhere in the markdown file with your summary.
  3. Run the “Transcript-Summary-Cleanup.ts” command.

That’s it.
If you spot a summary with broken or non-clickable timestamps, just run this script and it’ll sort things out.
If you want more fixes you see along the way, add your own rules in the applyStringReplacements section.

        // Add more mistakes you find along the way
        {
            from: /placeholder1a/gi,
            to: 'placeholder1a'
        },

This is handy when you often do summaries from the same performer or channel and the same names or expressions crop up with the same misspellings and you do not want to rectify these one by one.


UPDATE

Update on ts file!

Sometimes you cannot get the clickable timestamps in any way (???)…
Then you need to go to YouTube and download your subs in the format it allows: srt, sbv or vtt.

Download any one of these and copy the content of your markdown file.
Make sure your source property has the Youtube link from the Webclipper.

Now you can run this .ts helper file before we even have had any AI summary come in.
The script can parse your vtt/sbv/srt file content (make you sure copy the full file contents into your md file) and convert to the desired format: text with clickable timestamps.
If it’s vtt you downloaded, make sure you have:

WEBVTT
Kind: captions
Language: [whatever]

on top.

Vtt conversion in action:
Obsidian_OhcaqOXYtE

Then, yes, this same file can be used to do clean up with once the summary is brought in.