mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-17 05:41:29 +02:00
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.
481 lines
18 KiB
Python
Executable File
481 lines
18 KiB
Python
Executable File
#!/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()
|