lux_video_thumbnail 0.0.1
lux_video_thumbnail: ^0.0.1 copied to clipboard
Generate video thumbnails at a timestamp on Android and web, with a video picker, timeline slider, and image preview widget.
lux_video_thumbnail #
Generate an encoded video thumbnail at a requested position, or let a user select one with an embedded widget or dialog. Initial platforms: Android and web.
This project is at an early stage of development. Contributions, feedback, and issue reports are welcome.
This source checkout is prepared for the initial release; the four federated packages have not been published yet. See PUBLICATION_CHECKLIST.md for validation and remaining launch prerequisites.
Installation and support #
After the federated packages are published, add lux_video_thumbnail: ^0.0.1
to your app's dependencies and run flutter pub get. For this source checkout,
follow the local override instructions below until hosted packages are available.
Requires Dart 3.10.9+, Flutter 3.38.10+ and Android API 24+ or a supported browser.
| Platform | Status |
|---|---|
| Android | Implemented; manually tested on a physical Android device and APK/JVM validated. Native media/picker coverage is not exhaustive. |
| Chrome / Edge | Real media tests in JavaScript and Wasm |
| Firefox | Real media tests in JavaScript fallback |
| Safari / mobile browsers | Not validated |
| iOS / desktop | Thumbnail generation not implemented |
Generate a thumbnail #
import 'package:lux_video_thumbnail/lux_video_thumbnail.dart';
final thumbnail = await LuxVideoThumbnail.generate(
source: VideoSource.bytes(videoBytes, mimeType: 'video/mp4'),
timestamp: const Duration(milliseconds: 1500),
width: 320,
format: ThumbnailFormat.jpeg,
quality: 85,
);
Image.memory(thumbnail.bytes);
Other sources:
VideoSource.path('/path/to/video.mp4'); // Android only; the file is borrowed.
VideoSource.path('content://...'); // Android content URI with access granted.
VideoSource.url('https://example.com/video.mp4');
The API returns VideoThumbnailData: encoded bytes, requested timestamp,
width, height, and actual encoded format. It releases decoder sessions and
intermediate thumbnails automatically. You can upload or save the returned bytes
using the storage/download APIs appropriate to your application and platform.
The timestamp is the requested position, not the exact presentation timestamp of the decoded frame. Video frame rate and platform decoder behavior determine which nearby frame is available. Negative positions and positions beyond a known video duration are rejected. The final endpoint selects the nearest available last frame. Optional dimensions must be from 1 to 4096 pixels; quality is 1–100.
Let the user select a frame #
VideoThumbnailPicker(
onChanged: (thumbnail) {
// Called when the current preview is ready, including the initial frame.
// Use thumbnail.bytes and thumbnail.timestamp in your application.
},
onError: (error) {
// Handle access, unsupported codec, timeout or other decoder errors.
},
selectVideoLabel: 'Choose video',
loadingLabel: 'Generating preview…',
emptyLabel: 'Select a video to choose a frame.',
errorLabel: 'Could not generate a thumbnail.',
previewLabel: 'Preview',
backTenFramesTooltip: 'Back 10 frames',
backOneFrameTooltip: 'Back 1 frame',
forwardOneFrameTooltip: 'Forward 1 frame',
forwardTenFramesTooltip: 'Forward 10 frames',
width: 320,
previewHeight: 180,
frameRate: 30, // Use the source FPS when it is known.
)
The widget includes a system video picker, a timestamp slider and an image preview.
Below the slider, icon-only controls jump back 10 frames, back 1 frame, forward 1
frame and forward 10 frames. Their default 30-fps step is a requested timestamp,
not a claim that the decoder exposes an exact video-frame presentation timestamp;
set frameRate to the source FPS when available. The loading spinner is centered
over the preview while a new image is being generated.
It debounces slider movements and serializes extraction. An older result cannot
replace a newer selection. Cancelling file selection preserves the previous video.
Sessions and widget-owned browser URLs are released on replacement or disposal.
The displayed preview time identifies the image currently shown while a new
position is still loading.
Use initialSource to start with a video, videoPicker to provide your own
asynchronous selector (return null on cancellation), and previewBuilder to
customize the image display. Sources supplied by the application remain owned by
that application; caller-created Blob URLs must be revoked by the caller when
no longer needed. The built-in web selector uses a file-backed Blob URL instead
of reading the whole video into Dart memory.
Choose a frame in a dialog #
Keep the embedded widget for inline layouts, or call videoThumbnailDialog from
a button using the same main package import:
final thumbnail = await videoThumbnailDialog(
context: context,
// Optional: omit this to choose a video inside the dialog.
initialSource: VideoSource.url('https://example.com/video.mp4'),
width: 320,
title: 'Choose a thumbnail',
selectVideoLabel: 'Choose video',
confirmLabel: 'Use thumbnail',
cancelLabel: 'Cancel',
);
if (thumbnail != null) {
// User confirmed: thumbnail.bytes and thumbnail.timestamp are ready to use.
}
The return type is Future<VideoThumbnailData?>. Only Confirm returns an image;
Cancel, Back and barrier dismissal return null. Confirm is disabled before a
preview is available, during selection/decoding/slider debounce, and after errors.
It cannot confirm the old image while a newer timestamp is pending. Cancelling
the file picker preserves the previous valid selection inside the open dialog.
The dialog reuses VideoThumbnailPicker, including resource ownership and
cleanup on route disposal. initialSource and a custom videoPicker remain
caller-owned. Options include encoding dimensions/format/quality, localized title,
buttons, picker labels and frame-control tooltips, previewBuilder, previewHeight, maxWidth, shape,
backgroundColor, frameRate, barrierDismissible and useRootNavigator. The content scrolls
on small screens. barrierDismissible: false prevents outside-tap dismissal;
Back still cancels. For nested navigators, set useRootNavigator: false as needed.
All three entry points remain available: LuxVideoThumbnail.generate for code,
VideoThumbnailPicker for embedding, and videoThumbnailDialog for modal selection.
The example app demonstrates both UI variants. Embedded-widget consumers that
provide their own confirmation UI can use onReadyChanged (initially false) to
track whether the latest requested preview is ready, alongside onChanged.
Platform behavior #
- Depend on the main package and import its public library. No manual web plugin registration or dependency on implementation packages is needed.
- Web decoding uses the browser's video and canvas APIs. Supported containers and codecs depend on the browser. Remote URLs must permit CORS frame access; the package cannot bypass the video's server policy.
- A generic browser media error cannot conclusively distinguish CORS, a missing
resource and an unsupported format. Only a canvas security failure reports
CorsBlockedException. The widget shows a generic error; application callbacks receive diagnostic errors and should avoid logging signed URLs or tokens. - WebM streams without a duration/seek index depend on browser seek support.
A large difference between the requested position and the presented frame can
also be valid for a low or variable frame rate. For finite media, the web
implementation can recover by decoding forward from the start. This is slower
than seeking and uses bounded 15-second waits; playback refusal or failure to
reach the position reports
GenerationFailedException(SEEK_UNAVAILABLE). Browser scheduling and decoder behavior still determine the nearby frame; recovery does not promise an exact presentation timestamp. A missing duration disables the widget's slider. Indexed MP4/WebM is preferable for selecting arbitrary timestamps. - Native filesystem paths do not work in a browser. Use bytes, a picked file or a suitable URL. Remote URLs must also support the browser's video loading/seeking.
- Android uses MediaMetadataRetriever. Original input files are never deleted.
Only temporaries created and tracked by the plugin can be deleted by it;
supplying a video path does not transfer ownership of that file.
Native seeks use the nearest decoded frame. Advanced
preferKeyFramerequests use a sync frame instead; the browser uses its ordinary seek for either hint. - iOS and desktop are not implemented by the thumbnail API and report a typed
unsupported-platform error. The legacy
getPlatformVersion()remains available.
The example application consumes only the public API. Unit/widget tests simulate platform decoding. Browser tests use a locally generated red/blue video and check actual decoded pixel colors at different positions. Compilation and automated browser results do not imply testing every native file dialog, codec or browser.
For development in a full source checkout (including packages/), prepare the
monorepo overrides with
tool/prepare_local_dependencies.ps1 before flutter pub get. Distribution
manifests use hosted versions. See development and publication
for the supported SDK floor, package order and remaining release blockers.
The pub.dev archive contains the main package and its Android/web example;
the federated implementations are separate dependencies. In an extracted archive,
use the hosted dependencies directly rather than generating monorepo overrides.
Advanced session APIs follow the same timestamp bounds: a position beyond a known
duration is rejected, the exact end selects the last available frame, and an
unknown duration never forces a nonnegative position to zero. Android accepts
microsecond requests while retaining the legacy millisecond channel field. The
legacy result name actualTimestamp means the normalized requested position;
it is not a measured frame PTS. Session operations are serialized; closing drains
accepted work and rejects new work. The picker skips obsolete requests still in
its queue and ignores stale selections/results after source changes.
Resource limits and saving #
Generation validates options in release builds. Quality is 1–100; decoded source and output dimensions are 1–4096 per axis (up to 16,777,216 pixels). Derived aspect-ratio dimensions must also fit. An oversized source is rejected even when requesting a small thumbnail, because Android may decode the native-size frame.
Each backend supports four open sessions, batches of 1–16 images, and up to 16
active/waiting operations per session. Android also limits its engine work queue
to 16 calls and 128 MiB of accepted byte payloads, each byte source to 64 MiB, and
retained thumbnail temporaries to 64. Encoded images and read chunks are limited
to 8 MiB. ThumbnailLimits exposes shared ceilings. Overflow rejects the request;
it never silently lowers quality or drops accepted session work. Dispose advanced
results and close sessions when finished. The public one-shot API handles both.
These ceilings do not guarantee a total process-memory budget: codecs, browsers, bridge copies and images retained by the app also consume memory. Native paths, picked browser files and URLs avoid copying an entire video into Dart memory; the 64 MiB input limit applies to byte sources, not streamed paths/URLs/Blobs.
The advanced VideoThumbnailResult.saveToFile() now returns Future<String>
(the saved path), replacing the prepublication Future<File> contract. It works
only on native platforms. The default is Directory.systemTemp, with a fresh
lux_thumbnail_* subdirectory for every save; an explicit directory is treated
as the parent. fileName must be a plain name. Existing files are never replaced.
The caller owns the saved file and its new parent directory, which survive result
disposal; delete them when no longer needed. Default temporary storage is not
permanent storage. Pass an application-managed persistent parent if required.
On web, saveToFile() throws UnsupportedError; use readBytes() or the public
VideoThumbnailData.bytes with your application's download/upload mechanism.
Shared APIs no longer import dart:io; native saving uses a conditional helper.
Reads reject negative offsets/lengths and offsets beyond EOF, return empty at
EOF and truncate a valid requested length at EOF. Disposed results reject access.
The Android plugin and example declare no broad storage permissions. The picker
uses the system file selection flow; direct paths/content URIs must already be
readable by the host app. INTERNET remains for remote videos. Android channel
resource overflow reports RESOURCE_LIMIT; invalid arguments report
ARGUMENT_ERROR. Dart validates typed inputs with ArgumentError/RangeError
and rejects full queues/sessions with StateError. Existing typed temporal/session
errors remain available.
License #
MIT, Copyright (c) 2026 Jônatas Feitosa. See LICENSE. External dependencies retain their respective licenses; the MIT grant covers this project's own code and does not replace third-party notices.