Why “LGPL-clean” is a goal worth setting
The standard Windows video-editing toolchain — FFmpeg with all the codecs enabled, the popular Windows playback engines, the typical Skia-on-WPF setup — has many possible license combinations. Some are permissive (MIT, BSD, LGPL with dynamic linking); some are not (GPL with its derivative-work and source-availability implications). For a personal-use tool that the author wants to be able to redistribute as a single zip without surprises, picking the LGPL/MIT/BSD path from the start matters.
The simplest way to fail at this is to take the “FFmpeg with everything turned on” option — that build contains libx264 (GPL), and bundling it makes the whole package effectively GPL. The simplest way to succeed is to be deliberate about every component that produces output. This article walks through what that meant in practice for DatasetVideoChunker.
Playback engine — LibVLCSharp instead of an FFmpeg-binding library
The initial pick for video playback was FFME (FFMediaElement), which embeds an FFmpeg-binding library and renders directly into a WPF control. FFME is excellent, well-maintained, and ships under a permissive license. The problem is a versioning mismatch: FFME’s 4.x line is built against FFmpeg 5.x / 6.x, while the bundled FFmpeg in this project is from the 7.x line (specifically BtbN’s n7.x build, which is the LGPL-clean Windows distribution).
FFME loads its FFmpeg DLLs by name — avcodec-58 or -59 for the 5.x/6.x lines. The 7.x build ships avcodec-62. The startup error is a quiet FileNotFoundException: Unable to load FFmpeg binaries, which would have left the user with a tool that fails to open any video file with no clear remediation path.
The clean alternative is LibVLCSharp (LGPL-2.1+) from VideoLAN. LibVLCSharp wraps the libvlc native library, which ships its own internal FFmpeg distribution that the wrapper version-locks against. There’s no version dance — the wrapper and the runtime are released together, by the same organisation, with a guarantee that they’ll work. The legal profile is also clean: libvlc is LGPL-2.1+ and is linked dynamically in our build (the DLLs sit next to the exe, not statically linked in), so the LGPL’s dynamic-linking exception applies.
The migration from FFME to LibVLCSharp is straightforward but not trivial — the API surface differs in a handful of ways:
- FFME exposes position as
TimeSpan; LibVLC exposes it aslong ms. - FFME’s
NaturalDurationis available after open; LibVLC’sLengthproperty returns0until the firstPlayingevent fires. - Speed is
SpeedRatio = Xon FFME,SetRate(X)on LibVLC. PositionChangedbecomesTimeChanged.- Open is
Media = new Media(libvlc, uri); Play();instead of a singleOpen(uri). - LibVLC events fire on a worker thread, so any UI work needs an explicit
Dispatcher.Invoke.
The migration produced a better playback experience overall — LibVLC handles a wider range of containers (especially older codecs in .mov and .mkv wrappers) without the version-mismatch failures. The license profile is clean, the user gets a real video player, the contributor gets one less moving part.
Stripping the GPL plugin from libvlc at build time
The VideoLAN.LibVLC.Windows NuGet package — the package that auto-deploys the native libvlc DLLs alongside the exe — ships 322 plugin DLLs in the libvlc/win-x64/plugins/ tree. Almost all are LGPL or compatible. One of them, libx264_10b_plugin.dll, wraps the GPL libx26410b encoder (an x264 variant for 10-bit colour depth).
The plugin is never invoked by DatasetVideoChunker — all encoding goes through the external ffmpeg.exe we ship separately, not through libvlc’s built-in encoders. But its mere presence in the distribution would contaminate the entire package under strict LGPL/GPL combination rules.
The fix lives in the project file as a build-time MSBuild target:
<Target Name="StripGplPlugins" AfterTargets="Build;Publish">
<Delete Files="$(OutDir)libvlc\win-x64\plugins\codec\libx264_10b_plugin.dll" />
</Target>
The plugin is removed from bin/ after every build and from publish/ after every publish. The distribution never contains it. A reader who unzips the release won’t find any GPL code anywhere. The project compliance is verifiable.
libopenh264 instead of libx264 — and the CRF-to-bitrate mapping
The LGPL-only FFmpeg build doesn’t include libx264. The first encoding pipeline of the app used -c:v libx264 and produced a silent “Unknown encoder” error from FFmpeg — the LGPL build only enables encoders compatible with its licensing.
The right replacement is libopenh264 (BSD-2-Clause with a Cisco patent grant). It’s bundled in every LGPL FFmpeg distribution, it produces standards-compliant H.264, and it plays everywhere — smartphones, players, browsers, sharing platforms. The one gap is that libopenh264 doesn’t expose a CRF (Constant Rate Factor) mode the way libx264 does. CRF is the convenient knob most users reach for first: “give me a target visual quality, you figure out the bitrate.” libopenh264 wants an explicit bitrate.
The mapping the app uses is a simple proportional formula:
bitrate_kbps = width × height × fps × factor / 1000
Where factor is derived from the user-facing CRF slider — a CRF of 18 (high quality) maps to a higher factor than a CRF of 28 (smaller files). The mapping is empirically tuned to produce visually equivalent output to the libx264 CRF the same number would target. It’s not a perfect translation — CRF responds to scene complexity, bitrate doesn’t — but for the typical dataset content (consistent footage, uniform encoding within a project) it produces predictable results across resolutions and framerates.
An additional rounding step matters: libopenh264 with yuv420p chroma subsampling (which is what every consumer player expects) requires even output width and height. Crop rectangles that come out odd after the user’s drag get rounded with a RoundEven() helper before being passed to FFmpeg’s -vf crop=W:H:X:Y. The visible delta is at most one pixel; the crash-on-encode it prevents is real.
FPS stretch — the -ss/-t input-side trick
The export window supports a “Stretch” mode for producing slow-motion or time-lapse outputs from the source clip — the user picks a target frame rate different from the source, and FFmpeg’s setpts filter retimes the video. The implementation took a moment to get right.
The naive form puts the trim flags after the input:
ffmpeg -i input.mp4 -ss START -t DURATION -filter:v "setpts=R*PTS" output.mp4
What this does: FFmpeg reads the entire input, applies the setpts retiming filter, then trims the retimed output to DURATION seconds. So if you ask for a 5-second slow-motion of a 1-second source at 2× stretch, you get a 2-second output truncated at the -t 5 mark, not the 5-second stretched-but-trimmed-source you expected. The trim is being applied to the wrong timeline.
The fix is to move -ss and -t before -i:
ffmpeg -ss START -t DURATION -i input.mp4 -filter:v "setpts=R*PTS" output.mp4
Now FFmpeg applies the input-side seek and duration trim before the filter chain, so the setpts filter sees the trimmed source as its input and produces a properly stretched output of the right duration. The trade-off is that input-side -ss has a sub-second granularity tied to the source’s keyframe positions (the seek can land slightly off the requested timestamp). For slow-motion, this is acceptable — a few frames of slop at the start of a stretched clip is invisible. For frame-accurate work, the -c:v copy path uses a different strategy entirely (no filter, frame-accurate trim with re-mux).
HighDPI — IgnorePixelScaling = true on SkiaSharp
The timeline control is a custom SKElement from SkiaSharp (MIT for the wrapper, BSD for the Skia native) that paints a multi-row layout: ruler with frame ticks, thumbnail strip, waveform, chunks band, selection band, playhead. The first version of the control rendered fine on a standard 96-DPI monitor and got cropped to roughly two-thirds of its expected width on a Windows 11 default-configuration laptop (which uses 150% DPI scaling).
The root cause is SkiaSharp’s default behaviour: paint in physical pixels. WPF’s ActualWidth and mouse coordinates are in DIPs (device-independent pixels). On a 150% DPI display, a control reporting ActualWidth = 1000 DIPs is actually 1500 physical pixels wide. Skia paints into the 1500-pixel surface, the mouse hit-test uses 1000 DIPs, and the user sees only the first 1000 pixels of the 1500-pixel surface.
The fix is one property: IgnorePixelScaling = true on the SKElement. SkiaSharp now paints in DIPs (1000 × paint-height), the surface matches the WPF layout, the mouse coordinates line up, and the control renders correctly on every DPI configuration. The tradeoff is a slightly softer paint at very high DPI (paint resolution = DIP resolution, not physical pixels), which on a timeline UI is invisible.
Normalised 0..1 crop coordinates
Each chunk in the project carries a CropRect with the crop region the user defined in the Chunk Editor modal. The natural way to store these is in pixels relative to the source video’s resolution — “crop from (120, 80) of size (1280, 720)” for a 1080p source.
The better way is in normalised coordinates: (x, y, w, h) as floats in the [0, 1] range, relative to the source dimensions. The storage cost is the same (four floats vs four ints). The benefit is that the same project re-opens correctly if the user later replaces the source video with a higher-resolution version of the same content — a project recorded against a 1080p edit and re-opened against a 4K master keeps every chunk’s crop in the right relative position. The conversion to pixel coordinates happens at export time, with even-rounding for the yuv420p constraint mentioned earlier.
Project files (.dvcproj) are JSON, so the normalised floats live in the file as readable values that survive cross-resolution edits. The cost is at most a few microseconds per chunk at load and export. The flexibility is significant.
Single composite playback log with rotation
The app writes a single logs/player.log append-only file capturing every LibVLC event, every FFmpeg invocation, every state transition of the timeline. When the file passes 1 MB, it’s rotated to player.log.1 (with the previous .1 getting deleted) and a fresh file is started.
The reason it exists in this shape is the long-tail of LibVLC error scenarios — a video that decodes fine on one machine and silently fails on another (different codec mix in the source, different driver version), an export that produces a 0-byte file with no clear error, a playback that stalls on a specific seek. The single composite log is what makes those reproducible: the user attaches player.log and the failure path is visible end-to-end. The 1 MB rotation keeps the file from growing indefinitely; one session’s worth of logs is roughly 20-100 KB, so the rotation rarely actually triggers.
Why IncludeNativeLibrariesForSelfExtract = false
One detail in the csproj that surprised me the first time I encountered it: with IncludeNativeLibrariesForSelfExtract = true (the default for single-file publish), .NET packs the libvlc native plugins inside the single exe and extracts them to a per-process temp directory at startup. libvlc’s Core.Initialize(path) then can’t find the plugins because it’s looking next to the exe, not in the temp directory — and emits a DllNotFoundException.
The fix is to set the flag to false so libvlc’s native files stay in plain libvlc/win-x64/ next to the exe. The exe is no longer a single file, the distribution is a folder rather than a one-file binary — a small ergonomic cost for the user who downloads a zip, expecting it to be self-extracting. In return, libvlc finds its plugins on every machine, every time.
LGPL compliance — the LICENSES/ folder and the third-party notices
The final compliance step is making the LGPL obligations explicit and shippable. The release zip includes a LICENSES/ folder at the root with the full text of each license the project depends on:
LGPL-2.1.txt— for libvlc, LibVLCSharp, FFmpeg LGPL.MIT.txt— for SkiaSharp wrapper, NAudio, .NET 8.BSD-3-Clause.txt— for Skia native, PyAV (where applicable in other projects).Microsoft.txt— for Microsoft.Web.WebView2 NuGet terms.
A THIRD_PARTY_NOTICES.md file lists every component, its upstream URL, its license, and the exact version shipped — the inventory a downstream user or a license auditor would need to verify compliance. The Welcome tab inside the app links directly to that folder (the “Licenses & credits” card opens it in Explorer with one click), so a user who wants the full audit trail finds it without leaving the app.
Credits and license
- VideoLAN — libvlc 3.0.21, LGPL-2.1+. The playback engine.
- LibVLCSharp — VideoLAN, LGPL-2.1+. The .NET wrapper.
- FFmpeg — LGPL-2.1+ (BtbN’s LGPL-shared build for Windows, FFmpeg n7.x line). The encoder.
- libopenh264 — BSD-2-Clause + Cisco patent grant. The H.264 encoder inside FFmpeg’s LGPL build.
- SkiaSharp — Mono Project, MIT (wrapper) + BSD (Skia native, by Google). The timeline rendering.
- NAudio — Mark Heath and contributors, MIT. Audio waveform extraction.
- .NET 8 + WPF — Microsoft, MIT.
- Microsoft.Web.WebView2 — Microsoft, distribution permitted.
The C# / WPF / Python code of the chunker itself is original ExpSoft work. The full third-party inventory lives in LICENSES/THIRD_PARTY_NOTICES.md. The redistribution profile is permissive: LGPL + MIT + BSD + Microsoft-EULA, no GPL components bundled.
Take-aways
- Picking LGPL/MIT/BSD-only from the start is much easier than retrofitting compliance later. The license profile of every component cascades through the whole project.
- Bundled NuGet packages can ship plugins you don’t want. A build-time MSBuild target that strips known-incompatible files keeps the distribution clean.
- libopenh264 with a CRF-to-bitrate mapping is a perfectly serviceable replacement for libx264 in the personal-use video pipeline. The output plays everywhere.
- For any FFmpeg filter that retimes (
setpts, stretching), put-ssand-ton the input side. Output-side trim and filter retiming produce a result that surprises everyone the first time. SKElement.IgnorePixelScaling = true. Always, on Windows. The default is the cause of the “my UI is two-thirds the size on this laptop” bug.