5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Swift + ChatGPT APIで撮った写真にいい感じのタイトルをつけてくれるアプリを作った

Last updated at Posted at 2024-12-13

はじめに

フリュー株式会社スマートフォンゲーム部でエンジニアをしています佐藤です。
業務で数年バックエンド開発を経験後、クライアント側にシフトしUnityでの開発に携わっています。
今回はこれまで経験がなかったSwiftとChatGPT APIを使ってさくっとiOSアプリを作ってみましたので、ご紹介します。

対象

  • SwiftUIを少し触ったことがあるくらいの方
  • ChatGPTのAPIを使ってみたい方

作成するアプリ

アプリ内でカメラを起動し、撮った写真に対してChatGPTがいい感じのタイトルをつけてくれるというアプリを作ります。
写真にタイトルがつくだけで一気に「作品感」が出ますよね。それを目指します。

Swiftのデザインフレームワーク

SwiftでUIを作成する方法としていくつかのデザインフレームワークが存在します。

  • SwiftUI
    • 2019年にリリースされたコードベースのフレームワーク
    • UIを状態に基づいて記述し、状態が変わるとUIが自動的に更新される宣言型のアプローチ
    • シンプルなコードのみでレイアウトの実装が可能
    • Xcode上でリアルタイムプレビューが可能
  • UIKit
    • iOSリリース当初から採用されているコードベースのフレームワーク
    • コードでUIを操作する命令型のアプローチ
    • SwiftUIと比較して詳細なUIの制御が可能
    • 部分的にSwiftUIへカスタマイズすることが可能
  • Storyboard
    • Xcodeに組み込まれているデザインツール
    • オブジェクトをドラッグ&ドロップで配置する
    • 主にUIKitと共に使う

今回は、現在では主流となっており初心者でも学びやすいとされているSwiftUIを採用します。
ただ、カメラで撮った写真を取り込む場合UIKitの機能を使わないと取り込めないため、今回は部分的にUIKitもカスタマイズして使います。

ChatGPT API

基本的にAPIの利用は有料です。
Open AIのアカウント作成時にクレジットカードを登録し、あらかじめクレジットをチャージしておくシステムとなっています。
クレジットが0になったとき自動課金するかどうかの設定も可能なため、OFFにしておくと安心です。

API keyの発行

Open AIのアカウント作成手順は省略します。公式サイトから作成可能です。
ログイン後、Dashboard画面のAPI keysを選択し、Create new secret keyボタンからシークレットキーを作成します。
スクリーンショット 2024-12-05 15.35.54.png
Nameは適当に分かりやすいもので大丈夫です。
ProjectDefault projectPermissionsAllでよいです。

作成できたシークレットキーはコピーして覚えておきます。

実装

ここからXcodeを起動し、実装に入ります。
iOSAppを選びます。
macOSだとUIKitが使用できないためご注意ください。

InterfaceSwiftUIを指定して新しくプロジェクトを作成します。

レイアウト作成

まずは画面のUIから組み立てていきます。

ContentView.swift
import SwiftUI

struct ContentView: View {
    
    var body: some View {
        ZStack {
            // 背景色を設定
            Color.gray.edgesIgnoringSafeArea(.all)
            
            VStack {
                // タイトル表示位置
                Rectangle()
                    .stroke(Color.gray, lineWidth: 5)
                    .frame(height: 80)
                
                // 画像表示位置
                Rectangle()
                    .fill(Color.black)
                    .frame(width: 200, height: 300, alignment: .center)
                    .overlay(Text("No Image").foregroundColor(.white))
                
                HStack {
                    // カメラボタン
                    Button(action: {
                        print("カメラ起動")
                    }) {
                        Image(systemName: "camera") // システムのカメラアイコン
                            .resizable()
                            .frame(width: 50, height: 50) // 画像のサイズ調整
                            .foregroundColor(.white) // アイコンの色
                    }
                    .padding()
                    // タイトル生成ボタン
                    Button(action: {
                        print("タイトル生成");
                    }) {
                        Image(systemName: "brain.head.profile") // 生成アイコン
                            .resizable()
                            .frame(width: 50, height: 50) // 画像のサイズ調整
                            .foregroundColor(.white) // アイコンの色
                    }
                    .padding()
                }
            }
        }
    }
}

VStackHStackZStackを使ってそれぞれの要素を配置します。
各ボタンはImage(systemName:)を使ってアイコン表示しています。

// カメラのアイコン
Image(systemName: "camera")

systemNameに設定可能な名前は以下の記事を参考にしてください。

この状態で実行すると以下のような画面が出来上がりました。
撮影した画像の表示、その下にカメラ起動ボタンとタイトル生成ボタンが配置されています。

カメラからの画像取得

次にカメラを起動して撮影した画像を取得します。
画像取得用としてImagePickerクラスを作ります。

ImagePicker.swift
ImagePicker.swift
import SwiftUI
import UIKit

// カメラで撮った写真を取り出すためのクラス
struct ImagePicker: UIViewControllerRepresentable {
    // 撮った写真の画像
    @Binding var image: UIImage?
    // カメラ表示を閉じるアクション
    @Environment(\.dismiss) var dismiss
    
    class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
        var parent: ImagePicker
        
        init(parent: ImagePicker) {
            self.parent = parent
        }
        
        func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
            if let uiImage = info[.originalImage] as? UIImage {
                parent.image = uiImage            
            }
            parent.dismiss()
        }
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }
    
    func makeUIViewController(context: Context) -> UIImagePickerController {
        let picker = UIImagePickerController()
        picker.delegate = context.coordinator
        picker.sourceType = .camera // カメラで撮った写真を使う
        return picker
    }
    
    func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
}

ここではUIKitの仕組みを利用し、カメラ機能の操作をしています。
UIViewControllerRepresentableを利用することにより、UIImagePickerController(UIKitのビューコントローラ)をSwiftUIで使用することが可能です。
@Binding var imageでこのクラスの外側から画像を取得できるようにし、@Environment(\.dismiss) var dismissで現在のカメラ表示を閉じるアクションの参照を取得しています。

Coordinator

class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
    var parent: ImagePicker
    
    init(parent: ImagePicker) {
        self.parent = parent
    }
    
    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
        if let uiImage = info[.originalImage] as? UIImage {
            parent.image = uiImage            
        }
        parent.dismiss()
    }
}

上記部分はUIImagePickerControllerのデリゲートを実装し、カメラの撮影結果の受け取り処理をしています。
imagePickerControllerメソッドでは、撮影された画像(UIImage)を info から取得し、parent.imageにセットしています。また、取得後はparent.presentationMode.wrappedValue.dismiss()でカメラビューを閉じています。

さらにUIViewControllerRepresentableを継承する場合、以下で説明するmakeUIViewControllerupdateUIViewControllerを実装する必要があります。

makeUIViewController

func makeUIViewController(context: Context) -> UIImagePickerController {
    let picker = UIImagePickerController()
    picker.delegate = context.coordinator
    picker.sourceType = .camera // カメラで撮った写真を使う
    return picker
}

makeUIViewControllerでは、UIKitのUIImagePickerControllerを作成しています。カメラで撮った画像を使用するため、sourceTypeでは.cameraを指定します。
※フォトライブラリから画像を取得する場合はここで.photoLibraryを指定することになります。

updateUIViewController

func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}

カメラビューは一度作成されたあと、更新する必要がないためupdateUIViewControllerでは何も処理を書きません。

ContentView側からの呼び出し処理

ContentView側でカメラ画面の起動と画像取得処理を追加します。

ContentView.swift
struct ContentView: View {
    @State private var showImagePicker = false
    @State private var uiImage: UIImage?

    var body: some View {
        ZStack {
        
                // 〜略〜
                
                // 画像表示位置
                if let image = uiImage {
                    Image(uiImage: image)
                        .resizable()
                        .scaledToFit()
                        .frame(height: 300, alignment: .center)
                } else {
                    Rectangle()
                        .fill(Color.black)
                        .frame(width: 200, height: 300, alignment: .center)
                        .overlay(Text("No Image").foregroundColor(.white))
                }

                HStack {
                    // カメラボタン
                    Button(action: {
                        showImagePicker = true
                    }) {
                        Image(systemName: "camera") // システムのカメラアイコン
                            .resizable()
                            .frame(width: 50, height: 50) // 画像のサイズ調整
                            .foregroundColor(.white) // アイコンの色
                    }
                    .padding()

                // 〜略〜
                
            }
        }
        .sheet(isPresented: $showImagePicker) {
            ImagePicker(image: $uiImage)
        }
    }
}

showImagePickerでカメラの表示状態を保持し、uiImageImagePickerから取得できる画像データを保持しています。
またuiImageが存在するかどうかで、画像表示部分のUIを書き換えており、カメラアイコン押下でshowImagePickertrueにし、.sheetを使うことによってモーダルビューでカメラ画面を起動しています。

プライバシー権限の設定

iOSアプリがカメラにアクセスするには、Info.plistに適切なプライバシー権限を追加する必要があります。
Xcode上でのプロジェクトのTARGETSからinfoタブを選択し、Privacy - Camera Usage Descriptionというkeyでカメラ使用時の文言を設定します。
※フォトライブラリから画像を取得する場合はPrivacy - Photo Library Usage Descriptionを追加することになります。
スクリーンショット 2024-12-09 11.28.47.png

また、カメラ機能を使用する場合Xcodeのシミュレーターでは動作確認ができません。そのため、動作確認をする際は実機を繋ぐ必要があるのでご注意ください。

タイトル生成

ここからはChatGPT APIを使ったタイトル生成処理です。
ContentViewにタイトル生成処理のメソッドを追加します。

ContentView.swift
ContentView.swift
import SwiftUI
import Foundation

struct ContentView: View {
    @State private var showImagePicker = false
    @State private var uiImage: UIImage?
    @State private var generatedTitle: String = ""
    private var apiKey: String

    init() {
        apiKey = ProcessInfo.processInfo.environment["OPENAI_API_KEY"] ?? "API Key not Set"
    }
    
    var body: some View {
        ZStack {
            // 背景色を設定
            Color.gray.edgesIgnoringSafeArea(.all)
            
            VStack {
                ZStack {
                    // タイトル表示位置
                    Rectangle()
                        .stroke(Color.gray, lineWidth: 5)
                        .frame(height: 80)
                    
                    if !generatedTitle.isEmpty {
                        Text("\(generatedTitle)")
                    }
                }

                // 〜略〜

                    Button(action: {
                        if let image = uiImage {
                            Task {
                                await generateTitle(image: image)
                            }
                        }
                    }) {
                        Image(systemName: "brain.head.profile") // 生成アイコン
                            .resizable()
                            .frame(width: 50, height: 50) // 画像のサイズ調整
                            .foregroundColor(.white) // アイコンの色
                    }
                    .padding()

                    // 〜略〜
            

    /// タイトルを生成する
    ///
    /// - Parameters:
    ///  - image: 撮った画像データ
    func generateTitle(image: UIImage) async {
        
        // UIImageをbase64に変換
        let imageData = image.jpegData(compressionQuality: 0.5)!
        let base64String = imageData.base64EncodedString()
        
        do {
            // chatgpt_apiへ送信
            let title = try await fetchGeneratedTitle(imageBase64: base64String)
            generatedTitle = title
        } catch {
            generatedTitle = "生成失敗: \(error.localizedDescription)"
        }
    }

    /// ChatGPT APIを使用して生成されたタイトルを取得する
    ///
    /// - Parameters:
    ///  - imageBase64: 撮った写真をbase64変換したもの
    func fetchGeneratedTitle(imageBase64: String) async throws -> String {
        let url = URL(string: "https://api.openai.com/v1/chat/completions")!
        
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
        request.addValue("application/json", forHTTPHeaderField: "Content-Type")
        
        // chatgpt_apiへ送るbodyの組み立て
        let requestBody = [
            "model": "gpt-4o-mini",
            "max_completion_tokens": 20,
            "messages": [
                [
                    "role": "system",
                    "content": [
                        [
                            "type": "text",
                            "text": "あなたはプロのコピーライターです。"
                        ]
                    ]
                ],
                [
                    "role": "user",
                    "content": [
                        [
                            "type": "text",
                            "text": "提供された画像にセンスある15文字以下のタイトルをつけてください"
                        ],
                        [
                            "type": "image_url",
                            "image_url": [
                                "url": "data:image/jpeg;base64,\(imageBase64)"
                            ]
                        ]
                    ]
                ]
            ]
        ] as [String : Any]

        // bodyの配列をjson形式に変換
        do {
            let json = try JSONSerialization.data(withJSONObject: body)
            print("json request: \(String(bytes: json, encoding: .utf8)!)")
            request.httpBody = json
        } catch(let e) {
            print(e)
        }
        
        // API実行
        let (data, response) = try await URLSession.shared.data(for: request)
        
        // HTTPレスポンスのチェック
        guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
            throw NSError(domain: "HTTPError", code: (response as? HTTPURLResponse)?.statusCode ?? -1, userInfo: [NSLocalizedDescriptionKey: "Invalid HTTP response"])
        }
        
        // レスポンスデータの解析
        do {
            if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
               let choices = json["choices"] as? [[String: Any]],
               let message = choices.first?["message"] as? [String: Any],
               let text = message["content"] as? String {
                return text.trimmingCharacters(in: .whitespacesAndNewlines)
            } else {
                throw NSError(domain: "ParsingError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid response format"])
            }
        } catch {
            throw NSError(domain: "ParsingError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to parse response: \(error.localizedDescription)"])
        }
    }
}

以下、部分的に解説をしていきます。

API Keyの設定

まず発行したAPI Keyを設定します。
XcodeメニューのProductSchemeEdit Scheme...で設定ダイアログを表示し、Run画面のArgumentタブを選択します。
Environment VariablesOPENAI_API_KEYというNameでAPI Keyを設定します。
スクリーンショット 2024-12-11 18.19.56(2).png

そしてContentViewinitで変数apiKeyに値を設定することによりソースコード内で使用できるようにします。

private var apiKey: String
    
    init() {
        apiKey = ProcessInfo.processInfo.environment["OPENAI_API_KEY"] ?? "API Key not Set"
    }

タイトル表示部分

@State private var generatedTitle: String = ""

// 〜略〜

ZStack {
    // タイトル表示位置
    Rectangle()
        .stroke(Color.gray, lineWidth: 5)
        .frame(height: 80)
    
    if !generatedTitle.isEmpty {
        Text("\(generatedTitle)")
    }
}

上記は生成されたタイトルを状態変数generatedTitleとして定義し、値が入ったらタイトル位置に表示しています。

タイトル生成ボタン

Button(action: {
    if let image = uiImage {
        // タイトル生成APIの実行
        Task {
            await generateTitle(image: image)
        }
    }
}) {
    Image(systemName: "brain.head.profile") // 生成アイコン
        .resizable()
        .frame(width: 50, height: 50) // 画像のサイズ調整
        .foregroundColor(.white) // アイコンの色
}
.padding()

上記のようにタイトル生成ボタンのaction処理を追加します。
uiImageがnullではない状態でボタンを押すと、generateTitleメソッドが実行されます。

generateTitle

func generateTitle(image: UIImage) async {
    
    // UIImageをbase64に変換
    let imageData = image.jpegData(compressionQuality: 0.5)!
    let base64String = imageData.base64EncodedString()
    
    do {
        // chatgpt_apiへ送信
        let title = try await fetchGeneratedTitle(imageBase64: base64String)
        generatedTitle = title
    } catch {
        generatedTitle = "生成失敗: \(error.localizedDescription)"
    }
}

上記のメソッドでは、まずカメラで撮影した画像データを受け取り、image.jpegData()UIImage型からjpegのData型に変換しています。このとき引数のcompressionQualityで圧縮率を設定しています。
さらにimageData.base64EncodedString()でbase64変換し文字列にします。
そして以下に解説するfetchGeneratedTitleメソッドでChatGPT APIを実行し、その結果を状態変数generatedTitleに設定しています。

fetchGeneratedTitle

APIリクエストの準備

let url = URL(string: "https://api.openai.com/v1/chat/completions")!
    
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")

apiKeyBearerとして設定します。

JSONリクエストボディの構築

"model": "gpt-4o-mini",
"max_completion_tokens": 20,

使用するモデルは比較的コスパが良いとされているgpt-4o-miniを採用します。また必要以上に応答のトークンが消費されないようmax_completion_tokensで上限を設定しています。

"role": "system",
"content": [
    [
        "type": "text",
        "text": "あなたはプロのコピーライターです。"
    ]
]

上記でシステムプロンプト(前提条件)を設定しています。

"role": "user",
"content": [
    [
        "type": "text",
        "text": "提供された画像にセンスある15文字以下のタイトルをつけてください"
    ],
    [
        "type": "image_url",
        "image_url": [
            "url": "data:image/jpeg;base64,\(imageBase64)"
        ]
    ]
]

上記でメインとなるテキスト指示と画像データ(base64エンコードされたデータ)を設定しています。

APIリクエストの送信

let (data, response) = try await URLSession.shared.data(for: request)

非同期でリクエストを送信しています。

HTTPレスポンスのチェック

guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
    throw NSError(domain: "HTTPError", code: (response as? HTTPURLResponse)?.statusCode ?? -1, userInfo: [NSLocalizedDescriptionKey: "Invalid HTTP response"])
}

レスポンスのステータスを確認し、問題があればエラーを投げるようにしています。

レスポンスの解析

do {
    if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
       let choices = json["choices"] as? [[String: Any]],
       let message = choices.first?["message"] as? [String: Any],
       let text = message["content"] as? String {
        return text.trimmingCharacters(in: .whitespacesAndNewlines)
    } else {
        throw NSError(domain: "ParsingError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid response format"])
    }
} catch {
    throw NSError(domain: "ParsingError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to parse response: \(error.localizedDescription)"])
}

レスポンスをJSON形式でパースし、解答を取り出していきます。
知りたい解答はchoices内のmessage内のcontentに入っているので、順次取り出し、ついでに前後の空白を削除した上で取得しています。

アニメーション

ここまでで、撮影した画像にChatGPTがいい感じのタイトルをつけてくれるようになりました。
ただタイトル表示の仕方が味気ないので、少しだけアニメーションをつけましょう。

Loading

APIを実行してからレスポンスがあるまで少し時間があるので、その間Loadingアニメーションをつけます。

Lottieアニメーション

Lottieは、簡単にアプリにアニメーションを組み込むことができるJSON形式のアニメーションファイルです。
インストール方法は以下READMEに記載があるので、ここでは割愛します。

Lottie公式サイトから使用したいアニメーションを選びます。
今回は以下リンク先のLoadingアニメーションにしました。

JSONをダウンロードし、loading.jsonとファイル名を変え、プロジェクト配下に置きContentView側から呼び出す処理を追加します。

ContentView.swift
import SwiftUI
import Lottie

struct ContentView: View {

    @State private var isLoading = false // Loading中であることの状態変数を追加

    // 〜略〜

                    // タイトル表示位置
                    Rectangle()
                        .stroke(Color.gray, lineWidth: 5)
                        .frame(height: 80)
                    
                    if isLoading {
                        // ローディング中はLoadingアニメーションを再生させる
                        LottieView(animation: .named("loading"))
                            .looping()
                            .frame(width: 300, height: 80)
                    } else if !generatedTitle.isEmpty {
                        Text("\(generatedTitle)")
                    }

                    // 〜略〜

    func generateTitle(image: UIImage) async {
        isLoading = true
        defer { isLoading = false } // 処理終了後にローディングを停止
        
        // 〜略〜
    }

これにより、タイトル生成ボタンを押してから生成結果が返ってくるまでLoadingアニメーションが再生されるようになりました。
Lottieのバージョンが4.3.0より前だと、UIKitLottieAnimationViewを使って実装する必要がありますのでご注意ください。

タイトル表示演出

タイトル表示は、1文字ずつ徐々に表示されるようアニメーションをつけます。以下記事を参考にしBlurViewクラスを作成しました。

BlurView.swift
BlurView.swift
import SwiftUI

// テキストを1文字ずつ徐々に表示させる
struct BlurView: View {
    let characters: Array<String.Element>
    let baseTime: Double
    let textSize: Double
    @State var blurValue: Double = 10 // ぼかしの値
    @State var opacity: Double = 0 // 透明度
    
    init(text:String, textSize: Double, startTime: Double) {
        characters = Array(text)
        self.textSize = textSize
        baseTime = startTime
    }
    
    var body: some View {
        HStack(spacing: 1) {
            ForEach(0..<characters.count) { index in
                Text(String(self.characters[index]))
                    .font(.custom("HiraMinProN-W3", fixedSize: textSize))
                    .foregroundColor(.black)
                    .blur(radius: blurValue) // ぼかしの設定
                    .opacity(opacity) // 透明度の設定
                    .animation(.easeInOut.delay( Double(index) * 0.15 ), value: blurValue) // 1文字ずつ表示されるようdelayでずらしてアニメーション
            }
        }
        .onAppear {
            // 初回表示時のみアニメーション開始
            DispatchQueue.main.asyncAfter(deadline: .now() + baseTime) {
                blurValue = 0
                opacity = 1
            }
        }
    }
}

さらにContentView側でBlurViewを呼び出すように実装を修正します。

ContentView.swift
if isLoading {
    LottieView(animation: .named("loading"))
        .looping()
        .frame(width: 300, height: 80)
} else if !generatedTitle.isEmpty {
    // 1文字ずつ徐々に表示させる
    BlurView(text: "\(generatedTitle)", textSize: 38, startTime: 0.41)
}

これにより、生成されたタイトルが1文字ずつ徐々に表示されるようなアニメーションになりました。

最終的な挙動

試しに机の上のMagic Keyboardを撮影してタイトルを生成してみました。
デモ動画 (1).gif
スタイリッシュなMagic Keyboardを「エレガントな入力」となかなかおしゃれに表現してくれています。
タイトルの出来具合についてはプロンプト次第なので、工夫の余地がありそうです。

まとめ

全体のソースコードは以下になります。

ContentView.swift
ContentView.swift
import SwiftUI
import Foundation
import Lottie

struct ContentView: View {
    @State private var showImagePicker = false
    @State private var uiImage: UIImage?
    @State private var generatedTitle: String = ""
    @State private var isLoading = false
    private var apiKey: String

    init() {
        apiKey = ProcessInfo.processInfo.environment["OPENAI_API_KEY"] ?? "API Key not Set"
    }
    
    var body: some View {
        ZStack {
            // 背景色を設定
            Color.gray.edgesIgnoringSafeArea(.all)
            
            VStack {
                ZStack {
                    // タイトル表示位置
                    Rectangle()
                        .stroke(Color.gray, lineWidth: 5)
                        .frame(height: 80)
                    
                    if isLoading {
                        LottieView(animation: .named("loading"))
                            .looping()
                            .frame(width: 300, height: 80)
                    } else if !generatedTitle.isEmpty {
                        BlurView(text: "\(generatedTitle)", textSize: 38, startTime: 0.41)
                    }
                }
                
                // 画像表示位置
                if let image = uiImage {
                    Image(uiImage: image)
                        .resizable()
                        .scaledToFit()
                        .frame(height: 300, alignment: .center)
                } else {
                    Rectangle()
                        .fill(Color.black)
                        .frame(width: 200, height: 300, alignment: .center)
                        .overlay(Text("No Image").foregroundColor(.white))
                }

                
                HStack {
                    Button(action: {
                        showImagePicker = true
                    }) {
                        Image(systemName: "camera") // システムのカメラアイコン
                            .resizable()
                            .frame(width: 50, height: 40) // 画像のサイズ調整
                            .foregroundColor(.white) // アイコンの色
                    }
                    .padding()
                    
                    Button(action: {
                        if let image = uiImage {
                            Task {
                                await generateTitle(image: image)
                            }
                        }
                    }) {
                        Image(systemName: "brain.head.profile") // 生成アイコン
                            .resizable()
                            .frame(width: 50, height: 50) // 画像のサイズ調整
                            .foregroundColor(.white) // アイコンの色
                    }
                    .padding()
                    
                }
            }
        }
        .sheet(isPresented: $showImagePicker) {
            ImagePicker(image: $uiImage)
        }
    }
    
    /// タイトルを生成する
    ///
    /// - Parameters:
    ///  - image: 撮った画像データ
    func generateTitle(image: UIImage) async {
        isLoading = true
        defer { isLoading = false } // 処理終了後にローディングを停止
        
        // UIImageをbase64に変換
        let imageData = image.jpegData(compressionQuality: 0.5)!
        let base64String = imageData.base64EncodedString()
        
        do {
            // chatgpt_apiへ送信
            let title = try await fetchGeneratedTitle(imageBase64: base64String)
            generatedTitle = title
        } catch {
            generatedTitle = "生成失敗: \(error.localizedDescription)"
        }
    }
    
    /// ChatGPT APIを使用して生成されたタイトルを取得する
    ///
    /// - Parameters:
    ///  - imageBase64: 撮った写真をbase64変換したもの
    func fetchGeneratedTitle(imageBase64: String) async throws -> String {
        let url = URL(string: "https://api.openai.com/v1/chat/completions")!
        
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
        request.addValue("application/json", forHTTPHeaderField: "Content-Type")
        
        // chatgpt_apiへ送るbodyの組み立て
        let requestBody = [
            "model": "gpt-4o-mini",
            "max_completion_tokens": 20,
            "messages": [
                [
                    "role": "system",
                    "content": [
                        [
                            "type": "text",
                            "text": "あなたはプロのコピーライターです。"
                        ]
                    ]
                ],
                [
                    "role": "user",
                    "content": [
                        [
                            "type": "text",
                            "text": "提供された画像にセンスある15文字以下のタイトルをつけてください"
                        ],
                        [
                            "type": "image_url",
                            "image_url": [
                                "url": "data:image/jpeg;base64,\(imageBase64)"
                            ]
                        ]
                    ]
                ]
            ]
        ] as [String : Any]
        
        // bodyの配列をjson形式に変換
        do {
            let json = try JSONSerialization.data(withJSONObject: requestBody)
            print("json request: \(String(bytes: json, encoding: .utf8)!)")
            request.httpBody = json
        } catch(let e) {
            print(e)
        }
        
        // API実行
        let (data, response) = try await URLSession.shared.data(for: request)
        
        // HTTPレスポンスのチェック
        guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
            throw NSError(domain: "HTTPError", code: (response as? HTTPURLResponse)?.statusCode ?? -1, userInfo: [NSLocalizedDescriptionKey: "Invalid HTTP response"])
        }
        
        // レスポンスデータの解析
        do {
            if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
               let choices = json["choices"] as? [[String: Any]],
               let message = choices.first?["message"] as? [String: Any],
               let text = message["content"] as? String {
                return text.trimmingCharacters(in: .whitespacesAndNewlines)
            } else {
                throw NSError(domain: "ParsingError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid response format"])
            }
        } catch {
            throw NSError(domain: "ParsingError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to parse response: \(error.localizedDescription)"])
        }
    }
}
ImagePicker.swift
ImagePicker.swift
import SwiftUI
import UIKit

// カメラで撮った写真を取り出すためのクラス
struct ImagePicker: UIViewControllerRepresentable {
    // 撮った写真の画像
    @Binding var image: UIImage?
    // カメラ表示を閉じるアクション
    @Environment(\.dismiss) var dismiss
    
    class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
        var parent: ImagePicker
        
        init(parent: ImagePicker) {
            self.parent = parent
        }
        
        func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
            if let uiImage = info[.originalImage] as? UIImage {
                parent.image = uiImage            
            }
            parent.dismiss()
        }
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }
    
    func makeUIViewController(context: Context) -> UIImagePickerController {
        let picker = UIImagePickerController()
        picker.delegate = context.coordinator
        picker.sourceType = .camera // カメラで撮った写真を使う
        return picker
    }
    
    func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
}
BlurView.swift
BlurView.swift
import SwiftUI

// テキストを1文字ずつ徐々に表示させる
struct BlurView: View {
    let characters: Array<String.Element>
    let baseTime: Double
    let textSize: Double
    @State var blurValue: Double = 10 // ぼかしの値
    @State var opacity: Double = 0 // 透明度
    
    init(text:String, textSize: Double, startTime: Double) {
        characters = Array(text)
        self.textSize = textSize
        baseTime = startTime
    }
    
    var body: some View {
        HStack(spacing: 1) {
            ForEach(0..<characters.count) { index in
                Text(String(self.characters[index]))
                    .font(.custom("HiraMinProN-W3", fixedSize: textSize))
                    .foregroundColor(.black)
                    .blur(radius: blurValue) // ぼかしの設定
                    .opacity(opacity) // 透明度の設定
                    .animation(.easeInOut.delay( Double(index) * 0.15 ), value: blurValue) // 1文字ずつ表示されるようdelayでずらしてアニメーション
            }
        }
        .onAppear {
            // 初回表示時のみアニメーション開始
            DispatchQueue.main.asyncAfter(deadline: .now() + baseTime) {
                blurValue = 0
                opacity = 1
            }
        }
    }
}

SwiftUIでのアプリ開発は初めてでしたが、導入自体は簡単で、宣言的UIや状態管理も分かりやすく、初心者でも本格的なアプリ開発が可能であることを実感できました。
ChatGPT APIに関しては、今回はURLSessionを使用して直接HTTPリクエストを送信しましたが、Open AIのClient Libraryもあるようなので、そちらを使うとより簡単に実装できるようになるかと思います。

バージョン情報

  • Xcode 16.1
  • iOS 15.8.3
  • Lottie 4.5
5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?