snapframes 0.0.1
snapframes: ^0.0.1 copied to clipboard
Flutter plugin for extracting video thumbnails on Android and iOS.
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart';
import 'package:snapframes/snapframes.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
String? _videoPath;
List<Uint8List>? _frames;
String? _error;
bool _loading = false;
// ---------- UI actions ----------
Future<void> _pickVideo() async {
setState(() {
_frames = null;
_error = null;
});
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.video,
allowMultiple: false,
);
final path = result?.files.single.path;
if (path != null) {
setState(() => _videoPath = path);
}
} catch (e) {
setState(() => _error = e.toString());
}
}
Future<void> _grab10Frames() async {
if (_videoPath == null) {
setState(() => _error = 'Сначала выберите видео');
return;
}
setState(() {
_loading = true;
_error = null;
_frames = null;
});
try {
final frames = await grab10FramesSafely(
source: _videoPath!,
format: SnapImageFormat.jpg,
quality: 80,
fastKeyframesFirst: true, // сначала быстрый режим по ключевым кадрам
);
setState(() => _frames = frames);
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _loading = false);
}
}
// ---------- UI ----------
@override
Widget build(BuildContext context) {
final theme = ThemeData(useMaterial3: true, colorSchemeSeed: Colors.green);
return MaterialApp(
theme: theme,
home: Scaffold(
appBar: AppBar(title: const Text('snapframes demo')),
body: Padding(
padding: const EdgeInsets.all(16),
child: ListView(
children: [
Row(
children: [
Expanded(
child: Text(
_videoPath ?? 'Видео не выбрано',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 12),
FilledButton.icon(
onPressed: _loading ? null : _pickVideo,
icon: const Icon(Icons.video_file_outlined),
label: const Text('Pick video'),
),
],
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _loading ? null : _grab10Frames,
icon: _loading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.collections),
label: const Text('Grab 10 frames (safe)'),
),
const SizedBox(height: 16),
if (_error != null)
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
if (_frames != null) ...[
Text('Получено кадров: ${_frames!.length}'),
const SizedBox(height: 8),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: _frames!.length,
itemBuilder: (context, index) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.memory(_frames![index], fit: BoxFit.cover),
);
},
),
],
],
),
),
),
);
}
}
/// ---------------- SAFETY HELPERS ----------------
int _safetyMarginMsFor(Duration d) {
final total = d.inMilliseconds;
if (total <= 0) return 500;
// 1 сек, но не больше четверти длительности и не меньше 250 мс
final margin = total ~/ 4;
return margin.clamp(250, 1000);
}
/// Равномерные таймстампы внутри [0, duration - safety).
List<int> _safeEvenTimestamps(Duration duration, {int count = 10}) {
final total = duration.inMilliseconds;
final safety = _safetyMarginMsFor(duration);
final maxTs = (total - safety).clamp(0, total - 1);
if (maxTs <= 0 || count <= 0) return const [];
final step = (maxTs ~/ (count + 1)).clamp(1, maxTs);
return List.generate(count, (i) => ((i + 1) * step).clamp(0, maxTs));
}
/// Кандидаты вокруг точки: сам ts и небольшие сдвиги.
List<int> _candidatesAround(int ts, int maxTs) {
final raw = <int>{ts, ts - 120, ts + 120, ts - 240, ts + 240};
return raw.where((v) => v >= 0 && v <= maxTs).toList();
}
/// Получить 10 кадров безопасно.
/// - Если [fastKeyframesFirst] = true — сперва батч по ключевым кадрам (очень быстро и устойчиво).
/// - Недостающие кадры добираются точными одиночными запросами со сдвигами.
Future<List<Uint8List>> grab10FramesSafely({
required String source,
int? width,
int? height,
SnapImageFormat format = SnapImageFormat.jpg,
int quality = 85,
bool fastKeyframesFirst = true,
}) async {
final duration = await getDuration(source);
final total = duration.inMilliseconds;
final safety = _safetyMarginMsFor(duration);
final maxTs = (total - safety).clamp(0, total - 1);
final timestamps = _safeEvenTimestamps(duration, count: 10);
if (timestamps.isEmpty) return const [];
final frames = <Uint8List>[];
// Попытка №1: быстрый батч по ключевым кадрам
if (fastKeyframesFirst) {
try {
final fastReq = BatchRequest(
source: source,
timestampsMs: timestamps,
width: width,
height: height,
format: format,
quality: quality,
keyframeOnly: true, // ВАЖНО: быстрый режим
);
final fastFrames = await getFramesBytes(fastReq);
frames.addAll(fastFrames);
} catch (_) {
// ок, перейдём к поштучным
}
}
// Если чего-то не добрали — берём поштучно с точным seek и сдвигами
if (frames.length < timestamps.length) {
// определим, сколько ещё нужно
final need = timestamps.length - frames.length;
// собираем поштучно (точный режим), идём по исходным ts
for (final ts in timestamps) {
if (frames.length >= timestamps.length) break; // уже добрали
Uint8List? got;
// кандидаты вокруг ts
for (final c in _candidatesAround(ts, maxTs)) {
try {
got = await getFrameBytes(
SnapRequest(
source: source,
timestampMs: c,
width: width,
height: height,
format: format,
quality: quality,
keyframeOnly: true,
// можно добавить keyframeOnly в SnapRequest, если нужно
),
);
break;
} catch (_) {/* пробуем следующий кандидат */}
}
if (got != null) {
frames.add(got);
}
if (frames.length >= timestamps.length) break;
if (frames.length >= timestamps.length - need) continue;
}
}
return frames;
}