CommunityImagengithub.com

ParthJadhav/ios-marketing-capture

Automatiza la captura de capturas de pantalla de marketing para una app iOS con SwiftUI en múltiples locales, dispositivos y apariencias. Cubre capturas de pantalla completa, renders de elementos aislados (tarjetas de carrusel, widgets) y nomenclatura de salida reproducible.

¿Qué es ios-marketing-capture?

Esta habilidad automatiza la captura de capturas de pantalla de marketing para aplicaciones iOS construidas con SwiftUI, generando dos flujos de salida paralelos: capturas de pantalla completa de cada pantalla relevante para marketing con datos de semilla deterministas y chrome de barra de estado real, y renders aislados de componentes específicos (tarjetas, widgets, gráficos) con fondos transparentes y esquinas redondeadas. A diferencia de XCUITest o Fastlane snapshot, utiliza un modo de captura dentro de la aplicación (`MarketingCapture.swift` con compilación condicional DEBUG) que evita la fragilidad de agregar un target de prueba UI, acelera la iteración al no requerir `xcodebuild test`, y permite acceso directo al estado real de la app, incluidos ViewModels, SwiftData e ImageRenderer. El proceso involucra definir pasos de captura (`CaptureStep`) con navegación, tiempos de espera y limpieza, renderizar elementos con `ImageRenderer` a escala 3x, y ejecutar un script de shell que compila, instala y recorre todos los locales mediante `simctl launch` con argumentos de idioma y localidad. Es compatible con los patrones de navegación más comunes (TabView, NavigationStack, NavigationSplitView) y maneja estados de vista personalizados a través de variables de `priming` estáticas.

Compatible con~Claude Code~Codex CLI~Cursor
npx skills add ParthJadhav/ios-marketing-capture

Installed? Explore more Imagen skills: steipete/songsee, affaan-m/frontend-design, mattpocock/improve-codebase-architecture · View all 6 →

Preguntar en tu IA favorita

Abre un nuevo chat con esta habilidad de agente ya precargada.

Documentación

iOS Marketing Capture

Resumen

Automatiza la captura reproducible de capturas de pantalla de marketing para una app iOS con SwiftUI en múltiples locales, con dos flujos de salida paralelos:

  1. Capturas de pantalla completa — cada pantalla relevante para marketing, con datos de semilla deterministas, status bar real y chrome de safe area
  2. Capturas de elementos — renders aislados de componentes específicos (tarjetas, widgets, gráficos) a cualquier escala, con fondo natural dentro de esquinas redondeadas y transparencia fuera

Esta habilidad es el paso de captura. Si el usuario también quiere páginas de marketing estilo Apple compuestas alrededor de las tomas (maquetas de dispositivos, titulares, degradados), combínala con la habilidad app-store-screenshots como paso de posprocesamiento.

Enfoque Principal

Modo de captura dentro de la aplicación, no XCUITest. Esta es una decisión difícil que se contrapone a las convenciones de Fastlane snapshot / XCUITest, y gana para casi cualquier proyecto real.

Por qué dentro de la aplicación en lugar de XCUITest:

  • Sin nuevo target de prueba. Agregar un target de prueba UI a un proyecto Xcode existente es una cirugía frágil de pbxproj. Muchos proyectos tienen cero targets de prueba y ningún xcodegen — agregar uno manualmente es propenso a errores.
  • Iteración más rápida. Una prueba UI tarda 30 s o más en lanzarse por ejecución. La captura dentro de la aplicación es simplemente un relanzamiento del binario instalado.
  • Sin xcodebuild test. Todo el flujo es xcodebuild build una vez, luego simctl launch por localidad. Sin la sobrecarga del bundle de prueba.
  • Acceso al estado real de la app. Se puede llamar a ViewModels, SwiftData, ImageRenderer y UIWindow.drawHierarchy directamente. XCUITest solo puede tocar y leer elementos de accesibilidad.
  • Los renders de elementos necesitan el mismo proceso de todas formas. ImageRenderer en vistas de widget o componentes aislados debe ejecutarse dentro del proceso de la app — no hay equivalente en XCUITest.

Cómo funciona:

  1. Un archivo MarketingCapture.swift solo para DEBUG reside en el target principal de la app
  2. Cuando se lanza con -MarketingCapture 1, la app siembra datos, luego un coordinador recorre una lista de CaptureSteps — cada paso navega, espera a que se estabilice, toma la captura y limpia
  3. Los PNG se escriben en el sandbox de la app en Documents/marketing/<locale>/
  4. Un script de shell compila una vez, instala, luego recorre las localidades relanzando con -AppleLanguages (xx) -AppleLocale xx, extrayendo los archivos mediante simctl get_app_container

Proceso

Trabaja a través de estos pasos en orden. No te saltes ninguno.

Paso 1: Recopilar requisitos

Haz al usuario estas preguntas una a la vez (no las agrupes — cada respuesta puede invalidar preguntas posteriores):

  1. Pantallas a capturar — "¿Qué pantallas quieres? Dame la ruta de navegación o el nombre de la pestaña para cada una." Obtén una lista concreta, no "los flujos principales".
  2. Elementos aislados — "¿Algún componente que quieras renderizar de forma independiente con fondos transparentes? (tarjetas de carrusel, widgets, tiles destacados, gráficos, etc.)"
  3. Locales — "¿Qué locales? (a) todos los locales en tu Localizable.xcstrings, (b) un subconjunto de App Store que especificaré, o (c) déjame darte una lista explícita." Si (a), busca en el archivo .xcstrings los códigos de localidad:
    python3 -c "import json; d=json.load(open('<path>/Localizable.xcstrings')); langs=set(); [langs.update(v.get('localizations',{}).keys()) for v in d['strings'].values()]; print(sorted(langs))"
    
  4. Dispositivo — "¿Qué simulador? (se recomienda iPhone 17 de 6.1" para funciones de diseño de iOS 26)" — verifica que el dispositivo esté disponible mediante xcrun simctl list devices available.
  5. Apariencia — "¿Solo modo claro, solo modo oscuro, o ambos?"
  6. Datos de semilla — "¿Cómo se pueblan los datos de demostración hoy? (a) la instalación nueva los siembra automáticamente, (b) hay un botón de depuración 'Cargar datos de demostración', (c) los agregas manualmente, (d) aún no existen datos de demostración." Luego: "¿Son los datos existentes lo suficientemente exhaustivos como para que cada pantalla que enumeraste se vea poblada para marketing? Audítalo con el usuario."

Paso 2: Exploración

Antes de escribir código, explora la base de código lo suficiente para responder:

  • ¿Utiliza el proyecto grupos de carpetas sincronizados de Xcode (Xcode 16+, PBXFileSystemSynchronizedRootGroup)? Si es así, los nuevos archivos se incluyen automáticamente en su target — no se necesitan ediciones de pbxproj. Verifica con grep -c PBXFileSystemSynchronized <proj>.xcodeproj/project.pbxproj.
  • ¿Cuál es el patrón de navegación raíz?
    • TabView(selection:) — el más común. Necesitas: el binding @State selectedTab, los índices de las pestañas y qué pestañas tienen NavigationStack anidado.
    • NavigationStack (una sola pila con un router) — necesitas: el binding de la ruta o el objeto router, además del conjunto de tipos NavigationLink(value:) / .navigationDestination.
    • NavigationSplitView — necesitas: el binding de selección de la barra lateral, el estado de navegación de la columna de detalle.
    • Coordinador personalizado / host UIKit — necesitas: el método navigate(to:) del coordinador o equivalente.
  • ¿Cómo se enrutan los deep links? Encuentra el manejador onOpenURL y el enum/switch que mapea las URLs al estado de navegación.
  • ¿Dónde están definidos los sembradores de datos de demostración? Traza la ruta del código desde el botón de depuración (si existe) hasta la función que realmente escribe en ModelContext. Si no existe un sembrador, consulta "Creación de un sembrador de datos de demostración" a continuación.
  • ¿Los widgets viven en un target separado? ¿Están los archivos de vista del widget y los tipos de entrada también en el target principal de la app? (Casi con certeza no — deben agregarse si deseas renderizarlos mediante ImageRenderer).
  • ¿Utiliza la app Live Activities / ActivityKit? Si es así, márcalo como un problema conocido (ver más abajo).
  • ¿Utiliza la app SwiftData + CloudKit sync (cloudKitDatabase: .automatic)? Si es así, márcalo como un problema conocido.
  • ¿Necesita alguna vista ser capturada en un estado no predeterminado? (por ejemplo, un temporizador a mitad de cuenta regresiva, un formulario parcialmente lleno, un gráfico con valores específicos). Si es así, cada una necesita un mecanismo de static var de preparación (consulta "Preparación del estado de la vista" a continuación).

Paso 3: Presentar el diseño al usuario

Antes de escribir código, resume tu plan en esta estructura. Obtén la aprobación explícita antes de continuar:

  1. Arquitectura (modo de captura dentro de la aplicación, archivo único, protegido por compilación condicional DEBUG)
  2. Lista de archivos (rutas exactas que crearás / modificarás)
  3. Plan de captura pantalla por pantalla (cómo se alcanza cada pantalla — índice de pestaña, ruta de navegación, disparador de hoja)
  4. Justificación del orden de captura (qué pantallas deben ir antes que otras — ver problema conocido #5)
  5. Enfoque de renderizado de elementos (qué componentes, cómo se envolverán)
  6. Diseño de salida (estructura de carpetas, convención de nomenclatura)
  7. Problemas conocidos relevantes para este proyecto (marcados en el Paso 2)
  8. Estados preparados necesarios (qué vistas, qué vars estáticas)

Paso 4: Implementar

Usa las plantillas en templates/ como puntos de partida. Son patrones de referencia, no andamios de copiar y pegar — cada proyecto tiene diferentes navegación, modelos y vistas. Las plantillas muestran los bloques de construcción; los compones para la app objetivo.

Archivos clave a producir:

  • <NombreDeLaApp>/Debug/MarketingCapture.swift — todo el sistema de captura, solo DEBUG. Contiene:
    • Enum MarketingCapture (análisis de argumentos de lanzamiento, ayudantes de salida, captura de ventana, vars de preparación)
    • Clase MarketingCaptureCoordinator (recorre [CaptureStep] y captura cada uno)
    • Enum MarketingElementHarness (renders de ImageRenderer de tarjetas, widgets, gráficos)
  • <NombreDeLaApp>/ContentView.swift (o donde sea que viva la vista raíz) — hook DEBUG que siembra datos y ejecuta el coordinador.
  • Cualquier vista que necesite estados preparados — hooks .onAppear protegidos por compilación condicional DEBUG y listeners .onReceive de descarte.
  • scripts/capture-marketing.sh — compilar + instalar + bucle por localidad.
  • .gitignore — agregar marketing/.

Paso 5: Verificar de forma iterativa

No entregues el script al usuario y esperes. Ejecútalo tú mismo contra un simulador y verifica al menos una localidad antes de declarar que está listo. Lee los PNG de salida con la herramienta Read para verificar visualmente que cada pantalla muestre lo que esperas. Los problemas comunes de ejecución se enumeran en "Problemas conocidos" a continuación.

Cuando encuentres un problema, arréglalo, vuelve a ejecutar todo el script (no solo la localidad que falla — las correcciones pueden regresar localidades anteriores), y vuelve a verificar visualmente.

Arquitectura: Captura Basada en Pasos

El coordinador impulsa la captura recorriendo una lista de valores CaptureStep. Cada paso es autónomo: sabe cómo navegar a su pantalla, cuánto tiempo esperar y cómo limpiar después.

struct CaptureStep {
    let name: String                        // nombre del archivo de salida, ej. "01-home"
    let navigate: @MainActor () -> Void     // poner la app en el estado correcto
    let settle: Duration                    // esperar a que terminen animaciones/cargas
    let cleanup: (@MainActor () -> Void)?   // limpiar antes del siguiente paso
}

El coordinador es un bucle simple:

for step in steps {
    step.navigate()
    try? await Task.sleep(for: step.settle)
    if let image = MarketingCapture.snapshotKeyWindow() {
        MarketingCapture.writePNG(image, name: step.name)
    }
    step.cleanup?()
    try? await Task.sleep(for: .milliseconds(400))  // animación de limpieza
}

Construcción de pasos para diferentes patrones de navegación

App con TabView (más común):

// Cambio de pestaña simple — solo establece el índice
CaptureStep(name: "01-home", navigate: { setTab(0) }, settle: .milliseconds(1800), cleanup: nil)

// Pestaña + hoja presentada
CaptureStep(
    name: "05-timer-setup",
    navigate: {
        setTab(3)
        pendingBrewRecipe = someRecipe
    },
    settle: .milliseconds(2000),
    cleanup: {
        NotificationCenter.default.post(name: MarketingCapture.dismissSheetNotification, object: nil)
        pendingBrewRecipe = nil
    }
)

App con NavigationStack + router:

// Empujar una ruta en la pila
CaptureStep(
    name: "02-detail",
    navigate: { router.push(.itemDetail(item)) },
    settle: .milliseconds(1800),
    cleanup: { router.popToRoot() }
)

App con NavigationSplitView:

// Seleccionar elemento de la barra lateral, luego detalle
CaptureStep(
    name: "03-detail",
    navigate: {
        sidebarSelection = .recipes
        detailSelection = recipes.first
    },
    settle: .milliseconds(1800),
    cleanup: { detailSelection = nil }
)

Orden: la regla de apilamiento

Captura cualquier pantalla que necesite un estado de navegación "limpio" ANTES de las pantallas que empujan en la misma pila. NavigationPath / @State anidados dentro de vistas hijas no se pueden desapilar desde el coordinador. Por lo tanto:

Bueno:  Estante (lista limpia) → Detalle del Café (empuja sobre la pila del estante)
Malo:   Detalle del Café → Estante (la pila todavía tiene el detalle empujado)

Si dos pantallas comparten un NavigationStack, captura primero la vista de nivel raíz.

Preparación del Estado de la Vista

Algunas pantallas necesitan ser capturadas en un estado no predeterminado específico: un temporizador a mitad de cuenta regresiva, un gráfico con valores particulares, un formulario medio lleno. El patrón:

  1. Agrega un static var a MarketingCapture para cada valor de preparación:

    /// Establecido por el coordinador antes de presentar la vista del temporizador.
    /// La vista lo lee en .onAppear para saltar a un tiempo transcurrido específico.
    static var pendingElapsedSeconds: Int?
    
    /// Establecido en true para mostrar la superposición de evaluación en el temporizador.
    static var pendingShowAssessment: Bool = false
    
  2. En la vista objetivo, agrega un .onAppear protegido por compilación condicional DEBUG que lea el valor de preparación:

    .onAppear {
        #if DEBUG
        if MarketingCapture.isActive, let elapsed = MarketingCapture.pendingElapsedSeconds {
            phase = .active
            timerVM.elapsedTime = TimeInterval(elapsed)
            timerVM.start()
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { timerVM.pause() }
        }
        #endif
    }
    
  3. En el coordinador, establece la var antes de navegar:

    CaptureStep(
        name: "06-timer-midway",
        navigate: {
            MarketingCapture.pendingElapsedSeconds = 75
            openTimerSheet(someRecipe)
        },
        settle: .milliseconds(2400),
        cleanup: {
            MarketingCapture.pendingElapsedSeconds = nil
            NotificationCenter.default.post(name: MarketingCapture.dismissSheetNotification, object: nil)
        }
    )
    

Creación de un Sembrador de Datos de Demostración

Si la app no tiene un mecanismo de datos de demostración existente, crea uno. Colócalo en <NombreDeLaApp>/Debug/DemoDataSeeder.swift, envuelto en #if DEBUG.

Directrices:

  • Siembra suficientes datos para que cada pantalla capturada se vea poblada. Audita la lista de pantallas contra la semilla.
  • Usa contenido realista: nombres de lugares reales, números plausibles, estados variados (algunos elementos "quedan pocos", otros "frescos", algunos con imágenes, otros sin).
  • Si la app usa SwiftData, escribe directamente en el ModelContext. Si usa Core Data, usa el contexto de objetos administrados. Si usa un backend REST, siembra a través de la capa de caché/almacenamiento local.
  • Haz que la siembra sea idempotente — verifica si los datos ya existen antes de insertar. El almacenamiento persiste entre relanzamientos del simulador, y volver a sembrar por localidad causa problemas de sincronización de CloudKit y bloqueos.
  • Incluye suficiente variedad para llenar diferentes estados de UI: los estados vacíos NO deben aparecer a menos que sean una pantalla de marketing.

Forma mínima:

#if DEBUG
enum DemoDataSeeder {
    static func seedIfEmpty(in context: ModelContext) {
        let existing = (try? context.fetchCount(FetchDescriptor<Item>())) ?? 0
        guard existing == 0 else { return }

        // Elementos con estados variados
        let items = [
            Item(name: "...", status: .active, ...),
            Item(name: "...", status: .lowStock, ...),
            // ...suficientes para llenar cada pantalla
        ]
        items.forEach { context.insert($0) }
        try? context.save()
    }
}
#endif

Renderizado de Elementos

Los elementos se renderizan mediante ImageRenderer a escala 3x con transparencia fuera de las esquinas redondeadas.

Tarjetas / filas de lista

@MainActor
static func renderCards(items: [Item], theme: AppTheme) {
    let cardWidth: CGFloat = 380

    for item in items {
        let card = ItemCard(item: item, theme: theme)
            .padding(.horizontal, 16)
            .padding(.vertical, 12)
            .frame(width: cardWidth)
            .background(theme.background)
            .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))

        let renderer = ImageRenderer(content: card)
        renderer.scale = 3
        renderer.isOpaque = false
        renderer.proposedSize = .init(width

…

[truncado para traducción]

Skills relacionados