Skip to content

Instantly share code, notes, and snippets.

@joshooaj
Last active March 19, 2024 13:36
Show Gist options
  • Save joshooaj/201708f8077cf530bdd8c08dc4e3b88b to your computer and use it in GitHub Desktop.
Save joshooaj/201708f8077cf530bdd8c08dc4e3b88b to your computer and use it in GitHub Desktop.
Create a timelapse video using MilestonePSTools to retrieve jpegs from a Milestone VMS and ffmpeg to compile those snapshots into an h264 timelapse video
function New-Timelapse {
<#
.SYNOPSIS
Exports still images from XProtect and creates a timelapse video using ffmpeg.
.DESCRIPTION
This example function saves jpeg images from the recordings of the specified
camera to a temp folder, and uses these images as input to the ffmpeg
command-line utility to generate a timelapse video from the images.
.PARAMETER Camera
Specifies a camera object such as is returned by Get-VmsCamera or Select-Camera.
.PARAMETER Start
Specifies the timestamp from which snapshots will be exported from the
specified camera. Example: (Get-Date).AddDays(-7)
.PARAMETER End
Specifies the timestamp at which the snapshot export should stop. Example: (Get-Date -Year 2022 -Month 5 -Day 5)
.PARAMETER OutputLength
Specifies the desired length of the resulting timelapse video. If the
timespan defined by the Start and End parameters should be compressed into a
30-second video, then you can specify (New-TimeSpan -Seconds 30).
.PARAMETER OutputFps
Specifies the framerate for the resulting video which can either be 30, or 60 FPS.
.PARAMETER OutputPath
Specifies the path, including file name, for the video file. For example: C:\temp\timelapse.mp4
.EXAMPLE
$params = @{
Camera = Select-Camera -SingleSelect
Start = (Get-Date).AddDays(-7)
End = Get-Date
OutputLength = New-TimeSpan -Seconds 30
OutputFps = 30
OutputPath = C:\temp\timelapse.mp4
}
New-Timelapse @params
The parameters for the timelapse are defined in a hashtable, and then "splatted" into the New-Timelapse cmdlet. The
resulting timelapse will be up to 30 seconds long, and play at 30fps. Though the resulting video can be shorter if
the recordings are not continuous.
.EXAMPLE
$params = @{
Camera = Get-VmsCamera -Id F09B8B40-3B23-4A7F-9A56-AE13F94BA18F
Start = (Get-Date).AddDays(-1)
End = Get-Date
OutputLength = New-TimeSpan -Seconds 30
OutputFps = 60
OutputPath = C:\temp\timelapse.mp4
}
New-Timelapse @params
Creates a 30 second long 60fps timelapse video of the last 24 hours for the camera with ID
'F09B8B40-3B23-4A7F-9A56-AE13F94BA18F'.
.NOTES
This sample is offered as-is and is not intended to be supported by @joshooaj
or Milestone Systems, though I am happy to repond to questions/issues as and
when I have the time.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory=$true, ValueFromPipeline=$true)]
[VideoOS.Platform.ConfigurationItems.Camera]
$Camera,
[Parameter(Mandatory=$true)]
[DateTime]
$Start,
[Parameter(Mandatory=$true)]
[DateTime]
$End,
[Parameter()]
[TimeSpan]
$OutputLength,
[Parameter()]
[ValidateSet(30, 60)]
[int]
$OutputFps = 30,
[Parameter(Mandatory=$true)]
[string]
$OutputPath
)
begin {
try {
$null = Get-VmsManagementServer -ErrorAction Stop
if ($null -eq (Get-Command ffmpeg -ErrorAction Ignore)) {
throw ([io.filenotfoundexception]::new('Please download ffmpeg and ensure the folder location is added to your PATH environment variable.', 'ffmpeg.exe'))
}
$result = & ffmpeg.exe -version -hide_banner -loglevel error 2>&1
if (!$?) {
$errorrecord = $result | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] } | Select-Object -First 1
throw $errorrecord
}
} catch {
throw
}
}
process {
if (Test-Path $OutputPath) {
throw "File already exists: $($OutputPath)"
}
$outputFrameCount = $OutputLength.TotalSeconds * $OutputFps
$sourceTimespan = $End - $Start
$sampleInterval = [Math]::Floor($sourceTimespan.TotalSeconds / $outputFrameCount)
$tempFolder = Join-Path ([io.path]::GetTempPath()) ([IO.Path]::GetRandomFileName())
Write-Verbose "Total Output Frames: $outputFrameCount"
Write-Verbose "Original Duration: $($sourceTimespan.TotalMinutes)"
Write-Verbose "Sample Interval: $sampleInterval seconds between images"
Write-Verbose "Temp Folder: $tempFolder"
try {
$null = New-Item -Path $tempFolder -ItemType Directory -ErrorAction Stop
$null = $Camera | Get-Snapshot -Timestamp $Start -EndTime $End -Interval $sampleInterval -Save -Path $tempFolder -Quality 100 -ErrorAction Stop
if (-not (Get-ChildItem (Join-Path $tempFolder '*.jpg'))) {
throw "Get-Snapshot failed to save any images for $($Camera.Name) between $Start and $End. Are there any recordings available during this time?"
}
$i = 0
foreach ($item in Get-ChildItem -Path $tempFolder | Sort-Object Name) {
$item | Move-Item -Destination (Join-Path $tempFolder "image_$($i.ToString().PadLeft(10, '0')).jpg") -ErrorAction Stop
$i += 1
}
$inputPattern = Join-Path $tempFolder 'image_%10d.jpg'
$ffmpegArgs = @(
"-framerate", 60, # No idea why this is hardcoded to 60 but I think it ended up helping with the unpredicable source "frame rate"
"-r", $OutputFps, # Sets the desired framerate of the resulting video
"-i", """$inputPattern""", # Specifies the source folder and filename pattern for the exported jpegs
"-c:v", "libx264", # Set the codec to x264
"-pix_fmt", "yuv420p", # I think this was needed when using the mjpeg transcoding option
"-vf", """crop=trunc(iw/2)*2:trunc(ih/2)*2""", # Intended to ensure the output resolution is divisible by 2
$OutputPath
)
& ffmpeg.exe @ffmpegArgs
}
catch {
throw
}
finally {
if ((Test-Path -Path $tempFolder)) {
Remove-Item -Path $tempFolder -Recurse -Force
}
}
}
}
@ChicagoJay
Copy link

ChicagoJay commented Jun 30, 2021

@jhendriks123 - Do you have some demo command lines, so we can see how to use this? Also, how do we point it to ffmpeg's location, since it's not usually in the $PATH?

Thanks!

@joshooaj
Copy link
Author

joshooaj commented Jul 2, 2021

Hi @ChicagoJay! So to use this function you would put it in your script, and then call it with the necessary parameters. Here's an example. Let's say I have the script above in a file called New-Timelapse.ps1, and I create the following script in a new file called CreateTimelapse.ps1 in the same folder.

# This line will "dot-source" the New-Timelapse.ps1 file and basically load that function up in memory making it ready to use in your current PowerShell script/session.
. $PSScriptRoot\New-Timelapse.ps1

# Change this if you're running it on a different machine than the Management Server
Connect-ManagementServer -Server localhost -AcceptEula
$timelapseParams = @{
    Camera = Select-Camera -SingleSelect
    Start = (Get-Date).AddDays(-7)
    End = Get-Date
    OutputLength = New-TimeSpan -Seconds 90
    OutputFps = 30
    OutputPath = 'C:\Temp\Timelapse.mp4'
}
New-Timelapse @timelapseParams

In this example, we're assuming it's running on the Management Server so if it's not, change Server to the IP/hostname of the Management Server and maybe put in a credential if "Current User" doesn't work. To have PowerShell ask you for a credential you can use Connect-ManagementServer -Server myserver -Credential (Get-Credential). There are also better ways of managing credentials for automation like using Microsoft's SecretManagement module or even Export/Import-Clixml to save/load an encrypted credential object on disk.

Assuming you have ffmpeg in your Path environment variable, this should generate up to a 90 second timelapse from the last 7 days of recordings once you select a camera from the dialog that is going to pop-up (select-camera will pop up an item picker dialog).

You can test to see if ffmpeg is in your path environment variable by typing ffmpeg -version in a console window (and not while you're in the folder ffmpeg is in). It should dump some information to the console. If not, make sure you have downloaded ffmpeg first of all, and then wherever you place the EXE, you can manually add it to your path environment variable or you could add it temporarily by putting something like this at the top of your script:

$env:path += ";C:\Path\To\Folder\Where\ffmpeg.exe\is"

It's been a while since I made a timelapse with this so I gave it a shot on one of our public demo cameras. So far so good!

2021-07-02 16_26_50-Windows PowerShell ISE

@ChicagoJay
Copy link

ChicagoJay commented Jul 6, 2021

BRILLIANT! Worked great!

Some gotchas:

  1. I needed to add a line to define $PSScriptRoot and
  2. You may want to add some error-checking, or use -Force in the invocation, as the script will throw an error (but continue) Connect-ManagementServer : Already connected to a Management Server. Include the -Force switch parameter to automatically disconnect from previous sessions. after the first time the script is run.
  3. There doesn't appear to be a way to control the length of the output video. There is a line, that says OutputLength = New-TimeSpan - Seconds 30 but changing that to other values seems to have no effect.

Point is, the script did almost exactly what I needed it to do, and it did so quickly (one month of source into 30 seconds of video took less than 5 minutes) and easily! So thanks very much, @jhendricks123!!!

@ChicagoJay
Copy link

ChicagoJay commented Sep 21, 2021

@jhendricks123 - the script appears to be ignoring the OutputLength, as I put OutputLength = New-TimeSpan -Minutes 3 (I also tried -seconds 180) and still got a 38 second video.

The script throws an error at the end, but I don't think it has anything to do with this. It says the temp file doesn't exist. I think it's getting cleaned up twice by the script.

Anyway - how can we get the OutputLength parameter to be obeyed?

Thanks!

Here is the transcript:

> .\TimeLapseTest.ps1
ffmpeg version n4.4-78-g031c0cb0b4-20210630 Copyright (c) 2000-2021 the FFmpeg developers
built with gcc 10-win32 (GCC) 20210408
configuration: --prefix=/ffbuild/prefix --pkg-config-flags=--static --pkg-config=pkg-config --cross-prefix=x86_64-w64-mingw32- --arch=x86_64 --target-os=mingw32 --enable-gpl --enable-version3 --disable-debug --disable-w32threads --enable-pthreads --enable-iconv --enable-libxml2 --enable-zlib --enable-libfreetype --enable-libfribidi --enable-gmp --enable-lzma --enable-fontconfig --enable-libvorbis --enable-opencl --enable-libvmaf --enable-vulkan --enable-amf --enable-libaom --enable-avisynth --enable-libdav1d --enable-libdavs2 --disable-libfdk-aac --enable-ffnvcodec --enable-cuda-llvm --enable-libglslang --enable-libgme --enable-libass --enable-libbluray --enable-libmp3lame --enable-libopus --enable-libtheora --enable-libvpx --enable-libwebp --enable-lv2 --enable-libmfx --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenjpeg --enable-librav1e --enable-librubberband --enable-schannel --enable-sdl2 --enable-libsoxr --enable-libsrt --enable-libsvtav1 --enable-libtwolame --enable-libuavs3d --enable-libvidstab --enable-libx264 --enable-libx265 --enable-libxavs2 --enable-libxvid --enable-libzimg --extra-cflags=-DLIBTWOLAME_STATIC --extra-cxxflags= --extra-ldflags=-pthread --extra-ldexeflags= --extra-libs=-lgomp --extra-version=20210630
libavutil      56. 70.100 / 56. 70.100
libavcodec     58.134.100 / 58.134.100
libavformat    58. 76.100 / 58. 76.100
libavdevice    58. 13.100 / 58. 13.100
libavfilter     7.110.100 /  7.110.100
libswscale      5.  9.100 /  5.  9.100
libswresample   3.  9.100 /  3.  9.100
libpostproc    55.  9.100 / 55.  9.100
ffmpeg version n4.4-78-g031c0cb0b4-20210630 Copyright (c) 2000-2021 the FFmpeg developers
  built with gcc 10-win32 (GCC) 20210408
  configuration: --prefix=/ffbuild/prefix --pkg-config-flags=--static --pkg-config=pkg-config --cross-prefix=x86_64-w64-mingw32- --arch=x86_64 --target-os=mingw32 --enable-gpl --enable-version3 --disable-debug --disable-w32threads --enable-pthreads --enable-iconv --enable-libxml2 --enable-zlib --enable-libfreetype --enable-libfribidi --enable-gmp --enable-lzma --enable-fontconfig --enable-libvorbis --enable-opencl --enable-libvmaf --enable-vulkan --enable-amf --enable-libaom --enable-avisynth --enable-libdav1d --enable-libdavs2 --disable-libfdk-aac --enable-ffnvcodec --enable-cuda-llvm --enable-libglslang --enable-libgme --enable-libass --enable-libbluray --enable-libmp3lame --enable-libopus --enable-libtheora --enable-libvpx --enable-libwebp --enable-lv2 --enable-libmfx --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenjpeg --enable-librav1e --enable-librubberband --enable-schannel --enable-sdl2 --enable-libsoxr --enable-libsrt --enable-libsvtav1 --enable-libtwolame --enable-libuavs3d --enable-libvidstab --enable-libx264 --enable-libx265 --enable-libxavs2 --enable-libxvid --enable-libzimg --extra-cflags=-DLIBTWOLAME_STATIC --extra-cxxflags= --extra-ldflags=-pthread --extra-ldexeflags= --extra-libs=-lgomp --extra-version=20210630
  libavutil      56. 70.100 / 56. 70.100
  libavcodec     58.134.100 / 58.134.100
  libavformat    58. 76.100 / 58. 76.100
  libavdevice    58. 13.100 / 58. 13.100
  libavfilter     7.110.100 /  7.110.100
  libswscale      5.  9.100 /  5.  9.100
  libswresample   3.  9.100 /  3.  9.100
  libpostproc    55.  9.100 / 55.  9.100
Input #0, image2, from 'C:\New-Timelapse-x3mtwbrt.r43\image_%10d.jpg':
  Duration: 00:00:19.20, start: 0.000000, bitrate: N/A
  Stream #0:0: Video: mjpeg (Baseline), yuvj420p(pc, bt470bg/unknown/unknown), 1920x1080 [SAR 30000:30000 DAR 16:9], 60 fps, 60 tbr, 60 tbn, 60 tbc
Stream mapping:
  Stream #0:0 -> #0:0 (mjpeg (native) -> h264 (libx264))
Press [q] to stop, [?] for help
[swscaler @ 000001c132256f40] deprecated pixel format used, make sure you did set range correctly
[libx264 @ 000001c13277f0c0] using SAR=1/1
[libx264 @ 000001c13277f0c0] using cpu capabilities: MMX2 SSE2Fast SSSE3 SSE4.2 AVX FMA3 BMI2 AVX2
[libx264 @ 000001c13277f0c0] profile High, level 4.0, 4:2:0, 8-bit
[libx264 @ 000001c13277f0c0] 264 - core 163 - H.264/MPEG-4 AVC codec - Copyleft 2003-2021 - http://www.videolan.org/x264.html - options: cabac=1 ref=3 deblock=1:0:0 analyse=0x3:0x113 me=hex subme=7 psy=1 psy_rd=1.00:0.00 mixed_ref=1 me_range=16 chroma_me=1 trellis=1 8x8dct=1 cqm=0 deadzone=21,11 fast_pskip=1 chroma_qp_offset=-2 threads=24 lookahead_threads=4 sliced_threads=0 nr=0 decimate=1 interlaced=0 bluray_compat=0 constrained_intra=0 bframes=3 b_pyramid=2 b_adapt=1 b_bias=0 direct=1 weightb=1 open_gop=0 weightp=2 keyint=250 keyint_min=25 scenecut=40 intra_refresh=0 rc_lookahead=40 rc=crf mbtree=1 crf=23.0 qcomp=0.60 qpmin=0 qpmax=69 qpstep=4 ip_ratio=1.40 aq=1:1.00
Output #0, mp4, to 'C:\Temp\Timelapse.mp4':
  Metadata:
    encoder         : Lavf58.76.100
  Stream #0:0: Video: h264 (avc1 / 0x31637661), yuv420p(tv, bt470bg/unknown/unknown, progressive), 1920x1080 [SAR 1:1 DAR 16:9], q=2-31, 30 fps, 15360 tbn
    Metadata:
      encoder         : Lavc58.134.100 libx264
    Side data:
      cpb: bitrate max/min/avg: 0/0/0 buffer size: 0 vbv_delay: N/A
frame= 1152 fps= 51 q=-1.0 Lsize=   43038kB time=00:00:38.30 bitrate=9205.4kbits/s speed= 1.7x
video:43023kB audio:0kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: 0.034731%
[libx264 @ 000001c13277f0c0] frame I:13    Avg QP:23.73  size:174571
[libx264 @ 000001c13277f0c0] frame P:296   Avg QP:25.99  size: 86388
[libx264 @ 000001c13277f0c0] frame B:843   Avg QP:29.39  size: 19234
[libx264 @ 000001c13277f0c0] consecutive B-frames:  2.0%  1.0%  0.8% 96.2%
[libx264 @ 000001c13277f0c0] mb I  I16..4:  1.9% 75.3% 22.8%
[libx264 @ 000001c13277f0c0] mb P  I16..4:  0.6% 11.4%  3.3%  P16..4: 42.0% 17.7% 15.6%  0.0%  0.0%    skip: 9.4%
[libx264 @ 000001c13277f0c0] mb B  I16..4:  0.1%  1.7%  0.7%  B16..8: 34.6%  4.9%  1.1%  direct: 4.3%  skip:52.4%  L0:48.6% L1:41.5% BI: 9.9%
[libx264 @ 000001c13277f0c0] 8x8 transform intra:72.4% inter:76.8%
[libx264 @ 000001c13277f0c0] coded y,uvDC,uvAC intra: 86.9% 50.4% 10.9% inter: 27.4% 14.2% 0.2%
[libx264 @ 000001c13277f0c0] i16 v,h,dc,p: 14% 50% 11% 24%
[libx264 @ 000001c13277f0c0] i8 v,h,dc,ddl,ddr,vr,hd,vl,hu: 12% 38% 13%  5%  4%  4%  7%  5% 12%
[libx264 @ 000001c13277f0c0] i4 v,h,dc,ddl,ddr,vr,hd,vl,hu: 13% 40%  9%  4%  7%  6%  9%  5%  8%
[libx264 @ 000001c13277f0c0] i8c dc,h,v,p: 61% 22% 14%  2%
[libx264 @ 000001c13277f0c0] Weighted P-Frames: Y:42.6% UV:19.6%
[libx264 @ 000001c13277f0c0] ref P L0: 38.9% 16.0% 22.1% 16.9%  6.0%
[libx264 @ 000001c13277f0c0] ref B L0: 60.2% 30.8%  9.0%
[libx264 @ 000001c13277f0c0] ref B L1: 84.8% 15.2%
[libx264 @ 000001c13277f0c0] kb/s:9178.09
Get-Item : Cannot find path 'C:\New-Timelapse-x3mtwbrt.r43' because it does not exist.
At C:\Program Files\WindowsPowerShell\Scripts\New-Timelapse.ps1:70 char:13
+             Get-Item $tempFolder | Remove-Item -Force
+             ~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : ObjectNotFound: (C:\New-Timelapse-x3mtwbrt.r43:String) [Get-Item], ItemNotFoundException
    + FullyQualifiedErrorId : PathNotFound,Microsoft.PowerShell.Commands.GetItemCommand

@joshooaj
Copy link
Author

It's hard to say exactly what's happening without knowing more about the available frame rate for the time period being exported and the verbose output with the calculated $sampleInterval. There could be a miscalculation there, or the call to ffmpeg needs to be updated to work properly?

You might check out the timelapse plugin from Visual Networks @ http://www.visualnetworks.co.nz/SoftwarePage.html to see if that is a bit more reliable

@ChicagoJay
Copy link

ChicagoJay commented Sep 22, 2021

I'm checking out the plugin, but it's not free.

How can I get you the requested info?

Here is the script I use to call the program:

$PSScriptRoot="C:\Program Files\WindowsPowerShell\Scripts"

# This line will "dot-source" the New-Timelapse.ps1 file and basically load that function up in memory making it ready to use in your current PowerShell script/session.
 . $PSScriptRoot\New-Timelapse.ps1

# Change this if you're running it on a different machine than the Management Server
Connect-ManagementServer -Force -Server milestone.fqdn -AcceptEula
$timelapseParams = @{
    Camera = Select-Camera -SingleSelect
    Start = Get-Date -Date 6/1/21 -Hour 6 -Minute 17 -Second 37
    End = Get-Date -Date 6/30/21 -Hour 13 -Minute 25 -Second 53
    OutputLength = New-TimeSpan -Seconds 180
    OutputFps = 30
    OutputPath = 'C:\Temp\Timelapse.mp4'
}
New-Timelapse @timelapseParams

Thanks again!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment