Build a Video Editor with Flutter and Rust
Build a desktop video editor using Flutter UI and a Rust/FFmpeg backend.
Introduction
30 Second Summary
Every time you trim a clip or add a filter in a video editor, there is a real-time pipeline decoding millions of pixels per second behind the scenes. Most editors hide that complexity behind proprietary code.
In this project, you will build a desktop video editor that combines Flutter's widget system for a polished UI with Rust's performance for a real-time FFmpeg video pipeline.
What You'll Build
You will open any video file, play it back in real-time inside a sleek dark-themed interface, set trim points on a visual timeline, and export the trimmed clip as a new MP4 file you can share with anyone.
By the end of this project, you'll have:
- A native Linux desktop app where you import any video, immediately see its first frame rendered in the preview panel, and play it back with smooth frame-by-frame streaming from Rust to Dart.
- A flat-design timeline with a draggable playhead and trim markers that let you visually select the exact segment you want to keep.
- A complete export pipeline that remuxes your trimmed segment to a new MP4 file with a progress bar showing real-time feedback.
- Secret Mission: Add a playback speed selector (0.5x, 1x, 2x) that adjusts the frame delivery rate from Rust in real-time.
Are there any prerequisites?
This project assumes you have the Flutter SDK installed with Linux desktop support enabled, the Rust toolchain installed, and FFmpeg development libraries installed on your system.
You should also have experience working with both Flutter and Rust individually.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters. You are building a video editor that combines Flutter's widget system for a polished desktop UI with Rust's zero-cost abstractions for a high-performance FFmpeg pipeline.
Scaffold the Flutter+Rust Project
To build a video editor, you need a working Flutter desktop project that can call into Rust code. flutter_rust_bridge handles the interop layer, generating all the glue code so a Dart function call transparently invokes a Rust function.
In this step, you will install the code generation tool, scaffold a full Flutter+Rust project, wire in the FFmpeg library on the Rust side, and replace the default UI with a clean dark-themed shell ready for video editing features.
In this step, get ready to:
- Install the flutter_rust_bridge codegen tool and scaffold the project.
- Add the ffmpeg-next crate and verify it initializes from Dart.
- Replace the default UI with a minimal dark-themed app shell.
Install flutter_rust_bridge_codegen and scaffold the project
The flutter_rust_bridge_codegen CLI generates all the binding code between Dart and Rust. It also includes a create command that scaffolds an entire Flutter+Rust project in one shot.
- Open a terminal.
- Move to your Desktop by running this command:
cd ~/Desktop
- Install the code generation tool by running this command:
cargo install flutter_rust_bridge_codegen
What does this command do?
This downloads and compiles the flutter_rust_bridge_codegen binary from crates.io. It compiles many dependencies, so expect it to take a few minutes on the first run.
Install taking a long time or failing?
The tool compiles from source and has many dependencies. A fresh install can take 3-5 minutes depending on your machine. If it fails with compilation errors, make sure your Rust toolchain is up to date by running rustup update and trying again.
help me troubleshoot a cargo install flutter_rust_bridge_codegen failure on Linux
- Scaffold the full Flutter+Rust project by running this command:
flutter_rust_bridge_codegen create video_editor
What does this command do?
This creates a video_editor directory containing a complete Flutter project with a rust/ subdirectory wired in. It generates binding glue code, a rust_builder/ package that hooks into the Flutter build, and a default Rust API file at rust/src/api/simple.rs with a sample greeting function.
- Move into the new project directory by running:
cd video_editor
Before you run the app, what do you expect to see in the Flutter window given that the scaffold wires a default Rust greeting function to the UI?
- Launch the app on your Linux desktop by running:
flutter run -d linux
A Flutter window appears showing a greeting message from Rust. This confirms the full Flutter-to-Rust pipeline is working end to end.
App fails to build or launch?
Make sure Flutter desktop support is enabled by running flutter config --enable-linux-desktop. Also confirm your FFmpeg development libraries are installed (on Arch, the ffmpeg package includes headers).
help me debug a flutter run -d linux build failure for a flutter_rust_bridge project
- Stop the running app by pressing q in the terminal.
Add ffmpeg-next to the Rust crate
The scaffold proved the bridge works. Now you need to bring in the FFmpeg bindings so Rust can decode video.
- Open rust/Cargo.toml in your editor.
- Find the [dependencies] section. You should see flutter_rust_bridge = "=2.12.0" already listed.
- Add the ffmpeg-next crate on the line below it so the dependencies section looks like this:
[dependencies]
flutter_rust_bridge = "=2.12.0"
ffmpeg-next = "8.1.0"
What does this addition do?
The ffmpeg-next crate provides safe Rust bindings to FFmpeg's C libraries. Version 8.1.0 links against the development headers you already have installed on your system.
- Now open rust/src/api/simple.rs. You will see the scaffold's default greeting function.
- Add the following test function below the existing code:
#[flutter_rust_bridge::frb]
pub fn test_ffmpeg() -> String {
// Initialize FFmpeg and confirm it loads successfully
ffmpeg_next::init().ok();
"FFmpeg initialized".to_string()
}
What does this code do?
- The #[flutter_rust_bridge::frb] annotation tells the code generator to expose this function to Dart.
- ffmpeg_next::init().ok() initializes all FFmpeg codec and format registries. If it succeeds, the function returns the confirmation string.
- Save rust/src/api/simple.rs and rust/Cargo.toml.
- Regenerate the Dart bindings so the new function is available by running:
flutter_rust_bridge_codegen generate
Do you expect the app to compile successfully, or do you think it might fail because of missing FFmpeg libraries?
- Run the app again to verify FFmpeg links correctly:
flutter run -d linux
The app launches and builds without linker errors. This confirms ffmpeg-next linked against your system's FFmpeg libraries successfully.
Linker errors mentioning avcodec or avformat?
This means the FFmpeg development libraries are not found. On Arch Linux, install the ffmpeg package which includes headers. On Debian/Ubuntu, you would need the -dev packages (libavcodec-dev, libavformat-dev, etc.).
help me fix FFmpeg linker errors when building a Rust crate with ffmpeg-next on Linux
- Stop the running app by pressing q in the terminal.
Clean up the scaffold and set up the app shell
The scaffold UI is a demo page you do not need. You will replace it with a minimal dark-themed shell that matches the video editor aesthetic.
- Open lib/main.dart and replace the entire file contents with this code:
import 'package:flutter/material.dart';
import 'src/rust/frb_generated.dart';
void main() async {
await RustLib.init();
runApp(const VideoEditorApp());
}
class VideoEditorApp extends StatelessWidget {
const VideoEditorApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData.dark().copyWith(
scaffoldBackgroundColor: const Color(0xFF1E1E1E),
colorScheme: const ColorScheme.dark(
primary: Color(0xFF6C63FF),
surface: Color(0xFF2D2D2D),
),
),
home: const Scaffold(
body: Center(child: Text('Video Editor')),
),
);
}
}
What does this code do?
- RustLib.init() initializes the Rust bridge before the app starts. This is required for any Rust calls to work.
- The dark theme uses Color(0xFF1E1E1E) for the background and Color(0xFF2D2D2D) for surfaces, giving the editor a professional dark aesthetic.
- The Color(0xFF6C63FF) accent will be used later for the timeline and playback controls.
- Save lib/main.dart and run the app:
flutter run -d linux
You should see a dark window with the text "Video Editor" centered on screen. No scaffold demo, no greeting string. Just a clean dark shell ready for video features.
Still seeing the old scaffold UI?
Make sure you saved lib/main.dart after replacing its contents. If using hot reload, a full restart (press R in the terminal) may be needed since the main() function changed.
help me troubleshoot why my Flutter app still shows old UI after editing main.dart
✔️ Awesome, I've got everything!
Great. Make sure you have saved all files before moving on.
ⓧ I'd like to double check the full code
Here is what lib/main.dart should look like:
import 'package:flutter/material.dart';
import 'src/rust/frb_generated.dart';
void main() async {
await RustLib.init();
runApp(const VideoEditorApp());
}
class VideoEditorApp extends StatelessWidget {
const VideoEditorApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData.dark().copyWith(
scaffoldBackgroundColor: const Color(0xFF1E1E1E),
colorScheme: const ColorScheme.dark(
primary: Color(0xFF6C63FF),
surface: Color(0xFF2D2D2D),
),
),
home: const Scaffold(
body: Center(child: Text('Video Editor')),
),
);
}
}
The [dependencies] section of rust/Cargo.toml should be:
[dependencies]
flutter_rust_bridge = "=2.12.0"
ffmpeg-next = "8.1.0"
And rust/src/api/simple.rs should contain the scaffold's default greeting function plus your test_ffmpeg function:
#[flutter_rust_bridge::frb]
pub fn test_ffmpeg() -> String {
// Initialize FFmpeg and confirm it loads successfully
ffmpeg_next::init().ok();
"FFmpeg initialized".to_string()
}
Your project skeleton is in place. You have a Flutter desktop app talking to Rust, with FFmpeg ready to decode video. Next up, you will build the import pipeline that opens a video file and displays its first frame in the preview panel.
Import Video and Display First Frame
The first thing a user does in any editor is open a file. By decoding and displaying the very first frame, you prove the entire pipeline works end-to-end.
This step connects three systems: a native file picker dialog, your Rust FFmpeg decoder that converts video pixels to RGBA bytes, and Flutter's image renderer that paints those bytes on screen. Once this chain works for a single frame, adding playback is just a matter of repeating it in a loop.
In this step, get ready to:
- Build Rust functions that open a video file and decode the first frame to RGBA bytes.
- Wire a Flutter file picker to call your Rust functions and convert the result to a displayable image.
- Compose a preview panel and info bar that renders the frame and shows video metadata.
Build the Rust video-open function
Your Rust backend needs two capabilities: reading video metadata (resolution, duration, frame rate) and extracting the first frame as raw RGBA pixels. Both use the ffmpeg-next crate you added in Step 1.
- Replace the contents of rust/src/api/simple.rs with the following struct definitions:
use flutter_rust_bridge::frb;
#[frb]
pub struct VideoInfo {
pub width: u32,
pub height: u32,
pub duration_secs: f64,
pub frame_rate: f64,
}
#[frb]
pub struct FrameData {
pub rgba: Vec<u8>,
pub timestamp_secs: f64,
pub width: u32,
pub height: u32,
}
What do these structs do?
VideoInfo holds the metadata Flutter needs to lay out the preview (dimensions, duration for the timeline, frame rate for playback timing).
FrameData carries raw RGBA pixel bytes plus the frame dimensions so Flutter can reconstruct the image without guessing the layout. The #[frb] attribute tells flutter_rust_bridge to generate Dart bindings for each struct.
- Below the structs, add the open_video function that reads video metadata:
#[frb]
pub fn open_video(path: String) -> anyhow::Result<VideoInfo> {
ffmpeg_next::init().ok();
let ictx = ffmpeg_next::format::input(&path)?;
let stream = ictx
.streams()
.best(ffmpeg_next::media::Type::Video)
.ok_or_else(|| anyhow::anyhow!("No video stream found"))?;
let decoder = ffmpeg_next::codec::context::Context::from_parameters(stream.parameters())?
.decoder()
.video()?;
let duration_secs = ictx.duration() as f64 / f64::from(ffmpeg_next::ffi::AV_TIME_BASE);
let frame_rate = f64::from(stream.avg_frame_rate());
Ok(VideoInfo {
width: decoder.width(),
height: decoder.height(),
duration_secs,
frame_rate,
})
}
What does this function do?
It opens the file with FFmpeg's demuxer, finds the best video stream, then reads the decoder parameters to extract width, height, and frame rate.
Duration is calculated by dividing the raw timestamp by AV_TIME_BASE (microseconds) to get seconds. The ? operator propagates any FFmpeg error back to Dart as an exception.
- Save rust/src/api/simple.rs.
- Regenerate the Dart bindings to confirm your structs and function compile by running:
flutter_rust_bridge_codegen generate
You should see output listing the generated Dart files. This confirms your Rust code compiles and the bindings are ready.
Seeing a compilation error?
If the error mentions anyhow, flutter_rust_bridge includes it automatically. Check that your Cargo.toml has flutter_rust_bridge = "=2.12.0" and ffmpeg-next = "8.1.0" under [dependencies].
help me fix a flutter_rust_bridge codegen compilation error
Now add the frame-decoding function that does the heavy lifting. This function opens the video, finds the first decodable frame, converts it from the video's native pixel format to RGBA, and returns the raw bytes.
- Below open_video, add the first part of get_first_frame which sets up the decoder and pixel-format scaler:
#[frb]
pub fn get_first_frame(path: String) -> anyhow::Result<FrameData> {
ffmpeg_next::init().ok();
let mut ictx = ffmpeg_next::format::input(&path)?;
let stream = ictx
.streams()
.best(ffmpeg_next::media::Type::Video)
.ok_or_else(|| anyhow::anyhow!("No video stream found"))?;
let video_stream_index = stream.index();
let context_decoder =
ffmpeg_next::codec::context::Context::from_parameters(stream.parameters())?;
let mut decoder = context_decoder.decoder().video()?;
let mut scaler = ffmpeg_next::software::scaling::context::Context::get(
decoder.format(),
decoder.width(),
decoder.height(),
ffmpeg_next::format::Pixel::RGBA,
decoder.width(),
decoder.height(),
ffmpeg_next::software::scaling::flag::Flags::BILINEAR,
)?;
let width = decoder.width();
let height = decoder.height();
- Immediately below (still inside the same function), add the decode loop that finds and returns the first frame:
for (stream, packet) in ictx.packets() {
if stream.index() == video_stream_index {
decoder.send_packet(&packet)?;
let mut decoded = ffmpeg_next::util::frame::video::Video::empty();
if decoder.receive_frame(&mut decoded).is_ok() {
let mut rgba_frame = ffmpeg_next::util::frame::video::Video::empty();
scaler.run(&decoded, &mut rgba_frame)?;
return Ok(FrameData {
rgba: rgba_frame.data(0).to_vec(),
timestamp_secs: 0.0,
width,
height,
});
}
}
}
Err(anyhow::anyhow!("Could not decode first frame"))
}
What does get_first_frame do?
The scaler converts pixels from the video's native format (usually YUV420) to RGBA, which Flutter can render directly. It loops through packets until it successfully decodes one frame.
frame.data(0).to_vec() copies the RGBA bytes into a Rust Vec<u8> that flutter_rust_bridge serializes into a Dart Uint8List. Each pixel is 4 bytes (R, G, B, A), so the total size is width × height × 4.
- Save rust/src/api/simple.rs.
- Regenerate bindings again to confirm the complete file compiles:
flutter_rust_bridge_codegen generate
You should see the same generated-file output as before. Both openVideo and getFirstFrame are now available in Dart.
Linker errors about FFmpeg symbols?
This means your FFmpeg development libraries are not installed or not found. On Arch Linux, confirm the ffmpeg package is installed (headers are included in the main package). On Debian/Ubuntu you would need the -dev variants (libavcodec-dev, libavformat-dev, etc.).
help me fix FFmpeg linker errors in my Rust crate
✔️ Awesome, I've got everything!
Great. Double check you have saved rust/src/api/simple.rs and the codegen completed without errors.
ⓧ I'd like to double check the full code
Your rust/src/api/simple.rs should contain these sections in order:
- use flutter_rust_bridge::frb; import at the top.
- VideoInfo struct with width, height, duration_secs, frame_rate fields.
- FrameData struct with rgba, timestamp_secs, width, height fields.
- open_video function returning VideoInfo.
- get_first_frame function returning FrameData with RGBA bytes.
Build the Flutter import UI
With your Rust backend ready, you need a Dart layer that opens a file dialog, calls your Rust functions, and converts the raw RGBA bytes into a ui.Image that Flutter can paint.
- Add the file_picker package for native file dialogs by running:
flutter pub add file_picker
You should see output confirming the package was resolved and added to pubspec.yaml.
- Replace the entire contents of lib/main.dart with the app entry point and theme setup:
import 'dart:ui' as ui;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart';
import 'src/rust/api/simple.dart';
import 'src/rust/frb_generated.dart';
void main() async {
await RustLib.init();
runApp(const VideoEditorApp());
}
class VideoEditorApp extends StatelessWidget {
const VideoEditorApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData.dark().copyWith(
scaffoldBackgroundColor: const Color(0xFF1E1E1E),
colorScheme: const ColorScheme.dark(
primary: Color(0xFF6C63FF),
surface: Color(0xFF2D2D2D),
),
),
home: const EditorScreen(),
);
}
}
What changed from before?
The new imports bring in dart:ui for image decoding, dart:typed_data for byte buffers, file_picker for the open-file dialog, and your generated Rust bindings from src/rust/api/simple.dart.
The home now points to EditorScreen() instead of the placeholder Scaffold. You will add this widget next.
- Below the VideoEditorApp class, add the EditorScreen widget with its state and ValueNotifier declarations:
class EditorScreen extends StatefulWidget {
const EditorScreen({super.key});
@override
State<EditorScreen> createState() => _EditorScreenState();
}
class _EditorScreenState extends State<EditorScreen> {
final _filePath = ValueNotifier<String?>(null);
final _videoInfo = ValueNotifier<VideoInfo?>(null);
final _currentFrame = ValueNotifier<ui.Image?>(null);
@override
void dispose() {
_currentFrame.value?.dispose();
super.dispose();
}
Why ValueNotifier instead of setState?
Each ValueNotifier holds a single piece of state (the file path, video metadata, or current frame). Widgets subscribe to only the notifier they care about using ValueListenableBuilder, so only the affected widget rebuilds when that value changes.
This avoids rebuilding the entire screen on every frame update during playback, which would be expensive.
- Immediately below the dispose method (still inside _EditorScreenState), add the import and frame-decoding methods:
Future<void> _importVideo() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.video,
);
if (result == null || result.files.single.path == null) return;
final path = result.files.single.path!;
_filePath.value = path;
final info = await openVideo(path: path);
_videoInfo.value = info;
final frame = await getFirstFrame(path: path);
await _updateFrame(frame);
}
Future<void> _updateFrame(FrameData frame) async {
final oldImage = _currentFrame.value;
final pixels = Uint8List.fromList(frame.rgba);
ui.decodeImageFromPixels(
pixels,
frame.width.toInt(),
frame.height.toInt(),
ui.PixelFormat.rgba8888,
(ui.Image img) {
_currentFrame.value = img;
oldImage?.dispose();
},
);
}
What does the import flow do?
_importVideo opens a native file picker filtered to video files. If the user selects a file, it calls your Rust openVideo for metadata and getFirstFrame for the RGBA pixel data.
_updateFrame uses decodeImageFromPixels with PixelFormat.rgba8888 to reconstruct a ui.Image from the raw bytes. It disposes the previous image to prevent memory leaks.
Compose the preview panel
You have the state and logic in place. Now you need a build method that wires the ValueNotifier values to actual widgets on screen.
- Below _updateFrame, add the _formatTime helper and the beginning of the build method with the toolbar:
String _formatTime(double seconds) {
final mins = (seconds ~/ 60).toString().padLeft(2, '0');
final secs = (seconds.toInt() % 60).toString().padLeft(2, '0');
return '$mins:$secs';
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
// Toolbar
Container(
height: 48,
color: const Color(0xFF2D2D2D),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
TextButton.icon(
onPressed: _importVideo,
icon: const Icon(Icons.folder_open, size: 18),
label: const Text('Import'),
),
],
),
),
- Continue the Column children by adding the preview panel directly after the toolbar Container:
// Preview
Expanded(
child: Center(
child: ValueListenableBuilder<ui.Image?>(
valueListenable: _currentFrame,
builder: (_, frame, __) {
if (frame == null) {
return const Text(
'Import a video to begin',
style: TextStyle(color: Colors.white54),
);
}
return RawImage(
image: frame,
fit: BoxFit.contain,
);
},
),
),
),
How does the preview panel work?
ValueListenableBuilder subscribes to _currentFrame. When the frame is null (no video loaded), it shows placeholder text. Once a frame arrives, it renders via RawImage which paints a ui.Image directly.
Expanded ensures the preview fills all available vertical space between the toolbar and info bar.
- Finally, add the info bar and close all the brackets to complete the file:
// Info bar
ValueListenableBuilder<VideoInfo?>(
valueListenable: _videoInfo,
builder: (_, info, __) {
if (info == null) return const SizedBox.shrink();
return Container(
height: 32,
color: const Color(0xFF2D2D2D),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Text(
'${info.width.toInt()}x${info.height.toInt()}',
style: const TextStyle(fontSize: 12, color: Colors.white54),
),
const SizedBox(width: 16),
Text(
'${info.frameRate.toStringAsFixed(1)} fps',
style: const TextStyle(fontSize: 12, color: Colors.white54),
),
const Spacer(),
Text(
_formatTime(info.durationSecs),
style: const TextStyle(fontSize: 12, color: Colors.white54),
),
],
),
);
},
),
],
),
);
}
}
What does the info bar show?
It displays three pieces of metadata from your Rust VideoInfo struct: resolution (e.g., 1920x1080), frame rate (e.g., 29.9 fps), and total duration formatted as mm:ss. The entire bar hides (SizedBox.shrink()) until a video is loaded.
- Save lib/main.dart.
Before you run this, what do you think you will see when the app launches without importing anything?
- Run the app to test the full pipeline:
flutter run -d linux
You should see your dark-themed app with an Import button in the top toolbar and the text "Import a video to begin" centered in the preview area.
- Click the Import button.
- Select any video file from your filesystem.
The first frame of your video now renders in the preview panel. Below it, the info bar shows the video's resolution, frame rate, and total duration.
Import button does nothing or crashes?
If the file picker does not open, confirm file_picker was added to pubspec.yaml by checking the dependencies section. Run flutter pub get if you see import errors.
If the app crashes after selecting a file, check that flutter_rust_bridge_codegen generate completed successfully. The generated bindings in src/rust/api/simple.dart must include openVideo and getFirstFrame functions.
help me debug why my Flutter app crashes when importing a video
✔️ Awesome, I've got everything!
Great. Confirm you see the first frame of your video displayed in the preview and the resolution, fps, and duration visible in the info bar below.
ⓧ I'd like to double check the full code
Your lib/main.dart should contain these sections in order:
- Six imports: dart:ui, dart:typed_data, material.dart, file_picker.dart, simple.dart, frb_generated.dart.
- main() function calling RustLib.init() then runApp.
- VideoEditorApp with dark theme pointing home to EditorScreen().
- EditorScreen StatefulWidget and _EditorScreenState with three ValueNotifiers.
- _importVideo calling openVideo and getFirstFrame.
- _updateFrame using decodeImageFromPixels.
- _formatTime helper.
- build() method with toolbar Container, preview Expanded with RawImage, and info bar ValueListenableBuilder.
Your entire video pipeline is proven: file picker to Rust decoder to Flutter renderer. Next up, you will turn this single-frame snapshot into continuous playback by streaming decoded frames from Rust at the video's native frame rate.
Stream Frames and Enable Playback
Right now your video editor shows a single frozen frame. That proves the pipeline works end-to-end, but it is not playback.
Real playback means delivering decoded frames continuously at the video's native frame rate. In this step, you will build a StreamSink-based pipeline where Rust decodes frames in a loop and pushes RGBA data to Dart, while Dart renders each frame as it arrives.
In this step, get ready to:
- Create a Rust playback function that streams decoded frames through a StreamSink.
- Wire the stream to your Flutter UI with play and pause controls.
- Test smooth frame-by-frame playback with pause and resume.
Create the Rust playback stream
The StreamSink pattern in flutter_rust_bridge lets a Rust function accept a sink parameter and push values into it over time. On the Dart side, calling that function returns a standard Dart Stream you can listen to.
- In rust/src/api/simple.rs, find the existing use flutter_rust_bridge::frb; line at the top of the file.
- Replace that single import line and add the new static variables by pasting this code at the very top of the file:
use flutter_rust_bridge::frb;
use flutter_rust_bridge::StreamSink;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
// Controls whether the playback loop is active or paused
static IS_PLAYING: AtomicBool = AtomicBool::new(false);
// Stores playback speed as fixed-point (100 = 1.0x)
static PLAYBACK_SPEED: AtomicU32 = AtomicU32::new(100);
What do these statics do?
IS_PLAYING is an atomic boolean that the decode loop checks on every iteration. Setting it to false pauses the loop without killing the thread.
PLAYBACK_SPEED stores the speed multiplier as an integer (100 means 1.0x). It defaults to normal speed and will become useful when you add speed controls later.
Now you will build the play_video function. It follows the same open-decode-scale pattern as get_first_frame, but instead of returning one frame, it loops through every packet and pushes each decoded frame into the sink.
- Below your existing get_first_frame function, add the first part of play_video which opens the file and sets up the decoder:
#[frb]
pub fn play_video(path: String, sink: StreamSink<FrameData>) -> anyhow::Result<()> {
ffmpeg_next::init().ok();
IS_PLAYING.store(true, Ordering::SeqCst);
let mut ictx = ffmpeg_next::format::input(&path)?;
let stream = ictx
.streams()
.best(ffmpeg_next::media::Type::Video)
.ok_or_else(|| anyhow::anyhow!("No video stream found"))?;
let video_stream_index = stream.index();
let time_base = f64::from(stream.time_base());
let frame_rate = f64::from(stream.avg_frame_rate());
// Base delay between frames at 1.0x speed
let base_frame_duration = std::time::Duration::from_secs_f64(1.0 / frame_rate);
let context_decoder =
ffmpeg_next::codec::context::Context::from_parameters(stream.parameters())?;
let mut decoder = context_decoder.decoder().video()?;
let mut scaler = ffmpeg_next::software::scaling::context::Context::get(
decoder.format(),
decoder.width(),
decoder.height(),
ffmpeg_next::format::Pixel::RGBA,
decoder.width(),
decoder.height(),
ffmpeg_next::software::scaling::flag::Flags::BILINEAR,
)?;
let width = decoder.width();
let height = decoder.height();
What does this setup do?
This mirrors the setup from get_first_frame but adds two new pieces. The StreamSink<FrameData> parameter is how Rust will push frames to Dart. The base_frame_duration calculates how long to sleep between frames to match the video's native frame rate.
- Directly below the setup code (still inside the same function), add the main decode loop that iterates through packets and sends frames:
for (stream_ref, packet) in ictx.packets() {
if !IS_PLAYING.load(Ordering::SeqCst) {
// Paused: spin-wait until resumed
while !IS_PLAYING.load(Ordering::SeqCst) {
std::thread::sleep(std::time::Duration::from_millis(50));
}
}
if stream_ref.index() == video_stream_index {
decoder.send_packet(&packet)?;
let mut decoded = ffmpeg_next::util::frame::video::Video::empty();
while decoder.receive_frame(&mut decoded).is_ok() {
let mut rgba_frame = ffmpeg_next::util::frame::video::Video::empty();
scaler.run(&decoded, &mut rgba_frame)?;
let pts = decoded.timestamp().unwrap_or(0);
let timestamp_secs = pts as f64 * time_base;
// Push the decoded RGBA frame to Dart
let _ = sink.add(FrameData {
rgba: rgba_frame.data(0).to_vec(),
timestamp_secs,
width,
height,
});
// Sleep to match the video's frame rate
let speed = PLAYBACK_SPEED.load(Ordering::SeqCst) as f64 / 100.0;
let sleep_duration = base_frame_duration.div_f64(speed);
std::thread::sleep(sleep_duration);
}
}
}
What does the decode loop do?
Each iteration checks IS_PLAYING first. If it is false, the thread enters a spin-wait loop, sleeping 50ms between checks until playback resumes.
For each video packet, it decodes the frame, scales it to RGBA, calculates the presentation timestamp, and pushes the data through the sink. Then it sleeps for the correct frame duration so Dart receives frames at the video's native pace.
- Complete the play_video function with the EOF flush, then add the pause_video and resume_video functions directly below:
// Flush remaining frames after reaching end of file
decoder.send_eof()?;
let mut decoded = ffmpeg_next::util::frame::video::Video::empty();
while decoder.receive_frame(&mut decoded).is_ok() {
let mut rgba_frame = ffmpeg_next::util::frame::video::Video::empty();
scaler.run(&decoded, &mut rgba_frame)?;
let pts = decoded.timestamp().unwrap_or(0);
let timestamp_secs = pts as f64 * time_base;
let _ = sink.add(FrameData {
rgba: rgba_frame.data(0).to_vec(),
timestamp_secs,
width,
height,
});
}
Ok(())
}
#[frb]
pub fn pause_video() {
IS_PLAYING.store(false, Ordering::SeqCst);
}
#[frb]
pub fn resume_video() {
IS_PLAYING.store(true, Ordering::SeqCst);
}
Why flush after EOF?
Video decoders buffer frames internally. After all packets are sent, calling send_eof() tells the decoder to flush any remaining buffered frames. Without this, the last few frames of the video would never appear.
- Save rust/src/api/simple.rs.
- Regenerate the Dart bindings by running this command in your project root:
flutter_rust_bridge_codegen generate
You should see output ending with a success message and no errors. This confirms Rust compiled and the new functions are now available in Dart.
Seeing a compile error?
Check that your play_video function has matching braces. The function opens at the signature and closes with Ok(()) } after the EOF flush. Also verify the use statements at the top include both StreamSink and the atomic imports.
help me fix a Rust compile error in my play_video function
Wire the stream to the Flutter UI
With the Rust side ready, you need Dart code that starts the stream, renders each incoming frame, and lets the user toggle playback.
- In lib/main.dart, find your existing ValueNotifier declarations inside _EditorScreenState (where _filePath, _videoInfo, and _currentFrame are declared).
- Add these two new ValueNotifiers directly below the existing ones:
// Tracks whether video is currently playing
final _isPlaying = ValueNotifier<bool>(false);
// Tracks the current playback position in seconds
final _currentTime = ValueNotifier<double>(0.0);
- Below your existing _updateFrame method, add the _togglePlayback method that controls the stream:
Future<void> _togglePlayback() async {
if (_isPlaying.value) {
// Currently playing, so pause
pauseVideo();
_isPlaying.value = false;
} else {
// Start or resume playback
_isPlaying.value = true;
resumeVideo();
final path = _filePath.value;
if (path == null) return;
// Listen to the frame stream from Rust
playVideo(path: path).listen(
(FrameData frame) {
final pixels = Uint8List.fromList(frame.rgba);
final oldImage = _currentFrame.value;
ui.decodeImageFromPixels(
pixels,
frame.width.toInt(),
frame.height.toInt(),
ui.PixelFormat.rgba8888,
(ui.Image img) {
_currentFrame.value = img;
oldImage?.dispose();
},
);
_currentTime.value = frame.timestampSecs;
},
onDone: () {
_isPlaying.value = false;
},
);
}
}
What does this method do?
When the user presses play, _togglePlayback calls resumeVideo() (which sets the Rust atomic flag to true) and starts listening to the playVideo() stream.
Each frame arrives as raw RGBA bytes. The method converts them to a ui.Image using decodeImageFromPixels, updates the ValueNotifier (which triggers the RawImage rebuild), and disposes the previous image to avoid memory leaks.
- Now add the playback controls UI. In your build method's Column, find the info bar widget (the ValueListenableBuilder<VideoInfo?>) and add this playback controls container directly below it:
// Playback controls
Container(
height: 48,
color: const Color(0xFF252525),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ValueListenableBuilder<bool>(
valueListenable: _isPlaying,
builder: (_, playing, __) => IconButton(
onPressed: _togglePlayback,
icon: Icon(playing ? Icons.pause : Icons.play_arrow),
color: Colors.white,
),
),
],
),
),
What does this widget do?
The ValueListenableBuilder<bool> rebuilds only the icon button whenever _isPlaying changes. It swaps between a play arrow and a pause icon depending on the current state. The dark container (Color(0xFF252525)) gives the controls bar a subtle visual separation from the preview.
- Save lib/main.dart.
Seeing an undefined function error?
Make sure you ran flutter_rust_bridge_codegen generate after saving the Rust file. The playVideo, pauseVideo, and resumeVideo functions only appear in Dart after code generation completes.
help me fix undefined function errors for playVideo in my Flutter app
✔️ Awesome, I've got everything!
Great. Make sure you have saved both rust/src/api/simple.rs and lib/main.dart.
ⓧ I'd like to double check the full code
Your rust/src/api/simple.rs should now have these sections in order: the three use imports, the two static declarations, the VideoInfo and FrameData structs, then open_video, get_first_frame, play_video, pause_video, and resume_video.
In lib/main.dart, confirm you have _isPlaying and _currentTime ValueNotifiers declared alongside the existing ones, the _togglePlayback method below _updateFrame, and the playback controls Container placed below the info bar in the Column.
Test playback
Everything is wired up. Before you run the app, think about what you expect to happen when you press play on an imported video.
- Do you think frames will update smoothly, or will there be a noticeable delay before the first frame arrives? Why?
- Run your app by executing:
flutter run -d linux
- Click Import and select any video file.
- Press the play button.
You should see smooth frame-by-frame playback in the preview panel. The frames update at the video's native frame rate. Press the pause button and the frame freezes in place. Press play again and playback resumes from where it stopped.
Video not playing or frames stuttering?
If nothing happens when you press play, confirm that _togglePlayback is correctly assigned to the IconButton's onPressed callback. Also check that you imported a video first (the path must not be null).
If frames stutter or play too fast, verify that base_frame_duration uses 1.0 / frame_rate and that the thread::sleep call is inside the receive_frame loop, not outside it.
help me debug video playback issues in my Flutter+Rust app
Your video editor now plays back video in real-time. Next up, you will build a visual timeline with a draggable playhead so users can jump to any point in the video.
Build the Timeline and Add Seeking
Your video plays forward, but there is no way to jump to a specific moment. Without a timeline, the user is stuck watching linearly from wherever playback started.
This step adds a visual timeline with a draggable playhead and wires seeking back to the Rust decoder. You will build an FFmpeg seek function in Rust that jumps to any timestamp and returns the decoded frame, then paint a custom timeline widget in Flutter that lets the user scrub through the video.
In this step, get ready to:
- Implement a Rust seek function that jumps to a timestamp and returns the RGBA frame.
- Build a custom-painted timeline widget with a draggable playhead.
- Add a formatted timestamp display that updates in real-time as you drag.
Implement seeking in Rust
FFmpeg's seek API converts a timestamp into internal time-base units and jumps to the nearest keyframe before that point. From there, you decode the next frame and return it as RGBA bytes.
- Open rust/src/api/simple.rs and add the first half of the seek_to_frame function below resume_video():
#[frb]
pub fn seek_to_frame(path: String, timestamp_secs: f64) -> anyhow::Result<FrameData> {
ffmpeg_next::init().ok();
let mut ictx = ffmpeg_next::format::input(&path)?;
let stream = ictx
.streams()
.best(ffmpeg_next::media::Type::Video)
.ok_or_else(|| anyhow::anyhow!("No video stream found"))?;
let video_stream_index = stream.index();
let context_decoder =
ffmpeg_next::codec::context::Context::from_parameters(stream.parameters())?;
let mut decoder = context_decoder.decoder().video()?;
// Set up RGBA scaler (same pattern as get_first_frame)
let mut scaler = ffmpeg_next::software::scaling::context::Context::get(
decoder.format(),
decoder.width(),
decoder.height(),
ffmpeg_next::format::Pixel::RGBA,
decoder.width(),
decoder.height(),
ffmpeg_next::software::scaling::flag::Flags::BILINEAR,
)?;
let width = decoder.width();
let height = decoder.height();
What does this code do?
This opening section follows the same pattern as get_first_frame. It opens the file, finds the video stream, creates a decoder, and sets up the RGBA pixel format scaler.
- Directly below the code you just added (still inside the same function), add the seek and decode logic:
// Convert timestamp to AV_TIME_BASE units and seek
let position = (timestamp_secs * f64::from(ffmpeg_next::ffi::AV_TIME_BASE)) as i64;
ictx.seek(position, ..position)?;
// Decode the first frame after the seek point
for (stream_ref, packet) in ictx.packets() {
if stream_ref.index() == video_stream_index {
decoder.send_packet(&packet)?;
let mut decoded = ffmpeg_next::util::frame::video::Video::empty();
if decoder.receive_frame(&mut decoded).is_ok() {
let mut rgba_frame = ffmpeg_next::util::frame::video::Video::empty();
scaler.run(&decoded, &mut rgba_frame)?;
return Ok(FrameData {
rgba: rgba_frame.data(0).to_vec(),
timestamp_secs,
width,
height,
});
}
}
}
Err(anyhow::anyhow!("Could not seek to timestamp"))
}
What does this code do?
The seek call jumps to the nearest keyframe at or before the requested timestamp. AV_TIME_BASE converts seconds into FFmpeg's internal microsecond unit.
After seeking, the packet loop decodes the first available frame, scales it to RGBA, and returns it. If no frame is found, an error is returned.
- Save rust/src/api/simple.rs.
- Regenerate the Dart bindings by running:
flutter_rust_bridge_codegen generate
- The command should complete without errors, confirming your new seekToFrame function is now callable from Dart.
Seeing a codegen error?
Check that the closing brace of seek_to_frame is present and the function has the #[frb] attribute above it. Also confirm the return type is anyhow::Result<FrameData> matching the existing struct.
Help me fix my flutter_rust_bridge codegen error for seek_to_frame
Build the timeline widget
The timeline needs three pieces on the Dart side: state to track the playhead position, a method that calls Rust to seek, and a custom-painted widget that responds to drag gestures.
- In lib/main.dart, add these ValueNotifiers to the _EditorScreenState class alongside the existing ones, and add the _seekTo method:
// Add these below the existing ValueNotifiers
final _progress = ValueNotifier<double>(0.0);
final _trimIn = ValueNotifier<double>(0.0);
final _trimOut = ValueNotifier<double>(1.0);
// Seek to a position (0.0 to 1.0) and update the frame
Future<void> _seekTo(double position) async {
final path = _filePath.value;
final info = _videoInfo.value;
if (path == null || info == null) return;
final timestamp = position * info.durationSecs;
final frame = await seekToFrame(path: path, timestampSecs: timestamp);
await _updateFrame(frame);
_progress.value = position;
_currentTime.value = timestamp;
}
What does this code do?
_progress tracks the playhead position as a value from 0.0 to 1.0. _trimIn and _trimOut define the trim region boundaries (used in the next step for export).
_seekTo converts the normalized position to a timestamp, calls the Rust seekToFrame function, and updates the frame, progress, and current time in one shot.
- Save lib/main.dart and confirm no analysis errors appear in your editor.
Now you need the visual timeline itself. This is a CustomPaint widget wrapped in a GestureDetector that translates horizontal drags into seek calls.
- In the build method's Column children, add this timeline Container as the last child (below the playback controls):
// Timeline
Container(
height: 64,
color: const Color(0xFF1E1E1E),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ValueListenableBuilder<double>(
valueListenable: _progress,
builder: (_, progress, __) => ValueListenableBuilder<double>(
valueListenable: _trimIn,
builder: (_, trimIn, __) => ValueListenableBuilder<double>(
valueListenable: _trimOut,
builder: (_, trimOut, __) => GestureDetector(
onHorizontalDragUpdate: (details) {
final box = context.findRenderObject() as RenderBox;
final localX = details.localPosition.dx;
final position = (localX / box.size.width).clamp(0.0, 1.0);
_seekTo(position);
},
child: CustomPaint(
size: const Size(double.infinity, 48),
painter: TimelinePainter(
progress: progress,
trimIn: trimIn,
trimOut: trimOut,
),
),
),
),
),
),
),
What does this code do?
Three nested ValueListenableBuilder widgets ensure the timeline repaints whenever progress, trim-in, or trim-out changes.
The GestureDetector captures horizontal drags, converts the touch X position to a 0.0-to-1.0 value using the widget width, then calls _seekTo to jump to that frame.
The TimelinePainter does not exist yet. You will add it as a new class at the bottom of lib/main.dart.
- Add the first part of the TimelinePainter class below the closing brace of EditorScreen:
class TimelinePainter extends CustomPainter {
final double progress;
final double trimIn;
final double trimOut;
TimelinePainter({
required this.progress,
required this.trimIn,
required this.trimOut,
});
@override
void paint(Canvas canvas, Size size) {
final trackPaint = Paint()..color = const Color(0xFF3D3D3D);
final trimPaint = Paint()..color = const Color(0xFF6C63FF).withOpacity(0.2);
final progressPaint = Paint()..color = const Color(0xFF6C63FF);
final playheadPaint = Paint()..color = Colors.white;
// Track bar (dark rounded line)
final trackRect = RRect.fromRectAndRadius(
Rect.fromLTWH(0, size.height / 2 - 2, size.width, 4),
const Radius.circular(2),
);
canvas.drawRRect(trackRect, trackPaint);
// Trim region highlight
final trimRect = Rect.fromLTWH(
trimIn * size.width,
size.height / 2 - 2,
(trimOut - trimIn) * size.width,
4,
);
canvas.drawRect(trimRect, trimPaint);
What does this code do?
The painter defines four paint styles for different visual elements. It draws a 4px rounded track bar as the timeline background, then overlays a semi-transparent accent region between the trim markers.
- Continue the paint method and close the class by adding this code directly below:
// Progress fill (accent color up to playhead)
final progressRect = RRect.fromRectAndRadius(
Rect.fromLTWH(0, size.height / 2 - 2, progress * size.width, 4),
const Radius.circular(2),
);
canvas.drawRRect(progressRect, progressPaint);
// Circular playhead (white dot)
canvas.drawCircle(
Offset(progress * size.width, size.height / 2),
6,
playheadPaint,
);
// Trim markers (accent-colored vertical bars)
final markerPaint = Paint()..color = const Color(0xFF6C63FF);
canvas.drawRect(
Rect.fromLTWH(trimIn * size.width - 2, size.height / 2 - 10, 4, 20),
markerPaint,
);
canvas.drawRect(
Rect.fromLTWH(trimOut * size.width - 2, size.height / 2 - 10, 4, 20),
markerPaint,
);
}
@override
bool shouldRepaint(TimelinePainter oldDelegate) =>
progress != oldDelegate.progress ||
trimIn != oldDelegate.trimIn ||
trimOut != oldDelegate.trimOut;
}
What does this code do?
The progress fill draws an accent-colored bar from the left edge up to the current playhead position. The playhead itself is a 6px white circle that moves with the progress value.
The trim markers are two vertical accent-colored bars at the in/out positions. shouldRepaint ensures the painter only redraws when any of the three values actually changes.
- Save lib/main.dart.
- Run the app to confirm the timeline bar appears at the bottom of the window:
flutter run -d linux
- You should see a dark horizontal track with a white playhead dot at the left edge, and two accent-colored vertical trim markers at each end.
Don't see the timeline?
Confirm the Container is placed as the last child in the Column widget's children list. If TimelinePainter is not recognized, check that you placed the class outside and below the _EditorScreenState class (not nested inside it).
Help me fix my TimelinePainter widget not showing up
Add timestamp display
The playhead gives a visual sense of position, but the user also needs to see the exact time in minutes and seconds. You will add a helper that formats a seconds value and wire it into the info bar.
- In _EditorScreenState, add this helper method (place it near your other methods like _seekTo):
// Format seconds as mm:ss
String _formatTime(double seconds) {
final mins = (seconds ~/ 60).toString().padLeft(2, '0');
final secs = (seconds.toInt() % 60).toString().padLeft(2, '0');
return '$mins:$secs';
}
What does this code do?
_formatTime takes a raw seconds value (like 73.4) and formats it as 01:13. Integer division (~/) extracts whole minutes, and modulo (% 60) extracts remaining seconds.
- In the info bar's Row widget (inside the ValueListenableBuilder<VideoInfo?>), find the Spacer() and add this timestamp display after it:
ValueListenableBuilder<double>(
valueListenable: _currentTime,
builder: (_, time, __) => Text(
'${_formatTime(time)} / ${_formatTime(info.durationSecs)}',
style: const TextStyle(fontSize: 12, color: Colors.white54),
),
),
What does this code do?
This ValueListenableBuilder listens to _currentTime and reformats every time the value changes. It displays the current position and total duration side by side (e.g. 00:42 / 02:15).
- Save lib/main.dart.
Before you test, what do you expect to happen when you drag the playhead to the middle of the timeline?
- Run the app, import a video file, and drag the white playhead dot along the timeline:
flutter run -d linux
The preview should jump to the frame at that position, and the timestamp on the right of the info bar should update in real-time as you drag. The accent-colored progress fill tracks your playhead.
Timestamp shows 00:00 and preview does not update?
Make sure _seekTo calls seekToFrame (the generated Dart name, camelCase) and not seek_to_frame (the Rust name). Also confirm the onHorizontalDragUpdate callback calls _seekTo(position) with the clamped value.
Help me debug why seeking is not updating the preview frame
✔️ Awesome, I've got everything!
Great. Double check you have saved both rust/src/api/simple.rs and lib/main.dart.
ⓧ I'd like to double check the full code
Scroll up and confirm each of these pieces is present in order:
- In rust/src/api/simple.rs: the seek_to_frame function (file open, decoder/scaler setup, seek with AV_TIME_BASE conversion, decode loop returning FrameData).
- In lib/main.dart: _progress, _trimIn, _trimOut ValueNotifiers.
- The _seekTo method calling seekToFrame and updating frame/progress/time.
- The _formatTime helper returning mm:ss format.
- The Timeline Container with nested ValueListenableBuilders, GestureDetector, and CustomPaint.
- The TimelinePainter class drawing track, trim region, progress, playhead, and trim markers.
- The timestamp ValueListenableBuilder<double> in the info bar after the Spacer().
Your video editor now has interactive navigation. You can jump to any point in the video by dragging the playhead, and you can see exactly where you are via the timestamp display. Next up, you will add trim markers and an export pipeline to produce a new file from the selected segment.
Add Trim Markers and Export
Import and playback alone do not make an editor. The real purpose of a video editor is to produce new files from selected portions of existing ones.
Your timeline already has trim markers at positions 0.0 and 1.0 (the full video). In this step, you'll build a Rust function that remuxes packets between those trim points into a new MP4, then wire it to a Flutter export button with real-time progress reporting.
In this step, get ready to:
- Build a Rust remux function that extracts trimmed video segments into new files.
- Add an Export button with a progress indicator to the toolbar.
- Test the full import-to-export workflow end to end.
Build the Rust export function
The export pipeline needs to take a start and end timestamp, extract the packets in that range from the source file, and write them to a new container. This technique is called remuxing. It stream-copies packets without re-encoding, which makes it extremely fast.
Why remux instead of re-encode?
Re-encoding decodes every frame and compresses it again. That is slow and lossy. Remuxing just copies the compressed packets to a new container without touching the bitstream.
The trade-off is that cuts align to the nearest keyframe rather than the exact requested timestamp. For a skeleton editor this is a good deal: instant exports with minimal quality loss.
- In rust/src/api/simple.rs, add the first half of the export_trimmed function below your existing seek_to_frame function:
#[frb]
pub fn export_trimmed(
input_path: String,
output_path: String,
start_secs: f64,
end_secs: f64,
sink: StreamSink<f64>,
) -> anyhow::Result<()> {
ffmpeg_next::init().ok();
// Open input and output file contexts
let mut ictx = ffmpeg_next::format::input(&input_path)?;
let mut octx = ffmpeg_next::format::output(&output_path)?;
// Copy stream parameters from input to output
let stream_mapping: Vec<usize> = ictx
.streams()
.enumerate()
.map(|(i, stream)| {
let mut ost = octx.add_stream(ffmpeg_next::encoder::find(stream.parameters().id())).unwrap();
ost.set_parameters(stream.parameters());
i
})
.collect();
octx.write_header()?;
What does this code do?
- The function accepts a StreamSink<f64> to push progress values (0.0 to 1.0) back to Dart as the export runs.
- It opens both the input file and the output file as FFmpeg format contexts.
- The stream_mapping loop copies every stream's codec parameters from input to output. This preserves the video and audio format without re-encoding.
- write_header() writes the container header to the output file.
- Directly below the octx.write_header()?; line (still inside the same function), add the seek and remux loop:
// Calculate total duration for progress reporting
let total_duration = end_secs - start_secs;
let position = (start_secs * f64::from(ffmpeg_next::ffi::AV_TIME_BASE)) as i64;
ictx.seek(position, ..position)?;
// Remux packets from start to end, sending progress
for (stream, mut packet) in ictx.packets() {
let ist_index = stream.index();
let time_base = stream.time_base();
let current_secs = packet.pts().unwrap_or(0) as f64 * f64::from(time_base);
if current_secs > end_secs {
break;
}
if current_secs >= start_secs {
let ost_index = stream_mapping[ist_index];
packet.set_stream(ost_index);
packet.set_position(-1);
let _ = packet.write_interleaved(&mut octx);
let progress = ((current_secs - start_secs) / total_duration).min(1.0);
let _ = sink.add(progress);
}
}
octx.write_trailer()?;
let _ = sink.add(1.0);
Ok(())
}
What does the remux loop do?
- It seeks to start_secs using the same AV_TIME_BASE conversion you used in seek_to_frame.
- Each packet's timestamp is checked. Once it passes end_secs, the loop breaks.
- write_interleaved copies the raw compressed packet to the output container without decoding.
- Progress is calculated as a fraction of the trimmed duration and pushed through the sink to Dart.
- Save rust/src/api/simple.rs.
- Regenerate the Dart bindings by running:
flutter_rust_bridge_codegen generate
- Confirm the command completes without errors. You should see output ending in a success message indicating the bindings were generated.
Seeing a compilation error?
- Check that the opening brace after write_header()?; is NOT present. The two code chunks above are one continuous function body. Make sure the second chunk follows directly after the first with no extra braces in between.
- Verify that the closing } of export_trimmed comes after Ok(()).
Still stuck? Help me fix a compilation error in my export_trimmed function in simple.rs
Add the export button and progress UI
The Rust pipeline is ready. Now you need a Dart method that opens a save dialog, converts the trim marker positions to timestamps, and calls the export stream.
- In lib/main.dart, find the line where your other ValueNotifiers are declared (near _trimOut). Add this new notifier below it:
final _exportProgress = ValueNotifier<double?>(null);
- Below your _seekTo method, add the _export method that drives the full export workflow:
Future<void> _export() async {
final path = _filePath.value;
final info = _videoInfo.value;
if (path == null || info == null) return;
final outputPath = await FilePicker.platform.saveFile(
dialogTitle: 'Export trimmed video',
fileName: 'trimmed_output.mp4',
);
if (outputPath == null) return;
// Convert trim positions (0.0-1.0) to timestamps
final startSecs = _trimIn.value * info.durationSecs;
final endSecs = _trimOut.value * info.durationSecs;
_exportProgress.value = 0.0;
// Call Rust export and stream progress back
exportTrimmed(
inputPath: path,
outputPath: outputPath,
startSecs: startSecs,
endSecs: endSecs,
).listen(
(double progress) {
_exportProgress.value = progress;
},
onDone: () {
_exportProgress.value = null;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Export complete')),
);
},
);
}
What does this method do?
- It opens a native save-file dialog using file_picker so the user picks where to save the output.
- _trimIn.value and _trimOut.value are normalized positions (0.0 to 1.0). Multiplying by durationSecs converts them to actual timestamps.
- exportTrimmed() returns a Stream<double> of progress values. The listener updates _exportProgress which drives the progress bar in the UI.
- When the stream completes, the notifier resets to null (hiding the progress bar) and a snackbar confirms success.
- Save lib/main.dart.
Now wire the Export button into the toolbar so the learner can trigger the export.
- In the toolbar Row's children list, find the const Spacer() widget. Add the Export button after it:
ValueListenableBuilder<double?>(
valueListenable: _exportProgress,
builder: (_, progress, __) {
if (progress != null) {
// Show progress bar during export
return SizedBox(
width: 120,
child: LinearProgressIndicator(value: progress),
);
}
// Show export button when idle
return TextButton.icon(
onPressed: _export,
icon: const Icon(Icons.file_download, size: 18),
label: const Text('Export'),
);
},
),
What does this widget do?
The ValueListenableBuilder watches _exportProgress. When it is null (no export running), the button appears. When it holds a value (export in progress), a LinearProgressIndicator fills up from 0.0 to 1.0.
- Save lib/main.dart.
Seeing a type error on exportTrimmed?
- Make sure you ran flutter_rust_bridge_codegen generate after saving the Rust file. The Dart bindings for exportTrimmed do not exist until the codegen runs.
- Check that your Rust function signature matches exactly. The return type should be anyhow::Result<()> and the sink should be StreamSink<f64>.
Still stuck? Help me fix a type error when calling exportTrimmed from Dart
Test the full export workflow
Before you run the app, think about what should happen when you click Export with the trim markers at their default positions (0.0 and 1.0). What portion of the video will be exported?
- Run the app:
flutter run -d linux
- Click Import and select any video file.
- Click Export in the top-right of the toolbar.
- In the save dialog, choose a location and filename for the output file.
- Watch the progress bar fill from left to right. When it finishes, you should see a snackbar at the bottom of the window reading Export complete.
Since the trim markers default to 0.0 and 1.0, the full video is exported. Open the exported file in any media player and confirm it plays correctly.
Export button does nothing?
- Make sure you imported a video first. The _export method returns early if _filePath is null.
- If the save dialog does not appear, check that file_picker is in your pubspec.yaml and you ran flutter pub get.
- If the progress bar appears but freezes at 0%, check the terminal for Rust panics. A missing output file path or invalid seek position can cause the Rust function to error silently.
Still stuck? Help me debug why my Export button is not working in the video editor
✔️ Awesome, I've got everything!
Great. Double check you have saved both rust/src/api/simple.rs and lib/main.dart.
ⓧ I'd like to double check the full code
Your rust/src/api/simple.rs should now contain all previous functions (open_video, get_first_frame, play_video, pause_video, resume_video, seek_to_frame) plus the new export_trimmed function at the bottom.
Your lib/main.dart should have:
- _exportProgress ValueNotifier declared alongside the other notifiers.
- The _export() method below _seekTo().
- A ValueListenableBuilder<double?> in the toolbar Row after the Spacer, switching between the Export button and a LinearProgressIndicator.
Scroll up and confirm each code chunk above is present in order.
Your video editor now handles the full lifecycle: import, preview, playback, seeking, and export. Next up, you'll tackle the Secret Mission and add playback speed control.
Secret mission
Add Playback Speed Control
Your video editor plays at a fixed 1x speed. Professional editors let you scrub through footage at 0.5x for detail work or 2x to skim. Can you wire a speed selector that passes runtime configuration from Dart back into the running Rust decode loop?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with free, open-source tools, so there are no ongoing costs.
Resources you used:
- The video_editor project directory on your Desktop (Flutter + Rust source code).
- Exported video files created during testing (wherever you saved them).
- The flutter_rust_bridge_codegen CLI tool installed via Cargo.
Keep everything running
No action needed. Choose this if you are still actively building on this video editor or plan to extend it with audio tracks, filters, or GPU-backed rendering via the Texture widget.
Pause - I'll come back to this later
Since this is a local desktop project with no running servers or cloud resources, there is nothing to pause. Your files will be waiting for you exactly as you left them.
- If you have flutter run still active in a terminal, press Ctrl+C to stop the app.
Delete - I don't want to use this again
Remove all project files and the CLI tool to start completely fresh.
- Stop any running instance of the app by pressing Ctrl+C in the terminal.
- Delete the project directory by running:
- Delete any exported video files you created during the project (check the output paths you chose in the save dialogs).
- Uninstall the flutter_rust_bridge_codegen CLI tool by running:
rm -rf ~/Desktop/video_editor
cargo uninstall flutter_rust_bridge_codegen
That's a Wrap!
That's a Wrap!
Nice work. You just built a fully functional desktop video editor from scratch, combining Flutter's widget system with Rust's high-performance FFmpeg pipeline.
You've learned how to:
- Build a complete Flutter+Rust desktop application using flutter_rust_bridge v2 code generation to stream real-time RGBA frames from an FFmpeg decoder to a reactive UI.
- Create a modern flat-design timeline widget with CustomPaint, a draggable playhead, trim markers, and video seeking bridged from Dart to Rust.
- Build a video export pipeline that remuxes trimmed segments to new MP4 files with live progress reporting, all managed with ValueNotifier state and zero third-party packages.
- Secret Mission: Added dynamic playback speed control (0.5x, 1x, 2x) by passing runtime configuration from Dart into a running Rust decode loop.
Ready to quiz yourself?