diff --git a/.gitea/workflows/apple.yml b/.gitea/workflows/apple.yml index 79eefe09..e429689d 100644 --- a/.gitea/workflows/apple.yml +++ b/.gitea/workflows/apple.yml @@ -755,7 +755,7 @@ jobs: bash tools/screenshots.sh ipad || echo "::warning::iPad 13\" screenshots skipped" # tvOS shoots only the scenes that exist there — the 06–09 gamepad-console scenes are # compiled out on tvOS (native focus engine), and an unknown name = a normal app launch. - SCENES="01-stream 02-hosts 05-settings 03-pair" \ + SCENES="01-stream 02-hosts 11-library 05-settings 03-pair" \ bash tools/screenshots.sh tvos || echo "::warning::Apple TV screenshots skipped" echo "Produced:"; ls -la screenshots || true diff --git a/clients/android/app/build.gradle.kts b/clients/android/app/build.gradle.kts index 171a617d..747d7a14 100644 --- a/clients/android/app/build.gradle.kts +++ b/clients/android/app/build.gradle.kts @@ -142,6 +142,10 @@ dependencies { // job runs `:app:testDebugUnitTest -PskipRustBuild` (see kit/build.gradle.kts). --- testImplementation(composeBom) testImplementation("androidx.compose.ui:ui-test-junit4") + // Deterministic cover art for the library scene: FakeImageLoaderEngine answers the coverflow's + // AsyncImage synchronously with generated posters — no network, no async race under the frozen + // animation clock. + testImplementation("io.coil-kt:coil-test:2.7.0") debugImplementation("androidx.compose.ui:ui-test-manifest") // the ComponentActivity test host testImplementation("junit:junit:4.13.2") // Real `org.json` for the shared-vectors test: the `org.json` inside `android.jar` is a stub diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt index df960c1c..fff2514e 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt @@ -69,7 +69,7 @@ import kotlinx.coroutines.delay * to be the same one whichever interface asked. */ @Composable -fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) { +internal fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit, padsOverride: List? = null) { BackHandler(onBack = onBack) var testing by remember { mutableStateOf(false) } ControllersBody( @@ -77,6 +77,7 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) { scroll = rememberScrollState(), testing = testing, onTestingChange = { testing = it }, + padsOverride = padsOverride, // The touch screen holds the probes for its whole life: events are OBSERVED (not consumed) // while the test is off, which is what keeps the "Last input" line live while browsing. // Nothing else here wants the pad, so there is no one to hand them to. @@ -99,7 +100,12 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) { * drops out of the probe slots and B is a HOLD (below). Everything reverts the moment it ends. */ @Composable -fun ConsoleControllersScreen(gamepadSetting: Int, onBack: () -> Unit, navActive: Boolean = true) { +internal fun ConsoleControllersScreen( + gamepadSetting: Int, + onBack: () -> Unit, + navActive: Boolean = true, + padsOverride: List? = null, +) { BackHandler(onBack = onBack) val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE val hazeState = remember { HazeState() } @@ -139,6 +145,7 @@ fun ConsoleControllersScreen(gamepadSetting: Int, onBack: () -> Unit, navActive: scroll = scroll, testing = testing, onTestingChange = { testing = it }, + padsOverride = padsOverride, // Only while testing: the rest of the time the screen's own nav holds the // probes, so the "Last input" line is a test-time readout here rather than // an always-on one. A pad that reaches this screen at all has already @@ -200,14 +207,17 @@ private fun ControllersBody( onTestingChange: (Boolean) -> Unit, observeInput: Boolean, contentPadding: PaddingValues, + padsOverride: List? = null, heading: @Composable () -> Unit, ) { val context = LocalContext.current val activity = context as? MainActivity - // Device list, re-read on every hot-plug event. + // Device list, re-read on every hot-plug event. [padsOverride] replaces it wholesale: the + // screenshot harness runs where no InputDevice can exist, and the connected-pad card is the + // point of that shot. var generation by remember { mutableIntStateOf(0) } - val pads = remember(generation) { Gamepad.pads() } + val pads = padsOverride ?: remember(generation) { Gamepad.pads() }.map(::padInfoOf) val others = remember(generation) { InputDevice.getDeviceIds() .toList() @@ -392,8 +402,8 @@ private fun ControllersBody( // Every real controller is forwarded now (Automatic forwards them all, each on its own // wire pad index) — not just the first. A joystick-only device Android doesn't classify as // a gamepad still can't be forwarded (the host wants a gamepad), so gate the badge on it. - pads.forEach { dev -> - PadRow(dev, forwarded = isForwarded(dev), gamepadSetting = gamepadSetting) + pads.forEach { info -> + PadRow(info, gamepadSetting = gamepadSetting) } } @@ -675,19 +685,19 @@ private fun DsRow(usbDev: android.hardware.usb.UsbDevice) { /** One detected gamepad: identity, what it streams as, and a rumble test. */ @Composable -private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) { +private fun PadRow(info: PadInfo, gamepadSetting: Int) { OutlinedCard(modifier = Modifier.fillMaxWidth()) { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text(dev.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) - if (forwarded) { + Text(info.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + if (info.forwarded) { // Android's own controller number (1-based; 0 = unassigned), shown so a multi-pad // user can tell which physical pad is which. The stream's wire pad index is // assigned separately (lowest-free per device) once streaming starts. - val number = dev.controllerNumber + val number = info.controllerNumber Text( if (number > 0) "forwarded · player $number" else "forwarded to host", style = MaterialTheme.typography.labelSmall, @@ -696,11 +706,11 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) { } } Text( - deviceDetail(dev), + info.detail, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - val resolved = Gamepad.prefFor(dev) + val resolved = info.resolvedPref Text( if (gamepadSetting == Gamepad.PREF_AUTO) { "Streams as: ${prefLabel(resolved)} (automatic)" @@ -711,9 +721,8 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) { style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - val canRumble = deviceHasVibrator(dev) - if (canRumble) { - OutlinedButton(onClick = { testRumble(dev) }) { Text("Test rumble") } + if (info.canRumble) { + OutlinedButton(onClick = { info.dev?.let(::testRumble) }) { Text("Test rumble") } } else { Text( "No rumble motors reported — host rumble will be silent", @@ -794,6 +803,32 @@ private fun Group(title: String, content: @Composable ColumnScope.() -> Unit) { private fun isForwarded(dev: InputDevice): Boolean = !dev.isVirtual && dev.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD +/** + * Everything [PadRow] renders, decoupled from [InputDevice] so the screenshot harness can compose + * the connected-pad card at all — Robolectric enumerates no input devices, and a marketing shot of + * "no controller detected" sells nothing. Production always maps a real device via [padInfoOf]; + * [dev] powers the rumble test and is absent only in the harness (the button then no-ops). + */ +internal data class PadInfo( + val name: String, + val detail: String, + val forwarded: Boolean, + val controllerNumber: Int, + val resolvedPref: Int, + val canRumble: Boolean, + val dev: InputDevice? = null, +) + +internal fun padInfoOf(dev: InputDevice): PadInfo = PadInfo( + name = dev.name, + detail = deviceDetail(dev), + forwarded = isForwarded(dev), + controllerNumber = dev.controllerNumber, + resolvedPref = Gamepad.prefFor(dev), + canRumble = deviceHasVibrator(dev), + dev = dev, +) + /** Whether the controller reports a rumble motor — via VibratorManager (API 31+) or the legacy Vibrator. */ private fun deviceHasVibrator(dev: InputDevice): Boolean = if (Build.VERSION.SDK_INT >= 31) { diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt index d2e287ff..85faf9ac 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt @@ -254,8 +254,10 @@ private fun MessageState(text: String) { ) } +// Internal (not private): the screenshot harness composes the real coverflow with mock games — +// the library screen itself can't be shot, its state comes off the network. @Composable -private fun Coverflow( +internal fun Coverflow( games: List, loader: ImageLoader, navActive: Boolean, diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt index 142e6c15..9f966565 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt @@ -173,6 +173,21 @@ class ScreenshotTest { @Test fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() } + /** The touch presentation, pads connected — the store's FEEL THE GAME frame. */ + @Test + fun controllers() = shootRoot("controllers") { ControllersScene() } + + /** + * The library coverflow with a mock shelf — the store's PICK & PLAY frame. Landscape: the + * orientation the coverflow actually runs in, and the only one wide enough for neighbours. + */ + @Test + @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") + fun library() = shootRoot("library") { LibraryScene() } + + @Test + fun libraryPortrait() = shootRoot("library-portrait") { LibraryScene() } + @Test fun consoleControllersLight() = shootRoot("console-controllers-light") { ConsoleControllersScene(paletteId = "holo") } diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt index c6cf105f..6717852d 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt @@ -1,5 +1,16 @@ package io.unom.punktfunk.screenshots +import android.content.Context +import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.Shader +import android.graphics.Typeface +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -35,8 +46,26 @@ import androidx.compose.runtime.CompositionLocalProvider import io.unom.punktfunk.GamepadHome import io.unom.punktfunk.GamepadInk import io.unom.punktfunk.GamepadPalette +import coil.ImageLoader +import coil.test.FakeImageLoaderEngine +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource import io.unom.punktfunk.ConsoleControllersScreen +import io.unom.punktfunk.ConsoleHeader +import io.unom.punktfunk.ConsoleLegendInset import io.unom.punktfunk.ConsoleLicensesScreen +import io.unom.punktfunk.ControllersScreen +import io.unom.punktfunk.Coverflow +import io.unom.punktfunk.GamepadAuroraBackground +import io.unom.punktfunk.GamepadHintBar +import io.unom.punktfunk.PadGlyph +import io.unom.punktfunk.PadInfo +import io.unom.punktfunk.consoleLegendInsets +import io.unom.punktfunk.consoleSafeArea +import io.unom.punktfunk.kit.Gamepad +import io.unom.punktfunk.kit.library.Artwork +import io.unom.punktfunk.kit.library.GameEntry +import androidx.compose.ui.platform.LocalConfiguration import io.unom.punktfunk.GamepadSettingsScreen import io.unom.punktfunk.HomeTile import io.unom.punktfunk.LocalGamepadInk @@ -510,8 +539,8 @@ internal fun ConsoleHomeScene(paletteId: String = "violet") { * whole risk. Their touch presentation is inked by the app theme, which is always dark, so nothing * before this could catch light-grey body text stranded on a pastel field. * - * Robolectric enumerates no input devices, so the controllers scene renders its deterministic - * "nothing connected" state. + * Robolectric enumerates no input devices, so the controllers scenes inject [shotPads] — the + * deterministic connected-pads state the store listing needs. */ @Composable internal fun ConsoleLicensesScene(paletteId: String = "violet") = @@ -520,14 +549,130 @@ internal fun ConsoleLicensesScene(paletteId: String = "violet") = @Composable internal fun ConsoleControllersScene(paletteId: String = "violet") = ConsolePalette(paletteId) { - ConsoleControllersScreen(gamepadSetting = 0, onBack = {}, navActive = false) + // Robolectric enumerates no input devices, so the shot injects the two pads the store + // listing talks about — the empty "no controller detected" state proves the palette but + // sells nothing. + ConsoleControllersScreen( + gamepadSetting = 0, onBack = {}, navActive = false, padsOverride = shotPads(), + ) } +/** + * The touch presentation of the same screen, with the same injected pads. Wrapped in a background + * [Surface]: the activity provides the dark ground in the app, and without one here the content + * color falls back to black-on-white while the cards stay dark. + */ +@Composable +internal fun ControllersScene() = + Surface(color = MaterialTheme.colorScheme.background) { + ControllersScreen(gamepadSetting = 0, onBack = {}, padsOverride = shotPads()) + } + +/** The two pads the store listing names: DualSense (adaptive triggers, LEDs, rumble) and Xbox. */ +internal fun shotPads() = listOf( + PadInfo( + name = "DualSense Wireless Controller", + detail = "054C:0CE6 · gamepad · joystick", + forwarded = true, controllerNumber = 1, + resolvedPref = Gamepad.PREF_DUALSENSE, canRumble = true, + ), + PadInfo( + name = "Xbox Wireless Controller", + detail = "045E:0B13 · gamepad · joystick", + forwarded = true, controllerNumber = 2, + resolvedPref = Gamepad.PREF_XBOXONE, canRumble = true, + ), +) + /** * Publish the palette locals `App` would normally provide. A scene that calls a console screen * directly gets the DEFAULT dark ink without this, and a pale-palette shot would then silently * prove nothing at all. */ +/** + * The game-library coverflow (the real [Coverflow] over the real console chrome) with a mock shelf. + * The library screen itself can't be shot — its state comes off the network — so the scene rebuilds + * the same shell [io.unom.punktfunk.LibraryScreen] draws around it: aurora, header, floating hint + * bar. Cover art is answered synchronously by coil-test's [FakeImageLoaderEngine] with generated + * posters, so the frozen animation clock never races an async load. + */ +@Composable +internal fun LibraryScene(paletteId: String = "violet") = ConsolePalette(paletteId) { + val context = LocalContext.current + val loader = remember { shotLibraryLoader(context) } + val games = remember { shotGames() } + val hazeState = remember { HazeState() } + val landscape = + LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + Box(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().hazeSource(hazeState)) { + GamepadAuroraBackground(Modifier.fillMaxSize()) + Column(Modifier.fillMaxSize().consoleSafeArea()) { + ConsoleHeader("Living Room PC — Library") + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Coverflow(games, loader, navActive = false, onLaunch = {}) + } + } + } + Box( + Modifier.align(Alignment.BottomStart) + .consoleLegendInsets(landscape) + .padding(ConsoleLegendInset), + ) { + GamepadHintBar( + listOf(PadGlyph.hint('A', "Launch"), PadGlyph.hint('B', "Close")), + hazeState = hazeState, + ) + } + } +} + +/** A believable shelf: four titles with art plus the Steam launcher entry (brand-mark tile). */ +private fun shotGames() = listOf( + GameEntry("custom:aurora", "custom", "Aurora Drift", Artwork("shot://art/aurora", null, null)), + GameEntry("steam:starfall", "steam", "Starfall Vale", Artwork("shot://art/starfall", null, null)), + GameEntry("heroic:neon", "heroic", "Neon Circuit", Artwork("shot://art/neon", null, null)), + GameEntry("gog:ember", "gog", "Ember Peaks", Artwork("shot://art/ember", null, null)), + GameEntry("steam:launcher", "steam", "Steam", Artwork(null, null, null), role = "launcher", icon = "steam"), +) + +private fun shotLibraryLoader(context: Context): ImageLoader { + val engine = FakeImageLoaderEngine.Builder() + .intercept("shot://art/aurora", cover(context, 0xFF6656F2, 0xFF141040, "A")) + .intercept("shot://art/starfall", cover(context, 0xFFE86FA8, 0xFF3A1030, "S")) + .intercept("shot://art/neon", cover(context, 0xFF35D0C5, 0xFF0A2A33, "N")) + .intercept("shot://art/ember", cover(context, 0xFFEF8F4B, 0xFF3A1608, "E")) + .default(ColorDrawable(0xFF221E44.toInt())) + .build() + return ImageLoader.Builder(context).components { add(engine) }.build() +} + +/** A generated 2:3 poster: vertical brand-adjacent gradient + a big monogram. */ +private fun cover(context: Context, top: Long, bottom: Long, mark: String): Drawable { + val w = 600 + val h = 900 + val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + canvas.drawRect( + 0f, 0f, w.toFloat(), h.toFloat(), + Paint(Paint.ANTI_ALIAS_FLAG).apply { + shader = LinearGradient( + 0f, 0f, 0f, h.toFloat(), top.toInt(), bottom.toInt(), Shader.TileMode.CLAMP, + ) + }, + ) + canvas.drawText( + mark, w / 2f, h / 2f + 110f, + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = 0xD9FFFFFF.toInt() + textSize = 320f + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + textAlign = Paint.Align.CENTER + }, + ) + return BitmapDrawable(context.resources, bmp) +} + @Composable private fun ConsolePalette(paletteId: String, content: @Composable () -> Unit) { val palette = GamepadPalette.named(paletteId) diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/TvScreenshotTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/TvScreenshotTest.kt index fef490a5..09dd2f9b 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/TvScreenshotTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/TvScreenshotTest.kt @@ -50,6 +50,10 @@ class TvScreenshotTest { @Test fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() } + /** The library coverflow at TV geometry — the store's PICK & PLAY frame for the TV listing. */ + @Test + fun library() = shootRoot("library") { LibraryScene() } + @Test fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() } } diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift index fd2f3b5a..190187ef 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift @@ -35,6 +35,11 @@ enum ShotScenes { ShotScene(name: "05-settings", orientation: .natural, colorScheme: .dark) { AnyView(ShotSettings()) }, + // 06–10 are the iOS/macOS console-shell block below; the library is cross-platform + // (tvOS renders the same coverflow), hence the number above that range. + ShotScene(name: "11-library", orientation: .landscape, colorScheme: .dark) { + AnyView(ShotLibrary()) + }, ] #if os(iOS) || os(macOS) // The gamepad-mode console screens (no tvOS — native focus engine there). Dev-only shots @@ -193,6 +198,24 @@ enum ShotMock { #endif } + /// A believable shelf for the library coverflow. Decoded rather than constructed: + /// `GameEntry`'s memberwise init is internal to PunktfunkKit, and Codable is its public + /// construction surface. No art URLs — the posters render their deterministic fallback + /// (title tiles, the Steam entry its brand mark), which is also what keeps the shot offline. + static let games: [GameEntry] = { + let json = """ + [ + {"id": "custom:aurora", "store": "custom", "title": "Aurora Drift", "art": {}}, + {"id": "steam:starfall", "store": "steam", "title": "Starfall Vale", "art": {}}, + {"id": "heroic:neon", "store": "heroic", "title": "Neon Circuit", "art": {}}, + {"id": "gog:ember", "store": "gog", "title": "Ember Peaks", "art": {}}, + {"id": "steam:launcher", "store": "steam", "title": "Steam", "art": {}, + "role": "launcher", "icon": "steam"} + ] + """ + return (try? JSONDecoder().decode([GameEntry].self, from: Data(json.utf8))) ?? [] + }() + /// A plausible-looking 32-byte SHA-256 for the trust card / pin lock glyphs. static let fingerprint = hostFingerprint(0) @@ -230,6 +253,19 @@ private struct ShotHome: View { } } +// MARK: - Library + +/// The library coverflow with the mock shelf — the store listing's PICK & PLAY frame. The real +/// `LibraryCoverflowView`, no network: artless entries settle to their deterministic fallback +/// posters, and the entrance's 700 ms backstop has long fired by the time the driver captures. +private struct ShotLibrary: View { + var body: some View { + LibraryCoverflowView( + games: ShotMock.games, artLoader: nil, + onLaunch: { _ in }, onDismiss: {}, controllerActive: false) + } +} + // MARK: - Gamepad-mode console screens (dev-only glass preview) #if os(iOS) || os(macOS) diff --git a/clients/apple/tools/screenshots.sh b/clients/apple/tools/screenshots.sh index 3eb88a9c..7a6cf789 100755 --- a/clients/apple/tools/screenshots.sh +++ b/clients/apple/tools/screenshots.sh @@ -45,7 +45,7 @@ BUNDLE_ID="io.unom.punktfunk" # The App Store set, in listing order — the first three are what most people ever see, so they are # the stream itself, the machines it found, and the couch/controller mode. Everything else in # ShotScenes.all is a dev scene; capture those with `SCENES="06-gamepad-home 10-edithost" ...`. -SCENES=(${SCENES:-01-stream 02-hosts 06-gamepad-home 09e-waking-modal 05-settings 03-pair}) +SCENES=(${SCENES:-01-stream 02-hosts 11-library 06-gamepad-home 09e-waking-modal 05-settings 03-pair}) SETTLE="${SETTLE:-4}" # seconds to let a scene lay out before capturing mkdir -p "$OUT"