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> rasterize({ required BuildContext context, required List slides, required ThemeProfile themeProfile, required String? projectPath, TlpLevel tlp = TlpLevel.none, int targetWidth = 1920, void Function(int done, int total)? onProgress, void Function(String phase, int done, int total)? onStage, }) 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 = []; try { for (var i = 0; i < slides.length; i++) { onStage?.call('prepare', i, slides.length); // 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; onStage?.call('render', i, slides.length); 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(); } onStage?.call('done', i + 1, slides.length); onProgress?.call(i + 1, slides.length); } } finally { imageCache.maximumSize = prevMaxSize; imageCache.maximumSizeBytes = prevMaxBytes; } return results; } static Future _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.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 _precachePaths( BuildContext context, List paths, ) async { final unique = paths.whereType().toSet(); final futures = >[]; 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; } }