<# .SYNOPSIS Detects scenes in a video file with ffmpeg and extracts each scene as a separate video clip file. .DESCRIPTION Analyzes the input video with ffmpeg scene-change detection, using the -Scene threshold, and computes the first and last frame (0-based) of each detected scene. Each scene is then extracted by calling Create-Clip.ps1 (located in the same directory as this script) with the scene frame range, which in turn calls Clone-Video.ps1. Clip files are always saved in the same directory as the input video. The clip filename is the input video filename plus the scene suffix, with the scene number starting at 1 (ex: P01.mp4 scene 1 becomes P01-Scene-01.mp4). For sources carrying a .skip marker, the marker stays last, as handled by Create-Clip.ps1 (ex: P01.skip.mp4 becomes P01-Scene-01.skip.mp4). Encoding is performed by Clone-Video.ps1: by default each clip keeps the resolution, frame rate and compression characteristics of the input video, unless overridden with extra parameters (ex: -Size, -FPS, -CRF) forwarded to Create-Clip.ps1. The list of scenes (index, start frame, end frame, frame count, start time, end time) is always displayed. If no scene change is detected, the whole video is treated as a single scene. Unless -List is specified, existing scene clip files from a previous run (same directory, same source name, same suffix pattern and same extension) are deleted before the new clips are created. Existing files are always replaced. Scene detection and the scene list are read-only: they always run, including with -List and -WhatIf. Only the deletion and the creation of files are subject to -WhatIf and -Confirm. Both switches are also forwarded to Create-Clip.ps1. .PARAMETER Video Path to the input video file. Can be absolute or relative to current directory. If the path has no extension, .mp4, .webm and .mkv are tried in turn. .PARAMETER Scene Threshold value between 0 and 1.0 to determine scene changes. Lower values mean more sensitive detection (more scenes). Default is 0.3. .PARAMETER Suffix Scene suffix to use for the output video clip files. Must contain a printf-style number placeholder such as %02d, which is replaced by the scene number starting at 1. By default, "-Scene-%02d" is used. .PARAMETER List Only displays the list of scenes. No video clip file is created and no existing file is deleted. .PARAMETER ExtraArgs Additional parameters passed as-is to Create-Clip.ps1 and from there to Clone-Video.ps1 (ex: -Size 720p -NoAudio -CRF 18 -LTX). The parameters -Video, -Frames, -Suffix and -Target are managed by this script and must not be repeated. .EXAMPLE .\Create-Scenes.ps1 -Video P01.mp4 -Scene 0.4 -List Lists the scenes detected in P01.mp4 without creating or deleting any file. .EXAMPLE .\Create-Scenes.ps1 -Video P01 -Scene 0.3 -Size 720p -NoAudio Creates P01-Scene-01.mp4, P01-Scene-02.mp4, ... scaled to 720p and without audio. .EXAMPLE .\Create-Scenes.ps1 -Video P01.mp4 -Suffix "-Shot-%03d" -WhatIf Shows which files would be deleted and created, without changing anything. #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( [Parameter(Mandatory = $true, Position = 0)] [ValidateNotNullOrEmpty()] [string]$Video, [Parameter(Mandatory = $false)] [ValidateRange(0.0, 1.0)] [double]$Scene = 0.3, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [ValidatePattern('%0?\d*d')] [string]$Suffix = '-Scene-%02d', [switch]$List, [Parameter(ValueFromRemainingArguments)] [object[]]$ExtraArgs ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $invariant = [Globalization.CultureInfo]::InvariantCulture function Resolve-VideoPath { param([string]$Path) $candidates = @($Path) + @('.mp4', '.webm', '.mkv' | ForEach-Object { "$Path$_" }) $found = $candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 if (-not $found) { throw "Video file not found: $Path" } (Resolve-Path -LiteralPath $found).Path } function ConvertTo-Number { param([string]$Text) # Parses plain numbers and ffprobe rationals (e.g. "30000/1001"). Returns $null for N/A or invalid values. $parts = $Text -split '/' $num = 0.0 $den = 1.0 if ($parts.Count -gt 2 -or -not [double]::TryParse($parts[0], [Globalization.NumberStyles]::Float, $invariant, [ref]$num)) { return $null } if ($parts.Count -eq 2 -and (-not [double]::TryParse($parts[1], [Globalization.NumberStyles]::Float, $invariant, [ref]$den) -or $den -eq 0)) { return $null } $num / $den } function Get-VideoInfo { param([string]$Path) $ffArgs = @('-hide_banner', '-v', 'error', '-count_frames', '-select_streams', 'v:0', '-show_entries', 'stream=avg_frame_rate,r_frame_rate,start_time,nb_read_frames,nb_frames,duration', '-of', 'default=noprint_wrappers=1', $Path) Write-Verbose ('ffprobe ' + ($ffArgs -join ' ')) $lines = & ffprobe @ffArgs if ($LASTEXITCODE -ne 0) { throw "ffprobe failed to read $Path" } $fields = @{} foreach ($line in $lines) { if ("$line" -match '^(\w+)=(.*)$') { $fields[$Matches[1]] = $Matches[2] } } $fps = ConvertTo-Number $fields['avg_frame_rate'] if (-not $fps) { $fps = ConvertTo-Number $fields['r_frame_rate'] } if (-not $fps) { throw "Cannot read the frame rate of $Path" } $startTime = ConvertTo-Number $fields['start_time'] if ($null -eq $startTime) { $startTime = 0.0 } $frames = ConvertTo-Number $fields['nb_read_frames'] if ($null -eq $frames) { $frames = ConvertTo-Number $fields['nb_frames'] } if ($null -eq $frames) { $frames = [Math]::Round((ConvertTo-Number $fields['duration']) * $fps) } if ($frames -lt 1) { throw "Cannot determine the frame count of $Path" } [pscustomobject]@{ Fps = $fps; StartTime = $startTime; TotalFrames = [int][Math]::Round($frames) } } function Get-SceneChangeTime { param([string]$Path, [double]$Threshold) $filter = "select='gt(scene,{0})',showinfo" -f $Threshold.ToString($invariant) $ffArgs = @('-hide_banner', '-nostats', '-loglevel', 'info', '-i', $Path, '-vf', $filter, '-an', '-f', 'null', '-') Write-Verbose ('ffmpeg ' + ($ffArgs -join ' ')) $output = & ffmpeg @ffArgs 2>&1 | Out-String if ($LASTEXITCODE -ne 0) { throw "ffmpeg scene detection failed on $Path" } [regex]::Matches($output, 'pts_time:([\d.]+)') | ForEach-Object { [double]::Parse($_.Groups[1].Value, $invariant) } } function Format-SceneSuffix { param([string]$Template, [int]$Number) # Replaces only the first printf-style placeholder (%d, %2d, %02d, %003d) with the zero-padded number. $placeholder = [regex]::Match($Template, '%0*(\d*)d') $width = [Math]::Max(1, [int]('0' + $placeholder.Groups[1].Value)) $Template.Substring(0, $placeholder.Index) + $Number.ToString('D' + $width, $invariant) + $Template.Substring($placeholder.Index + $placeholder.Length) } function Get-SceneList { param($Info, [double[]]$ChangeTimes, [string]$Template) # pts_time * fps is exact only for constant frame rate videos; with variable frame rate, # scene boundaries can be off by one frame. $boundaries = @($ChangeTimes | ForEach-Object { [int][Math]::Round(($_ - $Info.StartTime) * $Info.Fps) } | Where-Object { $_ -gt 0 -and $_ -lt $Info.TotalFrames } | Sort-Object -Unique) $starts = @(0) + $boundaries for ($i = 0; $i -lt $starts.Count; $i++) { $start = [int]$starts[$i] $end = if ($i + 1 -lt $starts.Count) { $starts[$i + 1] - 1 } else { $Info.TotalFrames - 1 } [pscustomobject]@{ Index = $i + 1 StartFrame = $start EndFrame = $end Frames = $end - $start + 1 StartTime = [Math]::Round($start / $Info.Fps, 3) EndTime = [Math]::Round($end / $Info.Fps, 3) Suffix = Format-SceneSuffix -Template $Template -Number ($i + 1) } } } function Get-SceneFilePattern { param([string]$VideoPath, [string]$Template) # Regex matching the clip files this script creates for this source; used to find files of previous runs. $ext = [IO.Path]::GetExtension($VideoPath) $stem = [IO.Path]::GetFileNameWithoutExtension($VideoPath) $skipMarker = '' if ($stem -match '^(.*)(\.skip)$') { $stem = $Matches[1] $skipMarker = '\.skip' } $placeholder = [regex]::Match($Template, '%0*\d*d') $suffixPattern = [regex]::Escape($Template.Substring(0, $placeholder.Index)) + '\d+' + [regex]::Escape($Template.Substring($placeholder.Index + $placeholder.Length)) '^' + [regex]::Escape($stem) + $suffixPattern + $skipMarker + [regex]::Escape($ext) + '$' } function Remove-ExistingScene { [CmdletBinding(SupportsShouldProcess)] param([string]$VideoPath, [string]$Pattern) $directory = [IO.Path]::GetDirectoryName($VideoPath) $existing = Get-ChildItem -LiteralPath $directory -File | Where-Object { $_.Name -match $Pattern -and $_.FullName -ne $VideoPath } foreach ($file in $existing) { if ($PSCmdlet.ShouldProcess($file.FullName, 'Delete existing scene file')) { Remove-Item -LiteralPath $file.FullName } } } function ConvertTo-ClipArgument { param([object[]]$Tokens) # Validates the extra arguments: every value must follow a parameter name, and parameter names without a value are switches. $params = @{} $pending = $null foreach ($token in $Tokens) { if ($token -is [string] -and $token -match '^-([A-Za-z]\w*)(:(.*))?$') { if ($pending) { $params[$pending] = $true } $pending = $Matches[1] if ($Matches[2]) { $params[$pending] = $Matches[3] $pending = $null } } elseif ($pending) { $params[$pending] = $token $pending = $null } else { throw "Unexpected argument '$token'. Extra parameters must be passed by name (e.g. -Size 720p)." } } if ($pending) { $params[$pending] = $true } $params } foreach ($tool in 'ffmpeg', 'ffprobe') { if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) { throw "$tool was not found in PATH." } } $createClipPath = Join-Path $PSScriptRoot 'Create-Clip.ps1' if (-not (Test-Path -LiteralPath $createClipPath -PathType Leaf)) { throw "Create-Clip.ps1 was not found next to this script: $createClipPath" } $videoPath = Resolve-VideoPath -Path $Video [object[]]$tokens = @() if ($null -ne $ExtraArgs) { $tokens = @($ExtraArgs) } $extraParams = ConvertTo-ClipArgument -Tokens $tokens foreach ($reserved in 'Video', 'Frames', 'Suffix', 'Target') { if ($extraParams.ContainsKey($reserved)) { throw "-$reserved is managed by Create-Scenes.ps1 and must not be passed as an extra parameter." } } $info = Get-VideoInfo -Path $videoPath $changeTimes = @(Get-SceneChangeTime -Path $videoPath -Threshold $Scene) $scenes = @(Get-SceneList -Info $info -ChangeTimes $changeTimes -Template $Suffix) $scenes | Format-Table -AutoSize | Out-Host if ($List) { return } $confirm = [bool]$PSBoundParameters['Confirm'] $pattern = Get-SceneFilePattern -VideoPath $videoPath -Template $Suffix Remove-ExistingScene -VideoPath $videoPath -Pattern $pattern -WhatIf:$WhatIfPreference -Confirm:$confirm $failures = 0 foreach ($clipScene in $scenes) { $frames = '{0}-{1}' -f $clipScene.StartFrame, $clipScene.EndFrame Write-Verbose ('Scene {0}: frames {1}, suffix {2}' -f $clipScene.Index, $frames, $clipScene.Suffix) # Extra parameters are forwarded as the original token array, not as the hashtable: Create-Clip.ps1 # collects unknown parameters through ValueFromRemainingArguments and parses them itself, while a # hashtable splat would hand it '-Name:value' tokens it does not understand. try { & $createClipPath -Video $videoPath -Frames $frames -Suffix $clipScene.Suffix -WhatIf:$WhatIfPreference -Confirm:$confirm @tokens if ($LASTEXITCODE -ne 0) { throw "Create-Clip.ps1 exited with code $LASTEXITCODE" } } catch { Write-Warning ('Scene {0} failed: {1}' -f $clipScene.Index, $_.Exception.Message) $failures++ } } if ($failures -gt 0) { throw "$failures scene(s) failed to create." }