Created
July 21, 2026 20:37
-
-
Save escamoteur/84df4417b06cb19cfa90da894283af16 to your computer and use it in GitHub Desktop.
Headless Flutter store-screenshot exporter — reference implementation (flutter_store_screenshots#2: external-image framing, exact-pixel validation, storefront/locale matrix)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // headless_store_export_reference.dart | |
| // | |
| // Self-contained reference implementation of a headless App Store / Play Store | |
| // screenshot exporter for Flutter, extracted from a production ASO pipeline | |
| // (15 storefronts x 3 device classes x 9 screens = 405 exact-pixel PNGs). | |
| // | |
| // Shared as working reference material for flutter_store_screenshots issue #2. | |
| // It demonstrates, end to end, the pieces proposed there: | |
| // | |
| // 1. Framing EXTERNALLY-CAPTURED raw PNGs (real app screens) instead of live | |
| // widgets - `ui.instantiateImageCodec` -> `RawImage` paints synchronously, | |
| // so there is no async gap between layout and `toImage`. | |
| // 2. Headless batch export: mount canvas -> settle -> rasterize -> write -> | |
| // `exit(code)`. Runs on a desktop target (e.g. `flutter run -d linux`), | |
| // no human tapping an export button. | |
| // 3. An exact store-pixel contract per device target, asserted per file | |
| // (a one-pixel miss is a store rejection - fail fast, don't ship it). | |
| // 4. A storefront x device x screen job matrix with storefront -> UI-locale | |
| // fan-out (en-US/GB/AU/CA all consume the same `en` capture) and a | |
| // per-cell localized caption resolver. | |
| // | |
| // Only dependency beyond Flutter itself: device_frame (^1.3.0). | |
| // | |
| // Layout on disk (inputs): raws/<deviceFolder>/<uiLocale>/<NN>_<screen>.png | |
| // Layout on disk (outputs): out/<deviceFolder>/<storefront>/<NN>_<screen>.png | |
| // | |
| // Run: flutter run -d linux -t lib/headless_store_export_reference.dart | |
| import 'dart:io'; | |
| import 'dart:ui' as ui; | |
| import 'package:device_frame/device_frame.dart'; | |
| import 'package:flutter/foundation.dart'; | |
| import 'package:flutter/material.dart'; | |
| import 'package:flutter/rendering.dart'; | |
| // --------------------------------------------------------------------------- | |
| // 1. Device targets: the exact store-pixel contract lives HERE, once. | |
| // logicalSize * pixelRatio MUST equal storeOutputPx - asserted on write. | |
| // --------------------------------------------------------------------------- | |
| class ExportTarget { | |
| const ExportTarget({ | |
| required this.folderName, | |
| required this.frame, | |
| required this.logicalSize, | |
| required this.pixelRatio, | |
| required this.storeOutputPx, | |
| }); | |
| final String folderName; | |
| final DeviceInfo frame; | |
| final Size logicalSize; // canvas logical size | |
| final double pixelRatio; // rasterization scale | |
| final Size storeOutputPx; // the size the store actually requires | |
| /// The aspect the raw capture must have to fill the frame without cropping. | |
| double get frameScreenAspect => | |
| frame.screenSize.width / frame.screenSize.height; | |
| } | |
| final targets = <ExportTarget>[ | |
| ExportTarget( | |
| folderName: 'ios_iphone_6_9', | |
| frame: Devices.ios.iPhone16ProMax, | |
| logicalSize: const Size(440, 956), | |
| pixelRatio: 3.0, | |
| storeOutputPx: const Size(1320, 2868), | |
| ), | |
| ExportTarget( | |
| folderName: 'ios_ipad_13', | |
| frame: Devices.ios.iPadPro13InchesM4, | |
| logicalSize: const Size(1032, 1376), | |
| pixelRatio: 2.0, | |
| storeOutputPx: const Size(2064, 2752), | |
| ), | |
| ExportTarget( | |
| folderName: 'android_phone', | |
| frame: Devices.android.samsungGalaxyS25, | |
| logicalSize: const Size(432, 768), | |
| pixelRatio: 2.5, | |
| storeOutputPx: const Size(1080, 1920), | |
| ), | |
| ]; | |
| // --------------------------------------------------------------------------- | |
| // 2. The matrix: storefronts fan out to UI locales; captions resolve per cell. | |
| // --------------------------------------------------------------------------- | |
| const screens = ['01_hook', '02_core', '03_cta']; | |
| /// 15 storefronts can share 10 UI-locale capture sets - the capture matrix is | |
| /// smaller than the storefront matrix, and this map is the whole trick. | |
| const uiLocaleByStorefront = <String, String>{ | |
| 'en-US': 'en', 'en-GB': 'en', 'en-AU': 'en', 'en-CA': 'en', | |
| 'de-DE': 'de', | |
| // ... | |
| }; | |
| /// Replace with your real localized-caption source (JSON asset, ARB, ...). | |
| String captionFor(String storefront, String screen) => | |
| '<$storefront caption for $screen>'; | |
| class ExportJob { | |
| const ExportJob(this.target, this.storefront, this.screen); | |
| final ExportTarget target; | |
| final String storefront; | |
| final String screen; | |
| String get uiLocale => uiLocaleByStorefront[storefront] ?? 'en'; | |
| String get rawPath => 'raws/${target.folderName}/$uiLocale/$screen.png'; | |
| String get outPath => 'out/${target.folderName}/$storefront/$screen.png'; | |
| } | |
| List<ExportJob> buildJobs() => [ | |
| for (final t in targets) | |
| for (final sf in uiLocaleByStorefront.keys) | |
| for (final s in screens) ExportJob(t, sf, s), | |
| ]; | |
| // --------------------------------------------------------------------------- | |
| // 3. Load the externally-captured raw so it paints SYNCHRONOUSLY. | |
| // --------------------------------------------------------------------------- | |
| Future<ui.Image?> loadRaw(String path) async { | |
| final file = File(path); | |
| if (!file.existsSync()) return null; | |
| final codec = await ui.instantiateImageCodec(await file.readAsBytes()); | |
| return (await codec.getNextFrame()).image; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // 4. The headless driver: one widget, one loop, exit code at the end. | |
| // --------------------------------------------------------------------------- | |
| void main() => runApp(const _ExporterApp()); | |
| class _ExporterApp extends StatelessWidget { | |
| const _ExporterApp(); | |
| @override | |
| Widget build(BuildContext context) => const MaterialApp( | |
| debugShowCheckedModeBanner: false, | |
| home: _ExporterHost(), | |
| ); | |
| } | |
| class _ExporterHost extends StatefulWidget { | |
| const _ExporterHost(); | |
| @override | |
| State<_ExporterHost> createState() => _ExporterHostState(); | |
| } | |
| class _ExporterHostState extends State<_ExporterHost> { | |
| final _boundaryKey = GlobalKey(); | |
| ExportJob? _job; | |
| ui.Image? _raw; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| WidgetsBinding.instance.addPostFrameCallback((_) => _run()); | |
| } | |
| Future<void> _run() async { | |
| var ok = 0, failed = 0, missing = 0; | |
| for (final job in buildJobs()) { | |
| final raw = await loadRaw(job.rawPath); | |
| if (raw == null) { | |
| missing++; | |
| debugPrint('MISSING RAW ${job.rawPath}'); | |
| continue; | |
| } | |
| // The frame's screen is filled with BoxFit.cover: if the raw's aspect | |
| // differs from the frame screen aspect, app content gets cropped. | |
| // Warn loudly - this catches mis-captured raws at export time. | |
| final rawAspect = raw.width / raw.height; | |
| if ((rawAspect - job.target.frameScreenAspect).abs() > 0.02) { | |
| debugPrint('WARN aspect mismatch ${job.rawPath}: ' | |
| 'raw ${rawAspect.toStringAsFixed(4)} vs frame ' | |
| '${job.target.frameScreenAspect.toStringAsFixed(4)} - will crop'); | |
| } | |
| setState(() { | |
| _job = job; | |
| _raw = raw; | |
| }); | |
| // Let layout + text paint settle (fonts rasterize on first use). | |
| await WidgetsBinding.instance.endOfFrame; | |
| await Future<void>.delayed(const Duration(milliseconds: 120)); | |
| try { | |
| await _rasterize(job); | |
| ok++; | |
| } catch (e) { | |
| failed++; | |
| debugPrint('FAILED ${job.outPath}: $e'); | |
| } | |
| raw.dispose(); | |
| } | |
| debugPrint('DONE - $ok ok, $failed failed, $missing raw missing'); | |
| await WidgetsBinding.instance.endOfFrame; | |
| exit(failed == 0 && missing == 0 ? 0 : 1); | |
| } | |
| Future<void> _rasterize(ExportJob job) async { | |
| final boundary = _boundaryKey.currentContext!.findRenderObject()! | |
| as RenderRepaintBoundary; | |
| final image = await boundary.toImage(pixelRatio: job.target.pixelRatio); | |
| try { | |
| // THE exact-pixel contract - fail fast instead of shipping a rejection. | |
| final want = job.target.storeOutputPx; | |
| if (image.width != want.width.round() || | |
| image.height != want.height.round()) { | |
| throw StateError('expected ${want.width.round()}x${want.height.round()}' | |
| ', got ${image.width}x${image.height}'); | |
| } | |
| final bytes = | |
| (await image.toByteData(format: ui.ImageByteFormat.png))!; | |
| final out = File(job.outPath)..parent.createSync(recursive: true); | |
| await out.writeAsBytes(bytes.buffer.asUint8List()); | |
| } finally { | |
| image.dispose(); | |
| } | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| final job = _job; | |
| if (job == null) return const SizedBox.shrink(); | |
| return Center( | |
| // FittedBox: the window can be ANY size; the boundary renders at the | |
| // target's logical size regardless, so output pixels never depend on | |
| // the host window. | |
| child: FittedBox( | |
| fit: BoxFit.contain, | |
| child: SizedBox.fromSize( | |
| size: job.target.logicalSize, | |
| child: RepaintBoundary( | |
| key: _boundaryKey, | |
| child: _Canvas(job: job, raw: _raw!), | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // 5. The marketing canvas: caption + framed EXTERNAL raw. Swap in your own | |
| // branding; the load-bearing part is RawImage (synchronous paint - the | |
| // decoded ui.Image is already in memory when the frame is drawn). | |
| // --------------------------------------------------------------------------- | |
| class _Canvas extends StatelessWidget { | |
| const _Canvas({required this.job, required this.raw}); | |
| final ExportJob job; | |
| final ui.Image raw; | |
| @override | |
| Widget build(BuildContext context) { | |
| return ColoredBox( | |
| color: const Color(0xFFF1E7D4), | |
| child: Column( | |
| children: [ | |
| Padding( | |
| padding: const EdgeInsets.all(24), | |
| child: Text( | |
| captionFor(job.storefront, job.screen), | |
| textAlign: TextAlign.center, | |
| style: const TextStyle(fontSize: 28, color: Colors.black87), | |
| ), | |
| ), | |
| Expanded( | |
| child: DeviceFrame( | |
| device: job.target.frame, | |
| screen: RawImage(image: raw, fit: BoxFit.cover), | |
| ), | |
| ), | |
| ], | |
| ), | |
| ); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment