xue_hua_media_compression 1.3.4
xue_hua_media_compression: ^1.3.4 copied to clipboard
Cross-platform Flutter FFI plugin for image and video compression powered by Rust.
import 'dart:io';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:xue_hua_media_compression/xue_hua_media_compression.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await XueHuaMediaCompression.initialize();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'XueHua Media Compression',
theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
home: const HomePage(),
);
}
}
/// 把字节数格式化为人类可读的字符串。
String formatBytes(int bytes) {
if (bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
var size = bytes.toDouble();
var unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit++;
}
return '${size.toStringAsFixed(unit == 0 ? 0 : 2)} ${units[unit]}';
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
String _backend = '...';
@override
void initState() {
super.initState();
XueHuaMediaCompression.videoBackendName().then((value) {
if (mounted) setState(() => _backend = value);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('雪花媒体压缩 Demo'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(28),
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'当前平台视频硬编后端: $_backend',
style: Theme.of(context).textTheme.bodySmall,
),
),
),
),
body: const SingleChildScrollView(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ImageCompressionCard(),
SizedBox(height: 16),
VideoCompressionCard(),
],
),
),
);
}
}
// ===========================================================================
// 图片压缩卡片
// ===========================================================================
class ImageCompressionCard extends StatefulWidget {
const ImageCompressionCard({super.key});
@override
State<ImageCompressionCard> createState() => _ImageCompressionCardState();
}
class _ImageCompressionCardState extends State<ImageCompressionCard> {
static const _formats = <String, ImageFormat>{
'JPEG': ImageFormat.jpeg,
'PNG': ImageFormat.png,
'WebP': ImageFormat.webP,
'AVIF': ImageFormat.avif,
};
ImageFormat _format = ImageFormat.jpeg;
double _quality = 80;
bool _busy = false;
String? _name;
String? _originalPath;
String? _compressedPath;
int? _originalSize;
int? _compressedSize;
Duration? _elapsed;
String? _error;
Future<void> _pickAndCompress() async {
setState(() {
_busy = true;
_error = null;
});
String? tempOutPath;
try {
const typeGroup = XTypeGroup(
label: 'images',
extensions: [
'jpg',
'jpeg',
'png',
'webp',
'gif',
'heic',
'heif',
'avif',
],
);
final file = await openFile(acceptedTypeGroups: [typeGroup]);
if (file == null) {
setState(() => _busy = false);
return;
}
final inputPath = file.path;
final originalSize = await file.length();
final tmpDir = Directory.systemTemp;
final ts = DateTime.now().millisecondsSinceEpoch;
tempOutPath = '${tmpDir.path}/xh_img_out_$ts${_outputExtension(_format)}';
final sw = Stopwatch()..start();
await XueHuaMediaCompression.image.compressFile(
inputPath: inputPath,
outputPath: tempOutPath,
format: _format,
quality: _quality.round(),
);
sw.stop();
final compressedSize = await File(tempOutPath).length();
setState(() {
_name = file.name;
_originalPath = inputPath;
_compressedPath = tempOutPath;
_originalSize = originalSize;
_compressedSize = compressedSize;
_elapsed = sw.elapsed;
});
} catch (e) {
setState(() => _error = '$e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
String _outputExtension(ImageFormat format) {
return switch (format) {
ImageFormat.jpeg => '.jpg',
ImageFormat.png => '.png',
ImageFormat.webP => '.webp',
ImageFormat.gif => '.gif',
ImageFormat.avif => '.avif',
ImageFormat.heic => '.heic',
};
}
@override
Widget build(BuildContext context) {
return _SectionCard(
icon: Icons.image_outlined,
title: '图片压缩',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Text('目标格式: '),
const SizedBox(width: 8),
DropdownButton<ImageFormat>(
value: _format,
items: _formats.entries
.map(
(e) =>
DropdownMenuItem(value: e.value, child: Text(e.key)),
)
.toList(),
onChanged: _busy
? null
: (v) => setState(() => _format = v ?? ImageFormat.jpeg),
),
],
),
if (_format == ImageFormat.png)
Text(
'PNG 为无损格式,从 JPEG 转换后体积通常会增大;PNG→PNG 可无损优化体积。',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
)
else
Row(
children: [
const Text('质量: '),
Expanded(
child: Slider(
value: _quality,
min: 1,
max: 100,
divisions: 99,
label: _quality.round().toString(),
onChanged: _busy
? null
: (v) => setState(() => _quality = v),
),
),
SizedBox(width: 36, child: Text(_quality.round().toString())),
],
),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: _busy ? null : _pickAndCompress,
icon: _busy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.upload_file),
label: Text(_busy ? '压缩中...' : '选择图片并压缩'),
),
if (_error != null) ...[
const SizedBox(height: 12),
_ErrorBox(message: _error!),
],
if (_originalPath != null &&
_compressedPath != null &&
_originalSize != null &&
_compressedSize != null) ...[
const SizedBox(height: 12),
_SizeResult(
name: _name ?? '',
originalSize: _originalSize!,
compressedSize: _compressedSize!,
elapsed: _elapsed,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _ImagePreview(label: '原图', path: _originalPath!),
),
const SizedBox(width: 8),
Expanded(
child: _ImagePreview(label: '压缩后', path: _compressedPath!),
),
],
),
],
],
),
);
}
}
// ===========================================================================
// 视频压缩卡片
// ===========================================================================
class VideoCompressionCard extends StatefulWidget {
const VideoCompressionCard({super.key});
@override
State<VideoCompressionCard> createState() => _VideoCompressionCardState();
}
class _VideoCompressionCardState extends State<VideoCompressionCard> {
static const _codecs = <String, VideoCodec>{
'H.264': VideoCodec.h264,
'H.265 (HEVC)': VideoCodec.h265,
};
VideoCodec _codec = VideoCodec.h264;
double _bitrateMbps = 2;
bool _busy = false;
String? _name;
int? _originalSize;
int? _compressedSize;
String? _resultBackend;
Duration? _elapsed;
String? _error;
Future<void> _pickAndCompress() async {
setState(() {
_busy = true;
_error = null;
_compressedSize = null;
_elapsed = null;
});
String? tempOutputPath;
var succeeded = false;
try {
const typeGroup = XTypeGroup(
label: 'videos',
extensions: ['mp4', 'mov', 'm4v', 'avi', 'mkv', 'webm'],
);
final file = await openFile(acceptedTypeGroups: [typeGroup]);
if (file == null) {
setState(() => _busy = false);
return;
}
final originalSize = await file.length();
final tmpDir = Directory.systemTemp;
tempOutputPath =
'${tmpDir.path}/xh_compressed_${DateTime.now().millisecondsSinceEpoch}.mp4';
setState(() {
_name = file.name;
_originalSize = originalSize;
});
final sw = Stopwatch()..start();
final result = await XueHuaMediaCompression.video.compress(
inputPath: file.path,
outputPath: tempOutputPath,
codec: _codec,
bitrate: (_bitrateMbps * 1000000).round(),
);
sw.stop();
setState(() {
_compressedSize = result.sizeBytes.toInt();
_resultBackend = result.backend;
_elapsed = sw.elapsed;
});
succeeded = true;
} catch (e) {
setState(() => _error = '$e');
} finally {
if (!succeeded && tempOutputPath != null) {
try {
final f = File(tempOutputPath);
if (await f.exists()) {
await f.delete();
}
} catch (_) {}
}
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
return _SectionCard(
icon: Icons.movie_outlined,
title: '视频压缩',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Text('编码: '),
const SizedBox(width: 8),
DropdownButton<VideoCodec>(
value: _codec,
items: _codecs.entries
.map(
(e) =>
DropdownMenuItem(value: e.value, child: Text(e.key)),
)
.toList(),
onChanged: _busy
? null
: (v) => setState(() => _codec = v ?? VideoCodec.h264),
),
],
),
Row(
children: [
const Text('码率: '),
Expanded(
child: Slider(
value: _bitrateMbps,
min: 0.5,
max: 20,
divisions: 39,
label: '${_bitrateMbps.toStringAsFixed(1)} Mbps',
onChanged: _busy
? null
: (v) => setState(() => _bitrateMbps = v),
),
),
SizedBox(
width: 64,
child: Text('${_bitrateMbps.toStringAsFixed(1)}M'),
),
],
),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: _busy ? null : _pickAndCompress,
icon: _busy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.video_file),
label: Text(_busy ? '压缩中...' : '选择视频并压缩'),
),
if (_originalSize != null) ...[
const SizedBox(height: 12),
_KeyValueRow(label: '文件', value: _name ?? ''),
_KeyValueRow(label: '原始大小', value: formatBytes(_originalSize!)),
],
if (_compressedSize != null) ...[
_SizeResult(
name: _name ?? '',
originalSize: _originalSize!,
compressedSize: _compressedSize!,
elapsed: _elapsed,
backend: _resultBackend,
compact: true,
),
],
if (_error != null) ...[
const SizedBox(height: 12),
_ErrorBox(message: _error!),
],
],
),
);
}
}
// ===========================================================================
// 复用小部件
// ===========================================================================
class _SectionCard extends StatelessWidget {
const _SectionCard({
required this.icon,
required this.title,
required this.child,
});
final IconData icon;
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
return Card(
elevation: 1,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(icon, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
Text(title, style: Theme.of(context).textTheme.titleMedium),
],
),
const Divider(height: 24),
child,
],
),
),
);
}
}
class _SizeResult extends StatelessWidget {
const _SizeResult({
required this.name,
required this.originalSize,
required this.compressedSize,
this.elapsed,
this.backend,
this.compact = false,
});
final String name;
final int originalSize;
final int compressedSize;
final Duration? elapsed;
final String? backend;
final bool compact;
@override
Widget build(BuildContext context) {
final saved = originalSize - compressedSize;
final ratio = originalSize == 0 ? 0.0 : saved / originalSize;
final isSmaller = saved > 0;
final color = isSmaller ? Colors.green : Colors.orange;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!compact) _KeyValueRow(label: '文件', value: name),
_KeyValueRow(label: '原始大小', value: formatBytes(originalSize)),
_KeyValueRow(label: '压缩后大小', value: formatBytes(compressedSize)),
_KeyValueRow(
label: isSmaller ? '节省' : '体积变化',
value:
'${formatBytes(saved.abs())} (${(ratio * 100).toStringAsFixed(1)}%)',
valueColor: color,
),
if (elapsed != null)
_KeyValueRow(label: '耗时', value: '${elapsed!.inMilliseconds} ms'),
if (backend != null) _KeyValueRow(label: '编码后端', value: backend!),
],
),
);
}
}
class _KeyValueRow extends StatelessWidget {
const _KeyValueRow({
required this.label,
required this.value,
this.valueColor,
});
final String label;
final String value;
final Color? valueColor;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 88,
child: Text(label, style: Theme.of(context).textTheme.bodySmall),
),
Expanded(
child: Text(
value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: valueColor,
fontWeight: valueColor != null
? FontWeight.bold
: FontWeight.normal,
),
),
),
],
),
);
}
}
class _ImagePreview extends StatelessWidget {
const _ImagePreview({required this.label, required this.path});
final String label;
final String path;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(label, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 4),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
File(path),
height: 120,
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (context, error, stack) => Container(
height: 120,
alignment: Alignment.center,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Text('无法预览该格式'),
),
),
),
],
);
}
}
class _ErrorBox extends StatelessWidget {
const _ErrorBox({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.onErrorContainer,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: SelectableText(
message,
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
),
],
),
);
}
}