Ocideck/lib/services/slide_rasterizer.dart

160 lines
5.9 KiB
Dart
Raw Normal View History

import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:path/path.dart' as p;
import '../models/deck.dart';
import '../models/settings.dart';
import '../models/slide.dart';
import '../widgets/slides/slide_preview.dart';
/// Renders the exact on-screen slide previews to PNG images so exports look
/// identical to what the user sees (WYSIWYG).
///
/// Each slide is mounted in an [Overlay] inside a keyed [RepaintBoundary] that
/// is positioned off-screen. A RepaintBoundary records its subtree into its
/// own layer regardless of where it sits on screen, so `toImage` captures the
/// full slide even though nothing is visible to the user.
class SlideRasterizer {
/// Logical size used while laying out a slide. The real output resolution is
/// [logicalSize] * [pixelRatio]; because the preview is fully proportional
/// the logical size only affects sampling quality.
static const Size logicalSize = Size(1280, 720);
/// Render [slides] to PNG bytes at [targetWidth] x (targetWidth * 9/16).
static Future<List<Uint8List>> rasterize({
required BuildContext context,
required List<Slide> slides,
required ThemeProfile themeProfile,
required String? projectPath,
TlpLevel tlp = TlpLevel.none,
int targetWidth = 1920,
void Function(int done, int total)? onProgress,
}) async {
final overlay = Overlay.of(context, rootOverlay: true);
final pixelRatio = targetWidth / logicalSize.width;
// The global image cache has a modest default budget (~100 MB / 1000
// entries). A deck with several full-resolution photos blows past that, so
// images decoded for earlier slides get evicted before later slides are
// captured — which made every image vanish after the first handful of
// slides. Raise the budget for the duration of the export, then restore it.
final imageCache = PaintingBinding.instance.imageCache;
final prevMaxSize = imageCache.maximumSize;
final prevMaxBytes = imageCache.maximumSizeBytes;
imageCache.maximumSize = (slides.length * 4) + 64;
imageCache.maximumSizeBytes = 1024 * 1024 * 1024; // 1 GB
final logo = _resolve(themeProfile.logoPath ?? '', projectPath);
final results = <Uint8List>[];
try {
for (var i = 0; i < slides.length; i++) {
// Warm this slide's images immediately before capturing it. Doing it
// per slide (instead of once up front) guarantees the bitmap is decoded
// and resident in the cache at capture time, no matter how many images
// the whole deck contains.
if (!context.mounted) break;
await _precachePaths(context, [
_resolve(slides[i].imagePath, projectPath),
_resolve(slides[i].imagePath2, projectPath),
logo,
]);
if (!context.mounted) break;
final key = GlobalKey();
final entry = OverlayEntry(
builder: (_) => Positioned(
left: -logicalSize.width - 100,
top: -logicalSize.height - 100,
child: RepaintBoundary(
key: key,
child: SizedBox(
width: logicalSize.width,
height: logicalSize.height,
child: SlidePreviewWidget(
slide: slides[i],
projectPath: projectPath,
themeProfile: themeProfile,
slideNumber: i + 1,
slideCount: slides.length,
tlp: tlp,
),
),
),
),
);
overlay.insert(entry);
try {
final png = await _capture(key, pixelRatio);
results.add(png);
} finally {
entry.remove();
}
onProgress?.call(i + 1, slides.length);
}
} finally {
imageCache.maximumSize = prevMaxSize;
imageCache.maximumSizeBytes = prevMaxBytes;
}
return results;
}
static Future<Uint8List> _capture(GlobalKey key, double pixelRatio) async {
// Allow build + layout + paint to settle before capturing. Wait for a
// couple of frames first so LayoutBuilder-driven subtrees (e.g. zoomed
// images) have a chance to lay out and paint their decoded bitmap.
await WidgetsBinding.instance.endOfFrame;
await WidgetsBinding.instance.endOfFrame;
RenderRepaintBoundary? boundary;
for (var attempt = 0; attempt < 30; attempt++) {
final obj = key.currentContext?.findRenderObject();
if (obj is RenderRepaintBoundary && !obj.debugNeedsPaint) {
boundary = obj;
break;
}
await Future<void>.delayed(const Duration(milliseconds: 16));
}
boundary ??=
key.currentContext!.findRenderObject() as RenderRepaintBoundary;
final ui.Image image = await boundary.toImage(pixelRatio: pixelRatio);
try {
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
return byteData!.buffer.asUint8List();
} finally {
image.dispose();
}
}
/// Decode and cache the given (already resolved) image paths, awaiting all of
/// them. Nulls and duplicates are ignored; decode errors are swallowed so a
/// single missing file never aborts the whole export.
static Future<void> _precachePaths(
BuildContext context,
List<String?> paths,
) async {
final unique = paths.whereType<String>().toSet();
final futures = <Future<void>>[];
for (final path in unique) {
if (!context.mounted) return;
futures.add(
precacheImage(FileImage(File(path)), context, onError: (_, _) {}),
);
}
await Future.wait(futures);
}
static String? _resolve(String imagePath, String? projectPath) {
if (imagePath.isEmpty) return null;
if (p.isAbsolute(imagePath)) return imagePath;
if (projectPath != null) return p.join(projectPath, imagePath);
return imagePath;
}
}