🍃

高校生がAI作品に特化した画像投稿サービス「Aivy」を個人開発した話 | SolidJS & Supabase & Cloudflare

2022/11/13に公開
4

こんにちは
だだっこぱんだと言います。だだっこではないです。
今回は初めてしっかりした個人開発をしたので、技術的な部分についていろいろとお話しします。

つくったもの

https://youtu.be/Nkk4BPydMJw
Aivy というAI作品に特化した画像投稿サイトを作りました🍃
https://aivy.run

👇よかったらこれ拡散してくれるとうれしいですの
https://twitter.com/ddPn08/status/1591750203178364929

ScreenShot


ホーム画面


投稿ページ


ユーザーページ


検索


投稿管理

なにこれ

AI作品に特化した画像投稿コミュニティサービスです。
ただ画像を投稿するだけでなく、プロンプトやモデル等のAI画像生成に使う情報を一緒に投稿できます。

この記事では

この記事では技術面にフォーカスしてお話しします。
どうして作ったの? とかその辺の話は下記リンクのNoteにまとめてありますので合わせてご覧ください。

https://note.com/ddpn08/n/nfe41a46a1ee1

開発体制

メンバー

  • ぼく(高校生)

開発フロー

完成まではゴリゴリローカルでした。タスク分けとか何も管理しませんでした。ある程度完成してからGithubのリポジトリ作り直してしっかりコミット分けたりするようにしました。
個人開発だとどうしても適当になっちゃいますね;

Github

こういうアプリケーションってOSSにするのはよろしくないんですかね?OSSにしていろんな人に機能追加とかしてもらえたら最高だなぁとか思ったのですが、認証とかその辺扱ってるのであまりよろしくないのかなとも思ってプライベートリポジトリで管理してます。
もしその辺知見ある方いらしたら教えていただけるとありがたいです。

※2022/11/14追記
OSS化

こちらでコード公開しますた
https://github.com/aivy-run/aivy

使用技術

今回使った技術はこれらになります。

  • SolidJs - 仮想DOMを使わないReactみたいな(?)
  • SolidStart - NextjsのSolidJs版(?)みたいなアプリケーションフレームワーク 詳しくはこちら
  • Supabase - 認証とデータベース
  • Vercel - ホスティング
  • CloudFlare - DDOS保護とかドメインとか
  • Cloudflare Images - 画像の保存/配信
  • Cloudflare Zaraz - GoogleAnalyticsを使うため

はじめはCloudflareWorkersを使おうと思っていたのですが、SolidStartがなんかまだ対応してないみたいなんですよね。(前回の記事で嘘つきましたすいません。)

https://github.com/solidjs/solid-start/issues/263

なので仕方なくVercelで...

SolidJs - SolidStart

これらを選んだ理由は単純に僕が好きだからです。仮想DOMを使わないのでパフォーマンスも良いですし、個人的にはcreateSignalがとても好きです。これのおかげでReactivity(?)みたいなのの仕組みがだいぶ理解できました。

デメリットとしてはまだできたばかりのフレームワークなのでバグがちょくちょく見つかります。開発中にいろいろとバグが見つかっては修正されるみたいなのがよくありました。
特にCloudflareWorkersが使えなかったのがすごい辛い...
速く対応してほしいですの

CSS全部書いた

SolidJsはまだできたばかり。SSR対応のUIライブラリはかなり少ないです。
なので1からsolid-styled-componentsでcssを書きました。割と大変でした。

これのおかげでかなりcssの理解が深まった

SSRは最小限に

SolidStartのSSRはNextjsみたいに簡単に扱えないのでなるべく使わない方針で行きました。
使ったのはユーザーページや投稿ページ等のOGPの設定が必要なページのみにとどめました。
そのほかは全部CSRです。(CSRと言ってもある程度の部分はサーバー側でレンダリングされるのですが)

Supabase

supabaseはもうほんとに強いですね。無料でここまでいろいろできるのはほんとにありがたいです。

認証

とりあえずTwitterアカウントでログインできるようにしています。
Supabaseのデメリットは他のプロバイダとのリンクができないところですね。メールアドレスが同じアカウントでないと新しいアカウントが作られてしまいます。

データベース

投稿の情報やプロフィール、いいね、フォロー、タグ...などを保存してます。500MBは多分超えないでしょう...
超えたら僕の月3000円のお小遣いが飛び散ります。

設計が正しいのかわからない

投稿、いいね、フォロー関係、コメント... みたいな単位でテーブルを分けているのですが、なかなかこういう開発をしたことがなく正しいのかどうなのかが全然わからんのです。
特に、現在投稿の情報にもいいね数を保存していて、いいねのInsert時にTriggerを使って投稿の情報のいいね数を増やしてます。これはベストな実装だったのだろうか...?

CloudflareImages

今回大活躍のサービスです。
とりあえず強みを紹介します。

安い(多分)

10万枚の画像保存につき月額5ドル、10万枚の画像配信につき月額1ドルです。
supabaseのストレージとか使おうとすると1GBじゃさすがに足りないのでPro($25)にする必要があります。
他のサービスは配信容量での従量課金だったりするので、高校生の僕はあんまり安心して使えないです。
CloudflareImagesは全て枚数計算です。しかも10万枚の配信で1ドル!
これならまだ安心して使えますね!

Flexible variantsが超強い

簡単に説明すると画像のリサイズとかブラー処理とかをサーバー側で配信時にやってくれる奴です。これのおかげで4Kとかの大きなサイズの画像を利用者に投げつけなくて済みます。

https://aivy.run/cdn-cgi/images/<ACCOUNT_HASH>/<IMAGE_ID>/w=400,h=800,blur=50

👆みたいなURLで画像を取得すると、横幅400ピクセル高さ800ピクセル、ブラーのかかった画像が取得できます。

できることはドキュメント見れば大体わかります。
https://developers.cloudflare.com/images/cloudflare-images/transform/flexible-variants

DirectCreatorUpload

普通は画像をアップロードする際に一度サーバーを介す必要があります。APIキーをブラウザに晒すのはまずいですからね。しかしそうすると帯域制限とかがかなり気になりますね...
そんな時はDirectCreatorUpload!アップロード用のURLだけ取得してブラウザから直接アップロードしちゃいましょ!そうすれば大きなサイズの画像もアップロードできます!

https://developers.cloudflare.com/images/cloudflare-images/upload-images/direct-creator-upload/

ちなみにv2だとカスタムIDが使えませんでした。なぞいのでコミュニティで聞こうとしたらアカウントが止められました。さらに謎いです🤔
1週間後くらいに復活したので良かったですが...

Cloudflare Zaraz

コードかかなくてもGoogleAnalyticsとかが使えるやつです。
ダッシュボードで追加してID入力すれば自動でhtmlにコードを挿入してくれます。
結構便利です。

https://developers.cloudflare.com/zaraz/

Exifの読み込み

AI画像と言えばプロンプト等の情報ですよね。もちろん一緒に投稿できます。
ついでに画像のexif情報からプロンプト等の情報を読み込み用にしました。
exifr というライブラリを使ってブラウザ上でexifを読み込んでいます。

https://github.com/MikeKovarik/exifr

NovelAIはDescriptionにプロンプトが、Commentにステップ等の情報が保存されています。比較的扱いやすかったです。

一方Automatic1111 Webuiの方はparametersに全て詰まったテキストが入っている感じでした、正規表現とかいろいろ使わないといけなくて少し大変でした。

👇書いたコードです。

parse-exif.ts
import type { ImageInformation } from '~/types/images'

const parseAutomatic1111Exif = (parameters: string) => {
    const result: Partial<ImageInformation> = {}
    const prompt = parameters.split(/\nNegative prompt:/)[0]
    const negative_prompt = parameters.split(/\nNegative prompt:/)[1]?.split(/Steps: \d+/)[0]
    const others = parameters.split(/\n/g).slice(-1)[0]
    result.prompt = prompt || ''
    result.negative_prompt = (negative_prompt || '').replace(/\n$/, '')
    if (others) {
        for (const prop of others.split(/, /g)) {
            const [key, value] = prop.split(': ')
            switch (key) {
                case 'Steps':
                    result.steps = value || ''
                    break
                case 'Sampler':
                    result.sampler = value || ''
                    break
                case 'CFG scale':
                    result.cfg_scale = value || ''
                    break
                case 'Seed':
                    result.seed = value || ''
                    break
            }
        }
    }
    return result
}

const parseNovelAIExif = (exif: Record<string, any>) => {
    const comment = JSON.parse(exif['Comment'])
    const prompt = exif['Description']
    const result: Partial<ImageInformation> = {
        prompt,
        negative_prompt: comment['uc'],
        steps: `${comment['steps']}`,
        sampler: `${comment['sampler']}`,
        cfg_scale: `${comment['scale']}`,
        seed: `${comment['seed']}`,
        model: `${exif['Software']}`,
    }
    return result
}

export const parseExif = (exif: any) => {
    if (exif?.['parameters']) return parseAutomatic1111Exif(exif['parameters'] as string)
    else if (exif?.['Software'] === 'NovelAI') return parseNovelAIExif(exif)
}

検索

検索は一番大事だと思って結構しっかり書きました。
シンプルにタイトルやプロンプトを入力しても良いですし、twitterのように(username: だだっこぱんだ)のように各プロパティを指定して検索できます。

👇書いたコード

supabase.ts
class Api {
    // ...

    public async search(builder: PostgrestFilterBuilder<any, any, any>, query: string) {
        // eslint-disable-next-line no-irregular-whitespace
        query = query.replace(/ /g, ' ')
        const options = (query.match(/\(.+?\)/g) || []).map((v) => v.replace(/^\(|\)$/g, ''))
        const words = query
            .replace(/\(.+?\)/g, '')
            .split(' ')
            .filter((v) => !!v)
        for (const option of options) {
            const [key, value] = option.split(':') as [string, string]
            const trimmed = value.trimStart()
            switch (key.trim()) {
                case 'username':
                    builder.ilike('profiles.username', `${trimmed}%`)
                    break
                case 'id':
                    builder.eq('profiles.id', trimmed)
                    break
                case 'tag':
                    builder.contains('tags', [trimmed.split(' ')])
                    break
                case 'prompt':
                    builder.ilike('information->0->>prompt', `%${trimmed}%`)
                    break
                case 'negative_prompt':
                    builder.ilike('information->0->>negative_prompt', `%${trimmed}%`)
                    break
            }
        }
        for (const v of words) {
            const word = v.trimStart()
            builder.or(
                `title.ilike.%${word}%,description.ilike.%${word}%,information->0->>prompt.ilike.%${word}%,tags.cs.{${word}}`,
            )
        }
    }

    // ...
}

ただこれ一つ問題があります。
現在information->0->>promptのようにしてプロンプトから検索しています。
配列にしているのには理由があり、今後複数枚投稿に対応していたいからです。
しかし、json配列から検索する方法がないのです。
rpcを使えばできなくもないようですが、sqlを書けない僕には荷が重い...
なるべくjsで完結させたい...
なにかご存じの方いらしたら教えてほしいです

反省点

デザインが...

いろんな人のデザインを参考にしていたのですが、なんかただのパクリみたいになってる気がしてちょっと申し訳なくなりました。
特にZennのデザインをめちゃくちゃ参考にしました。

時間かかった

始めは1週間程度でできるかなとか思ったのですが割と時間かかっちゃいました。もう少しコーディング速くなりたいです。かといって適当にやるのは良くないですけどね。

学んだこと

  • データベースの知識 (Postgresql)
  • デザインのむずかしさ。
  • お金があまりなくても開発はできる。無料サービスに感謝。

今後

いずれCloudflareWorkersに移行したいです。solid-startのコード書こうかなとも思ってます。
Workersが使えればキャッシュ関係もいじれるのでうまいことISRのようなものも実装したいですね。

サービスに関する今後はNoteの方に書きます。

https://note.com/ddpn08/n/nfe41a46a1ee1

良ければぜひ使ってください

使ってもらえるかが一番の不安要素なので良かったらぜひ1枚だけでも投稿してみてください。
画像生成AIはほんとに簡単に触れます!使ったことない方もこの機会にぜひ一度!
https://aivy.run

Discussion

タイラータイラー

めっちゃ素敵なサービスですね!何より高校生でここまでのものを作っているのがすごいです!

オープンソース化も是非するべきだと思います!認証認可周りの設定がちゃんとできていて、隠すべきキーなどがきちんと隠されている状態であればオープンソース化しても問題ないはずなのですが、心配であれば全然レビューに協力しますよ!

だだっこぱんだだだっこぱんだ

なるほど!であればOSS化も進めていきたいと思います!
レビューの件TwitterにDM送らせていただきました。ご確認のほどよろしくお願いいたします。

chihiroschihiros

面白い記事でした!
モノとしては全く違うものなんですが、投稿サイトを個人で作っている所だったので
構成など参考にさせていただきます