From 4cc09d6369abb689e959a18ddd0e086f4abe83a8 Mon Sep 17 00:00:00 2001 From: Claude Toupin Date: Sun, 1 Nov 2026 11:02:32 -0500 Subject: [PATCH] Change -Suffix parameter in Create-Clip.ps1 from optional empty string to required non-empty string with default value "-Clip", removing the hardcoded "-Clip" prefix from target name derivation so suffix is inserted directly before extension (e.g. default behavior unchanged: P01.mp4 -> P01-Clip.mp4, but custom -Suffix "-Shot-1" now makes P01.mp4 -> P01-Shot-1.mp4 instead of P01-Clip-Shot-1.mp4), add ValidateNotNullOrEmpty attribute, update documentation to reflect new default and behavior, and add installation --- Create-Clip.ps1 | 17 +-- Create-Scenes.cmd | 2 + Create-Scenes.ps1 | 268 ++++++++++++++++++++++++++++++++++++++++++++++ README.md | 49 ++++++++- 4 files changed, 323 insertions(+), 13 deletions(-) create mode 100644 Create-Scenes.cmd create mode 100644 Create-Scenes.ps1 diff --git a/Create-Clip.ps1 b/Create-Clip.ps1 index 39ad3a6..ba10919 100644 --- a/Create-Clip.ps1 +++ b/Create-Clip.ps1 @@ -1,8 +1,8 @@ <# .SYNOPSIS Creates a new clipped video file using Clone-Video.ps1, requiring the -Video and -Frames parameters. - The filename used for the -Target parameter is automatically derived from the -Video filename, with "-Clip" inserted before the video file extension (e.g., P01.mp4 becomes P01-Clip.mp4). - When the video name carries a '.skip' marker before its extension, "-Clip" is inserted before that marker so it stays last (e.g., P01.skip.mp4 becomes P01-Clip.skip.mp4). + The filename used for the -Target parameter is automatically derived from the -Video filename, with the -Suffix value (default "-Clip") inserted before the video file extension (e.g., P01.mp4 becomes P01-Clip.mp4). + When the video name carries a '.skip' marker before its extension, the suffix is inserted before that marker so it stays last (e.g., P01.skip.mp4 becomes P01-Clip.skip.mp4). Any extra parameters will be pass directly to Clone-Video.ps1, except for parameters -Video, -Frames, -Suffix and -Target. .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. @@ -14,8 +14,8 @@ - %: Represents the last frame index. Can be used alone or in ranges (e.g., "%", "10-%", "-%") - all: Extract all frames (same as "0-%") .PARAMETER Suffix - Optional text appended to the derived -Target name after "-Clip" and before the file extension - (e.g. -Suffix "-Shot-1" makes P01.mp4 become P01-Clip-Shot-1.mp4). A '.skip' marker still stays last. + Text inserted into the derived -Target name before the file extension. Defaults to "-Clip" and cannot be empty. + Example: -Suffix "-Clip-Shot-1" makes P01.mp4 become P01-Clip-Shot-1.mp4. A '.skip' marker still stays last. #> [CmdletBinding(SupportsShouldProcess)] param( @@ -26,7 +26,8 @@ param( [string]$Frames, [Parameter(Mandatory = $false)] - [string]$Suffix = '', + [ValidateNotNullOrEmpty()] + [string]$Suffix = '-Clip', [Parameter(ValueFromRemainingArguments)] [object[]]$ExtraArgs @@ -50,12 +51,12 @@ if (-not (Test-Path -LiteralPath $videoPath)) { } } -# Derive the -Target path: insert "-Clip" before the file extension (e.g., P01.mp4 -> P01-Clip.mp4) +# Derive the -Target path: insert -Suffix before the file extension (e.g., P01.mp4 -> P01-Clip.mp4) $sourceExt = [System.IO.Path]::GetExtension($videoPath) $sourceBase = [System.IO.Path]::GetFileNameWithoutExtension($videoPath) $sourceDir = [System.IO.Path]::GetDirectoryName($videoPath) -# A '.skip' marker must stay immediately before the container extension, so "-Clip" is +# A '.skip' marker must stay immediately before the container extension, so the suffix is # inserted before it rather than after (e.g., P01.skip.mp4 -> P01-Clip.skip.mp4). # The marker's original casing is preserved. $skipMarker = '' @@ -64,7 +65,7 @@ if ($sourceBase -match '^(.*)(\.skip)$') { $skipMarker = $Matches[2] } -$targetName = "$sourceBase-Clip$Suffix$skipMarker$sourceExt" +$targetName = "$sourceBase$Suffix$skipMarker$sourceExt" $targetPath = if ([string]::IsNullOrEmpty($sourceDir)) { $targetName } else { Join-Path $sourceDir $targetName } $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path diff --git a/Create-Scenes.cmd b/Create-Scenes.cmd new file mode 100644 index 0000000..00ccd2c --- /dev/null +++ b/Create-Scenes.cmd @@ -0,0 +1,2 @@ +@echo off +pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Create-Scenes.ps1" %* diff --git a/Create-Scenes.ps1 b/Create-Scenes.ps1 new file mode 100644 index 0000000..17d4ef9 --- /dev/null +++ b/Create-Scenes.ps1 @@ -0,0 +1,268 @@ +<# +.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." } diff --git a/README.md b/README.md index e537fbb..9e5eb28 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ A collection of PowerShell scripts for video processing tasks using ffmpeg and f - **PowerShell 7+** (pwsh.exe) - **ffmpeg** - Must be in system PATH - **ffprobe** - Must be in system PATH -- **rife-ncnn-vulkan** - Must be in system PATH +- **rife-ncnn-vulkan** - Must be in system PATH and also defined the environment variable **RIFE_HOME** to the same directory +- **upscayl-bin** - Must be in system PATH and also defined the environment variable **UPSCAYL_HOME** to the same directory ## ffmpeg @@ -15,7 +16,31 @@ To install ffmpeg on Windows, download it from [ffmpeg.org](https://ffmpeg.org/d ## rife-ncnn-vulkan -To install rife-ncnn-vulkan on Windows, download it from [rife-ncnn-vulkan](https://github.com/nihui/rife-ncnn-vulkan) and add it to your system PATH. +To install rife-ncnn-vulkan on Windows, see the [RIFE 4.26 installation guide](RIFE.md). Add it to your system PATH. + +## upscayl-bin + +To install upscayl-bin on Windows, see the [Upscalers installation guide](Upscalers.md). Add it to your system PATH. + +## NCNN_GPU_ID + +The environment variable **NCNN_GPU_ID** is used to specify the GPU device ID for ncnn-based applications. + +This environment variable must be set for both `rife-ncnn-vulkan` and `upscayl-bin`. + +The value stored in **NCNN_GPU_ID** will be either: + +- **`0`** — use the first Vulkan-capable GPU (device index 0). If you have multiple GPUs, they'd be `0`, `1`, `2`, etc. +- **`-1`** — use CPU only (no Vulkan GPU is available). + +These map directly to the `-g` flag values accepted by both `rife-ncnn-vulkan` and `upscayl-bin`: + +| `-g` value | Meaning | +| --- | --- | +| `0` | First GPU | +| `1` | Second GPU | +| `2` | Third GPU | +| `-1` | CPU | --- @@ -23,15 +48,20 @@ To install rife-ncnn-vulkan on Windows, download it from [rife-ncnn-vulkan](http | Script | Purpose | | :--- | :--- | +| Clone-Image.ps1 | Clone/copy image files with optional resizing (cover-scale then crop), format conversion, and quality/compression settings | | Clone-Video.ps1 | Clone/copy video files with optional frame range and sequence extraction | | Compare-Videos.ps1 | Combine 2 to 4 video files into a single 1920x1080 side-by-side or grid comparison video | +| Create-Clip.ps1 | Create a clip of a frame range from a video, named from the source with a suffix (default `-Clip`) | | Create-FullSubtitle.ps1 | Creates a full subtitle file from a video file and two SRT subtitle files by combining them with adjusted timing and sequential numbering | +| Create-PillarBox.ps1 | Wrap a video in a 16:9 frame with selectable side-bar effects (black, white, blurred-background variants) | +| Create-Scenes.ps1 | Detect scene changes with ffmpeg and extract each scene as a separate clip using Create-Clip.ps1 | | Create-ShortClip.ps1 | Create short clips from videos with cropping and overlay options | | Create-Turnaround.ps1 | Combine a sequence of images into a single turnaround (character reference sheet) image, in a row or a grid | | Crop-Clips.ps1 | Batch crop videos generated by LTX-2, Kling and Wan from their non-standard resolutions to standard ones (also scales any square video to 1080x1080) | -| Get-Frames.ps1 | Extract a range of frames to PNG images | +| Get-Frame.ps1 | Extract a specific frame of a video to PNG, with the image name derived from the video and frame index | | Get-FramesCount.ps1 | Get total frame count of a video | -| Get-LastFrame.ps1 | Extract a specific frame or last frame to PNG | +| Get-FramesRange.ps1 | Extract a range of frames to PNG images | +| Get-LastFrame.ps1 | Extract a specific frame or last frame to PNG, with an optional filename pattern | | Get-VideoAudio.ps1 | Extracts audio in either WAV or MP3 format from a video file | | Get-VideoInfo.ps1 | Extract and display video metadata from comment tags | | Stich-InBetween.ps1 | Generate interpolated in-between frames between two images using RIFE and encode to video | @@ -43,7 +73,16 @@ To install rife-ncnn-vulkan on Windows, download it from [rife-ncnn-vulkan](http ## Scripts Installation -The scripts are available from **git.gurusushi.dyndns.org**, repository: **guru/Video-Processing-Scripts**. +The scripts are available from two locations: + +- SVN: **public-svn**, project: **Video Processing Scripts**. +- Git: **git.gurusushi.dyndns.org**, repository: **guru/Video-Processing-Scripts**. + +```bash title="Clone the repository from Git" +git clone https://git.gurusushi.dyndns.org/guru/Video-Processing-Scripts.git +``` + +**Note**: there is a `Update-Video-Processing-Scripts.cmd` script that can be used to update the directory from Git to get the latest version. Add the project folder to your PATH environment variable to access the scripts from any location.