Skip to content

Instantly share code, notes, and snippets.

@escamoteur
Created July 21, 2026 20:37
Show Gist options
  • Select an option

  • Save escamoteur/84df4417b06cb19cfa90da894283af16 to your computer and use it in GitHub Desktop.

Select an option

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)
// 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