Agent Skills by ALSEL
汎用DevOps・インフラ⭐ リポ 237品質スコア 93/100

ios-marketing-capture

SwiftUI iOSアプリのマーケティング用スクリーンショットを複数のロケール・デバイス・外観にわたって自動で撮影したい場合に使用します。フルスクリーン撮影、カルーセルカードやウィジェットなどの個別要素のレンダリング、再現可能な出力ファイル名付けに対応しています。マーケティング用スクリーンショット、ロケール別スクリーンショット、ウィジェットレンダリング、App Storeアセット、fastlane代替、simctlスクリーンショットなどで起動します。

description の原文を見る

Use when the user wants to automate capture of marketing screenshots for a SwiftUI iOS app across multiple locales, devices, or appearances. Covers full-screen shots, isolated element renders (carousel cards, widgets), and reproducible output naming. Triggers on marketing screenshots, locale screenshots, widget renders, App Store assets, fastlane-alternative, simctl screenshots.

SKILL.md 本文

iOS マーケティングキャプチャ

概要

SwiftUI iOS アプリ向けにマーケティングスクリーンショットの取得を自動化します。複数のロケール対応で、2つの並列出力ストリームを提供します:

  1. フルスクリーンキャプチャ — すべてのマーケティング関連画面を、決定論的にシード化されたデータ、実際のステータスバー/セーフエリアクロムで取得
  2. 要素キャプチャ — 特定のコンポーネント(カード、ウィジェット、チャート)を任意のスケールで独立して描画。角丸内側は自然な背景、外側は透明

このスキルはキャプチャステップです。ユーザーが Apple スタイルのマーケティングページ(デバイスモックアップ、ヘッドライン、グラデーション)をスクリーンショットの周囲に合成したい場合は、app-store-screenshots スキルを後処理ステップとして組み合わせてください。

コアアプローチ

アプリ内キャプチャモードを採用。XCUITest 慣例との判断の交換トレードオフですが、ほぼすべての実プロジェクトで優れています。

XCUITest より アプリ内の理由:

  • 新しいテストターゲット不要。 UI テストターゲットを既存 Xcode プロジェクトに追加すると pbxproj が壊れやすくなります。多くのプロジェクトはテストターゲットがなく xcodegen もない — 手動追加はエラーが起きやすい。
  • 反復が高速。 UI テストは実行ごとに 30 秒以上起動に要します。アプリ内キャプチャはインストール済みバイナリの再起動だけです。
  • xcodebuild test 不要。 ワークフロー全体が xcodebuild build を 1 回、ロケールごとに simctl launch を実行するだけです。テストバンドルのオーバーヘッドなし。
  • 実アプリの状態にアクセス可能。 ViewModel、SwiftData、ImageRenderer、UIWindow.drawHierarchy を直接呼び出せます。XCUITest はタップとアクセシビリティ要素の読み取りのみです。
  • 要素描画はプロセス内が必須。 ウィジェットビューまたは独立したコンポーネント上の ImageRenderer はアプリプロセス内で実行する必要があります — XCUITest に相当するものはありません。

動作方法:

  1. DEBUG のみの MarketingCapture.swift ファイルがメインアプリターゲットに存在
  2. -MarketingCapture 1 で起動されたとき、アプリはデータをシード化し、コーディネーターが CaptureStep リストを走査します — 各ステップはナビゲート、整定待機、スナップショット、クリーンアップを実行
  3. PNG はアプリのサンドボックス Documents/marketing/<locale>/ ディレクトリに書き込まれます
  4. シェルスクリプトが 1 回ビルドしてインストール。-AppleLanguages (xx) -AppleLocale xx で再起動してロケールをループし、simctl get_app_container でファイルを取得します

プロセス

以下のステップを順番に実行してください。先へ進まないでください。

ステップ 1: 要件の確認

ユーザーにこれらの質問を一度に 1 つ(バッチ処理しない — 各回答が後の質問を無効化する可能性)してください:

  1. キャプチャするスクリーン — "どのスクリーンをキャプチャしたいですか?各画面のナビゲーションパスまたはタブ名を具体的なリストで教えてください。"
  2. 独立した要素 — "透明背景で独立して描画したいコンポーネントはありますか?(カルーセルカード、ウィジェット、ヒロータイル、チャートなど)"
  3. ロケール — "どのロケール?(a) Localizable.xcstrings のすべてのロケール、(b) 指定する App Store サブセット、または (c) 明示的なリストを教えてください。" (a) の場合、.xcstrings ファイルをグリップしてロケールコードを抽出:
    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. デバイス — "どのシミュレーター?(iOS 26 デザイン機能向けに 6.1" iPhone 17 推奨)" — デバイスが xcrun simctl list devices available で利用可能か確認してください。
  5. 外観 — "ライトのみ、ダークのみ、または両方?"
  6. シードデータ — "デモデータはどう入力されますか?(a) 新規インストール時に自動シード、(b) デバッグの『デモデータを読み込む』ボタン、(c) 手動追加、(d) デモデータはまだない。" 次に: "既存データはすべてのリストアップ画面がマーケティング用に入力されたような十分な量ですか?ユーザーと一緒に監査してください。"

ステップ 2: 調査

コードを書く前に、以下に答えられるほどコードベースを調査してください:

  • プロジェクトは Xcode 同期フォルダグループ(Xcode 16+、PBXFileSystemSynchronizedRootGroup)を使用していますか?はいの場合、新規ファイルは自動的にターゲットに含まれます — pbxproj 編集は不要。grep -c PBXFileSystemSynchronized <proj>.xcodeproj/project.pbxproj で確認。
  • ルートナビゲーションパターンは何か?
    • TabView(selection:) — 最も一般的。必要なもの: @State selectedTab バインディング、タブインデックス、ネストされた NavigationStack を持つタブ。
    • NavigationStack(単一スタックとルーター) — 必要なもの: パスバインディングまたはルーターオブジェクト、NavigationLink(value:) / .navigationDestination タイプのセット。
    • NavigationSplitView — 必要なもの: サイドバーセレクションバインディング、詳細列のナビゲーション状態。
    • カスタムコーディネーター / UIKit ホスト — 必要なもの: コーディネーターの navigate(to:) メソッドまたは相当。
  • ディープリンクはどう経路分析されますか?onOpenURL ハンドラと URL をナビゲーション状態にマップする列挙型/スイッチを見つけてください。
  • デモデータシーダーはどこで定義されていますか?デバッグボタン(ある場合)から実際に ModelContext に書き込む関数までのコードパスをトレース。シーダーが存在しない場合は、以下の「デモデータシーダーの作成」を参照。
  • ウィジェットは別のターゲットに存在していますか?ウィジェットビューファイルとエントリタイプはメインアプリターゲットにもありますか?(ほぼ確実にいいえ — ImageRenderer 経由で描画する場合は追加する必要があります。)
  • アプリは Live Activities / ActivityKit を使用していますか?はいの場合、これを既知の落とし穴としてフラグ(以下を参照)。
  • アプリは SwiftData + CloudKit 同期(cloudKitDatabase: .automatic)を使用していますか?はいの場合、既知の落とし穴としてフラグ。
  • 非デフォルト状態でキャプチャする必要のあるビューはありますか?(例: タイマーがカウントダウン中、フォームが部分的に入力、特定の値のチャート)。はいの場合、各々に static var プライミング機構が必要(下記「ビュー状態のプライミング」参照)。

ステップ 3: 設計をユーザーに提示

コードを書く前に、この構造で計画をまとめてください。コード作成前に明示的な承認を得てください:

  1. アーキテクチャ(アプリ内キャプチャモード、単一ファイル、DEBUG ゲート)
  2. ファイルリスト(作成/変更する正確なパス)
  3. スクリーン別キャプチャ計画(各画面への到達方法 — タブインデックス、ナビゲーションパス、シートトリガー)
  4. キャプチャ順序の根拠(どのスクリーンが他の前に来る必要があるか — 落とし穴 #5 参照)
  5. 要素描画アプローチ(どのコンポーネント、どう包装するか)
  6. 出力レイアウト(フォルダ構造、命名規則)
  7. このプロジェクトに関連する既知の落とし穴(ステップ 2 でフラグ)
  8. 必要なプライム状態(どのビュー、どの static vars)

ステップ 4: 実装

templates/ のテンプレートを開始点として使用してください。これらは参照パターンであり、コピペ足場ではありません — すべてのプロジェクトのナビゲーション、モデル、ビューは異なります。テンプレートは構成要素を示します。ターゲットアプリ用に組み立ててください。

生成する主要ファイル:

  • <AppName>/Debug/MarketingCapture.swift — キャプチャシステム全体、DEBUG のみ。含む:
    • MarketingCapture enum(起動引数パース、出力ヘルパー、ウィンドウスナップショット、プライミング vars)
    • MarketingCaptureCoordinator クラス([CaptureStep] を走査してスナップショット)
    • MarketingElementHarness enum(カード、ウィジェット、チャートの ImageRenderer 描画)
  • <AppName>/ContentView.swift(またはルートビューがあるところ) — DEBUG フック。データシード実行とコーディネーター実行。
  • プライム状態が必要なビュー — DEBUG ゲート .onAppear フックと .onReceive 閉じるリスナー。
  • scripts/capture-marketing.sh — ビルド + インストール + ロケール別ループ。
  • .gitignoremarketing/ を追加。

ステップ 5: 反復的に検証

スクリプトをユーザーに渡して待たないでください。シミュレーターに対して自分で実行し、完了宣言前に少なくとも 1 つのロケールを検証してください。Read ツールで出力 PNG を読み、各スクリーンが期待を示しているか視覚的に検証してください。一般的なランタイム問題は下記「既知の落とし穴」に記載。

問題が見つかったら、修正して、スクリプト全体を再実行し(失敗ロケールだけでなく — 修正は以前のロケールで回帰可能)、視覚的に再検証。

アーキテクチャ: ステップベースのキャプチャ

コーディネーターは CaptureStep 値のリストを走査してキャプチャを駆動します。各ステップは自己完結: 画面へのナビゲート方法、待機時間、クリーンアップ方法を知っています。

struct CaptureStep {
    let name: String                        // 出力ファイル名、例 "01-home"
    let navigate: @MainActor () -> Void     // アプリを正しい状態に
    let settle: Duration                    // アニメーション/読み込み待ち
    let cleanup: (@MainActor () -> Void)?   // 次ステップ前にティアダウン
}

コーディネーターはシンプルなループ:

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))  // クリーンアップアニメーション
}

ナビゲーションパターン別のステップ構築

TabView アプリ(最も一般的):

// シンプルなタブ切り替え — インデックスを設定するだけ
CaptureStep(name: "01-home", navigate: { setTab(0) }, settle: .milliseconds(1800), cleanup: nil)

// タブ + 表示シート
CaptureStep(
    name: "05-timer-setup",
    navigate: {
        setTab(3)
        pendingBrewRecipe = someRecipe
    },
    settle: .milliseconds(2000),
    cleanup: {
        NotificationCenter.default.post(name: MarketingCapture.dismissSheetNotification, object: nil)
        pendingBrewRecipe = nil
    }
)

NavigationStack + ルーターアプリ:

// ルートをスタックにプッシュ
CaptureStep(
    name: "02-detail",
    navigate: { router.push(.itemDetail(item)) },
    settle: .milliseconds(1800),
    cleanup: { router.popToRoot() }
)

NavigationSplitView アプリ:

// サイドバー項目を選択し、詳細を選択
CaptureStep(
    name: "03-detail",
    navigate: {
        sidebarSelection = .recipes
        detailSelection = recipes.first
    },
    settle: .milliseconds(1800),
    cleanup: { detailSelection = nil }
)

順序付け: スタッキングルール

同じスタックにプッシュするスクリーンより前に、「クリーン」なナビゲーション状態が必要なスクリーンをキャプチャしてください。 子ビュー内にネストされた NavigationPath / @State はコーディネーターからはポップできません。だから:

良い:  棚(クリーンリスト) → コーヒー詳細(棚のスタックにプッシュ)
悪い: コーヒー詳細 → 棚(スタックにまだ詳細がプッシュされたまま)

2 つのスクリーンが NavigationStack を共有する場合、ルートレベルビューを最初にキャプチャしてください。

ビュー状態のプライミング

特定の非デフォルト状態でキャプチャする必要のあるスクリーン — タイマーが途中でカウントダウン、特定の値のチャート、部分的に入力されたフォーム。パターン:

  1. プライミング値ごとに MarketingCapturestatic var を追加:

    /// コーディネーターがタイマービュー表示前に設定。
    /// ビューは .onAppear で読み取り、特定の経過時間にジャンプします。
    static var pendingElapsedSeconds: Int?
    
    /// 真に設定して、タイマーに評価オーバーレイを表示。
    static var pendingShowAssessment: Bool = false
    
  2. ターゲットビューに DEBUG ゲート .onAppear を追加。プライミング値を読み取り:

    .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. コーディネーターでナビゲート前に var を設定:

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

デモデータシーダーの作成

アプリにデモデータ機構がない場合は作成してください。<AppName>/Debug/DemoDataSeeder.swift に配置し、#if DEBUG でラップ。

ガイドライン:

  • キャプチャするすべてのスクリーンが入力されるだけ十分なデータをシード。 スクリーンリストに対してシードを監査。
  • リアルな内容を使用: 実際の地名、妥当な数字、多様な状態(一部が「ストック少」、一部が「新鮮」、画像あり/なし)。
  • アプリが SwiftData を使用する場合、ModelContext に直接書き込み。Core Data なら managed object context。REST バックエンドなら local キャッシュ/ストア層経由でシード。
  • シードをべき等に — 挿入前にデータが既に存在するか確認。ストアはシミュレータ再起動を超えて永続化。ロケール別にシードし直すと CloudKit 同期チャーンとクラッシュを引き起こします。
  • 異なる UI 状態を埋めるのに十分な多様性を含める: 空の状態はマーケティングスクリーンでない限り表示されてはいけません。

最小形:

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

        // 異なる状態を持つアイテム
        let items = [
            Item(name: "...", status: .active, ...),
            Item(name: "...", status: .lowStock, ...),
            // ...すべてのスクリーンを埋めるのに十分
        ]
        items.forEach { context.insert($0) }
        try? context.save()
    }
}
#endif

要素描画

要素は ImageRenderer で 3x スケール、角丸外側は透明で描画。

カード / リスト行

@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: cardWidth, height: nil)

        guard let image = renderer.uiImage else { continue }
        MarketingCapture.writePNG(image, name: "card-\(slugify(item.name))", subfolder: "elements")
    }
}

ウィジェット

ウィジェットビューは特殊な処理が必要です。通常 WidgetKit のプロセス内で実行され、システム提供のパディングと背景に依存。

@MainActor
static func renderWidget(
    name: String,
    size: CGSize,
    cornerRadius: CGFloat? = nil,
    @ViewBuilder content: () -> some View
) {
    let isAccessory = size.height <= 80
    let radius = cornerRadius ?? (isAccessory ? 8 : 22)
    let contentPadding: CGFloat = isAccessory ? 0 : 16

    let view = content()
        .padding(contentPadding)
        .frame(width: size.width, height: size.height)
        .background(theme.background)
        .clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
        .environment(\.colorScheme, .light)

    let renderer = ImageRenderer(content: view)
    renderer.scale = 3
    renderer.isOpaque = false
    renderer.proposedSize = .init(width: size.width, height: size.height)

    guard let image = renderer.uiImage else { return }
    MarketingCapture.writePNG(image, name: name, subfolder: "elements")
}

// 標準 iPhone ウィジェットサイズ(ポイント、iPhone 14-17 サイズクラス)
enum WidgetSize {
    static let small  = CGSize(width: 170, height: 170)
    static let medium = CGSize(width: 364, height: 170)
    static let large  = CGSize(width: 364, height: 382)
    static let accessoryCircular    = CGSize(width: 76, height: 76)
    static let accessoryRectangular = CGSize(width: 172, height: 76)
    static let accessoryInline      = CGSize(width: 257, height: 26)
}

// 使用例:
renderWidget(name: "widget-pulse-small", size: WidgetSize.small) {
    PulseSmallView(entry: PulseEntry(
        date: Date(),
        count: 2,
        streak: 5,
        lastItemName: "Morning Routine"
    ))
}

チャート / スタンドアロンビュー

任意の SwiftUI ビューを要素として描画可能。同じ方法でラップ — 明示的なサイズ、背景、コーナークリップ:

@MainActor
static func renderChart() {
    let chart = MyChartView(values: ChartData.sample)
        .frame(width: 420, height: 420)
        .background(theme.background)
        .clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))

    let renderer = ImageRenderer(content: chart)
    renderer.scale = 3
    renderer.isOpaque = false
    renderer.proposedSize = .init(width: 420, height: 420)

    guard let image = renderer.uiImage else { return }
    MarketingCapture.writePNG(image, name: "chart-overview", subfolder: "elements")
}

既知の落とし穴

これらはすべて実プロジェクトを悩ませた実際のバグです。このリストを負荷を支えるものとして扱ってください。

1. Live Activities はアプリ起動をまたいで永続化

ActivityKit Live Activities はプロセス終了を超えて生存。アプリがキャプチャ中に Live Activity を開始したら(例: タイマーの start())、次のロケール再起動がそれを継承。fresh シードがアクティビティが参照するモデルを削除と組み合わせ、SwiftData 永続プロパティアサーションが発生。

修正: マーケティングキャプチャブロックの最初の最初に <ActivityManager>.shared.endImmediately() を呼び出し、データに触る前に。ビュー onDisappear でキャプチャモード時に timerVM.stop()(または LA を適切に終わらせる何か)も呼び出し。

2. ロケール別に再シードしない

SwiftData + CloudKit をロケール別にシードすると同期チャーンとクラッシュ。SwiftData ストアは再起動をまたいで永続化 — データはロケール非依存のデモコンテンツ。1 回目の実行時にシード 1 回、以降はスキップ:

contentVM.fetchItems()
if contentVM.allItems.isEmpty {
    DemoDataSeeder.seedIfEmpty(in: modelContext)
    contentVM.fetchItems()
}

3. シード前にセットアップする ViewModel は古いスナップショットを保持

ルートビューの onAppear がマーケティングシード前に someVM.setup(modelContext:) を呼び出す場合、VM は空のストアのスナップショットを保持。シード後、データが必要なすべての VM に対して someVM.refresh()(またはその同等のフェッチメソッド)を呼び出し。

4. トリガーバインディングを nil に設定しても、シートを閉じない

親ビューが .fullScreenCover(item: $request) を表示し、request が内部 @State で駆動される場合、トリガーバインディングを設定(: pendingItem = nil)しても、カバーに何もしません。カバーは up のまま、次のスクリーンショットはナビゲートした画面の代わりにそれをキャプチャ。

修正: NotificationCenter 経由で消去シグナルをブロードキャスト。表示されたビューがリッスン:

// MarketingCapture.swift
static let dismissSheetNotification = Notification.Name("MarketingCapture.dismissSheet")

// 表示されたビュー body 内
.onReceive(NotificationCenter.default.publisher(for: MarketingCapture.dismissSheetNotification)) { _ in
    dismiss()
}

次にステップの cleanup で通知をポスト。カバーアニメーションが完了するまで少なくとも 900ms、次のステップを開始する前に許可。

5. NavigationPath は外からはポップできない

子ビューが @State private var navigationPath = NavigationPath() を保持し、深いリンクがそれにプッシュする場合、コーディネーターはポップできません。解決法: キャプチャシーケンスを並べ替える。スタックにプッシュするスクリーンはクリーンスタックが必要なスクリーンの後に来る。例: 棚を最初にキャプチャ、次にコーヒー詳細にプッシュ — その逆はしない。

6. ウィジェットビューは通常、拡張ターゲットのみ

ユーザーのウィジェットビューがウィジェット拡張ターゲットのみの場合、メインアプリターゲットの MarketingCapture.swift から参照できません。以下のいずれか:

  • (a) ウィジェットビューファイル(およびエントリタイプと共有ヘルパー)をメインアプリターゲットの membership に追加。プロジェクトが同期フォルダグループを使用する場合、PBXFileSystemSynchronizedBuildFileExceptionSet.membershipExceptions を編集する意味。**重要な落とし穴:

ライセンス: MIT(寛容ライセンスのため全文を引用しています) · 原本リポジトリ

詳細情報

作者
ParthJadhav
リポジトリ
ParthJadhav/ios-marketing-capture
ライセンス
MIT
最終更新
2026/4/10

Source: https://github.com/ParthJadhav/ios-marketing-capture / ライセンス: MIT

本サイトは GitHub 上で公開されているオープンソースの SKILL.md ファイルをクロール・インデックス化したものです。 各スキルの著作権は原作者に帰属します。掲載に問題がある場合は info@alsel.co.jp または /takedown フォームよりご連絡ください。
原作者: ParthJadhav · ParthJadhav/ios-marketing-capture · ライセンス: MIT