Detect Black Bars

🧩 Syntax:
/**
 * @description Detect black bars in video for cropping using ffmpeg cropdetect filter. Analyzes 9 segments of the video (excluding first and last segments from 11 total) and saves crop parameters to Variables.CropParam if black bars are detected by at least 65% of analyzed frames.
 * @help Dependencies: FFmpeg. If crop parameters are found, they are saved to Variables.CropParam in format "crop=width:height:x:y". Executes ffmpeg cropdetect on multiple video segments for accurate detection.
 * @author Generated for FileFlows
 * @param {int} AnalysisFrames Number of frames to analyze per segment (default: 25)
 * @param {string} CropdetectLimit Cropdetect threshold (0.0-1.0, default: 0.24)
 * @param {int} CropdetectRound Cropdetect rounding (default: 2)
 * @param {int} MinCropDifference Minimum pixel difference to consider cropping (default: 10)
 * @param {int} ConsistencyThreshold Percentage of frames that must have same crop (1-100, default: 65)
 * @output No black bars detected
 * @output Black bars detected, crop parameters saved to Variables.CropParam
 */
// Get ffmpeg tool path globally
let ffmpeg = Flow.GetToolPath('ffmpeg');
if (!ffmpeg) {
    Logger.ELog("FFmpeg tool not found");
    Flow.Fail("FFmpeg tool not found. Please configure FFmpeg in the system settings.");
}
// Get ffprobe tool path globally
let ffprobe = Flow.GetToolPath('ffprobe');
if (!ffprobe) {
    Logger.ELog("FFprobe tool not found");
    Flow.Fail("FFprobe tool not found. Please configure FFprobe in the system settings.");
}


function Script(AnalysisFrames, CropdetectLimit, CropdetectRound, MinCropDifference, ConsistencyThreshold)
{
    // Check if ffmpeg is available
    if (!ffmpeg) {
        return -1;
    }

    // Set default values
    AnalysisFrames = AnalysisFrames || 25;
    CropdetectLimit = CropdetectLimit || "0.24";
    CropdetectRound = CropdetectRound || 2;
    MinCropDifference = MinCropDifference || 10;
    ConsistencyThreshold = ConsistencyThreshold || 65;

    // Get the input file path from Flow.WorkingFile
    var fi = FileInfo(Flow.WorkingFile);
    
    Logger.ILog(`Starting black bar detection for: ${fi.Name}`);
    
    // Get video duration
    let duration = getVideoDuration(fi.FullName);
    if (duration <= 0) {
        Logger.ELog("Could not determine video duration");
        return -1;
    }
    
    Logger.ILog(`Video duration: ${duration} seconds`);
    
    // Calculate segment times (11 segments total, use middle 9)
    let totalSegments = 11;
    let segmentDuration = duration / totalSegments;
    let analysisSegments = [];
    
    // Skip first and last segment (segments 2-10, 0-indexed: 1-9)
    for (let i = 1; i <= 9; i++) {
        let startTime = i * segmentDuration;
        analysisSegments.push(startTime);
    }
    
    Logger.ILog("Analyzing " + analysisSegments.length + " segments with " + AnalysisFrames + " frames each (consistency threshold: " + ConsistencyThreshold + "%)");
    
    // Use global variables that get updated from every segment
    const globalCropFrequencyMap = {};
    let totalCropMatches = 0;
    let originalWidth = 0;
    let originalHeight = 0;
    
    // Create global counter object to pass by reference
    let globalCounters = {
        totalMatches: 0
    };
    
    // Analyze all segments together using the original approach
    for (let i = 0; i < analysisSegments.length; i++) {
        let startTime = analysisSegments[i];
        Logger.ILog("Analyzing segment " + (i + 1) + "/" + analysisSegments.length + " at " + startTime.toFixed(2) + "s");
        
        let segmentResult = analyzeCropDetectOriginal(fi.FullName, startTime, AnalysisFrames, CropdetectLimit, CropdetectRound, globalCropFrequencyMap, globalCounters);
        if (segmentResult) {
            // Store original dimensions (should be consistent across segments)
            if (segmentResult.originalWidth && segmentResult.originalHeight) {
                originalWidth = segmentResult.originalWidth;
                originalHeight = segmentResult.originalHeight;
            }
        }
        
        Flow.PartPercentageUpdate(((i + 1) / analysisSegments.length) * 100);
    }
    
    // Get the final total from the global counter
    totalCropMatches = globalCounters.totalMatches;
    
    if (totalCropMatches === 0) {
        Logger.ILog("No crop data detected in any segment");
        return 1;
    }
    
     if (totalCropMatches < 75 ) {
        Logger.ILog("Error counting crop matches. Total < 75 lines.");
        return -1;
    }

    Logger.ILog(`Total crop line matches found: ${totalCropMatches}`);
    // Logger.ILog(`Global crop frequency map: ${JSON.stringify(globalCropFrequencyMap)}`);
    
    // Find the most consistent crop value
    let finalCrop = findMostConsistentCrop(globalCropFrequencyMap, totalCropMatches, ConsistencyThreshold, MinCropDifference, originalWidth, originalHeight);
    
    if (finalCrop) {
        let cropParam = `crop=${finalCrop.width}:${finalCrop.height}:${finalCrop.x}:${finalCrop.y},`;
        Variables.CropParam = cropParam;
        
        Logger.ILog(`Black bars detected! Crop parameters: ${cropParam}`);
        Flow.AdditionalInfoRecorder("Crop", cropParam, 1000);
        Flow.AdditionalInfoRecorder("Original", `${originalWidth}x${originalHeight}`, 1000);
        Flow.AdditionalInfoRecorder("Cropped", `${finalCrop.width}x${finalCrop.height}`, 1000);
        
        return 2;
    } else {
        Logger.ILog(`No consistent black bars detected (need ${(ConsistencyThreshold)}% consistency)`);
        return 1;
    }
}

function getVideoDuration(inputFile) {
    var executeArgs = new ExecuteArgs();
    executeArgs.command = ffprobe;
    executeArgs.argumentList = [
        '-v', 'quiet',
        '-show_entries', 'format=duration',
        '-of', 'csv=p=0',
        inputFile
    ];
    
    let duration = 0;
    
    executeArgs.add_Output((line) => {
        // ffprobe outputs duration as a decimal number (seconds)
        let durationValue = parseFloat(line.trim());
        if (!isNaN(durationValue) && durationValue > 0) {
            duration = durationValue;
        }
    });
    
    let result = Flow.Execute(executeArgs);
    
    if (result.exitCode !== 0) {
        Logger.WLog("FFprobe failed to get video duration");
        return 0;
    }
    
    return duration;
}

function analyzeCropDetectOriginal(inputFile, startTime, frames, limit, round, globalCropFrequencyMap, globalCounters) {
    const executeArgs = new ExecuteArgs();
    executeArgs.command = ffmpeg;
    executeArgs.argumentList = [
        '-ss', startTime.toString(),
        '-i', inputFile,
        '-vframes', frames.toString(),
        '-vf', 'cropdetect=' + limit + ':' + round + ':0',
        '-f', 'null',
        '-'
    ];

    let originalWidth = 0;
    let originalHeight = 0;

    executeArgs.add_Error(function(line) {
        const cropMatch = line.match(/crop=(\d+):(\d+):(\d+):(\d+)/);
        if (cropMatch) {
            const cropKey = cropMatch[0]; // e.g., "crop=3840:2160:60:40"
            globalCropFrequencyMap[cropKey] = (globalCropFrequencyMap[cropKey] || 0) + 1;
            globalCounters.totalMatches++; // Increment global counter
        }

        const streamMatch = line.match(/Stream.*Video.*?(\d+)x(\d+)/);
        if (streamMatch) {
            originalWidth = parseInt(streamMatch[1]);
            originalHeight = parseInt(streamMatch[2]);
        }
    });

    const result = Flow.Execute(executeArgs);

    if (result.exitCode !== 0) {
        Logger.WLog("FFmpeg cropdetect failed for segment at " + startTime + "s");
        return null;
    }

    return {
        originalWidth: originalWidth,
        originalHeight: originalHeight
    };
}

function findMostConsistentCrop(globalCropFrequencyMap, totalMatches, consistencyThreshold, minDifference, originalWidth, originalHeight) {
    if (totalMatches === 0 || Object.keys(globalCropFrequencyMap).length === 0) {
        return null;
    }
    
    const requiredMatches = Math.ceil(totalMatches * (consistencyThreshold / 100));
    Logger.ILog("Looking for crop with at least " + requiredMatches + " matches out of " + totalMatches + " total (" + consistencyThreshold + "% threshold)");
    
    // Find the crop with the highest frequency
    let bestCropKey = null;
    let bestFrequency = 0;
    
    for (let cropKey in globalCropFrequencyMap) {
        let frequency = globalCropFrequencyMap[cropKey];
        let percentage = ((frequency / totalMatches) * 100);
        Logger.ILog("Crop " + cropKey + ": " + frequency + " matches (" + percentage.toFixed(1) + "%)");
        
        // Error check: if percentage > 100%, something is wrong
        if (percentage > 100) {
            Logger.ELog("Error: Crop frequency percentage is " + percentage.toFixed(1) + "% which is > 100%. Data corruption detected.");
            return -1;
        }
        
        if (frequency > bestFrequency) {
            bestCropKey = cropKey;
            bestFrequency = frequency;
        }
    }
    
    // Check if the best crop meets the consistency threshold
    if (bestFrequency < requiredMatches) {
        Logger.ILog(`Best crop ${bestCropKey} has ${bestFrequency} matches, but need ${requiredMatches} minimum`);
        return null;
    }
    
    // Parse the crop parameters
    const cropMatch = bestCropKey.match(/crop=(\d+):(\d+):(\d+):(\d+)/);
    if (!cropMatch) {
        Logger.ELog(`Failed to parse crop parameters from ${bestCropKey}`);
        return null;
    }
    
    const cropData = {
        width: parseInt(cropMatch[1]),
        height: parseInt(cropMatch[2]),
        x: parseInt(cropMatch[3]),
        y: parseInt(cropMatch[4])
    };
    
    // Check if cropping is actually needed (significant difference from original)
    if (originalWidth && originalHeight) {
        let widthDiff = originalWidth - cropData.width;
        let heightDiff = originalHeight - cropData.height;
        
        Logger.ILog(`Original dimensions: ${originalWidth}x${originalHeight}`);
        Logger.ILog(`Crop dimensions: ${cropData.width}x${cropData.height}`);
        Logger.ILog(`Dimension differences: width=${widthDiff}, height=${heightDiff} (threshold=${minDifference})`);
        
        if (widthDiff < minDifference && heightDiff < minDifference) {
            Logger.ILog(`Crop difference too small (${widthDiff}x${heightDiff}), skipping crop`);
            return null;
        }
    }
    
    Logger.ILog(`Consistent crop found: ${bestCropKey} with ${bestFrequency}/${totalMatches} matches (${((bestFrequency / totalMatches) * 100).toFixed(1)}%)`);
    
    return cropData;
}