VirtualContactSheet 2.0.0

dotnet add package VirtualContactSheet --version 2.0.0
                    
NuGet\Install-Package VirtualContactSheet -Version 2.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="VirtualContactSheet" Version="2.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="VirtualContactSheet" Version="2.0.0" />
                    
Directory.Packages.props
<PackageReference Include="VirtualContactSheet" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add VirtualContactSheet --version 2.0.0
                    
#r "nuget: VirtualContactSheet, 2.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package VirtualContactSheet@2.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=VirtualContactSheet&version=2.0.0
                    
Install as a Cake Addin
#tool nuget:?package=VirtualContactSheet&version=2.0.0
                    
Install as a Cake Tool

VirtualContactSheet (.NET)

NuGet CI

Virtual Contact Sheet — a contact-sheet generator, originally a C# port of vcs.rb. It composes a grid ("contact sheet") with a metadata header, optional title, per-thumbnail captions, drop shadows, polaroid frames, and a signature footer — either from frames extracted from a video at regular intervals, or from a folder (or list) of images.

  • Frame capture & metadata: FFMpegCore — wraps ffmpeg / ffprobe; binaries can be on PATH or pointed to via ffBinaryFolder
  • Composition / drawing: SkiaSharp (MIT) — chosen for its native drop-shadow, text, and canvas compositing support
  • Image decoding: SkiaSharp again — jpg, png, gif, bmp, webp, heif/heic, avif …, with EXIF orientation applied
  • Targets: .NET 10, cross-platform (Windows / Linux / macOS)

Example

A 4×4 contact sheet with a custom footer:

Example contact sheet

using VirtualContactSheet;
using VirtualContactSheet.VideoProcessing;

var video = new Video("ons3on3cup_hdtv.mp4");

var options = new ContactSheetOptions
{
    Columns = 4,
    Rows = 4,
    Signature = "Made in .NET with VirtualContactSheet",
};

await video.SaveContactSheetAsync("example-contact-sheet.png", options);

Or with the CLI:

vcs ons3on3cup_hdtv.mp4 -c 4 -r 4 -s "Made in .NET with VirtualContactSheet" -o example-contact-sheet.png

The same thing from a folder of photos, no ffmpeg involved:

Example image contact sheet

using VirtualContactSheet;
using VirtualContactSheet.ImageProcessing;

var photos = ImageCollection.FromFolder("holiday");

var options = new ContactSheetOptions
{
    Columns = 4,
    Rows = 4,
    Signature = "Made in .NET with VirtualContactSheet",
    Format = SheetFormat.Jpg,
};

await photos.SaveContactSheetAsync("example-image-contact-sheet.jpg", options);

Cells are a uniform size, so the two portrait shots are letterboxed into the landscape cell the rest of the folder dictates — new SkiaImageLoader(ImageFit.Cover) crops them to fill instead.

Upgrading from VideoContactSheet

The project was renamed in 2.0.0, and so were the packages. VideoContactSheet stops at 1.0.1; nothing upgrades in place.

Was Now
VideoContactSheet VirtualContactSheet
VideoContactSheet.Cli VirtualContactSheet.Cli

Both CLI packages install a command called vcs, so uninstall the old tool before installing the new one:

dotnet tool uninstall -g VideoContactSheet.Cli
dotnet tool install -g VirtualContactSheet.Cli

In library code, video types moved to their own namespace, so add one using:

using VirtualContactSheet;
using VirtualContactSheet.VideoProcessing;   // Video, VideoInfo, IFrameCapturer, ...

HeaderBuilder is now VideoHeaderBuilder, and ContactSheet.Thumbnail carries a string? Caption instead of a TimeIndex — the Thumbnail(SKBitmap, TimeIndex, bool) constructor still exists and formats the timestamp for you. See CHANGELOG.md.

Requirements

  • .NET 10 SDK
  • ffmpeg and ffprobe — only for video; image sheets need neither
  • On headless Linux, you may also need libfontconfig1 for text rendering

Getting ffmpeg

The library never ships ffmpeg: it looks for the binaries on PATH, or wherever ffBinaryFolder points. Any of these works:

Platform Install
Windows winget install Gyan.FFmpeg, or pwsh tools/download-ffmpeg.ps1 for a repo-local copy
Debian/Ubuntu apt install ffmpeg
macOS brew install ffmpeg

tools/download-ffmpeg.ps1 fetches the version pinned in tools/ffmpeg.json, verifies its SHA-256, and drops it in tools/ffmpeg/win-x64/, from where the CLI build copies it next to vcs.exe. That copy is a convenience for local development — the binaries are git-ignored, are never published in either NuGet package, and the build works fine without them.

Namespaces

Namespace Contents
VirtualContactSheet Everything shared: ContactSheet, ContactSheetOptions, TextStyle, SheetFormat, HeaderColumns, TimeIndex, CaptureException
VirtualContactSheet.VideoProcessing Video, VideoInfo, IFrameCapturer / FfmpegCapturer, IVideoInfoProvider / FfprobeVideoInfoProvider, FrameAnalysis
VirtualContactSheet.ImageProcessing ImageCollection, ImageInfo, IImageLoader / SkiaImageLoader, IImageInfoProvider / SkiaImageInfoProvider, ImageFit, ImageSelection

Library usage — video

using VirtualContactSheet;
using VirtualContactSheet.VideoProcessing;

// ffmpeg/ffprobe on PATH:
var video = new Video("movie.mkv");

// — or — binaries shipped next to the exe:
var video = new Video("movie.mkv", ffBinaryFolder: AppContext.BaseDirectory);

// Metadata
var info = await video.GetInfoAsync();
Console.WriteLine($"Duration: {info.Duration}, {info.Video?.Width}x{info.Video?.Height}");

// Build a 3x3 sheet
var options = new ContactSheetOptions
{
    Columns = 3,
    Rows = 3,
    ThumbnailWidth = 320,
    Format = SheetFormat.Jpg,
    Title = "My Movie",
    SoftShadow = true,
    Timestamp = true,
};
await video.SaveContactSheetAsync("out.jpg", options);

// Single frame
var bmp = await video.CaptureFrameAsync(TimeIndex.Parse("1:22"), width: 640, evadeBlank: true);

Library usage — images

ImageCollection mirrors Video: point it at a source, reuse the same ContactSheetOptions, and call the same SaveContactSheetAsync. No ffmpeg involved.

using VirtualContactSheet;
using VirtualContactSheet.ImageProcessing;

// Every supported image in a folder, ordered by file name:
var photos = ImageCollection.FromFolder("holiday-2026", recursive: true);

// — or — an explicit list, kept in the order given:
var photos = ImageCollection.FromFiles(["cover.jpg", "beach.png", "sunset.jpg"]);

// Metadata (dimensions/format/size per image, read from the file headers)
var info = await photos.GetInfoAsync();
Console.WriteLine($"{info.Count} images, {info.First?.Width}x{info.First?.Height}");

var options = new ContactSheetOptions
{
    Columns = 4,
    Rows = 4,
    ThumbnailWidth = 320,
    Title = "Holiday 2026",
    Format = SheetFormat.Jpg,
};

await photos.SaveContactSheetAsync("holiday.jpg", options);

How the options map to images

Option Meaning for an image collection
Columns, Rows Grid capacity. More images than cells: see Selection below
ThumbnailWidth Cell width; the height follows AspectRatio, then ThumbnailHeight, else the aspect ratio of the first image
Timestamp Toggles the caption overlay — the file name instead of a time index
Title, Signature, ShowHeader As for video; the header lists folder, image count, total size, dimensions and formats
Interval, From, To, Highlights, blank-frame evasion Video-only, ignored here

Extra knobs that live on the collection itself:

// More images than cells? Sample evenly (default) or render all of them.
photos.Selection = ImageSelection.All;

// Caption text per thumbnail; return null for none.
photos.Caption = image => Path.GetFileNameWithoutExtension(image.Path);

// Cells are uniform, so images that do not match are letterboxed (default),
// cropped, or stretched:
var cropped = ImageCollection.FromFolder("photos", loader: new SkiaImageLoader(ImageFit.Cover));

Implement IImageLoader (or IImageInfoProvider) to plug in a different decoder, exactly like IFrameCapturer for video.

CLI

Install as a .NET global tool (requires ffmpeg/ffprobe on PATH):

dotnet tool install -g VirtualContactSheet.Cli

The vcs CLI mirrors the original script's options:

vcs video.avi
vcs -i 3m30 input.wmv -o output.jpg
vcs --from 3m --to 18m -i 2m input.avi
vcs -c 4 -r 5 --polaroid --no-shadow -T "Holiday" clip.mp4

It takes images too — pass a folder, or the image files themselves:

vcs holiday/                          # one sheet from the folder -> holiday.png
vcs holiday/ --recursive --all        # every image, subfolders included
vcs holiday/ --fit cover -c 5 -r 4    # crop to fill the cells instead of letterboxing
vcs holiday/*.jpg -o summer.jpg -f jpg

Each video and each folder produces its own sheet; loose image files are combined into a single one. Wildcards are expanded by vcs itself, so vcs holiday/*.jpg behaves the same in cmd and PowerShell as it does in a POSIX shell.

Video Images
--columns, --rows, --width, --height, --aspect ✅ ✅
--format, --output, --title, --signature ✅ ✅
--timestamp (caption: time index / file name) ✅ ✅
--polaroid, --shadow ✅ ✅
--interval, --from, --to, --highlight ✅ —
--recursive, --fit, --all — ✅

Using an option against the wrong kind of input is an error rather than a silent no-op, unless the run mixes both — vcs clip.mp4 holiday/ -i 2m is fine, and the interval applies to the video.

Run vcs --help for the full list. Use --ffmpeg-folder <dir> to point at a local copy of the binaries instead of relying on PATH; image sheets never invoke ffmpeg at all.

Feature mapping vs. vcs.rb

vcs.rb feature Status Notes
Grid (rows × columns) ✅ Columns, Rows
Interval-based capture ✅ Interval
From / To range ✅ From, To
Thumbnail width ✅ ThumbnailWidth
Formats png/jpg ✅ + webp
Title / header / signature ✅ metadata header auto-built from ffprobe
Timestamp overlay ✅ Timestamp
Drop shadow ✅ SoftShadow, ShadowSize
Polaroid frame ✅ Polaroid
Highlights ✅ Highlights (rendered in a band on top)
Blank-frame evasion ✅ BlankEvasion, BlankThreshold, alternatives
Single-frame capture ✅ CaptureFrameAsync
Video metadata (streams) ✅ GetInfoAsync → VideoInfo
Capturer: ffmpeg ✅ FfmpegCapturer via FFMpegCore (implement IFrameCapturer for libav/mplayer)
YAML profiles ⬜ configure via ContactSheetOptions in code instead

Architecture

ContactSheet                  SkiaSharp grid composition + styling   (shared)
 └─ ContactSheetOptions       all grid/style/filter settings

VideoProcessing
 Video                        orchestrator (probe → capture → compose)
 ├─ IVideoInfoProvider        metadata abstraction
 │   └─ FfprobeVideoInfoProvider   FFProbe.AnalyseAsync → VideoInfo
 ├─ IFrameCapturer            frame extraction abstraction
 │   └─ FfmpegCapturer        FFMpegCore pipe → PNG → SKBitmap
 └─ TimeIndex                 flexible time parsing ("3m30", "1:22", "90")

ImageProcessing
 ImageCollection              orchestrator (probe → load/scale → compose)
 ├─ IImageInfoProvider        metadata abstraction
 │   └─ SkiaImageInfoProvider SKCodec header → ImageInfo
 ├─ IImageLoader              decode/scale abstraction
 │   └─ SkiaImageLoader       decode → EXIF straighten → fit the cell (contain/cover/stretch)
 └─ ImageSelection            how a large collection is reduced to the grid

Swap in another capturer (libav, mplayer) by implementing IFrameCapturer and passing it to the Video constructor; the same goes for IImageLoader and ImageCollection.

Changelog

See CHANGELOG.md.

License

Mirror of a GPL-3.0 project; treat this port accordingly.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.0.0 37 9/24/2026