Skip to content

Instantly share code, notes, and snippets.

@albertony
Last active June 26, 2018 05:36
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save albertony/7727aa860e18e154f480275f98f4fd1d to your computer and use it in GitHub Desktop.
Save albertony/7727aa860e18e154f480275f98f4fd1d to your computer and use it in GitHub Desktop.
Shotcut-SourceFileFixer.ps1
<#
.SYNOPSIS
Fix source file references in Shotcut video editor project files (.mlt).
.DESCRIPTION
Parses a specified Shotcut project file (in the XML-based MLT format),
finds all source file resources (producers), checks if the referenced files
exists and matches the found files with the hash values stored for them in
the project file.
If the stored file hash does not match the resolved file, the project
file will be updated with the correct hash value. Specify argument
-Confirm to be prompted what to do before any changes are made permanent
by saving back the project file, or use argument -WhatIf to never do any
permanent modifications but just see if anything would have been done.
Currently ConfirmImpact of this script is set to 'High', which means you
will be prompted by default, without having to specify -Confirm, and
instead you have to specify -Confirm:$false to save changes without being
prompted.
Shotcut manages file paths relative to the directory of the project file,
so this script does the same by default. Can optionally resolve paths
against a different location specified in argument -SourceDirectory, but
make sure you understand the implications first.
When specifying alternative source path with -SourceDirectory, the default
behavior is to only use this for checking existance and hash, updating
hash if necessary, but by also specifying -UpdatePaths the file references
in the project file will be updated accordingly.
This functionality can be used to check and fix basic file reference issues
in the project file, but you can also use it to implement a proxy editing
work process: To increase responsiveness during the editing process you
create low resolution versions of the source files which you use for
creating the Shotcut project. Then just before exporting your finished
project you replace the low resolution files with the original full
resolution files. The Shotcut project file includes a generated hash value
for each source file, so when simply replacing the source files used by an
existing project the hash values will no longer be correct. This script
fixes that. It is not critical to do so, the Shotcut project will still be
fully usable, but some minor features may be affected. According to the
Shotcut developer the hash is used for two things only: When a file is
missing to determine if the replacement file offered is the same (gives
yellow warning symbol in the dialog when not), and as a key for looking up
cached thumbnails and audio levels
(https://forum.shotcut.org/t/jerky-video-in-timeline/5992/8). Before going
into the proxy editing process, try disabling the option
"Realtime (frame dropping)" from the settings menu in the Shotcut user
interface. This makes the timeline playback smoother because it stops
trying to ensure playback is in realtime, but also it enables additional
parallel processing for effects.
.NOTES
When saving a modified file the existing whitespace and formatting
are mostly kept, but the following minor changes were impossible to avoid:
- Adding encoding to the XML declaration (changing from
<?xml version="1.0"?> to <?xml version="1.0" encoding="utf-8"?>).
- Removing unnecessary whitespace before "/>" (e.g. changing from
<track producer="background" /> to <track producer="background"/>).
Version history:
22.06.2018: Initial version
.LINK
https://www.shotcut.org/
.PARAMETER ProjectFile
The Shotcut project file (.mlt) to process, with absolute or relative path.
.PARAMETER SourceDirectory
Alternative source directory for resolving relative path to source files
in the Shotcut project file (.mlt). If also -UpdatePaths the project file
will be updated with new paths into the alternative source directory,
without it is only used for checking existance and hash values.
Default is the directory of the project file, this is what Shotcut is using
so be careful if you are saving changes to the project file using a
different source directory.
.PARAMETER UpdatePaths
When specifying -SourceDirectory, then update source file paths in the
project file according to the specified path.
.EXAMPLE
Shotcut-SourceFileFixer.ps1 myproject.mlt -WhatIf
Verify that the stored hash value for source files in the project file
myproject.mlt in current directory is correct. Report any issues, but do
not perform any permanent changes.
.EXAMPLE
Shotcut-SourceFileFixer.ps1 .\Projects\myproject.mlt
Verify file hashes in the project file specified with relative path, update
all that are not matching. Depending on the ConfirmPreference setting it
may or may not prompt for confirmation before saving any changes back to
the project file (the default is to always prompt).
.EXAMPLE
Shotcut-SourceFileFixer.ps1 .\Projects\myproject.mlt -Confirm
Prompt for confirmation before saving file, regardless of current
ConfirmPreference setting.
.EXAMPLE
Shotcut-SourceFileFixer.ps1 C:\Shotcut\Projects\myproject.mlt -Confirm:$false
Save any changes back to the project file without confirmation.
.EXAMPLE
Shotcut-SourceFileFixer.ps1 myproject.mlt -SourceDirectory source\lowres
Verify file hashes in the project file, but resolve relative file
references according to specified directory path source\lowres instead
of the project file directory. Do not change paths in the project file,
only hash values.
.EXAMPLE
Shotcut-SourceFileFixer.ps1 myproject.mlt -SourceDirectory source\lowres -UpdatePaths
Update all source file references in the project file to make them relative
to the specified directory source directory source\lowres instead of the
project file directory. Verify file hashes and update if needed.
#>
[CmdletBinding(SupportsShouldProcess=$true, ConfirmImpact='High')]
param
(
[Parameter(Mandatory=$true,Position=0, ParameterSetName='OriginalSource')]
[Parameter(Mandatory=$true,Position=0, ParameterSetName='DifferentSource')]
[string] $ProjectFile,
[Parameter(Mandatory=$true, ParameterSetName='DifferentSource')]
[string] $SourceDirectory,
[Parameter(Mandatory=$false, ParameterSetName='DifferentSource')]
[switch] $UpdatePaths
)
$projectFileObject = Get-Item $ProjectFile -ErrorAction Ignore
if ($projectFileObject -eq $null)
{
throw "File `"$ProjectFile`" does not exist!"
}
if (-not $SourceDirectory)
{
$SourceDirectory = $projectFileObject.Directory.FullName
}
$xml = New-Object System.Xml.XmlDocument
$xml.PreserveWhitespace = $true
$xml.Load($projectFileObject.FullName)
$hasher = [System.Security.Cryptography.HashAlgorithm]::Create("MD5")
$numberOfFoundFiles = 0
$numberOfMissingFiles = 0
$numberOfChangedHashes = 0
$numberOfChangedPaths = 0
foreach ($producer in $xml.mlt.SelectNodes("producer"))
{
$type = $producer.SelectSingleNode("property[@name='mlt_service']").'#text'
#if ($type -in 'avformat-novalidate', 'qimage', 'pixbuf') # Only consider producers with file resource (silently skip things like color, count etc)
if ($type -notin 'color', 'count', 'luma') # Only consider producers with file resource (silently skip things like color, count etc), see shotcut\src\mltxmlchecker.cpp
{
$resourceProperty = $producer.SelectSingleNode("property[@name='resource']") # File paths are stored here
$fileDetailProperty = $producer.SelectSingleNode("property[@name='shotcut:detail']") # Sometimes the file path is also here, but sounds like just additional info
$fileRelativePath = $resourceProperty.'#text'
if ($fileRelativePath)
{
$fileFullPath = Join-Path $SourceDirectory $fileRelativePath # This also converts path separators from / to \
if (Test-Path $fileFullPath -ErrorAction Ignore)
{
if ($UpdatePaths)
{
$commonRootLevels = 0
$commonRoot = $projectFileObject.Directory
while ($commonRoot -and -not $fileFullPath.StartsWith($commonRoot.FullName))
{
$commonRoot = $commonRoot.Parent
$commonRootLevels++
}
if ($commonRoot)
{
$newFileRelativePath = $fileFullPath.Substring($commonRoot.FullName.Length + 1)
$newFileRelativePath = "..\"*$commonRootLevels + $newFileRelativePath
}
else
{
$newFileRelativePath = $fileFullPath
}
$newFileRelativePath = $newFileRelativePath.Replace('\','/')
if ($resourceProperty.'#text' -ne $newFileRelativePath)
{
$resourceProperty.'#text' = "$newFileRelativePath"
Write-Host "Changed file path in `"$($producer.id)`" to `"${newFileRelativePath}`""
if ($fileDetailProperty)
{
if ($fileDetailProperty.'#text' -eq $fileRelativePath) # Update additional property only if existing value is the existing path
{
Write-Verbose "Detail property in `"$($producer.id)`" was set to previous path, updating to new path"
$fileDetailProperty.'#text' = "$newFileRelativePath"
}
else
{
Write-Verbose "Keeping existing value of detail property in `"$($producer.id)`": `"$($fileDetailProperty.'#text')`""
}
}
$fileRelativePath = $newFileRelativePath # Keep new path for further references below
$numberOfChangedPaths++
}
else
{
Write-Verbose "Existing path is correct for `"$($producer.id)`": ${fileRelativePath}"
}
}
$hashProperty = $producer.SelectSingleNode("property[@name='shotcut:hash']")
$prefixSize = $suffixSize = 1000000
$inputStream = New-Object IO.FileStream($fileFullPath, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read)
$fileSize = $inputStream.Length
if ($fileSize -gt $prefixSize+$suffixSize)
{
$fileBytes = New-Object byte[] ($prefixSize+$suffixSize)
$prefixReadSize = $inputStream.Read($fileBytes, 0, $prefixSize)
if ($prefixReadSize -lt $prefixSize)
{
Write-Warning "Read ${prefixReadSize} of expected ${prefixSize} prefix bytes from file ${fileRelativePath}"
}
$inputStream.Position = $inputStream.Length - $suffixSize
$suffixReadSize = $inputStream.Read($fileBytes, $prefixSize, $suffixSize)
if ($suffixReadSize -lt $suffixSize)
{
Write-Warning "Read ${suffixReadSize} of expected ${suffixSize} suffix bytes from file ${fileRelativePath}"
}
$readSize = $prefixReadSize + $suffixReadSize
}
else
{
$fileBytes = New-Object byte[] ($fileSize)
$readSize = $inputStream.Read($fileBytes, 0, $fileSize)
if ($readSize -lt $fileSize)
{
Write-Warning "Read ${readSize} of expected ${fileSize} bytes from file ${fileRelativePath}"
}
}
$hashBytes = $hasher.ComputeHash($fileBytes, 0, $readSize)
$inputStream.Close()
$builder = New-Object System.Text.StringBuilder
$hashBytes | Foreach-Object { [void] $builder.Append($_.ToString("X2")) }
$newHash = $builder.ToString().ToLower()
if ($hashProperty.'#text' -ne $newHash)
{
$hashProperty.'#text' = "$newHash"
Write-Host "Changed hash in `"$($producer.id)`" for file `"${fileRelativePath}`""
$numberOfChangedHashes++
}
else
{
Write-Verbose "Existing hash in `"$($producer.id)`" for file `"${fileRelativePath}`" is valid"
}
$numberOfFoundFiles++
}
else
{
Write-Warning "Resource of `"$($producer.id)`" does not point to an existing file: ${fileFullPath}"
$numberOfMissingFiles++
}
}
else
{
Write-Verbose "Skipping `"$($producer.id)`": No resource"
}
}
else
{
Write-Verbose "Skipping `"$($producer.id)`": Type `"${type}`" is not known to have file resource"
}
}
Write-Host "Processed $($numberOfFoundFiles+$numberOfMissingFiles) producer source file resources"
if ($numberOfChangedPaths -or $numberOfChangedHashes -or $numberOfMissingFiles)
{
if ($numberOfChangedPaths)
{
Write-Host "${numberOfChangedPaths} file path$(if($numberOfChangedPaths -gt 1){'s'}) changed"
}
if ($numberOfChangedHashes)
{
Write-Host "${numberOfChangedHashes} file hash$(if($numberOfChangedHashes -gt 1){'es'}) changed"
}
if ($numberOfMissingFiles)
{
Write-Warning "Skipped ${numberOfMissingFiles} missing file$(if($numberOfMissingFiles -gt 1){'s'})"
}
if ($numberOfChangedPaths -or $numberOfChangedHashes)
{
$reason = [System.Management.Automation.ShouldProcessReason]::None
if ($PSCmdlet.ShouldProcess("Save changes to file `"${ProjectFile}`"", "Are you sure you want to save changes to file `"${ProjectFile}`"?", "Confirm", [ref]$reason))
{
$writer = New-Object System.Xml.XmlTextWriter $projectFileObject.FullName, $null
$writer.Formatting = "None"
$xml.Save($writer)
$writer.Close()
Write-Host "File `"${ProjectFile}`" saved"
}
else
{
if ($reason -ne [System.Management.Automation.ShouldProcessReason]::WhatIf)
{
Write-Host "All changes discarded (file not saved)"
}
}
}
}
else
{
Write-Host "All OK"
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment