Add store capture harness: record real wallet flows into screenshots + video

Drives the real app through real flows on a device while recording the screen,
then cuts ONE recording into both deliverables — store screenshots and
marketing video — so a listing can't drift out of sync with its own trailer.

integration_test/demo/
  DemoSession adds what a passing test lacks: pacing (beat/hold/typeSlowly, so
  footage is watchable rather than a strobe), timing marks reported through
  binding.reportData, and seed guards. Scenarios reuse the existing 25 page
  robots rather than reimplementing navigation:
    core_store_set     dashboard, receive, send (composed, NEVER broadcast),
                       wallets, security — the store screenshot set
    onboarding_create  welcome -> PIN -> seed -> dashboard
    restore_from_seed  restore -> syncing
    swap_and_buy       Trocador quote, stopping BEFORE creating a trade; Buy
                       skipped when no provider key, matching what reviewers see

tools/demo/
  capture.sh   orchestrates boot/record/cut/shots/reel
  marks.py     maps timing marks onto the recording; cuts, extracts frames,
               builds manifests
  reel.py      branded master; reuses such_graphics video_transitions/video_qa
               when ~/src/such-graphics is present, degrades cleanly when not
               (this repo is public and must build without it)
  build-apk.sh local APK via the CI container image

Seed safety — the demo wallet is a real wallet with real funds:
  - seed comes from Vaultwarden at capture time, never the repo
  - guardNoSeedOnScreen() fails the run on a visible mnemonic, catching both
    the known seed and the generic shape of one (so freshly generated seeds
    are caught too)
  - unavoidable seed screens are wrapped in blindfold(); those spans are
    excised from the video with 0.6s padding, and screenshots honour the same
    padding
  - marks.py refuses to build a publishable master if any clip was cut without
    marks, since blindfold spans could not have been excised

Apple App Preview gets no touch indicators, captions, cards or music, and
reel.py enforces the 15-30s window rather than emitting an out-of-spec file.
Everything else keeps taps on, drawn by SurfaceFlinger via show_touches rather
than composited, so they look real.

Play sizing: the harness builds its own 1080x1920 AVD. The stock Medium Phone
AVD is 1080x2400 = 2.22:1 and Play rejects phone screenshots above 2:1.

Also gitignores android/app/upload-keystore.jks (and *.jks/*.keystore): the
Android workflow decodes ANDROID_KEYSTORE_BASE64 to exactly that path, so the
Play upload signing key was one `git add -A` away from being committed.
This commit is contained in:
jwinterm
2026-07-31 15:25:29 -04:00
parent 4be0eb9613
commit c396e94b77
24 changed files with 2716 additions and 0 deletions
+10
View File
@@ -88,6 +88,11 @@ cw_monero/cw_monero/android/.cxx/
android/key.properties
android/app/key.jks
# The Android CI workflow decodes ANDROID_KEYSTORE_BASE64 to this exact path.
# That is the Play upload signing key — it must never be committable.
android/app/upload-keystore.jks
android/app/*.jks
android/app/*.keystore
**/tool/.secrets-prod.json
**/tool/.secrets-test.json
@@ -234,3 +239,8 @@ scripts/reown_flutter
docs/links.md
docs/UPSTREAM_SYNC.md
scripts/windows/setup-windows-runner.ps1
# Capture harness: local config (Vaultwarden host, demo addresses). The
# committed template is tools/demo/demo.env.example.
tools/demo/demo.env
__pycache__/
+60
View File
@@ -0,0 +1,60 @@
import 'package:cw_core/wallet_type.dart';
/// Capture-time configuration, injected by `tools/demo/capture.sh` via
/// `--dart-define`. Nothing here is baked into a shipped build.
///
/// The demo wallet seed is pulled from the self-hosted Vaultwarden at capture
/// time and passed straight through — it is never written to the repo, never
/// committed, and never placed in `.secrets.g.dart`.
class DemoConfig {
/// BIP39 / Wownero seed for the demo wallet.
static const String walletSeed = String.fromEnvironment('DEMO_WALLET_SEED');
/// Which wallet the demo restores into. Hash Bags is Wownero-first, so that
/// is the default face of the app in store material.
static const String _walletTypeName =
String.fromEnvironment('DEMO_WALLET_TYPE', defaultValue: 'wownero');
/// A receive address used to populate the Send screen. This is a
/// *destination shown on camera*, so it should be an address you are happy
/// to publish — the capture script defaults it to the demo wallet's own
/// address rather than a stranger's.
static const String sendToAddress =
String.fromEnvironment('DEMO_SEND_ADDRESS');
/// Amount typed into the Send screen. Never broadcast in the core scenario.
static const String sendAmount =
String.fromEnvironment('DEMO_SEND_AMOUNT', defaultValue: '0.5');
/// Amount typed into the Swap screen.
static const String swapAmount =
String.fromEnvironment('DEMO_SWAP_AMOUNT', defaultValue: '10');
/// PIN used for the demo wallet.
static List<int> get pin {
const raw = String.fromEnvironment('DEMO_PIN', defaultValue: '0801');
return raw.split('').map(int.parse).toList();
}
static bool get hasSeed => walletSeed.trim().isNotEmpty;
static WalletType get walletType {
switch (_walletTypeName.toLowerCase()) {
case 'monero':
return WalletType.monero;
case 'bitcoin':
return WalletType.bitcoin;
case 'litecoin':
return WalletType.litecoin;
case 'wownero':
default:
return WalletType.wownero;
}
}
/// Human label for captions/log lines.
static String get walletLabel {
final n = _walletTypeName.toLowerCase();
return n.isEmpty ? 'Wownero' : '${n[0].toUpperCase()}${n.substring(1)}';
}
}
+241
View File
@@ -0,0 +1,241 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
/// Shared plumbing for the marketing / store-capture scenarios.
///
/// These scenarios are NOT correctness tests. They exist to drive the real app
/// through real flows at a pace a human can watch, while the host records the
/// screen. Three things separate them from the suites in `test_suites/`:
///
/// 1. **Pacing.** A passing test rips through screens in milliseconds. That
/// footage is unwatchable. [beat] holds on a screen long enough to read it.
///
/// 2. **Marks.** Each beat is timestamped relative to session start and
/// reported through `binding.reportData`, which the driver writes to
/// `integration_test/integration_response_data.json`. `capture.sh` reads
/// that file to build the reel manifest, so captions land on the exact
/// frame the action happens instead of being hand-timed.
///
/// 3. **Seed safety.** The demo wallet is a REAL wallet. [guardNoSeedOnScreen]
/// fails the run if a mnemonic is visible while recording, so a seed can
/// never end up in a published video. Scenarios that must pass through a
/// seed screen wrap it in [blindfold].
class DemoSession {
DemoSession(this.tester, this.binding, this.scenario);
final WidgetTester tester;
final IntegrationTestWidgetsFlutterBinding binding;
final String scenario;
final Stopwatch _clock = Stopwatch();
final List<Map<String, dynamic>> _marks = <Map<String, dynamic>>[];
/// Device-clock epoch (ms) at the moment the scenario started.
///
/// The host starts `adb screenrecord` before the app is even installed, so
/// mark timestamps (relative to scenario start) mean nothing to the video
/// on their own. capture.sh reads the device clock at record start, and the
/// difference between that and this value is the offset from the first frame
/// of video to mark 0. Both numbers come from the DEVICE clock, so host/guest
/// clock skew cannot corrupt the alignment.
int startedAtEpochMs = 0;
/// Words that, seen together in bulk, mean a mnemonic is on screen.
static const int _mnemonicWordThreshold = 12;
/// Extra secrets the host injects (the demo wallet seed) that must never be
/// rendered while the camera rolls. Populated from --dart-define.
static const String _demoSeed = String.fromEnvironment('DEMO_WALLET_SEED');
double get _elapsed => _clock.elapsedMilliseconds / 1000.0;
void begin() {
startedAtEpochMs = DateTime.now().millisecondsSinceEpoch;
_clock.start();
_marks.add(<String, dynamic>{
'at': 0.0,
'label': 'start',
'caption': null,
});
}
/// Record a beat. [caption], when given, becomes a burned caption in the
/// social reel at exactly this timestamp.
void mark(String label, {String? caption}) {
_marks.add(<String, dynamic>{
'at': double.parse(_elapsed.toStringAsFixed(3)),
'label': label,
'caption': caption,
});
tester.printToConsole('[demo:$scenario] ${_elapsed.toStringAsFixed(2)}s $label');
}
/// Hold on the current screen for [seconds] of WALL CLOCK time, pumping
/// frames throughout.
///
/// Deliberately not `pumpAndSettle`: the dashboard runs a perpetual sync
/// indicator animation, so `pumpAndSettle` would spin until it times out.
/// Pumping on a wall-clock deadline is both safe and what makes the footage
/// watchable.
Future<void> beat([double seconds = 1.6]) async {
final deadline = DateTime.now().add(
Duration(milliseconds: (seconds * 1000).round()),
);
while (DateTime.now().isBefore(deadline)) {
await tester.pump(const Duration(milliseconds: 33)); // ~30fps
}
}
/// Beat, then record a mark — the common "let the viewer read this" move.
Future<void> hold(String label, {String? caption, double seconds = 1.8}) async {
mark(label, caption: caption);
await beat(seconds);
}
/// Tap a finder with camera-friendly lead-in and follow-through, so the
/// viewer sees WHAT is about to be tapped before the screen changes.
Future<void> tap(
Finder finder, {
required String label,
String? caption,
double before = 0.9,
double after = 1.4,
}) async {
await beat(before);
mark(label, caption: caption);
await tester.tap(finder, warnIfMissed: false);
await beat(after);
}
/// Type text at human speed instead of instantly, which reads as a paste and
/// looks fake on camera. `perChar` is the inter-keystroke delay.
Future<void> typeSlowly(
Finder field,
String text, {
double perChar = 0.06,
}) async {
await tester.tap(field, warnIfMissed: false);
await beat(0.4);
final buffer = StringBuffer();
for (final rune in text.runes) {
buffer.writeCharCode(rune);
await tester.enterText(field, buffer.toString());
await tester.pump(Duration(milliseconds: (perChar * 1000).round()));
}
await beat(0.5);
}
/// Every rendered string currently on screen.
Iterable<String> _visibleText() sync* {
for (final element in find.byType(Text).evaluate()) {
final widget = element.widget as Text;
final data = widget.data;
if (data != null && data.isNotEmpty) yield data;
}
for (final element in find.byType(EditableText).evaluate()) {
final widget = element.widget as EditableText;
final data = widget.controller.text;
if (data.isNotEmpty) yield data;
}
}
/// Fail loudly if a mnemonic is on screen while recording.
///
/// Two independent checks, because either alone has a blind spot: the known
/// demo seed may be split across widgets, and an unknown seed (a freshly
/// generated one) will not match any known string.
void guardNoSeedOnScreen({String? because}) {
final visible = _visibleText().toList();
if (_demoSeed.isNotEmpty) {
final needle = _demoSeed.trim().toLowerCase();
final haystack = visible.join(' ').toLowerCase();
if (haystack.contains(needle)) {
fail('SEED ON CAMERA (demo wallet seed rendered) '
'${because ?? ''} — scenario "$scenario". Recording aborted.');
}
// Also catch the seed rendered one-word-per-widget.
final words = needle.split(RegExp(r'\s+')).toSet();
final onScreenWords = haystack.split(RegExp(r'[^a-z]+')).toSet();
final overlap = words.intersection(onScreenWords).length;
if (words.length >= _mnemonicWordThreshold &&
overlap >= _mnemonicWordThreshold) {
fail('SEED ON CAMERA (demo seed words rendered individually, '
'$overlap matches) ${because ?? ''} — scenario "$scenario".');
}
}
// Generic shape check: any single widget holding many lowercase words is
// almost certainly a mnemonic in this app.
for (final text in visible) {
final words = text
.trim()
.split(RegExp(r'\s+'))
.where((w) => RegExp(r'^[a-zA-Z]{3,}$').hasMatch(w))
.toList();
if (words.length >= _mnemonicWordThreshold) {
fail('SEED ON CAMERA (widget holds ${words.length} word-like tokens) '
'${because ?? ''} — scenario "$scenario". Recording aborted.');
}
}
}
/// Run [body] with the recording explicitly marked as unsafe-to-publish.
///
/// Used for the unavoidable seed screens in the create/restore scenarios.
/// The frames still get recorded — we cannot stop the host recorder from
/// here — so this brackets them with marks. `capture.sh` reads the resulting
/// `blindfold` spans and passes them to reel.py as regions to cut, and
/// refuses to emit a publishable master if any span is unhandled.
Future<void> blindfold(String reason, Future<void> Function() body) async {
mark('blindfold_start', caption: null);
tester.printToConsole(
'[demo:$scenario] BLINDFOLD START ($reason) — frames from here are '
'NOT publishable until cut',
);
try {
await body();
} finally {
mark('blindfold_end', caption: null);
tester.printToConsole('[demo:$scenario] BLINDFOLD END');
}
}
/// Publish the marks back to the host through the driver.
void finish() {
_clock.stop();
mark('end');
final existing = binding.reportData ?? <String, dynamic>{};
existing['demo'] = <String, dynamic>{
'scenario': scenario,
'started_at_epoch_ms': startedAtEpochMs,
'duration': double.parse(_elapsed.toStringAsFixed(3)),
'marks': _marks,
'blindfold_spans': _blindfoldSpans(),
};
binding.reportData = existing;
}
/// Pair up blindfold_start/blindfold_end marks into [start, end] windows.
List<Map<String, double>> _blindfoldSpans() {
final spans = <Map<String, double>>[];
double? open;
for (final m in _marks) {
if (m['label'] == 'blindfold_start') {
open = m['at'] as double;
} else if (m['label'] == 'blindfold_end' && open != null) {
spans.add(<String, double>{'start': open, 'end': m['at'] as double});
open = null;
}
}
// An unclosed span means the scenario crashed mid-seed. Treat the whole
// remainder as unsafe rather than assuming it ended.
if (open != null) {
spans.add(<String, double>{'start': open, 'end': _elapsed});
}
return spans;
}
}
@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../components/common_test_cases.dart';
import '../../components/common_test_flows.dart';
import '../../robots/dashboard_page_robot.dart';
import '../../robots/send_page_robot.dart';
import '../demo_config.dart';
import '../demo_session.dart';
/// CORE STORE SET — the screens that become App Store / Play screenshots and
/// the backbone of the marketing reel.
///
/// Dashboard → Receive → Send (composed, never broadcast) → Wallets → Security.
///
/// If the device already holds the demo wallet, this shoots it directly. If it
/// does not, the scenario restores from seed first — slower, and the restore
/// frames get excised as a blindfold span, but the footage is still correct.
///
/// NOTHING here broadcasts a transaction. The Send screen is filled in and
/// held on the review step so the UI is fully populated, then backed out of.
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('demo: core store set', (tester) async {
FlutterError.onError = (FlutterErrorDetails details) {
debugPrint('FlutterError caught: ${details.exception}');
};
final demo = DemoSession(tester, binding, 'core_store_set');
final flows = CommonTestFlows(tester);
final cases = CommonTestCases(tester);
final dashboard = DashboardPageRobot(tester);
final send = SendPageRobot(tester: tester);
demo.begin();
await flows.startAppFlow(const ValueKey('demo_core_store_set'));
await demo.beat(3.0);
// If the device was not pre-seeded with the demo wallet, restore it now.
// The seed screens are bracketed so the frames can be cut.
if (DemoConfig.hasSeed && find.text('Restore').evaluate().isNotEmpty) {
await demo.blindfold('restoring demo wallet from seed', () async {
await flows.welcomePageToRestoreWalletThroughSeedsFlow(
DemoConfig.walletType,
DemoConfig.walletSeed,
DemoConfig.pin,
);
});
await demo.beat(2.0);
}
// ---------------- 1. Dashboard ------------------------------------
await dashboard.isDashboardPage();
demo.guardNoSeedOnScreen(because: 'on dashboard');
await demo.hold(
'dashboard',
caption: 'Your keys. Your coins.',
seconds: 3.5,
);
// ---------------- 2. Receive --------------------------------------
await dashboard.navigateToReceivePage();
await demo.beat(2.0);
demo.guardNoSeedOnScreen(because: 'on receive page');
await demo.hold(
'receive',
caption: 'Receive in a tap',
seconds: 4.0,
);
await cases.goBack();
await demo.beat(1.5);
// ---------------- 3. Send (compose only) --------------------------
await dashboard.navigateToSendPage();
await send.checkIfSendPageIsVisible();
await demo.beat(1.2);
demo.mark('send_open', caption: 'Send with confidence');
if (DemoConfig.sendToAddress.isNotEmpty) {
// Typed at human speed — an instant fill reads as a paste on camera.
final addressField = find.byType(EditableText).first;
await demo.typeSlowly(addressField, DemoConfig.sendToAddress);
demo.mark('send_address_entered');
}
await demo.beat(1.0);
await send.enterSendAmount(DemoConfig.sendAmount);
await demo.hold('send_amount_entered', seconds: 2.5);
// Deliberately NOT calling onSendButtonPressed(). We show a fully
// composed transaction and walk away — no funds move to make a video.
demo.mark('send_composed_not_broadcast');
await cases.goBack();
await demo.beat(1.5);
// ---------------- 4. Wallet list ----------------------------------
await dashboard.navigateToWalletsListPage();
await demo.beat(2.0);
demo.guardNoSeedOnScreen(because: 'on wallet list');
await demo.hold(
'wallets',
caption: 'Wownero, Monero, Bitcoin & more',
seconds: 3.5,
);
await cases.goBack();
await demo.beat(1.5);
// ---------------- 5. Security & backup ----------------------------
await dashboard.openDrawerMenu();
await demo.beat(2.0);
await demo.hold(
'menu',
caption: 'Everything under your control',
seconds: 3.0,
);
demo.guardNoSeedOnScreen(because: 'in drawer menu');
demo.finish();
});
}
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../components/common_test_flows.dart';
import '../../robots/dashboard_page_robot.dart';
import '../demo_config.dart';
import '../demo_session.dart';
/// ONBOARDING — first launch through to a live wallet.
///
/// The strongest marketing story the app has: no email, no KYC, no account,
/// wallet in under a minute.
///
/// SEED SAFETY: this flow generates a brand-new wallet, so a real mnemonic
/// WILL be rendered on the seed + verification screens. Those beats run inside
/// `blindfold`, which records the span into the driver output. `capture.sh`
/// refuses to promote a master to the publishable directory while any
/// blindfold span is uncut, so the frames cannot reach a store listing by
/// accident.
///
/// The wallet created here is throwaway. Never fund it.
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('demo: onboarding + create wallet', (tester) async {
FlutterError.onError = (FlutterErrorDetails details) {
debugPrint('FlutterError caught: ${details.exception}');
};
final demo = DemoSession(tester, binding, 'onboarding_create');
final flows = CommonTestFlows(tester);
final dashboard = DashboardPageRobot(tester);
demo.begin();
await flows.startAppFlow(const ValueKey('demo_onboarding_create'));
await demo.beat(3.0);
await demo.hold(
'welcome',
caption: 'No email. No KYC. No account.',
seconds: 3.5,
);
// The create flow walks PIN setup, seed display and seed verification in
// one call. Everything from here until the dashboard is unsafe to publish.
await demo.blindfold('new wallet seed is displayed and verified', () async {
await flows.welcomePageToCreateNewWalletFlow(
DemoConfig.walletType,
DemoConfig.pin,
);
});
await demo.beat(2.5);
// Back on safe ground — verify it and shoot the payoff.
await dashboard.isDashboardPage();
demo.guardNoSeedOnScreen(because: 'post-create dashboard');
await demo.hold(
'wallet_ready',
caption: 'A ${DemoConfig.walletLabel} wallet in under a minute',
seconds: 4.0,
);
demo.finish();
});
}
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../components/common_test_flows.dart';
import '../../robots/dashboard_page_robot.dart';
import '../demo_config.dart';
import '../demo_session.dart';
/// RESTORE — bring an existing wallet back from its seed.
///
/// The "you are not locked in" story: the seed is yours, the wallet is
/// portable, Hash Bags is just a viewer for keys you already hold.
///
/// SEED SAFETY: the entry screen necessarily shows the demo wallet's real
/// mnemonic, so the whole restore is wrapped in `blindfold`. The publishable
/// cut starts at the dashboard. Do not lift that bracket to "get a better
/// shot" — the demo wallet holds real funds.
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('demo: restore from seed', (tester) async {
FlutterError.onError = (FlutterErrorDetails details) {
debugPrint('FlutterError caught: ${details.exception}');
};
final demo = DemoSession(tester, binding, 'restore_from_seed');
final flows = CommonTestFlows(tester);
final dashboard = DashboardPageRobot(tester);
if (!DemoConfig.hasSeed) {
fail('restore_from_seed needs DEMO_WALLET_SEED. Run it through '
'tools/demo/capture.sh, which pulls the seed from Vaultwarden.');
}
demo.begin();
await flows.startAppFlow(const ValueKey('demo_restore_from_seed'));
await demo.beat(3.0);
await demo.hold(
'welcome',
caption: 'Already have a wallet?',
seconds: 3.0,
);
await demo.blindfold('demo wallet mnemonic is typed on screen', () async {
await flows.welcomePageToRestoreWalletThroughSeedsFlow(
DemoConfig.walletType,
DemoConfig.walletSeed,
DemoConfig.pin,
);
});
await demo.beat(3.0);
await dashboard.isDashboardPage();
demo.guardNoSeedOnScreen(because: 'post-restore dashboard');
await demo.hold(
'restored',
caption: 'Your seed. Your wallet. Anywhere.',
seconds: 4.0,
);
// Let the sync indicator run a little — real syncing is part of the story
// and it looks alive on camera.
await demo.hold('syncing', caption: 'Syncing from the network', seconds: 4.0);
demo.finish();
});
}
@@ -0,0 +1,114 @@
import 'package:cw_core/crypto_currency.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../components/common_test_cases.dart';
import '../../components/common_test_flows.dart';
import '../../robots/dashboard_page_robot.dart';
import '../../robots/exchange_page_robot.dart';
import '../demo_config.dart';
import '../demo_session.dart';
/// SWAP + BUY — the exchange surface.
///
/// Shows a Trocador swap being configured and quoted. It deliberately stops
/// BEFORE `onExchangeButtonPressed()`: creating a trade moves real funds and
/// burns a real Trocador order, neither of which should happen to make a
/// marketing clip.
///
/// BUY is gated behind provider keys (see the MoonPay key-gating commit). A
/// capture build has empty secrets, so the Buy action is hidden and this
/// scenario logs that it skipped rather than failing — which is also the
/// correct store-review posture.
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('demo: swap and buy', (tester) async {
FlutterError.onError = (FlutterErrorDetails details) {
debugPrint('FlutterError caught: ${details.exception}');
};
final demo = DemoSession(tester, binding, 'swap_and_buy');
final flows = CommonTestFlows(tester);
final cases = CommonTestCases(tester);
final dashboard = DashboardPageRobot(tester);
final exchange = ExchangePageRobot(tester);
demo.begin();
await flows.startAppFlow(const ValueKey('demo_swap_and_buy'));
await demo.beat(3.0);
if (DemoConfig.hasSeed && find.text('Restore').evaluate().isNotEmpty) {
await demo.blindfold('restoring demo wallet from seed', () async {
await flows.welcomePageToRestoreWalletThroughSeedsFlow(
DemoConfig.walletType,
DemoConfig.walletSeed,
DemoConfig.pin,
);
});
await demo.beat(2.0);
}
await dashboard.isDashboardPage();
await demo.beat(1.5);
// ---------------- Swap --------------------------------------------
await dashboard.navigateToExchangePage();
await demo.beat(2.5);
await exchange.isExchangePage();
demo.guardNoSeedOnScreen(because: 'on exchange page');
await demo.hold(
'swap_open',
caption: 'Swap without an account',
seconds: 3.0,
);
await exchange.displayBothExchangeCards();
await demo.beat(1.5);
// Pick a pair. Wrapped because currency pickers are the most brittle part
// of this screen — a picker change upstream should degrade the clip, not
// kill the whole capture run.
try {
await exchange.selectDepositCurrency(CryptoCurrency.wow);
await demo.beat(1.2);
await exchange.selectReceiveCurrency(CryptoCurrency.btc);
await demo.hold('pair_selected', caption: 'WOW → BTC', seconds: 2.5);
await exchange.enterDepositAmount(DemoConfig.swapAmount);
await demo.hold(
'quote',
caption: 'Live rate, no sign-up',
seconds: 4.0,
);
} catch (e) {
tester.printToConsole('[demo] swap pair selection degraded: $e');
demo.mark('swap_pair_skipped');
}
// Stop here on purpose — no trade is created.
demo.mark('swap_quoted_not_executed');
await demo.beat(1.5);
// ---------------- Buy (only if a provider is actually enabled) ------
await cases.goBack();
await demo.beat(1.5);
final buyVisible = find.text('Buy').evaluate().isNotEmpty;
if (buyVisible) {
await dashboard.navigateToBuyPage();
await demo.beat(2.5);
demo.guardNoSeedOnScreen(because: 'on buy page');
await demo.hold('buy', caption: 'Buy with card, where available', seconds: 3.5);
} else {
tester.printToConsole(
'[demo] Buy is hidden — no provider key in this build. Skipping, '
'which matches what store reviewers will see.',
);
demo.mark('buy_hidden_no_provider');
}
demo.finish();
});
}
@@ -0,0 +1,70 @@
import XCTest
// fastlane snapshot UI test for Hash Bags.
//
// IMPORTANT — Flutter caveat: Flutter renders the whole UI into ONE native view.
// XCUITest finds "elements" only through the ACCESSIBILITY tree. So for taps to
// work you must either (a) match by the visible label text, or (b) add
// Semantics(identifier: 'send_button') around the widgets you drive. Wallet flows
// (create/restore, PIN) are painful to automate, so the recommended pattern is to
// launch straight into a screenshot-ready DEMO wallet via a launch argument that
// the Flutter app recognizes (see store/SCREENSHOTS.md), then shoot the key
// screens. Adjust identifiers/waits to match the actual UI.
final class HashBagsScreenshotsUITests: XCTestCase {
var app: XCUIApplication!
override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
setupSnapshot(app) // from SnapshotHelper.swift (fastlane snapshot init)
// Tell the Flutter app to restore a demo wallet + skip onboarding so the
// shots show a real dashboard. Handle this arg in Dart (see the guide).
app.launchArguments += ["--dart-define=SCREENSHOT_MODE=true", "FASTLANE_SNAPSHOT"]
app.launch()
}
func testScreenshots() throws {
// 1) Home / dashboard — wait for it to settle, then shoot.
_ = app.otherElements["home_dashboard"].waitForExistence(timeout: 45)
snapshot("01-Home")
// 2) Receive
tapFirst(["Receive", "receive_action"])
snapshot("02-Receive")
goBack()
// 3) Send
tapFirst(["Send", "send_action"])
snapshot("03-Send")
goBack()
// 4) Swap (Trocador)
tapFirst(["Swap", "exchange_action"])
snapshot("04-Swap")
goBack()
// 5) Settings
tapFirst(["Settings", "settings_action"])
snapshot("05-Settings")
}
// MARK: - helpers
/// Tap the first element (button or generic) matching any of the given
/// accessibility labels/identifiers.
private func tapFirst(_ names: [String], timeout: TimeInterval = 15) {
for n in names {
let btn = app.buttons[n]
if btn.waitForExistence(timeout: 2) { btn.tap(); return }
let any = app.descendants(matching: .any)[n]
if any.exists { any.tap(); return }
}
XCTFail("Could not find any of: \(names). Add a Semantics(identifier:) to that widget.")
}
private func goBack() {
let back = app.navigationBars.buttons.element(boundBy: 0)
if back.waitForExistence(timeout: 3) { back.tap() }
}
}
+3
View File
@@ -0,0 +1,3 @@
app_identifier("com.suchsoftware.hashwallet")
team_id("32J6BB6VUS") # Apple Developer Team (Such Software LLC)
# apple_id("you@such.software") # fill in only if you later use fastlane to upload
+11
View File
@@ -0,0 +1,11 @@
default_platform(:ios)
platform :ios do
desc "Generate App Store screenshots (iPhone 6.9\" + iPad 13\") via snapshot"
lane :screenshots do
capture_ios_screenshots # reads ios/fastlane/Snapfile
# Optional: add device frames + captions (needs `brew install imagemagick`):
# frame_screenshots(white: true)
UI.success("Screenshots in ios/screenshots/en-US/ — upload to App Store Connect")
end
end
+23
View File
@@ -0,0 +1,23 @@
# fastlane snapshot config for Hash Bags App Store screenshots.
# Run on macOS (Mac-mini runner or your Mac): cd ios && fastlane screenshots
#
# These two devices cover Apple's required sizes:
# iPhone 16 Pro Max -> 6.9" (1290x2796 / 1320x2868) [REQUIRED]
# iPad Pro 13" (M4) -> 13" (2064x2752) [required if you ship iPad]
devices([
"iPhone 16 Pro Max",
"iPad Pro 13-inch (M4)"
])
languages(["en-US"])
# The Runner scheme's Test action must include the RunnerUITests target
# (see store/SCREENSHOTS.md). only_testing keeps us to just the screenshot test.
scheme("Runner")
only_testing(["RunnerUITests/HashBagsScreenshotsUITests"])
output_directory("./screenshots")
clear_previous_screenshots(true)
concurrent_simulators(false) # wallet sync is flaky under parallel sims
override_status_bar(true) # clean status bar: 9:41, full battery/wifi
stop_after_first_error(true)
+94
View File
@@ -0,0 +1,94 @@
# Hash Bags — store screenshots
Three ways to produce the App Store / Play screenshots.
**Play (and all marketing video) runs on Linux** via the automated capture
harness — Path C, below. Only the **iOS** sets need macOS, because an iPhone/iPad
Simulator can't run on the Linux box.
## Required sizes (the Simulator outputs these exactly — don't hand-size)
| Store | Device to capture | Size | Count |
|---|---|---|---|
| App Store | **iPhone 16 Pro Max** (6.9") | 1290×2796 / 1320×2868 | 3–10 (required) |
| App Store | **iPad Pro 13" (M4)** | 2064×2752 | required *if* you ship iPad |
| Google Play | Pixel-class emulator | 1080×1920 (9:16) | 2–8 (required) |
---
## Path A — Manual (recommended for the first submission; ~15 min)
```bash
# iPhone
xcrun simctl boot "iPhone 16 Pro Max"; open -a Simulator
flutter run -d "iPhone 16 Pro Max" # from repo root; navigate the app, then:
store/screenshot.sh 01-home
store/screenshot.sh 02-receive
store/screenshot.sh 03-send
store/screenshot.sh 04-swap
store/screenshot.sh 05-settings
# iPad — repeat booted on the iPad sim
xcrun simctl boot "iPad Pro 13-inch (M4)"
flutter run -d "iPad Pro 13-inch (M4)"
OUT_DIR=store/screenshots-ipad store/screenshot.sh 01-home # ...etc
# Android (Play) — boot a Pixel emulator, run the app, then:
adb exec-out screencap -p > store/screenshots-play/01-home.png
```
`store/screenshot.sh` captures the booted simulator at the exact store size.
---
## Path B — Automated (fastlane snapshot; repeatable, multi-device/language)
Scaffolded here: `ios/fastlane/{Appfile,Snapfile,Fastfile}` +
`ios/RunnerUITests/HashBagsScreenshotsUITests.swift`.
**One-time setup (on the Mac):**
1. `cd ios && fastlane snapshot init` — drops `SnapshotHelper.swift` (matched to your fastlane version) into `ios/`. Move it into `ios/RunnerUITests/`.
2. In **Xcode** (`ios/Runner.xcworkspace`): File ▸ New ▸ Target ▸ **UI Testing Bundle**, name it **`RunnerUITests`**, target = `Runner`. Add `SnapshotHelper.swift` + `HashBagsScreenshotsUITests.swift` to it. Then edit the **Runner scheme ▸ Test** action and tick `RunnerUITests`.
3. **Screenshot-ready state:** wallet flows (create/PIN/seed/sync) are painful to drive, so make the app launch straight into a demo wallet when it sees the flag. In Dart, gate on `const bool.fromEnvironment('SCREENSHOT_MODE')` (or check `Platform.environment`/launch args): when true, restore a known demo wallet, skip onboarding, and stub balances. The UITest already passes `--dart-define=SCREENSHOT_MODE=true`.
4. **Accessibility:** the test taps by label/identifier. Add `Semantics(identifier: 'send_action')` (etc.) to the widgets you shoot, or rely on visible text ("Send", "Receive", "Swap", "Settings"). Tune the identifiers in the test.
**Run:**
```bash
cd ios && fastlane screenshots # → ios/screenshots/en-US/<device>-01-Home.png ...
```
---
---
## Path C — Automated capture harness (Play + all video; runs on Linux)
`tools/demo/` drives the real app on a headless emulator while recording the
screen, then cuts one recording into **both** the screenshots and the video.
See `tools/demo/README.md`.
```bash
cp tools/demo/demo.env.example tools/demo/demo.env # fill in; gitignored
export BW_SESSION=$(bw unlock --raw) # demo seed from Vaultwarden
tools/demo/build-apk.sh # debug APK via the CI container (Flutter 3.32.0)
tools/demo/capture.sh boot # creates/boots a 1080x1920 AVD
tools/demo/capture.sh all # record all scenarios, cut, shoot, assemble
```
Why it exists, beyond convenience:
- **Sizing.** It builds its own AVD at exactly 1080×1920 (16:9). The stock
"Medium Phone" AVD is 1080×2400 = **2.22:1**, which Play rejects (max 2:1).
- **Consistency.** Screenshots are frames of the same recording the trailer is
cut from, taken at the timestamps the scenario reports.
- **Seed safety.** Frames containing a mnemonic are bracketed by the scenario
and excised automatically; the tooling refuses to emit a publishable master
if any clip wasn't verified. The demo wallet holds real funds.
- **Apple rules.** The `apple-preview` profile force-disables touch indicators,
captions and music, and enforces Apple's 15–30s window.
Scenarios cover the core store set, onboarding/create, restore-from-seed, and
swap/buy.
---
## Upload
- **App Store Connect** ▸ your app ▸ version ▸ Previews and Screenshots → drag the 6.9" iPhone (and 13" iPad) sets.
- **Play Console** ▸ Store listing ▸ Phone screenshots → the 1080×1920 set; plus the **icon** (`store/play-icon-512.png`) and **feature graphic** (`store/play-feature-graphic-1024x500.png`).
> Reality check: for a *first* launch, Path A (manual) is faster. Path B pays off
> when you re-shoot across versions/locales. Either way it's a Mac task.
+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="500" viewBox="0 0 1024 500">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0a1a12"/>
<stop offset="0.55" stop-color="#0f2b1c"/>
<stop offset="1" stop-color="#123f28"/>
</linearGradient>
<radialGradient id="glow" cx="0.70" cy="0.30" r="0.65">
<stop offset="0" stop-color="#2EA56A" stop-opacity="0.30"/>
<stop offset="1" stop-color="#2EA56A" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="1024" height="500" fill="url(#bg)"/>
<rect width="1024" height="500" fill="url(#glow)"/>
<!-- Hash Bags mark -->
<g transform="translate(104,130)">
<rect width="240" height="240" rx="53" fill="#1a5c38"/>
<text x="120" y="180" font-family="monospace" font-size="180" font-weight="700"
fill="#ffffff" text-anchor="middle">#</text>
</g>
<!-- Wordmark + taglines -->
<g transform="translate(404,0)">
<text x="0" y="228" font-family="sans-serif" font-size="98" font-weight="700"
fill="#e8efe9">Hash Bags</text>
<text x="4" y="292" font-family="sans-serif" font-size="31" font-weight="500"
fill="#8fd6b0">Open-source, non-custodial wallet</text>
<text x="4" y="342" font-family="sans-serif" font-size="27" font-weight="400"
fill="#7fae97">Wownero · Monero · Bitcoin · Litecoin · and more</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Manual screenshot helper (the fast path for a first submission).
# Boot the target simulator, run the app, navigate to a screen, then run this to
# capture it at App-Store-exact dimensions.
#
# Usage: ./screenshot.sh <name> # captures the booted simulator
# OUT_DIR=~/hb-shots ./screenshot.sh 01-home
#
# Recommended flow (on the Mac):
# xcrun simctl boot "iPhone 16 Pro Max"; open -a Simulator
# (cd .. && flutter run -d "iPhone 16 Pro Max") # navigate to each screen, then:
# ./screenshot.sh 01-home ; ./screenshot.sh 02-receive ; ...
# # repeat booted on "iPad Pro 13-inch (M4)" for the iPad set.
set -euo pipefail
name="${1:?usage: screenshot.sh <name>}"
out="${OUT_DIR:-./screenshots-manual}"
mkdir -p "$out"
xcrun simctl io booted screenshot "$out/$name.png"
dims=$(python3 -c "import struct;d=open('$out/$name.png','rb').read(26);print('x'.join(map(str,struct.unpack('>II',d[16:24]))))" 2>/dev/null || echo "?")
echo "saved $out/$name.png ($dims)"
+129
View File
@@ -0,0 +1,129 @@
# Hash Bags capture harness
Drives the real app through real wallet flows on an emulator while recording
the screen, then cuts the recording into store screenshots and marketing video.
One recording produces **both** deliverables. Screenshots are pulled from the
same footage as the video, at the timestamps the scenario reports, so a listing
can't drift out of sync with its own trailer.
```
integration_test/demo/scenarios/*.dart drive the app, emit timing marks
│
▼
tools/demo/capture.sh record <scenario> adb screenrecord + flutter drive
│
├── marks.py cut → trims lead-in, EXCISES seed frames
├── marks.py shots → store screenshots at native 1080x1920
└── reel.py → branded master (social / apple-preview / play)
```
## Quick start
```bash
cp tools/demo/demo.env.example tools/demo/demo.env # fill in, gitignored
export BW_SESSION=$(bw unlock --raw) # Vaultwarden
tools/demo/build-apk.sh # optional but recommended — see below
tools/demo/capture.sh boot # 1080x1920 headless AVD
tools/demo/capture.sh all # record every scenario + assemble a reel
```
`build-apk.sh` is **not** a strict prerequisite — `capture.sh record` runs
`flutter drive`, which builds its own instrumented debug APK. Run it first
anyway on a fresh checkout: it validates the entire toolchain in one shot,
mints the local signing key, and warms the Gradle cache, so your first `record`
doesn't spend twenty minutes compiling only to hit a config error. It also
leaves you a sideloadable APK.
Individual steps:
```bash
tools/demo/capture.sh record core_store_set
tools/demo/capture.sh shots core_store_set
tools/demo/capture.sh reel social
tools/demo/capture.sh reel apple-preview --overlays none
```
Output lands in `~/Build/hash-wallet/demo/` (never in the repo), split into
`raw/`, `cut/`, `screenshots/` and `publish/`.
## Scenarios
| Scenario | Shows | Notes |
|---|---|---|
| `core_store_set` | Dashboard, Receive, Send, Wallets, Security | The store screenshot set. Send is **composed, never broadcast**. |
| `onboarding_create` | Welcome → PIN → seed → dashboard | Creates a throwaway wallet. Never fund it. |
| `restore_from_seed` | Restore from mnemonic → syncing | Uses the demo wallet seed. |
| `swap_and_buy` | Trocador swap quote, Buy | Stops **before** creating a trade. Buy is skipped when no provider key is present. |
Scenarios reuse the existing page robots in `integration_test/robots/`. They
are not correctness tests — they add pacing (`beat`, `hold`, `typeSlowly`) so
the footage is watchable, and timing marks so captions land on the right frame.
## Seed safety
The demo wallet is a **real wallet with a real balance** — that is what makes
the screenshots honest, and it is also why the harness is paranoid:
- The seed lives in Vaultwarden. It is fetched at capture time into one shell
variable and passed as a `--dart-define`. It is never written to the repo,
to `.secrets.g.dart`, or to `demo.env`.
- `DemoSession.guardNoSeedOnScreen()` fails the run if a mnemonic is rendered
while recording. It checks for the known demo seed (whole and word-scattered)
*and* for the generic shape of a mnemonic, so a freshly generated seed is
caught too.
- Flows that must pass through a seed screen wrap it in `blindfold()`. Those
spans are reported to the host and **excised from the video**, padded by
0.6s on each side. Screenshots use the same padding.
- `marks.py manifest` **refuses** to build a publishable master if any clip was
cut without marks, since blindfold spans could not have been excised.
Do not lift those guards to get a nicer shot.
## Overlays and the Apple App Preview
Touch indicators are the system-level ripple (`settings put system
show_touches 1`), not a composited fake cursor — so they appear in the
recording exactly as a user would see them.
They are **on by default**, and **force-disabled for `apple-preview`**.
`capture.sh reel apple-preview` refuses to run against footage recorded with
`--overlays taps`. The App Preview shows the app as-is: no touch indicators,
no captions, no title/CTA cards, no music bed. `reel.py` also enforces
Apple's 15–30s duration window and will fail rather than emit an out-of-spec
preview.
## Store sizing
The harness creates its own AVD at exactly **1080×1920 (16:9)**. Do not point
it at the stock "Medium Phone" AVD: that is 1080×2400 = **2.22:1**, and Google
Play rejects phone screenshots above **2:1**. `marks.py shots` warns if the
frames it extracts violate that ratio.
iOS App Store screenshots and the App Preview still have to come from a Mac —
see `store/SCREENSHOTS.md` for the fastlane snapshot path.
## Why a container for the build
The repo pins Flutter 3.32.0. A host Flutter 3.38+ **cannot resolve the
dependency set at all** — `hive_generator` wants an analyzer that still ships
the `macros` package, which newer Dart SDKs removed. `build-apk.sh` therefore
builds inside the same image CI uses.
It also mints a **throwaway** signing key: `android/app/build.gradle` wires the
debug build type to `signingConfigs.release`, so a local build cannot even
configure without a keystore. A capture APK must never be signed with the Play
upload key, and minting a separate one keeps that impossible by construction.
## Reusing this elsewhere
`reel.py` imports `such_graphics.video_transitions` (tuned xfade presets) and
`such_graphics.video_qa` (decode-level "did this render black" gate) from
`~/src/such-graphics` when that repo is checked out, and falls back to a local
subset when it is not — this repo is public and must build without it. Point
`SUCH_GRAPHICS_HOME` elsewhere if needed.
The music-bed handling (random offset per render, so repeat uploads of the same
variant don't feel identical) is lifted from
`~/src/medusa-multi-tenant-platform/tools/marketing-video/build.mjs`.
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env bash
# Build a Hash Bags APK locally using the SAME container image CI uses.
#
# Why a container: this repo pins Flutter 3.32.0. A newer host Flutter (3.38+)
# cannot resolve the dependency set at all -- hive_generator wants an analyzer
# that still ships the `macros` package, which newer Dart SDKs removed. Rather
# than fight that, we build inside
# ghcr.io/cake-tech/cake_wallet:debian13-flutter3.32.0-ndkr28-go1.24.1-ruststablenightly
# which is exactly what .github/workflows/build-android.yml uses.
#
# Usage:
# tools/demo/build-apk.sh # debug, x86_64 (emulator capture)
# BUILD_MODE=release ABI=arm64 tools/demo/build-apk.sh
# SKIP_DEPS=1 tools/demo/build-apk.sh # re-build only, deps already fetched
#
# The container runs as root (FLUTTER_ROOT lives under /root in the image), so
# the script chowns everything back to the invoking user on exit -- otherwise
# you get root-owned build artifacts scattered through your worktree.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
IMAGE="${CI_IMAGE:-ghcr.io/cake-tech/cake_wallet:debian13-flutter3.32.0-ndkr28-go1.24.1-ruststablenightly}"
BUILD_MODE="${BUILD_MODE:-debug}"
ABI="${ABI:-x86_64}"
SKIP_DEPS="${SKIP_DEPS:-0}"
HOST_UID="$(id -u)"
HOST_GID="$(id -g)"
case "$ABI" in
x86_64) TARGET_PLATFORM="android-x64" ;;
arm64) TARGET_PLATFORM="android-arm64" ;;
all) TARGET_PLATFORM="" ;;
*) echo "ABI must be x86_64 | arm64 | all" >&2; exit 1 ;;
esac
echo "==> image $IMAGE"
echo "==> mode $BUILD_MODE"
echo "==> abi $ABI (${TARGET_PLATFORM:-universal})"
echo "==> repo $REPO_ROOT"
docker run --rm \
--network host \
-v "$REPO_ROOT":/work \
-w /work \
-e SKIP_DEPS="$SKIP_DEPS" \
-e BUILD_MODE="$BUILD_MODE" \
-e TARGET_PLATFORM="$TARGET_PLATFORM" \
-e HOST_UID="$HOST_UID" \
-e HOST_GID="$HOST_GID" \
"$IMAGE" bash -c '
set -euo pipefail
# Chown back on ANY exit path so a failed build does not leave a root-owned
# worktree behind.
trap "chown -R ${HOST_UID}:${HOST_GID} /work 2>/dev/null || true" EXIT
git config --global --add safe.directory "*"
step() { echo; echo "########## $* ##########"; }
if [[ "$SKIP_DEPS" != "1" ]]; then
step "torch_dart prebuilt"
pushd scripts >/dev/null
if [[ ! -d torch_dart ]]; then
wget -q https://github.com/MrCyjaneK/torch_dart/releases/download/v1.0.17/torch_dart-v1.0.17.tar.gz -O torch_dart.tar.gz
mkdir -p torch_dart && tar -xzf torch_dart.tar.gz -C torch_dart && rm torch_dart.tar.gz
else echo "already present"; fi
popd >/dev/null
step "reown_flutter prebuilt"
pushd scripts >/dev/null
if [[ ! -d reown_flutter ]]; then
wget -q https://github.com/cake-tech/reown_flutter/releases/download/v0.0.4/reown_flutter-v0.0.4.tar.gz -O reown_flutter.tar.gz
mkdir -p reown_flutter && tar -xzf reown_flutter.tar.gz -C reown_flutter && rm reown_flutter.tar.gz
else echo "already present"; fi
popd >/dev/null
step "bitbox flutter"
# Guard on the ARTIFACT, not the directory. A half-finished checkout with no
# api.aar looks "done" by directory alone, and gradle then fails deep into
# assembleDebug with an opaque "Could not find :api:".
pushd scripts >/dev/null
if [[ -e bitbox_flutter/go/api/api.aar ]]; then
echo "api.aar already built"
else
./build_bitbox_flutter.sh
fi
popd >/dev/null
step "monero_c prebuilt .so bundle"
./scripts/prepare_moneroc.sh
MONERO_C_TAG=$(cd scripts/monero_c && git describe --tags)
echo "monero_c TAG: $MONERO_C_TAG"
BUNDLE_DIR="scripts/monero_c/release/$MONERO_C_TAG"
mkdir -p "$BUNDLE_DIR"
if [[ ! -d "$BUNDLE_DIR/x86_64-linux-android" ]]; then
pushd "$BUNDLE_DIR" >/dev/null
wget -q https://github.com/MrCyjaneK/monero_c/releases/download/v0.18.4.6-RC1/release-bundle.zip
unzip -q release-bundle.zip && rm release-bundle.zip
popd >/dev/null
else echo "bundle already extracted"; fi
step "stage jniLibs"
declare -A ABI_MAP=(
[aarch64-linux-android]=arm64-v8a
[armv7a-linux-androideabi]=armeabi-v7a
[x86_64-linux-android]=x86_64
)
for target in "${!ABI_MAP[@]}"; do
abi="${ABI_MAP[$target]}"
[[ -d "$BUNDLE_DIR/$target" ]] || { echo "SKIP $target"; continue; }
mkdir -p "android/app/src/main/jniLibs/$abi"
for coin in monero wownero; do
src="$BUNDLE_DIR/$target/lib${coin}_wallet2_api_c.so"
[[ -f "$src" ]] && cp -f "$src" "android/app/src/main/jniLibs/$abi/"
done
done
find android/app/src/main/jniLibs -name "*.so" | sort
step "reown yttrium uniffi bindings"
# The prebuilt reown_flutter tarball that build-android.yml fetches does NOT
# contain the generated Kotlin uniffi bindings. Without them, gradle dies
# deep inside assembleDebug with a wall of
# e: ...Extensions.kt: Unresolved reference "uniffi"
# which says nothing about the real cause.
#
# Upstream generates them in a SEPARATE image layer
# (scripts/android/docker/Dockerfile.reown runs build_reown_deps.sh), which
# the GitHub workflow never runs -- it assumes the runner already has them.
# That is why the Gitea mac-mini runner builds green while a clean
# replication of build-android.yml does not.
if find scripts/reown_flutter -type d -name uniffi 2>/dev/null | grep -q .; then
echo "uniffi bindings present"
elif [[ -x scripts/android/build_reown_deps.sh ]]; then
echo "uniffi bindings missing -- generating via build_reown_deps.sh"
echo "(this needs the Rust/uniffi toolchain and is slow on a cold run)"
./scripts/android/build_reown_deps.sh || {
echo
echo "FATAL: could not generate the reown uniffi bindings."
echo "Options:"
echo " 1. Build the upstream deps image: scripts/android/docker/build.sh"
echo " 2. Use an APK from the Gitea runner, which already has them:"
echo " https://git.such.software/Builds/hash-wallet/actions"
exit 1
}
else
echo "FATAL: no uniffi bindings and no build_reown_deps.sh to generate them." >&2
exit 1
fi
step "android configure (generates pubspec.yaml, manifest, app_properties)"
pushd scripts/android >/dev/null
source ./app_env.sh cakewallet
./app_config.sh
popd >/dev/null
fi
step "local throwaway signing key"
# android/app/build.gradle wires the DEBUG build type to signingConfigs.release
# (see `debug { signingConfig signingConfigs.release }`). With no key.properties
# present, `file(null)` blows up gradle configuration before a single source
# file compiles -- so even a debug build needs a keystore.
#
# CI supplies the real Play upload key from secrets. Locally we mint a
# throwaway instead: a capture/demo APK must never be signed with the upload
# key, and this keeps that impossible by construction.
if [[ ! -f android/key.properties ]]; then
keytool -genkeypair -v \
-keystore android/app/debug-capture.jks \
-storepass hashbags -keypass hashbags \
-alias hashbags-debug \
-keyalg RSA -keysize 2048 -validity 3650 \
-dname "CN=Hash Bags Capture, OU=Demo, O=Such Software LLC, C=US" 2>/dev/null
cat > android/key.properties <<EOF
storePassword=hashbags
keyPassword=hashbags
keyAlias=hashbags-debug
storeFile=debug-capture.jks
EOF
echo "minted throwaway keystore android/app/debug-capture.jks (NOT the upload key)"
else
echo "android/key.properties already present — leaving it alone"
fi
step "flutter pub get"
flutter pub get
step "generate secrets (empty defaults)"
# Real keys are injected by CI only. Locally these stay empty, which is the
# correct posture for a capture build: no live Trocador/MoonPay credentials
# end up in a demo APK.
[[ -f lib/.secrets.g.dart ]] && echo "secrets already generated" || dart run tool/generate_new_secrets.dart
step "codegen (mobx + hive adapters) -- this is the slow one"
bash model_generator.sh
step "localization"
dart run tool/generate_localization.dart
step "compile svg assets"
./compile_graphics.sh
step "build apk ($BUILD_MODE / ${TARGET_PLATFORM:-universal})"
EXTRA=""
[[ -n "$TARGET_PLATFORM" ]] && EXTRA="--target-platform=$TARGET_PLATFORM"
flutter build apk --dart-define-from-file=env.json --$BUILD_MODE $EXTRA
echo
echo "==> artifacts:"
ls -la build/app/outputs/flutter-apk/*.apk
'
# scripts/android/app_icon.sh points assets/images/app_logo.png at an ABSOLUTE
# path built from `pwd`, which inside the container is /work. That leaves a
# tracked file replaced by a symlink that dangles on the host and shows up as a
# typechange in `git status`. Put it back.
if [[ -L "$REPO_ROOT/assets/images/app_logo.png" ]]; then
git -C "$REPO_ROOT" checkout -- assets/images/app_logo.png 2>/dev/null \
&& echo "restored assets/images/app_logo.png (container symlink removed)"
fi
echo
echo "Done. APK(s) under $REPO_ROOT/build/app/outputs/flutter-apk/"
+365
View File
@@ -0,0 +1,365 @@
#!/usr/bin/env bash
# Hash Bags capture harness — drive real wallet flows while recording screen.
#
# One recording produces BOTH deliverables: the video (trimmed and captioned
# from the scenario's timing marks) and the store screenshots (frames pulled at
# those same marks, at native device resolution). Shooting them separately is
# how listings end up inconsistent.
#
# tools/demo/capture.sh boot
# tools/demo/capture.sh record core_store_set
# tools/demo/capture.sh shots core_store_set
# tools/demo/capture.sh reel social
# tools/demo/capture.sh all
#
# Options:
# --target android|linux where to run (default: android)
# --overlays taps|none touch indicators (default: taps)
# --avd NAME emulator AVD (default: HashBags_Capture)
# --serial NAME adb serial (default: autodetect)
#
# OVERLAYS AND THE APPLE APP PREVIEW
# The `apple-preview` reel profile force-disables overlays and refuses to run
# with --overlays=taps. The App Preview must be the app as a user sees it,
# with no synthetic touch indicators composited on top. Everything else
# (social cuts, Play listing video, docs GIFs) keeps taps on by default,
# because there they genuinely help a viewer follow the interaction.
#
# SEED SAFETY
# Scenarios bracket every frame containing a mnemonic in a `blindfold` span
# and report those spans back through the driver. This script refuses to
# promote any master to the publishable directory while a blindfold span is
# still inside the cut. The demo wallet holds real funds — that guard is not
# optional and should not be edited out.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$HERE/../.." && pwd)"
# shellcheck source=lib/secrets.sh
source "$HERE/lib/secrets.sh"
demo_load_env "$HERE/demo.env"
IMAGE="${CI_IMAGE:-ghcr.io/cake-tech/cake_wallet:debian13-flutter3.32.0-ndkr28-go1.24.1-ruststablenightly}"
ANDROID_SDK="${ANDROID_HOME:-$HOME/Android/Sdk}"
ADB="$ANDROID_SDK/platform-tools/adb"
EMULATOR_BIN="$ANDROID_SDK/emulator/emulator"
TARGET="android"
OVERLAYS="taps"
AVD="${DEMO_AVD:-HashBags_Capture}"
SERIAL="${DEMO_SERIAL:-}"
# Build outputs live outside the repo, per the workstation conventions.
OUT_ROOT="${DEMO_OUT_ROOT:-$HOME/Build/hash-wallet/demo}"
RAW_DIR="$OUT_ROOT/raw" # untrimmed recordings + driver JSON
CUT_DIR="$OUT_ROOT/cut" # trimmed, blindfold-free clips
SHOT_DIR="$OUT_ROOT/screenshots" # store screenshots
PUB_DIR="$OUT_ROOT/publish" # finished masters — only safe material lands here
SCENARIOS=(core_store_set onboarding_create restore_from_seed swap_and_buy)
# On-device path for the in-flight recording.
DEV_CAPTURE=/data/local/tmp/hb-capture.mp4
log() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[fail]\033[0m %s\n' "$*" >&2; exit 1; }
# ---------------------------------------------------------------------------
# arg parsing
# ---------------------------------------------------------------------------
CMD="${1:-help}"; shift || true
POSITIONAL=()
while [[ $# -gt 0 ]]; do
case "$1" in
--target) TARGET="$2"; shift 2 ;;
--overlays) OVERLAYS="$2"; shift 2 ;;
--avd) AVD="$2"; shift 2 ;;
--serial) SERIAL="$2"; shift 2 ;;
-h|--help) CMD="help"; shift ;;
*) POSITIONAL+=("$1"); shift ;;
esac
done
set -- "${POSITIONAL[@]+"${POSITIONAL[@]}"}"
# ---------------------------------------------------------------------------
# device helpers
# ---------------------------------------------------------------------------
# Wait up to `timeout` seconds for a device to appear. A freshly launched
# emulator takes appreciably longer than a fixed sleep to register with adb,
# and the port varies (5554, 5556, ...) depending on what else is running.
detect_serial() {
[[ -n "$SERIAL" ]] && return 0
local timeout="${1:-90}" waited=0
while (( waited < timeout )); do
SERIAL="$("$ADB" devices | awk '$2=="device" {print $1; exit}')"
[[ -n "$SERIAL" ]] && { log "device: $SERIAL"; return 0; }
sleep 2; waited=$((waited + 2))
done
die "no device appeared within ${timeout}s. Run: tools/demo/capture.sh boot"
}
adbs() { "$ADB" -s "$SERIAL" "$@"; }
wait_for_boot() {
log "waiting for device boot"
"$ADB" -s "$SERIAL" wait-for-device
local tries=0
until [[ "$("$ADB" -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" == "1" ]]; do
((tries++)); [[ $tries -gt 180 ]] && die "device did not finish booting"
sleep 2
done
log "device booted ($SERIAL)"
}
cmd_boot() {
if "$ADB" devices | grep -q "device$"; then
log "a device is already booted"; detect_serial; return 0
fi
log "booting AVD $AVD (headless)"
# -feature -GLDMA: with swiftshader_indirect on a headless host, the guest
# graphics stack otherwise aborts on
# "Assertion failed: !rcEnc->featureInfo()->hasReadColorBufferDma"
# which kills screencap and screenrecord — i.e. exactly the two things this
# harness needs. Disabling the DMA colour-buffer path costs a little capture
# throughput and makes both work.
nohup "$EMULATOR_BIN" -avd "$AVD" -no-window -no-audio -no-boot-anim \
-gpu swiftshader_indirect -feature -GLDMA -no-snapshot-save \
> "$OUT_ROOT/emulator.log" 2>&1 &
sleep 5
detect_serial
wait_for_boot
}
set_overlays() {
# Native touch indicators. Far cleaner than compositing a fake cursor: this
# is the real system-level ripple, drawn by SurfaceFlinger, so it appears in
# screenrecord exactly as a user would see it.
local on="$1"
if [[ "$on" == "taps" ]]; then
log "touch indicators ON"
adbs shell settings put system show_touches 1 || true
adbs shell settings put system pointer_location 0 || true
else
log "touch indicators OFF"
adbs shell settings put system show_touches 0 || true
adbs shell settings put system pointer_location 0 || true
fi
}
prep_device_for_camera() {
# A clean status bar. Google's own demo-mode API — no root needed.
adbs shell settings put global sysui_demo_allowed 1 || true
adbs shell am broadcast -a com.android.systemui.demo -e command enter >/dev/null 2>&1 || true
adbs shell am broadcast -a com.android.systemui.demo -e command clock -e hhmm 0941 >/dev/null 2>&1 || true
adbs shell am broadcast -a com.android.systemui.demo -e command battery -e level 100 -e plugged false >/dev/null 2>&1 || true
adbs shell am broadcast -a com.android.systemui.demo -e command network -e wifi show -e level 4 >/dev/null 2>&1 || true
adbs shell am broadcast -a com.android.systemui.demo -e command network -e mobile show -e datatype none -e level 4 >/dev/null 2>&1 || true
adbs shell am broadcast -a com.android.systemui.demo -e command notifications -e visible false >/dev/null 2>&1 || true
# Kill animations-off if a previous test run set them; we WANT animations.
adbs shell settings put global window_animation_scale 1 || true
adbs shell settings put global transition_animation_scale 1 || true
adbs shell settings put global animator_duration_scale 1 || true
}
exit_demo_mode() {
adbs shell am broadcast -a com.android.systemui.demo -e command exit >/dev/null 2>&1 || true
}
# ---------------------------------------------------------------------------
# record
# ---------------------------------------------------------------------------
cmd_record() {
local scenario="${1:-}"
[[ -n "$scenario" ]] || die "usage: capture.sh record <scenario> (${SCENARIOS[*]})"
[[ -f "$REPO_ROOT/integration_test/demo/scenarios/$scenario.dart" ]] \
|| die "no such scenario: $scenario"
mkdir -p "$RAW_DIR"
detect_serial
wait_for_boot
set_overlays "$OVERLAYS"
prep_device_for_camera
# Pull the demo seed only if the scenario needs it.
local seed=""
if [[ "$scenario" != "onboarding_create" ]]; then
if seed="$(demo_secret seed 2>/dev/null)"; then
log "demo wallet seed loaded from Vaultwarden ($(echo "$seed" | wc -w) words)"
else
warn "no demo seed available — scenario will run against whatever wallet the device already has"
seed=""
fi
fi
local send_addr="${DEMO_SEND_ADDRESS:-}"
local dev_epoch_start
dev_epoch_start="$(adbs shell date +%s%3N | tr -d '\r')"
log "starting screenrecord"
# /data/local/tmp, not /sdcard: the emulator's emulated-storage FUSE layer
# is flaky headless ("Transport endpoint is not connected"), and screenrecord
# then fails to open its output. /data/local/tmp is a plain filesystem that
# adb can always read and write.
adbs shell rm -f "$DEV_CAPTURE" || true
adbs shell screenrecord --size 1080x1920 --bit-rate 12000000 \
--time-limit 180 "$DEV_CAPTURE" &
local rec_pid=$!
sleep 2 # let the encoder actually start before the app launches
log "driving scenario: $scenario"
set +e
docker run --rm --network host \
-v "$REPO_ROOT":/work -w /work \
-e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \
-e DEMO_SEED="$seed" -e DEMO_SEND_ADDR="$send_addr" \
-e SCENARIO="$scenario" -e SERIAL="$SERIAL" \
"$IMAGE" bash -c '
trap "chown -R ${HOST_UID}:${HOST_GID} /work 2>/dev/null || true" EXIT
git config --global --add safe.directory "*"
flutter drive \
--driver=test_driver/integration_test.dart \
--target=integration_test/demo/scenarios/'"$scenario"'.dart \
-d "$SERIAL" \
--dart-define-from-file=env.json \
--dart-define=DEMO_WALLET_SEED="$DEMO_SEED" \
--dart-define=DEMO_SEND_ADDRESS="$DEMO_SEND_ADDR"
' 2>&1 | tee "$RAW_DIR/$scenario.drive.log"
local drive_rc=${PIPESTATUS[0]}
set -e
log "stopping screenrecord"
adbs shell pkill -INT screenrecord || true
wait "$rec_pid" 2>/dev/null || true
sleep 3 # screenrecord needs a moment to finalise the moov atom
adbs pull "$DEV_CAPTURE" "$RAW_DIR/$scenario.mp4" >/dev/null \
|| die "could not pull recording — did screenrecord start?"
adbs shell rm -f "$DEV_CAPTURE" || true
# Driver output: marks + blindfold spans.
if [[ -f "$REPO_ROOT/integration_test/integration_response_data.json" ]]; then
cp "$REPO_ROOT/integration_test/integration_response_data.json" \
"$RAW_DIR/$scenario.marks.json"
else
warn "no driver output — captions and screenshots will be unavailable"
fi
echo "$dev_epoch_start" > "$RAW_DIR/$scenario.recstart"
exit_demo_mode
if [[ $drive_rc -ne 0 ]]; then
warn "scenario exited non-zero ($drive_rc); the recording was still saved."
warn "check $RAW_DIR/$scenario.drive.log"
fi
log "raw recording: $RAW_DIR/$scenario.mp4"
cmd_cut "$scenario"
}
# ---------------------------------------------------------------------------
# cut — trim launch/install lead-in and excise every blindfold span
# ---------------------------------------------------------------------------
cmd_cut() {
local scenario="${1:-}"
[[ -n "$scenario" ]] || die "usage: capture.sh cut <scenario>"
local raw="$RAW_DIR/$scenario.mp4"
[[ -f "$raw" ]] || die "no raw recording for $scenario"
mkdir -p "$CUT_DIR"
python3 "$HERE/marks.py" cut \
--raw "$raw" \
--marks "$RAW_DIR/$scenario.marks.json" \
--recstart "$RAW_DIR/$scenario.recstart" \
--out "$CUT_DIR/$scenario.mp4" \
--manifest "$CUT_DIR/$scenario.manifest.json"
log "cut clip: $CUT_DIR/$scenario.mp4"
}
# ---------------------------------------------------------------------------
# shots — store screenshots pulled from the recording at mark timestamps
# ---------------------------------------------------------------------------
cmd_shots() {
local scenario="${1:-}"
[[ -n "$scenario" ]] || die "usage: capture.sh shots <scenario>"
mkdir -p "$SHOT_DIR"
python3 "$HERE/marks.py" shots \
--raw "$RAW_DIR/$scenario.mp4" \
--marks "$RAW_DIR/$scenario.marks.json" \
--recstart "$RAW_DIR/$scenario.recstart" \
--outdir "$SHOT_DIR"
log "screenshots: $SHOT_DIR"
}
# ---------------------------------------------------------------------------
# reel — assemble a finished master
# ---------------------------------------------------------------------------
cmd_reel() {
local profile="${1:-social}"
mkdir -p "$PUB_DIR"
if [[ "$profile" == "apple-preview" && "$OVERLAYS" == "taps" ]]; then
die "apple-preview requires --overlays none. Re-record with:
tools/demo/capture.sh record <scenario> --overlays none
An App Preview must show the app as-is, without composited touch indicators."
fi
local manifest="$PUB_DIR/$profile.manifest.json"
local music_args=()
# apple-preview ignores music by design; reel.py enforces that too.
if [[ -n "${DEMO_MUSIC:-}" && "$profile" != "apple-preview" ]]; then
if [[ -f "$DEMO_MUSIC" ]]; then
music_args=(--music "$DEMO_MUSIC")
else
warn "DEMO_MUSIC set but not found: $DEMO_MUSIC — rendering silent"
fi
fi
python3 "$HERE/marks.py" manifest \
--cutdir "$CUT_DIR" --profile "$profile" --out "$manifest" \
--scenarios "${SCENARIOS[*]}" "${music_args[@]+"${music_args[@]}"}"
python3 "$HERE/reel.py" \
--manifest "$manifest" --profile "$profile" \
-o "$PUB_DIR/hashbags-$profile.mp4"
log "master: $PUB_DIR/hashbags-$profile.mp4"
}
cmd_all() {
cmd_boot
for s in "${SCENARIOS[@]}"; do
cmd_record "$s" || warn "scenario $s failed; continuing"
cmd_shots "$s" || warn "shots for $s failed; continuing"
done
cmd_reel social
log "done. publishable output under $PUB_DIR"
}
cmd_help() { sed -n '2,40p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }
mkdir -p "$OUT_ROOT"
if [[ "$TARGET" == "linux" ]]; then
die "--target linux is not implemented.
The Linux desktop build DOES render the phone UI (linux/my_application.cc
forces a 720x1280 window when DESKTOP_FORCE_MOBILE is set), and Xvfb +
ffmpeg x11grab are available on this box, so it is a reasonable fast-iteration
path. What it needs first:
1. ./configure_hash_wallet.sh linux (builds the x86_64-linux-gnu monero_c libs)
2. a recorder branch here using: Xvfb :99 + DESKTOP_FORCE_MOBILE=Y
flutter drive -d linux + ffmpeg -f x11grab -video_size 720x1280 -i :99
It is deliberately NOT wired up half-way: desktop chrome, fonts and insets
differ from the phone build, so Linux footage must never be used for a store
listing. Use --target android for anything shippable."
elif [[ "$TARGET" != "android" ]]; then
die "--target must be 'android' (see help)"
fi
case "$CMD" in
boot) cmd_boot ;;
record) cmd_record "${1:-}" ;;
cut) cmd_cut "${1:-}" ;;
shots) cmd_shots "${1:-}" ;;
reel) cmd_reel "${1:-social}" ;;
all) cmd_all ;;
help|*) cmd_help ;;
esac
+58
View File
@@ -0,0 +1,58 @@
# Hash Bags capture harness — local configuration TEMPLATE.
#
# Copy to tools/demo/demo.env (which is gitignored) and fill in.
# Per ~/src/docs/engineering/developer-workstations.md: human credentials live
# in Vaultwarden; ignored development values sit beside the worktree; only
# reviewed templates get committed.
#
# cp tools/demo/demo.env.example tools/demo/demo.env
#
# NEVER put the demo wallet seed in this file. It is fetched from Vaultwarden
# at capture time. NEVER put the Vaultwarden hostname in a committed file
# either — that is infrastructure detail and does not belong in a public repo.
# ---------------------------------------------------------------------------
# Vaultwarden
# ---------------------------------------------------------------------------
# Your self-hosted server URL. Set it here (this file is gitignored), then:
# bw config server "$VAULTWARDEN_SERVER"
# bw login
# export BW_SESSION=$(bw unlock --raw)
VAULTWARDEN_SERVER=https://vault.example.internal
# Name of the Vaultwarden item holding the demo wallet. The seed may live in
# the item's notes, its password field, or a custom field named `seed`.
VAULTWARDEN_ITEM=hash-bags-demo-wallet
# ---------------------------------------------------------------------------
# Demo wallet presentation
# ---------------------------------------------------------------------------
# Which chain is the face of the app in store material. Hash Bags is
# Wownero-first. One of: wownero | monero | bitcoin | litecoin
DEMO_WALLET_TYPE=wownero
# Destination shown on the Send screen. This APPEARS ON CAMERA — use an
# address you are happy to publish. Defaulting it to the demo wallet's own
# receive address avoids putting a stranger's address in a store listing.
DEMO_SEND_ADDRESS=
# Amounts typed on camera. Nothing is ever broadcast by the scenarios.
DEMO_SEND_AMOUNT=0.5
DEMO_SWAP_AMOUNT=10
# PIN entered during capture (digits only).
DEMO_PIN=0801
# ---------------------------------------------------------------------------
# Capture targets
# ---------------------------------------------------------------------------
# AVD created by the harness at exactly 1080x1920 (16:9). Do not point this at
# a 1080x2400 phone AVD: that is 2.22:1 and Google Play rejects phone
# screenshots with an aspect ratio above 2:1.
DEMO_AVD=HashBags_Capture
# Where captures and renders land. Kept out of the repo on purpose.
DEMO_OUT_ROOT=$HOME/Build/hash-wallet/demo
# Optional music bed baked into the `social` reel. Ignored by apple-preview.
# DEMO_MUSIC=/home/jw/src/meatspace/assets/music/instrumental_vibey.mp3
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# Demo-wallet secret retrieval from the self-hosted Vaultwarden.
#
# The demo wallet is a REAL wallet with a REAL balance -- that is what makes
# the screenshots honest. Its seed therefore never lands in this repo, in
# .secrets.g.dart, in a shell history, or in a committed env file. It is
# fetched at capture time, held in a shell variable for the life of one
# capture run, and passed to the app as a --dart-define.
#
# Sourced by capture.sh; not meant to be run directly.
#
# Configuration lives in tools/demo/demo.env (gitignored). Copy
# demo.env.example and fill it in. Per ~/src/docs/engineering/
# developer-workstations.md: human credentials in Vaultwarden, ignored
# development values beside the worktree, only reviewed templates committed.
# ---------------------------------------------------------------------------
# bw CLI bootstrap
# ---------------------------------------------------------------------------
demo_secrets_check() {
if ! command -v bw >/dev/null 2>&1; then
cat >&2 <<'EOF'
ERROR: the Bitwarden CLI (`bw`) is not installed, so the demo wallet seed
cannot be fetched from Vaultwarden.
Install one of:
npm install -g @bitwarden/cli
sudo snap install bw
Then point it at the self-hosted server and log in (do this once):
bw config server "$VAULTWARDEN_SERVER" # value lives in tools/demo/demo.env
bw login
export BW_SESSION=$(bw unlock --raw)
Alternatively, for a one-off run you can bypass Vaultwarden entirely:
DEMO_WALLET_SEED='word word ...' tools/demo/capture.sh record core_store_set
...but do not put that in a file, and do not leave it in your shell history
(prefix the command with a space if HISTCONTROL=ignorespace).
EOF
return 1
fi
return 0
}
# demo_secret <field-name>
#
# Resolution order:
# 1. An already-exported env var of the same name (CI / one-off override).
# 2. A custom field on the Vaultwarden item named $VAULTWARDEN_ITEM.
# 3. The item's password/notes, when the field name is `seed`.
demo_secret() {
local field="$1"
local envname
envname="$(echo "$field" | tr '[:lower:]-' '[:upper:]_')"
# 1. explicit override
local existing="${!envname:-}"
if [[ -n "$existing" ]]; then
printf '%s' "$existing"
return 0
fi
demo_secrets_check || return 1
if [[ -z "${BW_SESSION:-}" ]]; then
echo "ERROR: BW_SESSION is not set. Unlock the vault first:" >&2
echo " export BW_SESSION=\$(bw unlock --raw)" >&2
return 1
fi
local item="${VAULTWARDEN_ITEM:-hash-bags-demo-wallet}"
local json
if ! json="$(bw get item "$item" 2>/dev/null)"; then
echo "ERROR: no Vaultwarden item named '$item'." >&2
echo "Create it and store the demo wallet seed there, or set" >&2
echo "VAULTWARDEN_ITEM in tools/demo/demo.env." >&2
return 1
fi
# Prefer an explicitly named custom field.
local value
value="$(printf '%s' "$json" | python3 -c '
import json, sys
field = sys.argv[1]
data = json.load(sys.stdin)
for f in (data.get("fields") or []):
if (f.get("name") or "").lower() == field.lower():
print(f.get("value") or "", end="")
sys.exit(0)
# fall back to notes / password for the seed itself
if field.lower() in ("seed", "demo_wallet_seed"):
print((data.get("notes") or (data.get("login") or {}).get("password") or ""), end="")
' "$field")"
if [[ -z "$value" ]]; then
echo "ERROR: Vaultwarden item '$item' has no field '$field'." >&2
return 1
fi
printf '%s' "$value"
}
# Load the gitignored local config if present.
demo_load_env() {
local envfile="$1"
if [[ -f "$envfile" ]]; then
# shellcheck disable=SC1090
set -a; source "$envfile"; set +a
fi
}
+377
View File
@@ -0,0 +1,377 @@
#!/usr/bin/env python3
"""
marks.py -- turn a scenario's timing marks into cuts, screenshots, manifests.
The scenarios in integration_test/demo/ report a list of marks through the
Flutter driver. Each mark carries a scenario-relative timestamp, a label, and
optionally a caption. This script maps those onto the raw screen recording.
Three subcommands:
cut Trim the install/launch lead-in and EXCISE every blindfold span
(frames where a mnemonic was on screen), producing a clip that is
safe to publish.
shots Pull store screenshots at native device resolution, one per
captioned mark. Frames are taken from the MIDDLE of each hold
rather than at its leading edge, which makes alignment robust
against the ~0.5s of encoder start-up jitter.
manifest Assemble the per-scenario clips into a reel.py manifest.
TIME BASE
recstart device-clock epoch (ms) read just before
`adb shell screenrecord` was launched
started_at_epoch_ms device-clock epoch (ms) at scenario start
video_time(mark) = (started_at_epoch_ms - recstart)/1000 + mark.at
- LEAD_CORRECTION
Both epochs come from the DEVICE clock, so host/guest skew cannot break
alignment. LEAD_CORRECTION absorbs the small delay between launching
screenrecord and the encoder producing its first frame.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
# Seconds between `screenrecord` being launched and it emitting frame 0.
# Measured empirically on the x86_64 emulator; override with --lead-correction.
LEAD_CORRECTION = 0.45
# Safety margin applied around every blindfold span. A span boundary lands on a
# mark, and the frames immediately either side can still show the tail of a
# seed screen (or its dismiss animation). Used by BOTH the cut and the
# screenshot paths -- they must agree, or a screenshot can land in a region the
# video cut treated as unsafe.
BLINDFOLD_PAD = 0.6
def run(cmd: list[str]) -> None:
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
sys.stderr.write(r.stderr[-3000:])
raise SystemExit(f"command failed: {' '.join(str(c) for c in cmd[:8])} ...")
def probe_duration(path: Path) -> float:
r = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=nk=1:nw=1", str(path)],
capture_output=True, text=True,
)
try:
return float(r.stdout.strip())
except ValueError:
raise SystemExit(f"cannot probe {path}")
def load_demo(marks_path: Path | None) -> dict | None:
"""Read the `demo` block out of the driver's response JSON."""
if not marks_path or not marks_path.exists():
return None
try:
data = json.loads(marks_path.read_text())
except json.JSONDecodeError:
return None
if not data:
return None
demo = data.get("demo")
# integrationDriver nests per-test results; dig one level if needed.
if demo is None:
for v in data.values():
if isinstance(v, dict) and "demo" in v:
demo = v["demo"]
break
if isinstance(v, str):
try:
inner = json.loads(v)
if isinstance(inner, dict) and "demo" in inner:
demo = inner["demo"]
break
except json.JSONDecodeError:
continue
return demo
def video_offset(demo: dict, recstart_path: Path | None, lead: float) -> float:
if not recstart_path or not recstart_path.exists():
return 0.0
try:
recstart = float(recstart_path.read_text().strip())
except ValueError:
return 0.0
started = float(demo.get("started_at_epoch_ms") or 0)
if not started:
return 0.0
return max((started - recstart) / 1000.0 - lead, 0.0)
# ---------------------------------------------------------------------------
# cut
# ---------------------------------------------------------------------------
def safe_spans(demo: dict, offset: float, duration: float) -> list[tuple[float, float]]:
"""Video-time spans that contain no mnemonic frames.
Everything before the scenario actually starts is also dropped -- that is
the install/launch lead-in, which is not part of the story.
"""
blind = [
(offset + float(s["start"]), offset + float(s["end"]))
for s in (demo.get("blindfold_spans") or [])
]
blind = [(max(a - BLINDFOLD_PAD, 0.0), min(b + BLINDFOLD_PAD, duration))
for a, b in blind]
blind.sort()
spans: list[tuple[float, float]] = []
cursor = offset
for a, b in blind:
if a > cursor:
spans.append((cursor, min(a, duration)))
cursor = max(cursor, b)
if cursor < duration:
spans.append((cursor, duration))
# Drop slivers that are not worth a cut.
return [(a, b) for a, b in spans if b - a > 0.8]
def cmd_cut(args: argparse.Namespace) -> None:
raw = Path(args.raw)
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
duration = probe_duration(raw)
demo = load_demo(Path(args.marks) if args.marks else None)
if demo is None:
print("WARNING: no marks available; copying raw recording unchanged. "
"This clip has NOT been checked for seed frames — do not publish "
"it without watching it.", file=sys.stderr)
run(["ffmpeg", "-y", "-v", "error", "-i", str(raw),
"-c", "copy", "-movflags", "+faststart", str(out)])
manifest = {"scenario": raw.stem, "verified_seed_free": False,
"captions": [], "duration": duration}
Path(args.manifest).write_text(json.dumps(manifest, indent=2))
return
offset = video_offset(demo, Path(args.recstart) if args.recstart else None,
args.lead_correction)
spans = safe_spans(demo, offset, duration)
if not spans:
raise SystemExit(
f"every frame of {raw} falls inside a blindfold span — nothing "
f"safe to publish. Check the scenario."
)
blind_count = len(demo.get("blindfold_spans") or [])
tmp = out.parent / f".{out.stem}-parts"
tmp.mkdir(exist_ok=True)
parts = []
for i, (a, b) in enumerate(spans):
p = tmp / f"part{i:02d}.mp4"
run(["ffmpeg", "-y", "-v", "error", "-ss", f"{a:.3f}", "-to", f"{b:.3f}",
"-i", str(raw), "-an", "-c:v", "libx264", "-preset", "veryfast",
"-crf", "18", "-pix_fmt", "yuv420p", str(p)])
parts.append(p)
if len(parts) == 1:
run(["ffmpeg", "-y", "-v", "error", "-i", str(parts[0]),
"-c", "copy", "-movflags", "+faststart", str(out)])
else:
listing = tmp / "concat.txt"
listing.write_text("".join(f"file '{p.resolve()}'\n" for p in parts))
run(["ffmpeg", "-y", "-v", "error", "-f", "concat", "-safe", "0",
"-i", str(listing), "-c", "copy", "-movflags", "+faststart",
str(out)])
for p in parts:
p.unlink(missing_ok=True)
(tmp / "concat.txt").unlink(missing_ok=True)
tmp.rmdir()
# Captions, rebased onto the CUT timeline.
captions = []
removed_before = 0.0
span_idx = 0
for m in demo.get("marks", []):
if not m.get("caption"):
continue
vt = offset + float(m["at"])
# find which safe span this mark falls in, and how much was removed
acc = 0.0
placed = None
for a, b in spans:
if a <= vt <= b:
placed = acc + (vt - a)
break
acc += b - a
if placed is not None:
captions.append({"at": round(placed, 3), "text": m["caption"],
"label": m["label"]})
manifest = {
"scenario": demo.get("scenario", raw.stem),
"verified_seed_free": True,
"blindfold_spans_excised": blind_count,
"duration": round(sum(b - a for a, b in spans), 3),
"captions": captions,
}
Path(args.manifest).write_text(json.dumps(manifest, indent=2))
print(f"cut {out.name}: {manifest['duration']:.1f}s, "
f"{blind_count} blindfold span(s) excised, "
f"{len(captions)} caption(s)")
# ---------------------------------------------------------------------------
# shots
# ---------------------------------------------------------------------------
def cmd_shots(args: argparse.Namespace) -> None:
raw = Path(args.raw)
outdir = Path(args.outdir)
outdir.mkdir(parents=True, exist_ok=True)
demo = load_demo(Path(args.marks) if args.marks else None)
if demo is None:
raise SystemExit(f"no marks for {raw}; cannot place screenshots")
duration = probe_duration(raw)
offset = video_offset(demo, Path(args.recstart) if args.recstart else None,
args.lead_correction)
# Same padding the video cut uses -- otherwise a screenshot could be taken
# from a region `cut` considered unsafe enough to excise.
blind = [(offset + float(s["start"]) - BLINDFOLD_PAD,
offset + float(s["end"]) + BLINDFOLD_PAD)
for s in (demo.get("blindfold_spans") or [])]
marks = demo.get("marks", [])
scenario = demo.get("scenario", raw.stem)
made = 0
for i, m in enumerate(marks):
if not m.get("caption"):
continue
start_vt = offset + float(m["at"])
# Land in the middle of the hold, not on its edge: the next mark tells
# us when this screen stops being on camera.
next_vt = duration
for later in marks[i + 1:]:
nv = offset + float(later["at"])
if nv > start_vt:
next_vt = nv
break
at = start_vt + min((next_vt - start_vt) / 2.0, 2.5)
at = min(at, duration - 0.2)
if any(a <= at <= b for a, b in blind):
print(f" skip {m['label']}: inside a blindfold span", file=sys.stderr)
continue
name = f"{scenario}-{made + 1:02d}-{m['label']}.png"
run(["ffmpeg", "-y", "-v", "error", "-ss", f"{at:.3f}", "-i", str(raw),
"-frames:v", "1", str(outdir / name)])
made += 1
print(f" {name} @{at:.2f}s")
print(f"{made} screenshot(s) -> {outdir}")
if made:
sample = next(outdir.glob(f"{scenario}-*.png"), None)
if sample:
dims = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height", "-of", "csv=p=0:s=x",
str(sample)], capture_output=True, text=True).stdout.strip()
print(f"dimensions: {dims}")
w, h = (int(x) for x in dims.split("x"))
if h / w > 2.0:
print(f"WARNING: aspect {h/w:.2f}:1 exceeds Google Play's 2:1 "
f"maximum for phone screenshots.", file=sys.stderr)
# ---------------------------------------------------------------------------
# manifest
# ---------------------------------------------------------------------------
def cmd_manifest(args: argparse.Namespace) -> None:
cutdir = Path(args.cutdir)
scenarios = args.scenarios.split()
clips = []
unsafe = []
for s in scenarios:
clip = cutdir / f"{s}.mp4"
meta = cutdir / f"{s}.manifest.json"
if not clip.exists():
continue
info = json.loads(meta.read_text()) if meta.exists() else {}
if not info.get("verified_seed_free", False):
unsafe.append(s)
dur = probe_duration(clip)
caption = ""
caps = info.get("captions") or []
if caps:
caption = caps[0]["text"]
# Apple previews get no captions at all.
if args.profile == "apple-preview":
caption = ""
clips.append({
"src": str(clip),
"caption": caption,
"seconds": round(min(dur, args.max_clip), 2),
"start": 0,
})
if not clips:
raise SystemExit(f"no cut clips found in {cutdir}. Record something first.")
if unsafe:
raise SystemExit(
"REFUSING to build a publishable master.\n"
f"These clips were never verified seed-free: {', '.join(unsafe)}\n"
"They were cut without driver marks, so blindfold spans could not "
"be excised. Re-record them, or watch each one and cut by hand."
)
manifest = {"transition": "cross_dissolve", "clips": clips}
if args.music:
manifest["music"] = args.music
Path(args.out).write_text(json.dumps(manifest, indent=2))
total = sum(c["seconds"] for c in clips)
print(f"manifest: {len(clips)} clip(s), ~{total:.0f}s -> {args.out}")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
c = sub.add_parser("cut")
c.add_argument("--raw", required=True)
c.add_argument("--marks")
c.add_argument("--recstart")
c.add_argument("--out", required=True)
c.add_argument("--manifest", required=True)
c.add_argument("--lead-correction", type=float, default=LEAD_CORRECTION)
c.set_defaults(func=cmd_cut)
s = sub.add_parser("shots")
s.add_argument("--raw", required=True)
s.add_argument("--marks")
s.add_argument("--recstart")
s.add_argument("--outdir", required=True)
s.add_argument("--lead-correction", type=float, default=LEAD_CORRECTION)
s.set_defaults(func=cmd_shots)
m = sub.add_parser("manifest")
m.add_argument("--cutdir", required=True)
m.add_argument("--profile", default="social")
m.add_argument("--out", required=True)
m.add_argument("--scenarios", required=True)
m.add_argument("--music", default=None)
m.add_argument("--max-clip", type=float, default=8.0)
m.set_defaults(func=cmd_manifest)
args = ap.parse_args()
args.func(args)
if __name__ == "__main__":
main()
+37
View File
@@ -0,0 +1,37 @@
{
"name": "hash",
"description": "Hash Bags — an open-source, non-custodial multi-currency wallet by Such Software LLC. Wownero-first, privacy-respecting, no KYC. Deep-forest green on near-black, clean geometric sans, the '#' mark. Calm and credible, not hype.",
"palette": {
"bg": "#0a1a12",
"bg_mid": "#0f2b1c",
"bg_hi": "#123f28",
"surface": "#123f28",
"ink": "#e8efe9",
"muted": "#7fae97",
"accent": "#2EA56A",
"accent_hi": "#8fd6b0",
"primary": "#1A5C38"
},
"type": {
"families": { "display": "Inter Display", "body": "Inter" },
"scale": { "title": 96, "subhead": 52, "body": 34, "caption": 48 },
"font_files": {
"display": "/usr/share/fonts/opentype/inter/InterDisplay-Bold.otf",
"body": "/usr/share/fonts/opentype/inter/Inter-SemiBold.otf"
},
"font_fallbacks": [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf"
]
},
"motion": {
"transition_default": "cross_dissolve",
"transition_social": "blur_punch"
},
"copy": {
"title": "Hash Bags",
"subtitle": "You hold the keys.",
"cta": "hash.boats",
"cta_sub": "open-source · non-custodial · no KYC"
}
}
+480
View File
@@ -0,0 +1,480 @@
#!/usr/bin/env python3
"""
reel.py -- assemble Hash Bags marketing video from recorded wallet captures.
Takes the raw screen recordings produced by capture.sh (one MP4 per scenario)
plus a manifest describing captions and pacing, and renders a finished master.
Profiles decide how much production sits on top of the raw capture:
social 1080x1920 vertical. Title card, per-clip burned captions on a
blurred fill, xfade transitions, CTA end card, optional music.
This is the TikTok / Reels / Shorts deliverable.
apple-preview App Store App Preview. DELIBERATELY BARE: no burned captions,
no title/CTA cards, no tap overlay, no music bed. Apple wants
the app itself on screen, and the recording is used as shot.
Trimmed to Apple's 15-30s window and left at device pixels.
play-listing Google Play listing video. Same production as `social` but
sized 1080x1920 and duration-capped at 30s.
raw Passthrough + QA only. Useful for eyeballing a capture.
The genuinely reusable pieces of ~/src/such-graphics (video_transitions for the
xfade preset table, video_qa for the "did this decode to black" gate) are
imported when that repo is checked out, and cleanly stubbed when it is not --
hash-wallet is public and must build without it.
Usage:
python3 tools/demo/reel.py --manifest reel.json --profile social -o out.mp4
python3 tools/demo/reel.py --manifest reel.json --profile apple-preview \
-o preview.mp4
Manifest shape:
{
"title": "Hash Bags", # optional, defaults from pack.json
"subtitle": "You hold the keys.",
"cta": "hash.boats",
"transition": "cross_dissolve", # optional
"music": "/path/to/bed.mp3", # optional; ignored by apple-preview
"clips": [
{"src": "captures/receive.mp4", "caption": "Receive in a tap",
"seconds": 6, "start": 1.5}
]
}
"""
from __future__ import annotations
import argparse
import json
import os
import random
import shutil
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
HERE = Path(__file__).resolve().parent
DEFAULT_PACK = HERE / "packs" / "hash" / "pack.json"
# ---------------------------------------------------------------------------
# such-graphics interop (optional)
# ---------------------------------------------------------------------------
SUCH_GRAPHICS_HOME = Path(
os.environ.get("SUCH_GRAPHICS_HOME", Path.home() / "src" / "such-graphics")
)
_FALLBACK_TRANSITIONS = {
"cross_dissolve": ("fade", 0.52),
"blur_punch": ("hblur", 0.42),
"black_dip": ("fadeblack", 0.34),
"slide_up": ("slideup", 0.40),
"zoom_punch": ("zoomin", 0.45),
"whip_left": ("smoothleft", 0.38),
}
@dataclass(frozen=True)
class Transition:
ffmpeg_name: str
duration: float
def load_transition(name: str) -> Transition:
"""Prefer such-graphics' tuned preset table; fall back to a local subset."""
if SUCH_GRAPHICS_HOME.is_dir():
sys.path.insert(0, str(SUCH_GRAPHICS_HOME))
try:
from such_graphics.video_transitions import ( # noqa: E402
get_video_transition,
)
t = get_video_transition(name)
return Transition(t.ffmpeg_name, t.duration)
except Exception:
pass # fall through to the local table
ff, dur = _FALLBACK_TRANSITIONS.get(name, _FALLBACK_TRANSITIONS["cross_dissolve"])
return Transition(ff, dur)
def run_video_qa(path: Path) -> str:
"""Decode-level QA. ffmpeg can emit a valid MP4 that is entirely black --
a real hazard with headless emulator capture, so this gate matters."""
if SUCH_GRAPHICS_HOME.is_dir():
sys.path.insert(0, str(SUCH_GRAPHICS_HOME))
try:
from such_graphics.video_qa import verify_video # noqa: E402
verify_video(path)
return "such_graphics.video_qa: PASS"
except ImportError:
pass
except Exception as exc: # QA genuinely failed -- surface it
raise SystemExit(f"VIDEO QA FAILED for {path}: {exc}")
return _local_black_check(path)
def _local_black_check(path: Path) -> str:
"""Minimal standalone equivalent: sample frames, fail if all are black."""
dur = probe_duration(path)
samples = [t for t in (0.5, dur * 0.25, dur * 0.5, dur * 0.75) if t < dur]
brightest = 0.0
for at in samples:
r = subprocess.run(
["ffmpeg", "-v", "error", "-ss", f"{at}", "-i", str(path),
"-frames:v", "1", "-vf", "scale=64:64,format=gray",
"-f", "rawvideo", "-"],
capture_output=True,
)
if r.stdout:
brightest = max(brightest, sum(r.stdout) / len(r.stdout))
if brightest < 4.0:
raise SystemExit(
f"VIDEO QA FAILED for {path}: decodes to black "
f"(peak mean luma {brightest:.2f}). The capture likely raced the "
f"app launch, or the emulator GPU produced no frames."
)
return f"local black-check: PASS (peak mean luma {brightest:.1f})"
# ---------------------------------------------------------------------------
# ffmpeg helpers
# ---------------------------------------------------------------------------
W, H, FPS = 1080, 1920, 30
def run(cmd: list[str]) -> None:
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
sys.stderr.write(r.stderr[-4000:])
raise SystemExit(f"ffmpeg failed: {' '.join(str(c) for c in cmd[:8])} ...")
def probe_duration(path: str | Path) -> float:
r = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=nk=1:nw=1", str(path)],
capture_output=True, text=True,
)
try:
return float(r.stdout.strip())
except ValueError:
raise SystemExit(f"cannot probe duration of {path} -- is it a valid video?")
def esc(text: str) -> str:
"""Escape text for the ffmpeg drawtext filter."""
return (
text.replace("\\", "\\\\")
.replace(":", "\\:")
.replace("'", "’")
.replace("%", "\\%")
)
class Pack:
def __init__(self, path: Path):
self.data = json.loads(Path(path).read_text())
self.palette = self.data["palette"]
self.copy = self.data.get("copy", {})
self.font = self._resolve_font("display")
self.body_font = self._resolve_font("body")
def _resolve_font(self, role: str) -> str:
t = self.data.get("type", {})
cand = [t.get("font_files", {}).get(role, "")]
cand += t.get("font_fallbacks", [])
for c in cand:
if c and Path(c).exists():
return c
raise SystemExit(
"no usable font found; install Inter or DejaVu, or edit "
f"{DEFAULT_PACK} type.font_fallbacks"
)
def hexc(self, key: str) -> str:
"""pack.json stores #rrggbb; ffmpeg wants 0xrrggbb."""
return "0x" + self.palette[key].lstrip("#")
def drawtext(pack: Pack, text: str, y: str, size: int, color: str,
box: bool = True, font: str | None = None) -> str:
parts = [
f"fontfile={font or pack.font}",
f"text='{esc(text)}'",
"x=(w-text_w)/2",
f"y={y}",
f"fontsize={size}",
f"fontcolor={color}",
]
if box:
parts += ["box=1", "boxcolor=black@0.55", "boxborderw=28"]
return "drawtext=" + ":".join(parts)
# ---------------------------------------------------------------------------
# segment builders
# ---------------------------------------------------------------------------
def build_card(pack: Pack, lines: list[str], seconds: float, out: Path) -> None:
"""Brand card: centered lines, first line accent-coloured and larger."""
draws = []
n = len(lines)
for i, line in enumerate(lines):
big = i == 0
color = pack.hexc("accent") if big else pack.hexc("ink")
size = pack.data["type"]["scale"]["title"] if big else pack.data["type"]["scale"]["subhead"]
yoff = int((i - (n - 1) / 2) * 150)
draws.append(
drawtext(pack, line, y=f"(h-text_h)/2+{yoff}", size=size,
color=color, box=False,
font=pack.font if big else pack.body_font)
)
# Flat brand field with a single lift pass. Deliberately plain: the card
# exists to frame the wordmark, not to compete with the app footage.
bg = (
f"color=c={pack.hexc('bg')}:s={W}x{H}:d={seconds}:r={FPS}[base];"
f"[base]drawbox=x=0:y=0:w={W}:h={H}:color={pack.hexc('bg_hi')}@0.35:t=fill[grad];"
f"[grad]" + ",".join(draws) + ",format=yuv420p[v]"
)
run([
"ffmpeg", "-y", "-f", "lavfi",
"-i", f"color=c={pack.hexc('bg')}:s={W}x{H}:d={seconds}:r={FPS}",
"-filter_complex", bg, "-map", "[v]", "-an",
"-c:v", "libx264", "-preset", "medium", "-crf", "18", str(out),
])
def build_clip(pack: Pack, src: str, caption: str, seconds: float, out: Path,
start: float = 0.0, captioned: bool = True) -> None:
"""Frame one capture on a blurred fill of itself, optionally caption it.
`start` seeks into the clip before taking `seconds`. Use it to skip past
the seed-entry frames of a restore recording so a mnemonic never lands on
camera even if the scenario paused there.
"""
chain = (
f"[0:v]scale={W}:{H}:force_original_aspect_ratio=increase,"
f"crop={W}:{H},boxblur=40:2,eq=brightness=-0.32:saturation=0.7[bg];"
f"[0:v]scale=-2:1480,pad=iw+8:ih+8:4:4:{pack.hexc('accent')}@0.9[fg];"
f"[bg][fg]overlay=(W-w)/2:(H-h)/2-60[cmp];"
)
tail = (
f"fade=t=in:st=0:d=0.4,fade=t=out:st={max(seconds - 0.4, 0.1):.2f}:d=0.4,"
f"setsar=1,fps={FPS},format=yuv420p[v]"
)
if captioned and caption:
cap = drawtext(pack, caption, y="h-260",
size=pack.data["type"]["scale"]["caption"],
color=pack.hexc("accent_hi"))
chain += f"[cmp]{cap},{tail}"
else:
chain += f"[cmp]{tail}"
run([
"ffmpeg", "-y", "-ss", f"{start}", "-t", f"{seconds}", "-i", src,
"-filter_complex", chain, "-map", "[v]", "-an",
"-c:v", "libx264", "-preset", "medium", "-crf", "18",
"-t", f"{seconds}", str(out),
])
def build_bare_clip(src: str, seconds: float, out: Path, start: float = 0.0) -> None:
"""Apple App Preview path: re-encode only. No framing, no caption, no
overlay -- the recording is the deliverable. Kept at source geometry so
the device pixels Apple expects survive untouched."""
run([
"ffmpeg", "-y", "-ss", f"{start}", "-t", f"{seconds}", "-i", src,
"-an", "-c:v", "libx264", "-preset", "slow", "-crf", "18",
"-pix_fmt", "yuv420p", "-r", str(FPS),
"-movflags", "+faststart", str(out),
])
def xfade_concat(segments: list[Path], transition: str, out: Path) -> None:
"""Chain N segments with a single xfade preset. Video only."""
t = load_transition(transition)
inputs: list[str] = []
for s in segments:
inputs += ["-i", str(s)]
durs = [probe_duration(s) for s in segments]
filt = []
prev = "[0:v]"
running = durs[0]
for i in range(1, len(segments)):
off = max(running - t.duration, 0.1)
label = f"[x{i}]"
filt.append(
f"{prev}[{i}:v]xfade=transition={t.ffmpeg_name}:"
f"duration={t.duration}:offset={off:.3f}{label}"
)
prev = label
running = running + durs[i] - t.duration
run([
"ffmpeg", "-y", *inputs,
"-filter_complex", ";".join(filt), "-map", prev,
"-c:v", "libx264", "-preset", "slow", "-crf", "18",
"-pix_fmt", "yuv420p", "-colorspace", "bt709", "-color_primaries", "bt709",
"-movflags", "+faststart", str(out),
])
def concat_simple(segments: list[Path], out: Path, tmp: Path) -> None:
"""Straight concat, no transitions (apple-preview keeps cuts honest)."""
listing = tmp / "concat.txt"
listing.write_text("".join(f"file '{s}'\n" for s in segments))
run([
"ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(listing),
"-c:v", "libx264", "-preset", "slow", "-crf", "18",
"-pix_fmt", "yuv420p", "-movflags", "+faststart", str(out),
])
def mix_music(video: Path, music: str, out: Path, seed: int | None = None) -> None:
"""Bake a random-offset slice of the bed under the master, with fades.
Random offset means repeat uploads of the same variant do not feel
identical -- lifted from the medusa marketing-video builder.
"""
dur = probe_duration(video)
bed_len = probe_duration(music)
rng = random.Random(seed)
max_off = max(bed_len - dur - 1.0, 0.0)
off = rng.uniform(0, max_off) if max_off > 0 else 0.0
run([
"ffmpeg", "-y", "-i", str(video), "-ss", f"{off:.2f}", "-i", music,
"-filter_complex",
f"[1:a]atrim=0:{dur:.2f},afade=t=in:st=0:d=0.5,"
f"afade=t=out:st={max(dur - 1.0, 0.1):.2f}:d=1.0,volume=0.35[a]",
"-map", "0:v", "-map", "[a]",
"-c:v", "copy", "-c:a", "aac", "-b:a", "192k",
"-shortest", "-movflags", "+faststart", str(out),
])
# ---------------------------------------------------------------------------
# profiles
# ---------------------------------------------------------------------------
APPLE_MIN, APPLE_MAX = 15.0, 30.0
PLAY_MAX = 30.0
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--manifest", required=True)
ap.add_argument("-o", "--out", required=True)
ap.add_argument("--profile", default="social",
choices=["social", "apple-preview", "play-listing", "raw"])
ap.add_argument("--pack", default=str(DEFAULT_PACK))
ap.add_argument("--transition", default=None)
ap.add_argument("--music", default=None, help="override manifest music bed")
ap.add_argument("--seed", type=int, default=None,
help="deterministic music offset (for reproducible renders)")
ap.add_argument("--keep-work", action="store_true")
args = ap.parse_args()
if not shutil.which("ffmpeg"):
raise SystemExit("ffmpeg not found on PATH")
pack = Pack(Path(args.pack))
m = json.loads(Path(args.manifest).read_text())
clips = m.get("clips", [])
if not clips:
raise SystemExit(f"{args.manifest} has no clips")
for c in clips:
if not Path(c["src"]).exists():
raise SystemExit(f"clip source missing: {c['src']}")
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
tmp = Path(tempfile.mkdtemp(prefix="hashbags-reel-"))
segs: list[Path] = []
apple = args.profile == "apple-preview"
branded = args.profile in ("social", "play-listing")
try:
if branded:
title = m.get("title", pack.copy.get("title", "Hash Bags"))
subtitle = m.get("subtitle", pack.copy.get("subtitle", ""))
seg = tmp / "00-title.mp4"
build_card(pack, [title, subtitle], 2.5, seg)
segs.append(seg)
for i, clip in enumerate(clips):
seg = tmp / f"{i + 1:02d}-clip.mp4"
seconds = float(clip.get("seconds", 5))
start = float(clip.get("start", 0))
if apple or args.profile == "raw":
build_bare_clip(clip["src"], seconds, seg, start=start)
else:
build_clip(pack, clip["src"], clip.get("caption", ""),
seconds, seg, start=start, captioned=True)
segs.append(seg)
if branded:
cta = m.get("cta", pack.copy.get("cta", "hash.boats"))
cta_sub = m.get("cta_sub", pack.copy.get("cta_sub", ""))
seg = tmp / "99-cta.mp4"
build_card(pack, [cta, cta_sub], 2.5, seg)
segs.append(seg)
silent = tmp / "master-silent.mp4"
if apple or args.profile == "raw":
# Honest cuts, no transition sugar, geometry preserved.
concat_simple(segs, silent, tmp)
else:
transition = (args.transition or m.get("transition")
or pack.data["motion"]["transition_default"])
xfade_concat(segs, transition, silent)
dur = probe_duration(silent)
# ---- profile duration gates ------------------------------------
if apple:
if dur > APPLE_MAX:
raise SystemExit(
f"apple-preview is {dur:.1f}s; Apple accepts {APPLE_MIN:.0f}-"
f"{APPLE_MAX:.0f}s. Trim clip `seconds` in {args.manifest}."
)
if dur < APPLE_MIN:
raise SystemExit(
f"apple-preview is {dur:.1f}s; Apple requires at least "
f"{APPLE_MIN:.0f}s. Lengthen clip `seconds` in {args.manifest}."
)
if args.profile == "play-listing" and dur > PLAY_MAX:
print(f"WARNING: {dur:.1f}s exceeds the {PLAY_MAX:.0f}s Play "
f"listing-video guidance", file=sys.stderr)
# ---- music ------------------------------------------------------
music = args.music or m.get("music")
if music and apple:
print("NOTE: apple-preview ignores the music bed on purpose "
"(App Preview should carry app audio or silence)",
file=sys.stderr)
music = None
if music and Path(music).exists():
mix_music(silent, music, out, seed=args.seed)
else:
shutil.copy(silent, out)
qa = run_video_qa(out)
print(f"wrote {out}")
print(f" profile {args.profile}")
print(f" duration {probe_duration(out):.1f}s")
print(f" qa {qa}")
if apple:
print(" overlays NONE (no captions/cards/tap effect) — per App "
"Preview deliverable rules")
finally:
if args.keep_work:
print(f" work dir {tmp}")
else:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
main()