diff --git a/.github/workflows/dev-deploy.yml b/.github/workflows/dev-deploy.yml index c728ecc..1f8928a 100644 --- a/.github/workflows/dev-deploy.yml +++ b/.github/workflows/dev-deploy.yml @@ -109,41 +109,60 @@ jobs: 'cd functions-go && go test ./...' working-directory: ./app - # Go版Cloud Run functions(statusGo)のデプロイ。既存のstatus(Node)とは別関数として - # 共存させ、フロントエンドの切替タイミングを制御できるようにしている - # (詳細は docs/backend.md「status エンドポイントのGo移植」を参照)。 - - name: enable required GCP services for Go functions + # OGP画像生成(userOGPGo)のQC。サンプルOGPをPNG/WebPでレンダリングして + # アーティファクト化し、生成物を目視確認できるようにする(描画の破綻を + # デプロイ前に検知する目的も兼ねる)。 + - name: generate OGP sample for QC + run: | + go test ./internal/ogpimage/ -run TestWriteQCArtifacts -count=1 -v + working-directory: ./app/functions-go + env: + OGP_QC_OUT: ${{ github.workspace }}/ogp-qc + + - name: upload OGP sample artifact + uses: actions/upload-artifact@v4 + with: + name: ogp-sample + path: ${{ github.workspace }}/ogp-qc/ + if-no-files-found: error + + # ここから Go版 Cloud Run functions のデプロイ。 + # Gen2(Cloud Run)関数のデプロイは内部でCloud Buildのコンテナビルドを伴い + # 1本あたり数分かかる。関数間に依存が無いため、以前のように1本ずつ直列で + # deployすると全体で15〜30分規模になっていた。そこで前提処理(サービス有効化・ + # Pub/Subトピック作成・認証情報取得・ロケーション解決)を先に済ませてから、 + # 全関数のdeployをバックグラウンドで同時に起動して一括waitする + # (体感で最も遅い1本分の時間に短縮)。 + + # HTTP関数とスケジュール関数(Pub/Subトリガー)の両方に必要なGCPサービスを一括有効化。 + - name: enable required GCP services run: | gcloud services enable \ cloudfunctions.googleapis.com \ run.googleapis.com \ cloudbuild.googleapis.com \ artifactregistry.googleapis.com \ + pubsub.googleapis.com \ + cloudscheduler.googleapis.com \ + eventarc.googleapis.com \ --project=d-shrine-dev - - name: deploy statusGo (Cloud Run function, Go) + # Cloud Functions(Gen2)の --trigger-topic は Gen1と異なりPub/Subトピックを + # 自動作成しない(存在しないトピックを指定すると + # `Validation failed for trigger ...: Resource not found` で即失敗する)。 + # そのため各スケジュール関数用トピックを事前に作成しておく + # (既に存在すれば describe が成功するのでスキップする、冪等な実装)。 + - name: create Pub/Sub topics for scheduled Go functions run: | - gcloud functions deploy statusGo \ - --project=d-shrine-dev \ - --gen2 \ - --runtime=go125 \ - --region=us-central1 \ - --source=. \ - --entry-point=StatusGo \ - --trigger-http \ - --allow-unauthenticated \ - --memory=256Mi \ - --timeout=30s - working-directory: ./app/functions-go + for TOPIC in ranking-update-go ranking-cache-go status-cache-backfill-go scheduled-ogp-delete-go; do + gcloud pubsub topics describe "$TOPIC" --project=d-shrine-dev \ + || gcloud pubsub topics create "$TOPIC" --project=d-shrine-dev + done - # Go版Cloud Run functions(sanpaiGo)のデプロイ。既存のsanpai(Node)とは別関数として - # 共存させ、フロントエンドの切替タイミングを制御できるようにしている - # (詳細は docs/backend.md「sanpai エンドポイントのGo移植」を参照)。 - # # GitHub OAuth Appのclient_id/client_secretは、Node版が使う # `firebase functions:config:get` (既にGCP側に設定済みの値)からそのまま取得し、 # 新規のGitHub Secretsは増やさない。ログに値が出ないよう ::add-mask:: で - # マスクしてから $GITHUB_ENV に渡す。 + # マスクしてから $GITHUB_ENV に渡す(sanpaiGoで使用)。 - name: fetch GitHub OAuth credentials for sanpaiGo run: | set -eo pipefail @@ -156,61 +175,9 @@ jobs: echo "SANPAI_GH_CLIENT_SECRET=$CLIENT_SECRET" >> "$GITHUB_ENV" working-directory: ./app - - name: deploy sanpaiGo (Cloud Run function, Go) - run: | - gcloud functions deploy sanpaiGo \ - --project=d-shrine-dev \ - --gen2 \ - --runtime=go125 \ - --region=us-central1 \ - --source=. \ - --entry-point=SanpaiGo \ - --trigger-http \ - --allow-unauthenticated \ - --memory=256Mi \ - --timeout=30s \ - --set-env-vars=GITHUB_CLIENT_ID=${{ env.SANPAI_GH_CLIENT_ID }},GITHUB_CLIENT_SECRET=${{ env.SANPAI_GH_CLIENT_SECRET }},SANPAI_NEXT_TIME_SECONDS=60 - working-directory: ./app/functions-go - - # Go版Cloud Run functions(registerGo)のデプロイ。既存のregister(Node)とは別関数として - # 共存させる(詳細は docs/backend.md「register エンドポイントのGo移植」を参照)。 - - name: deploy registerGo (Cloud Run function, Go) - run: | - gcloud functions deploy registerGo \ - --project=d-shrine-dev \ - --gen2 \ - --runtime=go125 \ - --region=us-central1 \ - --source=. \ - --entry-point=RegisterGo \ - --trigger-http \ - --allow-unauthenticated \ - --memory=256Mi \ - --timeout=30s - working-directory: ./app/functions-go - - # Go版Cloud Run functions(rankingGo)のデプロイ。既存のranking(Node)とは別関数として - # 共存させる(詳細は docs/backend.md「ranking エンドポイントのGo移植」を参照)。 - - name: deploy rankingGo (Cloud Run function, Go) - run: | - gcloud functions deploy rankingGo \ - --project=d-shrine-dev \ - --gen2 \ - --runtime=go125 \ - --region=us-central1 \ - --source=. \ - --entry-point=RankingGo \ - --trigger-http \ - --allow-unauthenticated \ - --memory=256Mi \ - --timeout=30s - working-directory: ./app/functions-go - - # Go版Cloud Run functions(ogpRewriteGo)のデプロイ。既存のogpRewrite(Node)とは - # 別関数として共存させる(詳細は docs/backend.md「ogpRewrite エンドポイントのGo移植」を参照)。 - # # FUNC_BASE_URLはNode版が functions.config().func.base_url から読む値と同じもので、 - # sanpaiGoのGitHub資格情報と同様に既存のFirebase Functions Configから取得する。 + # sanpaiGoのGitHub資格情報と同様に既存のFirebase Functions Configから取得する + # (ogpRewriteGoで使用)。 - name: fetch base_url for ogpRewriteGo run: | set -eo pipefail @@ -219,50 +186,6 @@ jobs: echo "OGP_FUNC_BASE_URL=$BASE_URL" >> "$GITHUB_ENV" working-directory: ./app - - name: deploy ogpRewriteGo (Cloud Run function, Go) - run: | - gcloud functions deploy ogpRewriteGo \ - --project=d-shrine-dev \ - --gen2 \ - --runtime=go125 \ - --region=us-central1 \ - --source=. \ - --entry-point=OgpRewriteGo \ - --trigger-http \ - --allow-unauthenticated \ - --memory=256Mi \ - --timeout=30s \ - --set-env-vars=FUNC_BASE_URL=${{ env.OGP_FUNC_BASE_URL }},OGP_PROJECT_ID=d-shrine-dev - working-directory: ./app/functions-go - - # スケジュール関数(Pub/Sub トリガー)のGo移植分。既存のNode版 - # (rankingUpdate/rankingCache/statusCacheBackfill/scheduledOgpDelete)とは - # 別のCloud Scheduler ジョブ・Pub/Subトピック・関数名で完全に独立してデプロイし、 - # 安定稼働を確認してからNode版を止める(詳細は docs/backend.md - # 「スケジュール関数のGo移植」を参照)。 - # - # Cloud SchedulerジョブはApp Engineのロケーションに紐づくため、 - # 既存プロジェクトの設定値を動的に取得して使う。 - - name: enable required GCP services for scheduled Go functions - run: | - gcloud services enable \ - pubsub.googleapis.com \ - cloudscheduler.googleapis.com \ - eventarc.googleapis.com \ - --project=d-shrine-dev - - # Cloud Functions(Gen2)の --trigger-topic は Gen1と異なりPub/Subトピックを - # 自動作成しない(存在しないトピックを指定すると - # `Validation failed for trigger ...: Resource not found` で即失敗する)。 - # そのため各スケジュール関数用トピックを事前に作成しておく - # (既に存在すれば describe が成功するのでスキップする、冪等な実装)。 - - name: create Pub/Sub topics for scheduled Go functions - run: | - for TOPIC in ranking-update-go ranking-cache-go status-cache-backfill-go scheduled-ogp-delete-go; do - gcloud pubsub topics describe "$TOPIC" --project=d-shrine-dev \ - || gcloud pubsub topics create "$TOPIC" --project=d-shrine-dev - done - # Cloud SchedulerジョブのlocationはプロジェクトのApp Engineアプリと # 同一でなければならない(App Engineが存在するプロジェクトでは、それ以外の # リージョンを指定すると `Location must equal ` エラーになる)。 @@ -282,126 +205,102 @@ jobs: echo "resolved App Engine location: $RAW_LOCATION -> $LOCATION" echo "APP_ENGINE_LOCATION=$LOCATION" >> "$GITHUB_ENV" - # timeout=300s: Node版はrunWith()を使わずデフォルト(60s)のままだが、 - # 全ユーザーをスキャンする処理のため安全側に長めの値を明示指定する - # (実行時間そのものはNode版と同等の想定で、上限を厳しくしすぎて - # ユーザー数増加時にタイムアウトする事故を避けるのが目的)。 - - name: deploy rankingUpdateGo (Cloud Run function, Go, Pub/Sub trigger) - run: | - gcloud functions deploy rankingUpdateGo \ - --project=d-shrine-dev \ - --gen2 \ - --runtime=go125 \ - --region=us-central1 \ - --source=. \ - --entry-point=RankingUpdateGo \ - --trigger-topic=ranking-update-go \ - --memory=256Mi \ - --timeout=300s + # 全Go関数のデプロイをバックグラウンドで同時起動し、まとめてwaitする。 + # 依存関係(トピック/認証情報/ロケーション)は上の先行ステップで解決済み。 + # timeout: HTTP関数は30s、スケジュール関数は全ユーザースキャン等で長くなり得るため + # 安全側に300sを明示。ログは関数ごとにファイルへ退避し、最後に順番に出力する + # (並行実行でログが混ざるのを防ぐ)。1本でも失敗したらステップを失敗させる。 + - name: deploy all Go Cloud Run functions in parallel working-directory: ./app/functions-go - - - name: create/update Cloud Scheduler job for rankingUpdateGo - run: | - gcloud scheduler jobs update pubsub ranking-update-go \ - --project=d-shrine-dev --location=${{ env.APP_ENGINE_LOCATION }} \ - --schedule="0 * * * *" \ - --topic=ranking-update-go \ - --message-body="{}" \ - --time-zone=Etc/UTC \ - || gcloud scheduler jobs create pubsub ranking-update-go \ - --project=d-shrine-dev --location=${{ env.APP_ENGINE_LOCATION }} \ - --schedule="0 * * * *" \ - --topic=ranking-update-go \ - --message-body="{}" \ - --time-zone=Etc/UTC - - # timeout=300s: rankingUpdateGoと同様の理由(全ユーザースキャンのため安全側)。 - - name: deploy rankingCacheGo (Cloud Run function, Go, Pub/Sub trigger) - run: | - gcloud functions deploy rankingCacheGo \ - --project=d-shrine-dev \ - --gen2 \ - --runtime=go125 \ - --region=us-central1 \ - --source=. \ - --entry-point=RankingCacheGo \ - --trigger-topic=ranking-cache-go \ - --memory=256Mi \ - --timeout=300s - working-directory: ./app/functions-go - - - name: create/update Cloud Scheduler job for rankingCacheGo - run: | - gcloud scheduler jobs update pubsub ranking-cache-go \ - --project=d-shrine-dev --location=${{ env.APP_ENGINE_LOCATION }} \ - --schedule="0 */2 * * *" \ - --topic=ranking-cache-go \ - --message-body="{}" \ - --time-zone=Etc/UTC \ - || gcloud scheduler jobs create pubsub ranking-cache-go \ - --project=d-shrine-dev --location=${{ env.APP_ENGINE_LOCATION }} \ - --schedule="0 */2 * * *" \ - --topic=ranking-cache-go \ - --message-body="{}" \ - --time-zone=Etc/UTC - - - name: deploy statusCacheBackfillGo (Cloud Run function, Go, Pub/Sub trigger) run: | - gcloud functions deploy statusCacheBackfillGo \ - --project=d-shrine-dev \ - --gen2 \ - --runtime=go125 \ - --region=us-central1 \ - --source=. \ - --entry-point=StatusCacheBackfillGo \ - --trigger-topic=status-cache-backfill-go \ - --memory=512Mi \ - --timeout=300s - working-directory: ./app/functions-go - - - name: create/update Cloud Scheduler job for statusCacheBackfillGo - run: | - gcloud scheduler jobs update pubsub status-cache-backfill-go \ - --project=d-shrine-dev --location=${{ env.APP_ENGINE_LOCATION }} \ - --schedule="*/30 * * * *" \ - --topic=status-cache-backfill-go \ - --message-body="{}" \ - --time-zone=Etc/UTC \ - || gcloud scheduler jobs create pubsub status-cache-backfill-go \ - --project=d-shrine-dev --location=${{ env.APP_ENGINE_LOCATION }} \ - --schedule="*/30 * * * *" \ - --topic=status-cache-backfill-go \ - --message-body="{}" \ - --time-zone=Etc/UTC - - # timeout=300s: OGPキャッシュファイル数が多い場合でも一覧・削除が - # 途中で打ち切られないよう安全側に長めの値を明示指定する。 - - name: deploy scheduledOgpDeleteGo (Cloud Run function, Go, Pub/Sub trigger) - run: | - gcloud functions deploy scheduledOgpDeleteGo \ - --project=d-shrine-dev \ - --gen2 \ - --runtime=go125 \ - --region=us-central1 \ - --source=. \ - --entry-point=ScheduledOgpDeleteGo \ - --trigger-topic=scheduled-ogp-delete-go \ - --memory=256Mi \ - --timeout=300s \ - --set-env-vars=STORAGE_BUCKET_NAME=d-shrine-dev.appspot.com - working-directory: ./app/functions-go - - - name: create/update Cloud Scheduler job for scheduledOgpDeleteGo + set -uo pipefail + declare -a PIDS NAMES + deploy() { + local name="$1"; shift + local entry="$1"; shift + echo "start deploy: $name" + ( gcloud functions deploy "$name" \ + --project=d-shrine-dev \ + --gen2 \ + --runtime=go125 \ + --region=us-central1 \ + --source=. \ + --entry-point="$entry" \ + "$@" ) > "/tmp/deploy-$name.log" 2>&1 & + PIDS+=("$!") + NAMES+=("$name") + } + + # HTTPトリガー関数 + deploy statusGo StatusGo \ + --trigger-http --allow-unauthenticated --memory=256Mi --timeout=30s + deploy sanpaiGo SanpaiGo \ + --trigger-http --allow-unauthenticated --memory=256Mi --timeout=30s \ + --set-env-vars="GITHUB_CLIENT_ID=$SANPAI_GH_CLIENT_ID,GITHUB_CLIENT_SECRET=$SANPAI_GH_CLIENT_SECRET,SANPAI_NEXT_TIME_SECONDS=60" + deploy registerGo RegisterGo \ + --trigger-http --allow-unauthenticated --memory=256Mi --timeout=30s + deploy rankingGo RankingGo \ + --trigger-http --allow-unauthenticated --memory=256Mi --timeout=30s + deploy ogpRewriteGo OgpRewriteGo \ + --trigger-http --allow-unauthenticated --memory=256Mi --timeout=30s \ + --set-env-vars="FUNC_BASE_URL=$OGP_FUNC_BASE_URL,OGP_PROJECT_ID=d-shrine-dev" + # userOGPGoは画像合成(base同梱)+WebPエンコード+アバター取得+GCSアップロードを + # 行うため、他HTTP関数より余裕を持たせる(512Mi/60s)。 + deploy userOGPGo UserOGPGo \ + --trigger-http --allow-unauthenticated --memory=512Mi --timeout=60s \ + --set-env-vars="STORAGE_BUCKET_NAME=d-shrine-dev.appspot.com" + + # Pub/Subトリガー(スケジュール)関数 + deploy rankingUpdateGo RankingUpdateGo \ + --trigger-topic=ranking-update-go --memory=256Mi --timeout=300s + deploy rankingCacheGo RankingCacheGo \ + --trigger-topic=ranking-cache-go --memory=256Mi --timeout=300s + deploy statusCacheBackfillGo StatusCacheBackfillGo \ + --trigger-topic=status-cache-backfill-go --memory=512Mi --timeout=300s + deploy scheduledOgpDeleteGo ScheduledOgpDeleteGo \ + --trigger-topic=scheduled-ogp-delete-go --memory=256Mi --timeout=300s \ + --set-env-vars="STORAGE_BUCKET_NAME=d-shrine-dev.appspot.com" + + fail=0 + for idx in "${!NAMES[@]}"; do + name="${NAMES[$idx]}" + if wait "${PIDS[$idx]}"; then + status=OK + else + status=FAILED + fail=1 + fi + echo "::group::deploy $status: $name" + cat "/tmp/deploy-$name.log" + echo "::endgroup::" + done + if [ "$fail" -ne 0 ]; then + echo "one or more Go function deployments failed" >&2 + fi + exit "$fail" + + # スケジュール関数用のCloud Schedulerジョブを作成/更新する(冪等)。 + # ジョブはトピックにメッセージを流すだけなので関数デプロイ完了後に一括で行う。 + # update が存在しないジョブに対して失敗したら create にフォールバックする。 + - name: create/update Cloud Scheduler jobs for scheduled Go functions run: | - gcloud scheduler jobs update pubsub scheduled-ogp-delete-go \ - --project=d-shrine-dev --location=${{ env.APP_ENGINE_LOCATION }} \ - --schedule="0 */1 * * *" \ - --topic=scheduled-ogp-delete-go \ - --message-body="{}" \ - --time-zone=Etc/UTC \ - || gcloud scheduler jobs create pubsub scheduled-ogp-delete-go \ - --project=d-shrine-dev --location=${{ env.APP_ENGINE_LOCATION }} \ - --schedule="0 */1 * * *" \ - --topic=scheduled-ogp-delete-go \ - --message-body="{}" \ - --time-zone=Etc/UTC + set -uo pipefail + upsert_job() { + local job="$1" schedule="$2" topic="$3" + gcloud scheduler jobs update pubsub "$job" \ + --project=d-shrine-dev --location="$APP_ENGINE_LOCATION" \ + --schedule="$schedule" \ + --topic="$topic" \ + --message-body="{}" \ + --time-zone=Etc/UTC \ + || gcloud scheduler jobs create pubsub "$job" \ + --project=d-shrine-dev --location="$APP_ENGINE_LOCATION" \ + --schedule="$schedule" \ + --topic="$topic" \ + --message-body="{}" \ + --time-zone=Etc/UTC + } + upsert_job ranking-update-go "0 * * * *" ranking-update-go + upsert_job ranking-cache-go "0 */2 * * *" ranking-cache-go + upsert_job status-cache-backfill-go "*/30 * * * *" status-cache-backfill-go + upsert_job scheduled-ogp-delete-go "0 */1 * * *" scheduled-ogp-delete-go diff --git a/app/functions-go/README.md b/app/functions-go/README.md index 8cd52fe..44db28c 100644 --- a/app/functions-go/README.md +++ b/app/functions-go/README.md @@ -38,6 +38,7 @@ functions-go/ register_test.go ogp_rewrite.go # ogpRewriteGo エンドポイント ogp_rewrite_test.go + userogp.go # userOGPGo エンドポイント(OGP画像生成) ranking_update.go # rankingUpdateGo (Pub/Subトリガー、スケジュール関数) ranking_update_test.go ranking_cache.go # rankingCacheGo (Pub/Subトリガー、スケジュール関数) @@ -52,9 +53,16 @@ functions-go/ performance/ performance.go # app/functions/performance.js の対象範囲のGoポート performance_test.go # performance.test.js と同一の入出力を検証 + ogpimage/ # OGP画像合成(userOGPGoが利用)。設計は同ディレクトリのREADME参照 + ogpimage.go # 合成本体(座標/クロップ/縮小/WebPエンコード) + radar.go # レーダーチャート描画 + base.png # ベース画像(2500x1313, go:embed) + fonts/ # Noto Sans JP(go:embed) ``` -`userOGP`(OGP画像生成)は対象外(理由は `docs/backend.md`「Go移植を見送った機能」を参照)。 +OGP画像生成 `userOGP` も Go へ移植済み(`userOGPGo`)。画像描画は `internal/ogpimage` +に分離している(詳細は `docs/backend.md`「`userOGP` エンドポイントのGo移植」および +`internal/ogpimage/README.md` を参照)。 ## 関数の命名規則(既存Node関数との共存) @@ -126,10 +134,13 @@ gcloud functions deploy statusGo \ `projectID == 'd-shrine' ? 300 : 60` とプロジェクトIDで分岐しているが、Go版は デプロイ時に明示的な値を渡す(dev: `60`。prod移植時は `300` を指定する)。 -`rankingGo`/`registerGo`/`ogpRewriteGo` も同様のパターンでデプロイしている。 +`rankingGo`/`registerGo`/`ogpRewriteGo`/`userOGPGo` も同様のパターンでデプロイしている。 `ogpRewriteGo` は追加で `FUNC_BASE_URL`(Node版の `functions.config().func.base_url` 相当)と `OGP_PROJECT_ID` を -`--set-env-vars` で渡す必要がある。 +`--set-env-vars` で渡す必要がある。`userOGPGo` は画像合成・WebPエンコード・ +アバター取得・GCSアップロードを行うため他HTTP関数より余裕を持たせ +(`--memory=512Mi --timeout=60s`)、OGPキャッシュ書き込み先の +`STORAGE_BUCKET_NAME` を `--set-env-vars` で渡す。 ## スケジュール関数(Pub/Subトリガー)のデプロイ diff --git a/app/functions-go/go.mod b/app/functions-go/go.mod index d4f26ff..3c46c4e 100644 --- a/app/functions-go/go.mod +++ b/app/functions-go/go.mod @@ -7,7 +7,10 @@ require ( cloud.google.com/go/storage v1.63.0 firebase.google.com/go/v4 v4.20.0 github.com/GoogleCloudPlatform/functions-framework-go v1.9.2 + github.com/HugoSmits86/nativewebp v1.3.0 github.com/cloudevents/sdk-go/v2 v2.15.2 + github.com/fogleman/gg v1.3.0 + golang.org/x/image v0.43.0 google.golang.org/api v0.287.0 google.golang.org/grpc v1.81.1 ) @@ -35,6 +38,7 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect diff --git a/app/functions-go/go.sum b/app/functions-go/go.sum index bcffb63..bb4be12 100644 --- a/app/functions-go/go.sum +++ b/app/functions-go/go.sum @@ -36,6 +36,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= +github.com/HugoSmits86/nativewebp v1.3.0 h1:n1egtEzSV4KwFtealr7dzdYq1wI/uj/bOQ/QcTcIyVE= +github.com/HugoSmits86/nativewebp v1.3.0/go.mod h1:YNQuWenlVmSUUASVNhTDwf4d7FwYQGbGhklC8p72Vr8= github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -58,6 +60,8 @@ github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMD github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -68,6 +72,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= @@ -139,6 +145,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= +golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= diff --git a/app/functions-go/internal/ogpimage/README.md b/app/functions-go/internal/ogpimage/README.md new file mode 100644 index 0000000..667c492 --- /dev/null +++ b/app/functions-go/internal/ogpimage/README.md @@ -0,0 +1,57 @@ +# internal/ogpimage + +ユーザーOGP画像(でばっぐのうりょくカード)を生成するパッケージ。 +`userOGPGo`(`app/functions-go/userogp.go`)から利用する。Node版 +(`app/functions/index.js` の `createOgp`、`canvas` + `chartjs-node-canvas`)相当の +カード合成を、外部プロセス依存なしのGoネイティブ実装として再現する。 + +## ファイル構成 + +| ファイル | 役割 | +| --- | --- | +| `ogpimage.go` | 合成本体。座標定数・フォント/base読込・テキスト/アイコン描画・クロップ&縮小・WebP/PNGエンコード。 | +| `radar.go` | レーダーチャート描画(Chart.js の radar 設定に対応)。ピクセル指定で解像度非依存。 | +| `base.png` | ベースカード画像(2500×1313)。`go:embed` で同梱。 | +| `fonts/NotoSansJP-Regular.otf` | 日本語フォント。`go:embed` で同梱(OFL)。 | +| `ogpimage_test.go` | 出力寸法・WebPマジック・レーダー幾何の単体テスト+QCサンプル生成。 | +| `testdata/` | QC用に生成したサンプルOGP(目視確認用の参照画像)。 | + +## 描画パイプライン + +1. `base.png`(2500×1313)を `gg.Context` に読み込む。 +2. デザイン基準(Node版=2500×1313)の座標定数を `scale = baseW/2500` で比例させて、 + 表示名・アイコン(円形クリップ)・ステータス・レーダーチャートを合成する。 +3. **カード領域をOG比(1200:630)でクロップ**する。元画像は上下左右の暗い背景余白が + 広く、そのまま縮小するとカード=文字が小さくなるため、カード塗り(黒/青)の実測 + 外接矩形に小マージンを付けた矩形を切り出す。 +4. クロップ結果を**一度だけ**最終出力サイズ(1200×630)へ高品質縮小(CatmullRom)する。 + 縮小前にクロップすることで、再拡大による画質劣化を避ける。 +5. WebP(可逆VP8L)でエンコードして返す(`EncodeWebP`)。QC比較用に `EncodePNG` もある。 + +## 座標・定数のメンテナンス + +- レイアウト座標はすべて Node版のデザイン基準(2500×1313)で `ogpimage.go` の定数として保持。 +- クロップに使うカード外接矩形(`cardMinX/MaxX/MinY/MaxY`)は `base.png` から実測した値。 + **`base.png` を差し替えたら再計測して更新すること。** +- 出力サイズは `outputWidth`/`outputHeight`(1200×630)。`ogpRewrite` が注入する + `og:image:width`/`:height` と一致させること。 + +## QC(画質確認) + +`TestWriteQCArtifacts` は環境変数 `OGP_QC_OUT` が指すディレクトリにサンプルOGP +(PNG/WebP)を書き出す。ローカルでの生成例: + +```bash +cd app/functions-go +OGP_QC_OUT="$PWD/internal/ogpimage/testdata" go test ./internal/ogpimage/ -run TestWriteQCArtifacts -v +``` + +CI(`.github/workflows/dev-deploy.yml`)でも同テストを実行し、生成物を +`actions/upload-artifact`(名前 `ogp-sample`)としてアップロードして目視確認できる。 + +## 依存 + +- `github.com/fogleman/gg` — 2D描画。 +- `golang.org/x/image/draw` — 高品質リサイズ(CatmullRom)。 +- `golang.org/x/image/font/opentype` — フォント読込。 +- `github.com/HugoSmits86/nativewebp` — 純Goの可逆WebPエンコーダ(cgo不要)。 diff --git a/app/functions-go/internal/ogpimage/base.png b/app/functions-go/internal/ogpimage/base.png new file mode 100644 index 0000000..55a7ac8 Binary files /dev/null and b/app/functions-go/internal/ogpimage/base.png differ diff --git a/app/functions-go/internal/ogpimage/fonts/NotoSansJP-Regular.otf b/app/functions-go/internal/ogpimage/fonts/NotoSansJP-Regular.otf new file mode 100644 index 0000000..4af556a Binary files /dev/null and b/app/functions-go/internal/ogpimage/fonts/NotoSansJP-Regular.otf differ diff --git a/app/functions-go/internal/ogpimage/ogpimage.go b/app/functions-go/internal/ogpimage/ogpimage.go new file mode 100644 index 0000000..5f1bb87 --- /dev/null +++ b/app/functions-go/internal/ogpimage/ogpimage.go @@ -0,0 +1,335 @@ +// Package ogpimage はユーザーOGP画像(でばっぐのうりょくカード)を生成する。 +// +// Node版(app/functions/index.js の createOgp)は canvas + chartjs-node-canvas で +// ベース画像に「表示名・アイコン・ステータス・レーダーチャート」を合成していた。 +// 本パッケージはそれを外部プロセス依存なしのGoネイティブ実装として再現する。 +// +// 設計方針: +// - 座標はNode版のデザイン基準(2500x1313)をそのまま定数として保持し、 +// 実際の base 画像の幅から scale = baseW/2500 を求めて全座標を比例縮小する。 +// これによりベース画像の解像度を変えてもレイアウトを1箇所で追従できる。 +// - ベースPNG(2500x1313)とフォント(Noto Sans JP)はバイナリに go:embed で同梱する。 +// Node版のように実行時にGCSからbase.pngをダウンロードする往復が不要になり +// 高速化する。 +// - レンダリングは base の元解像度(2500x1313)で行い、合成後に「カード領域」を +// OG比(outputWidth:outputHeight)でクロップしてから最終出力サイズへ一度だけ +// 高品質縮小する。元画像には上下左右に暗い背景の余白が広く、そのまま縮小すると +// カード=文字が小さくなってしまうため、余白を除去して相対的に大きく見せる +// (元解像度でレンダリング→クロップ→縮小、の順にすることで再拡大による画質劣化を避ける)。 +// - 出力はWebP(可逆VP8L)。フラットなカード画像はPNGより大幅に小さくなる。 +package ogpimage + +import ( + "bytes" + _ "embed" + "fmt" + "image" + "image/color" + _ "image/jpeg" // アバターJPEGのデコード用 + "image/png" + "sync" + + "github.com/HugoSmits86/nativewebp" + "github.com/fogleman/gg" + xdraw "golang.org/x/image/draw" + "golang.org/x/image/font" + "golang.org/x/image/font/opentype" +) + +//go:embed base.png +var basePNGData []byte + +//go:embed fonts/NotoSansJP-Regular.otf +var notoSansJPData []byte + +// デザイン基準(Node版 index.js の座標系。base.png=2500x1313 を前提にしていた)。 +const ( + designWidth = 2500.0 + + nameX = 700.0 + nameY = 310.0 + nameMaxRight = 1280.0 // fillText の maxWidth 引数(userPos.max) + baseFontSize = 60.0 // fontStyle: 60px + lineHeight = 100.0 // fontStyle.lineHeight + + avatarX = 680.0 + avatarY = 431.0 + avatarSize = 215.0 + + // ステータス3行。左パネル(青)のステータス行の右端は実測で design x≈1292、左端は624。 + // せんとうりょく等が大きい値(例: 99999)でも右端をはみ出さないよう、開始位置を + // 左へ寄せ(680→640)、さらに上限幅ガードを設ける。 + // 「せんとうりょく:99999」=645px は縮小なしで収まり、6桁など上限を超える値のみ + // canvasのfillText(maxWidth)と同様に横方向へ縮小する。 + statsX = 640.0 + statsMaxWidth = 650.0 // 640 + 650 = 1290 ≦ パネル右端1292 + statsY = 740.0 + + // チャート(chartPost + chartWidth/Height=550)。中心はボックス中央。 + chartCenterX = 1325.0 + 550.0/2.0 // 1600 + chartCenterY = 300.0 + 550.0/2.0 // 575 + // レーダー半径とラベル距離はChart.jsの見た目に合わせて調整した値。 + // (Chart.jsはpointLabelsの領域を差し引いて描画半径を決めるため、 + // 550pxボックス内にラベルが収まるよう半径を抑えている) + chartRadius = 185.0 + chartLabelDist = 225.0 + labelFontSize = 25.0 + + gridLineWidth = 3.0 + dataStrokeW = 2.0 + // グリッド(同心リング)の分割数。Chart.js の stepSize=10 相当は15本だが、 + // 蜘蛛の巣状で見づらいため約半分の8本に減らしている。 + radarGridSteps = 8 + + // 最終出力サイズ(OGP標準)。クロップ後にこのサイズへ一度だけ縮小する。 + outputWidth = 1200 + outputHeight = 630 + + // カード(塗り=黒/青)の外接矩形。base.png(2500x1313)から実測した値 + // (black|blue 画素の bbox)。base.png を差し替えたら再計測して更新すること。 + cardMinX = 594.0 + cardMaxX = 1905.0 + cardMinY = 228.0 + cardMaxY = 1084.0 + // カードの丸角/枠を切り落とさないための余白(デザイン基準px)。 + cardMargin = 20.0 +) + +// Params は1枚のOGP画像を生成するための入力。 +type Params struct { + DisplayName string + Avatar image.Image // 円形クリップして描画。nilなら描画しない。 + Level int + Points int + Total int + // レーダー各軸の値(0..150)。 + HP int + Power int + Intelligence int + Defence int + Agility int +} + +var ( + baseOnce sync.Once + baseImg image.Image + baseErr error + fontOnce sync.Once + fontSFNT *opentype.Font + fontErr error + faceCache sync.Map // float64(size) -> font.Face +) + +func loadBase() (image.Image, error) { + baseOnce.Do(func() { + baseImg, baseErr = png.Decode(bytes.NewReader(basePNGData)) + }) + return baseImg, baseErr +} + +func loadFont() (*opentype.Font, error) { + fontOnce.Do(func() { + fontSFNT, fontErr = opentype.Parse(notoSansJPData) + }) + return fontSFNT, fontErr +} + +// newFace はサイズ(px)ごとに font.Face を生成してキャッシュする。DPI=72なので +// opentype.FaceOptions.Size(pt) はそのままpxとして扱える。 +func newFace(sizePx float64) (font.Face, error) { + if f, ok := faceCache.Load(sizePx); ok { + return f.(font.Face), nil + } + sfnt, err := loadFont() + if err != nil { + return nil, err + } + face, err := opentype.NewFace(sfnt, &opentype.FaceOptions{ + Size: sizePx, + DPI: 72, + Hinting: font.HintingFull, + }) + if err != nil { + return nil, err + } + faceCache.Store(sizePx, face) + return face, nil +} + +func rgb(r, g, b uint8) color.Color { return color.RGBA{R: r, G: g, B: b, A: 255} } + +// Render はカード画像を合成して image.Image を返す。 +func Render(p Params) (image.Image, error) { + base, err := loadBase() + if err != nil { + return nil, fmt.Errorf("load base: %w", err) + } + dc := gg.NewContextForImage(base) + scale := float64(dc.Width()) / designWidth + + nameFace, err := newFace(baseFontSize * scale) + if err != nil { + return nil, fmt.Errorf("name face: %w", err) + } + labelFace, err := newFace(labelFontSize * scale) + if err != nil { + return nil, fmt.Errorf("label face: %w", err) + } + + dc.SetColor(rgb(255, 255, 255)) + + // 表示名(canvasのmaxWidth相当: はみ出す場合は横方向に縮小) + drawTextTopScaled(dc, nameFace, p.DisplayName, nameX*scale, nameY*scale, (nameMaxRight-nameX)*scale) + + // アイコン(円形クリップ) + if p.Avatar != nil { + drawCircularAvatar(dc, p.Avatar, avatarX*scale, avatarY*scale, avatarSize*scale) + } + + // ステータス(maxWidthなし) + dc.SetColor(rgb(255, 255, 255)) + stats := []string{ + fmt.Sprintf("れべる:%d", p.Level), + fmt.Sprintf("ポイント:%d", p.Points), + fmt.Sprintf("せんとうりょく:%d", p.Total), + } + for i, line := range stats { + drawTextTopScaled(dc, nameFace, line, statsX*scale, (statsY+lineHeight*float64(i))*scale, statsMaxWidth*scale) + } + + // レーダーチャート + drawRadar(dc, radarParams{ + cx: chartCenterX * scale, + cy: chartCenterY * scale, + radius: chartRadius * scale, + labelDist: chartLabelDist * scale, + values: [5]float64{float64(p.HP), float64(p.Power), float64(p.Intelligence), float64(p.Defence), float64(p.Agility)}, + maxValue: 150, + gridSteps: radarGridSteps, + labels: [5]string{"たいりょく", "ちから", "かしこさ", "しゅびりょく", "すばやさ"}, + gridWidth: gridLineWidth * scale, + dataStroke: dataStrokeW * scale, + labelFace: labelFace, + }) + + return cropCardAndResize(dc.Image(), scale), nil +} + +// cropCardAndResize は合成済み画像(base元解像度)から、カード領域をOG比で切り出し、 +// 最終出力サイズ(outputWidth×outputHeight)へ一度だけ高品質縮小する。 +func cropCardAndResize(src image.Image, scale float64) image.Image { + // デザイン基準でのクロップ矩形(OG比を保ちつつカード全体を含む最小矩形)。 + boxL := cardMinX - cardMargin + boxR := cardMaxX + cardMargin + boxT := cardMinY - cardMargin + boxB := cardMaxY + cardMargin + boxW := boxR - boxL + boxH := boxB - boxT + cx := (boxL + boxR) / 2 + cy := (boxT + boxB) / 2 + + ratio := float64(outputWidth) / float64(outputHeight) + cropH := boxH + if w := boxW / ratio; w > cropH { + cropH = w + } + cropW := cropH * ratio + + // 実ピクセルへ換算し、中心を合わせつつ画像内へクランプ。 + sw := float64(src.Bounds().Dx()) + sh := float64(src.Bounds().Dy()) + cwp := cropW * scale + chp := cropH * scale + if cwp > sw { + cwp = sw + } + if chp > sh { + chp = sh + } + x0 := cx*scale - cwp/2 + y0 := cy*scale - chp/2 + if x0 < 0 { + x0 = 0 + } + if y0 < 0 { + y0 = 0 + } + if x0+cwp > sw { + x0 = sw - cwp + } + if y0+chp > sh { + y0 = sh - chp + } + rect := image.Rect(int(x0+0.5), int(y0+0.5), int(x0+cwp+0.5), int(y0+chp+0.5)).Add(src.Bounds().Min) + + sub := image.Image(src) + if si, ok := src.(interface { + SubImage(r image.Rectangle) image.Image + }); ok { + sub = si.SubImage(rect) + } + + out := image.NewRGBA(image.Rect(0, 0, outputWidth, outputHeight)) + xdraw.CatmullRom.Scale(out, out.Bounds(), sub, sub.Bounds(), xdraw.Over, nil) + return out +} + +// drawTextTopScaled は「上揃え(textBaseline=top)」でテキストを描く。maxWidth>0 かつ +// テキスト幅がそれを超える場合は、canvasのfillText(maxWidth)と同様に横方向へ縮小する。 +func drawTextTopScaled(dc *gg.Context, face font.Face, text string, x, y, maxWidth float64) { + dc.SetFontFace(face) + ascent := float64(face.Metrics().Ascent) / 64.0 + w, _ := dc.MeasureString(text) + sx := 1.0 + if maxWidth > 0 && w > maxWidth { + sx = maxWidth / w + } + dc.Push() + dc.Translate(x, y+ascent) + if sx != 1.0 { + dc.Scale(sx, 1) + } + dc.DrawString(text, 0, 0) + dc.Pop() +} + +// drawCircularAvatar はアバターを size×size に縮小し、半径 size/2*0.9 の円でクリップして描く +// (Node版 userIconCanvas の ri = width/2*0.9 と同じ)。 +func drawCircularAvatar(dc *gg.Context, avatar image.Image, x, y, size float64) { + iSize := int(size + 0.5) + scaled := image.NewRGBA(image.Rect(0, 0, iSize, iSize)) + xdraw.CatmullRom.Scale(scaled, scaled.Bounds(), avatar, avatar.Bounds(), xdraw.Over, nil) + + dc.Push() + dc.DrawCircle(x+size/2, y+size/2, size/2*0.9) + dc.Clip() + dc.DrawImage(scaled, int(x+0.5), int(y+0.5)) + dc.ResetClip() + dc.Pop() +} + +// EncodeWebP は Render 結果を可逆WebP(VP8L)としてエンコードする。 +func EncodeWebP(p Params) ([]byte, error) { + img, err := Render(p) + if err != nil { + return nil, err + } + var buf bytes.Buffer + if err := nativewebp.Encode(&buf, img, nil); err != nil { + return nil, fmt.Errorf("encode webp: %w", err) + } + return buf.Bytes(), nil +} + +// EncodePNG は Render 結果をPNGとしてエンコードする(主にQC/比較用)。 +func EncodePNG(p Params) ([]byte, error) { + img, err := Render(p) + if err != nil { + return nil, err + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, fmt.Errorf("encode png: %w", err) + } + return buf.Bytes(), nil +} diff --git a/app/functions-go/internal/ogpimage/ogpimage_test.go b/app/functions-go/internal/ogpimage/ogpimage_test.go new file mode 100644 index 0000000..abf04a5 --- /dev/null +++ b/app/functions-go/internal/ogpimage/ogpimage_test.go @@ -0,0 +1,108 @@ +package ogpimage + +import ( + "bytes" + "image" + "image/color" + "math" + "os" + "path/filepath" + "testing" +) + +// syntheticAvatar はテスト用の擬似アバター(放射状グラデーションの矩形)を作る。 +// 実アバターはGitHubから取得するため、ローカルQCでは合成画像で代用する。 +func syntheticAvatar(size int) image.Image { + img := image.NewRGBA(image.Rect(0, 0, size, size)) + cx, cy := float64(size)/2, float64(size)/2 + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + dx, dy := float64(x)-cx, float64(y)-cy + d := math.Hypot(dx, dy) / (float64(size) / 2) + r := uint8(80 + 120*d) + g := uint8(180 * (1 - d)) + b := uint8(200) + img.Set(x, y, color.RGBA{R: r, G: g, B: b, A: 255}) + } + } + return img +} + +func sampleParams() Params { + return Params{ + DisplayName: "でばっぐ 太郎", + Avatar: syntheticAvatar(460), + Level: 42, + Points: 12345, + Total: 99999, + HP: 120, + Power: 85, + Intelligence: 140, + Defence: 60, + Agility: 95, + } +} + +func TestRenderOutputDimensions(t *testing.T) { + img, err := Render(sampleParams()) + if err != nil { + t.Fatalf("Render: %v", err) + } + want := image.Rect(0, 0, outputWidth, outputHeight) + if img.Bounds() != want { + t.Fatalf("rendered bounds = %v, want %v (OG standard, cropped+resized)", img.Bounds(), want) + } +} + +func TestEncodeWebPMagic(t *testing.T) { + data, err := EncodeWebP(sampleParams()) + if err != nil { + t.Fatalf("EncodeWebP: %v", err) + } + if len(data) < 12 { + t.Fatalf("webp too short: %d bytes", len(data)) + } + // RIFF ヘッダ + "WEBP" フォーマットタグ。 + if !bytes.Equal(data[0:4], []byte("RIFF")) || !bytes.Equal(data[8:12], []byte("WEBP")) { + t.Fatalf("not a webp: header=% x", data[0:12]) + } +} + +func TestAxisAngleAndPoint(t *testing.T) { + // 軸0は真上(画面座標で中心の真上=yが小さい方)。 + x, y := axisPoint(100, 100, 50, axisAngle(0)) + if math.Abs(x-100) > 1e-6 || math.Abs(y-50) > 1e-6 { + t.Errorf("axis0 point = (%.3f,%.3f), want (100,50)", x, y) + } + // 5軸が72度刻みで一巡する。 + if got := axisAngle(5) - axisAngle(0); math.Abs(got-2*math.Pi) > 1e-9 { + t.Errorf("full turn = %.6f, want 2π", got) + } +} + +// TestWriteQCArtifacts は OGP_QC_OUT が設定されているときだけ、目視確認用の +// サンプル画像(PNG/WebP)を testdata/ に書き出す(CIのアーティファクト化にも使う)。 +func TestWriteQCArtifacts(t *testing.T) { + outDir := os.Getenv("OGP_QC_OUT") + if outDir == "" { + t.Skip("OGP_QC_OUT not set; skipping artifact generation") + } + if err := os.MkdirAll(outDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + pngData, err := EncodePNG(sampleParams()) + if err != nil { + t.Fatalf("EncodePNG: %v", err) + } + webpData, err := EncodeWebP(sampleParams()) + if err != nil { + t.Fatalf("EncodeWebP: %v", err) + } + if err := os.WriteFile(filepath.Join(outDir, "sample_ogp.png"), pngData, 0o644); err != nil { + t.Fatalf("write png: %v", err) + } + if err := os.WriteFile(filepath.Join(outDir, "sample_ogp.webp"), webpData, 0o644); err != nil { + t.Fatalf("write webp: %v", err) + } + t.Logf("QC artifacts: png=%d bytes, webp=%d bytes -> %s", len(pngData), len(webpData), outDir) +} diff --git a/app/functions-go/internal/ogpimage/radar.go b/app/functions-go/internal/ogpimage/radar.go new file mode 100644 index 0000000..7f923cd --- /dev/null +++ b/app/functions-go/internal/ogpimage/radar.go @@ -0,0 +1,116 @@ +// レーダーチャート描画。Node版(userOGP)が chartjs-node-canvas で描いていた +// 5軸レーダーチャートを、外部プロセス(Chart.js)に依存せずGoネイティブで再現する。 +// +// Chart.js の radar 設定(app/functions/index.js の chartconfig)に対応: +// - 5軸(たいりょく/ちから/かしこさ/しゅびりょく/すばやさ) +// - min=0, max=150, グリッドは stepSize=10 ごと +// - グリッド/軸線/ラベル色 rgb(242,242,242) +// - データ塗り rgba(0,168,228,0.6) / 枠線 rgb(0,117,159) +// - 頂点マーカー非表示 +// +// 座標系はピクセル(出力解像度)で受け取り、解像度非依存にしている +// (呼び出し側 ogpimage.Render が base 実寸から係数を掛けて渡す)。 +package ogpimage + +import ( + "math" + + "github.com/fogleman/gg" + "golang.org/x/image/font" +) + +// radarParams はレーダーチャート1枚を描くためのピクセル指定。 +type radarParams struct { + cx, cy float64 // 中心座標(px) + radius float64 // max(=150)に対応する半径(px) + labelDist float64 // 中心からラベル中心までの距離(px) + values [5]float64 // 各軸の値(0..maxValue) + maxValue float64 // 満点(=150) + gridSteps int // 同心グリッドの分割数(Chart.js: max/stepSize = 150/10 = 15) + labels [5]string + gridWidth float64 // グリッド/軸線の線幅(px) + dataStroke float64 // データ枠線の線幅(px) + labelFace font.Face +} + +// axisAngle は軸 i の角度(ラジアン)を返す。頂点は真上(12時)始まりで時計回り +// (Chart.js radar と同じ並び)。画面座標はyが下向きなので真上は -90度。 +func axisAngle(i int) float64 { + return -math.Pi/2 + float64(i)*(2*math.Pi/5) +} + +// axisPoint は中心(cx,cy)から角度theta・距離rの点を返す。 +func axisPoint(cx, cy, r, theta float64) (float64, float64) { + return cx + r*math.Cos(theta), cy + r*math.Sin(theta) +} + +// drawRadar は dc に対してレーダーチャートを描画する。 +func drawRadar(dc *gg.Context, p radarParams) { + // --- 同心グリッド(多角形リング) --- + dc.SetRGB255(242, 242, 242) + dc.SetLineWidth(p.gridWidth) + for step := 1; step <= p.gridSteps; step++ { + r := p.radius * float64(step) / float64(p.gridSteps) + for i := 0; i < 5; i++ { + x, y := axisPoint(p.cx, p.cy, r, axisAngle(i)) + if i == 0 { + dc.MoveTo(x, y) + } else { + dc.LineTo(x, y) + } + } + dc.ClosePath() + dc.Stroke() + } + + // --- 軸線(中心から各頂点) --- + for i := 0; i < 5; i++ { + x, y := axisPoint(p.cx, p.cy, p.radius, axisAngle(i)) + dc.MoveTo(p.cx, p.cy) + dc.LineTo(x, y) + dc.Stroke() + } + + // --- データ多角形 --- + for i := 0; i < 5; i++ { + v := p.values[i] + if v < 0 { + v = 0 + } + if v > p.maxValue { + v = p.maxValue + } + r := p.radius * v / p.maxValue + x, y := axisPoint(p.cx, p.cy, r, axisAngle(i)) + if i == 0 { + dc.MoveTo(x, y) + } else { + dc.LineTo(x, y) + } + } + dc.ClosePath() + dc.SetRGBA255(0, 168, 228, 153) // rgba(0,168,228,0.6) + dc.SetLineWidth(p.dataStroke) + dc.SetStrokeStyle(gg.NewSolidPattern(rgb(0, 117, 159))) + dc.FillPreserve() + dc.Stroke() + + // --- ラベル --- + if p.labelFace != nil { + dc.SetFontFace(p.labelFace) + dc.SetRGB255(242, 242, 242) + for i := 0; i < 5; i++ { + theta := axisAngle(i) + x, y := axisPoint(p.cx, p.cy, p.labelDist, theta) + // 左方向へ伸びる軸(すばやさ/しゅびりょく)のラベルはやや右寄せにして、 + // テキストが中心(レーダー)側に被らないよう外側(左)へ少しだけ逃がす。 + // 完全な右寄せ(1.0)だと左の青パネルに重なるため、控えめの0.7にする。 + // 右側/上下は従来どおり中央寄せ(右寄せにするとカード外へはみ出すため)。 + ax := 0.5 + if math.Cos(theta) < -0.3 { + ax = 0.7 + } + dc.DrawStringAnchored(p.labels[i], x, y, ax, 0.5) + } + } +} diff --git a/app/functions-go/internal/ogpimage/testdata/sample_ogp.png b/app/functions-go/internal/ogpimage/testdata/sample_ogp.png new file mode 100644 index 0000000..a044752 Binary files /dev/null and b/app/functions-go/internal/ogpimage/testdata/sample_ogp.png differ diff --git a/app/functions-go/internal/ogpimage/testdata/sample_ogp.webp b/app/functions-go/internal/ogpimage/testdata/sample_ogp.webp new file mode 100644 index 0000000..3ac68f5 Binary files /dev/null and b/app/functions-go/internal/ogpimage/testdata/sample_ogp.webp differ diff --git a/app/functions-go/ogp_rewrite.go b/app/functions-go/ogp_rewrite.go index b50e348..40b78a4 100644 --- a/app/functions-go/ogp_rewrite.go +++ b/app/functions-go/ogp_rewrite.go @@ -62,7 +62,8 @@ func runOgpRewrite(ctx context.Context, w http.ResponseWriter, username string) projectID := os.Getenv("OGP_PROJECT_ID") nowUnix := time.Now().Unix() - ogpURL := fmt.Sprintf("https://us-central1-%s.cloudfunctions.net/userOGP?user=%s&t=%d", projectID, username, nowUnix) + // og:image は Go版OGP生成関数(userOGPGo)を指す。userOGPGoはWebP(1200x630)を返す。 + ogpURL := fmt.Sprintf("https://us-central1-%s.cloudfunctions.net/userOGPGo?user=%s&t=%d", projectID, username, nowUnix) description := fmt.Sprintf("これが%sの でばっぐのうりょくだ!", username) title := fmt.Sprintf("%sの でばっぐのうりょく - でばっぐ神社", username) @@ -88,7 +89,9 @@ func runOgpRewrite(ctx context.Context, w http.ResponseWriter, username string) replacements := []struct{ from, to string }{ { fmt.Sprintf(``, baseURL), - fmt.Sprintf(``, ogpURL), + // og:image を差し替えると同時に、WebPであることと固定サイズ(1200x630)を + // クローラに明示する og:image:type / :width / :height を後続に注入する。 + fmt.Sprintf(``, ogpURL), }, { ``, diff --git a/app/functions-go/ogp_rewrite_test.go b/app/functions-go/ogp_rewrite_test.go index 53c7590..6089c79 100644 --- a/app/functions-go/ogp_rewrite_test.go +++ b/app/functions-go/ogp_rewrite_test.go @@ -51,17 +51,25 @@ func TestOgpRewrite_Success(t *testing.T) { if strings.Contains(body, `content="でばっぐ神社"`) { t.Errorf("default title text should have been replaced; body=%s", body) } - if !strings.Contains(body, "userOGP?user=octocat") { - t.Errorf("og:image should point to userOGP; body=%s", body) + if !strings.Contains(body, "userOGPGo?user=octocat") { + t.Errorf("og:image should point to userOGPGo; body=%s", body) } ogImageTag := extractMetaTag(body, "og:image") twitterImageTag := extractMetaTag(body, "twitter:image") - if ogImageTag == "" || !strings.Contains(ogImageTag, "userOGP?user=octocat") { + if ogImageTag == "" || !strings.Contains(ogImageTag, "userOGPGo?user=octocat") { t.Errorf("og:image tag not rewritten correctly: %q", ogImageTag) } - if twitterImageTag == "" || !strings.Contains(twitterImageTag, "userOGP?user=octocat") { + if twitterImageTag == "" || !strings.Contains(twitterImageTag, "userOGPGo?user=octocat") { t.Errorf("twitter:image tag not rewritten correctly: %q", twitterImageTag) } + // WebP出力に伴い og:image:type / :width / :height を注入していること。 + if !strings.Contains(body, `property="og:image:type" content="image/webp"`) { + t.Errorf("og:image:type=image/webp not injected; body=%s", body) + } + if !strings.Contains(body, `property="og:image:width" content="1200"`) || + !strings.Contains(body, `property="og:image:height" content="630"`) { + t.Errorf("og:image dimensions not injected; body=%s", body) + } if got := rec.Header().Get("Cache-Control"); got != "public, max-age=300, s-maxage=300" { t.Errorf("Cache-Control = %q, want public, max-age=300, s-maxage=300", got) } diff --git a/app/functions-go/userogp.go b/app/functions-go/userogp.go new file mode 100644 index 0000000..29f5141 --- /dev/null +++ b/app/functions-go/userogp.go @@ -0,0 +1,250 @@ +// ユーザーOGP画像生成(userOGP)エンドポイントのGo実装。 +// +// Node版(app/functions/index.js の exports.userOGP / createOgp)からの移植。 +// コールドスタート短縮とfunction起動課金削減のため Go/Cloud Run functions として +// デプロイする(関数名は userOGPGo)。ogpRewriteGo の og:image をこの関数に向ける。 +// +// Node版との差分(意図的な改善): +// - ベース画像とフォント(Noto Sans JP)をバイナリに同梱(internal/ogpimage)し、 +// 実行時のGCSからの base.png ダウンロードを廃止。 +// - レーダーチャートは chartjs-node-canvas(外部プロセス)ではなく +// Goネイティブ描画(internal/ogpimage)で再現。 +// - 表示名/アイコンは GitHub API ではなく Firestore の display_name / image_path を +// 使用し、GitHub APIへの往復(レート制限リスク)を排除。 +// - 出力を PNG から WebP(可逆VP8L)へ変更しファイルサイズを削減。 +// キャッシュオブジェクトは ogps/{user}.webp。 +package gofunctions + +import ( + "context" + "errors" + "fmt" + "image" + "io" + "log" + "net/http" + "net/url" + "os" + "time" + + "cloud.google.com/go/firestore" + "cloud.google.com/go/storage" + "github.com/GoogleCloudPlatform/functions-framework-go/functions" + + "github.com/428lab/debug-shrine/functions-go/internal/ogpimage" + "github.com/428lab/debug-shrine/functions-go/internal/performance" +) + +func init() { + functions.HTTP("UserOGPGo", userOGPHandler) +} + +var userOGPHTTPClient = &http.Client{Timeout: 10 * time.Second} + +func userOGPHandler(w http.ResponseWriter, r *http.Request) { + setCORSHeaders(w, r) + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Methods", "GET,OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization") + w.WriteHeader(http.StatusNoContent) + return + } + + username := r.URL.Query().Get("user") + if username == "" { + // Node版と同じく user 未指定は404。 + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("user not found.")) + return + } + + ctx := r.Context() + bucketName := os.Getenv("STORAGE_BUCKET_NAME") + if bucketName == "" { + log.Printf("userOGP: STORAGE_BUCKET_NAME is not set") + writeError(w, http.StatusInternalServerError, "internal error") + return + } + + storageCli, err := getStorageClient(ctx) + if err != nil { + log.Printf("userOGP: getStorageClient error: %v", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + bucket := storageCli.Bucket(bucketName) + objectPath := fmt.Sprintf("ogps/%s.webp", username) + + // 既存キャッシュがあればそのURLへ。無ければ生成してからURLを得る。 + exists, err := objectExists(ctx, bucket, objectPath) + if err != nil { + log.Printf("userOGP: objectExists error: %v", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + + if !exists { + status, ok, err := generateAndUploadOGP(ctx, bucket, objectPath, username) + if err != nil { + log.Printf("userOGP: generate error: %v", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + if !ok { + // 未登録ユーザー。 + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("user not found.")) + return + } + _ = status + } + + imageURL := ogpImageURL(bucketName, username) + if os.Getenv("FUNCTIONS_EMULATOR") != "" { + // エミュレーター上はリダイレクトせずURL文字列を返す(Node版と同じ挙動)。 + _, _ = io.WriteString(w, imageURL) + return + } + http.Redirect(w, r, imageURL, http.StatusFound) +} + +func objectExists(ctx context.Context, bucket *storage.BucketHandle, name string) (bool, error) { + _, err := bucket.Object(name).Attrs(ctx) + if errors.Is(err, storage.ErrObjectNotExist) { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +// generateAndUploadOGP はユーザーのOGP画像を生成しGCSへアップロードする。 +// 未登録ユーザーの場合は ok=false を返す。 +func generateAndUploadOGP(ctx context.Context, bucket *storage.BucketHandle, objectPath, screenName string) (performance.FormattedPerformance, bool, error) { + client, err := getFirestoreClient(ctx) + if err != nil { + return performance.FormattedPerformance{}, false, err + } + userDoc, err := findUserByScreenName(ctx, client, screenName) + if err != nil { + return performance.FormattedPerformance{}, false, err + } + if userDoc == nil { + return performance.FormattedPerformance{}, false, nil + } + + var userData userDocument + if err := userDoc.DataTo(&userData); err != nil { + return performance.FormattedPerformance{}, false, err + } + + formatted, err := resolveUserStatus(ctx, userDoc, userData, screenName) + if err != nil { + return performance.FormattedPerformance{}, false, err + } + + avatar, err := fetchAvatar(ctx, userData.ImagePath) + if err != nil { + return performance.FormattedPerformance{}, false, fmt.Errorf("fetch avatar: %w", err) + } + + displayName := userData.DisplayName + if displayName == "" { + displayName = screenName + } + + webp, err := ogpimage.EncodeWebP(ogpimage.Params{ + DisplayName: displayName, + Avatar: avatar, + Level: formatted.Level, + Points: formatted.Points, + Total: formatted.Total, + HP: formatted.HP, + Power: formatted.Power, + Intelligence: formatted.Intelligence, + Defence: formatted.Defence, + Agility: formatted.Agility, + }) + if err != nil { + return performance.FormattedPerformance{}, false, err + } + + if err := uploadObject(ctx, bucket, objectPath, "image/webp", webp); err != nil { + return performance.FormattedPerformance{}, false, err + } + return formatted, true, nil +} + +// resolveUserStatus は status キャッシュを version 判定つきで解決する。 +// 現行バージョンのキャッシュがあれば再利用し、無い/旧版なら再計算して書き戻す +// (status エンドポイントおよびNode版 userOGP と同一のキャッシュ運用)。 +func resolveUserStatus(ctx context.Context, userDoc *firestore.DocumentSnapshot, userData userDocument, screenName string) (performance.FormattedPerformance, error) { + if statusCacheIsCurrent(userData.Status, userData.StatusVersion) { + return fromFirestoreStatus(*userData.Status).FormattedPerformance, nil + } + + activities, err := loadActivities(ctx, userDoc.Ref) + if err != nil { + return performance.FormattedPerformance{}, err + } + raw := performance.UserPerformance(activities, screenName) + formatted := performance.UserFormattedPerformance(raw, performance.AppendData{ + Exp: int(userData.Exp), + User: performance.UserInfo{ + DisplayName: userData.DisplayName, + ScreenName: userData.ScreenName, + GithubImagePath: userData.ImagePath, + }, + }) + lastSanpai := formatLastSanpai(userData.LastSanpai) + if _, err := userDoc.Ref.Update(ctx, []firestore.Update{ + {Path: "status", Value: toFirestoreStatus(formatted, lastSanpai)}, + {Path: "status_version", Value: performance.StatusLogicVersion}, + }); err != nil { + return performance.FormattedPerformance{}, err + } + return formatted, nil +} + +func fetchAvatar(ctx context.Context, avatarURL string) (image.Image, error) { + if avatarURL == "" { + return nil, errors.New("empty avatar url") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, avatarURL, nil) + if err != nil { + return nil, err + } + resp, err := userOGPHTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("avatar fetch status %d", resp.StatusCode) + } + img, _, err := image.Decode(resp.Body) + if err != nil { + return nil, err + } + return img, nil +} + +func uploadObject(ctx context.Context, bucket *storage.BucketHandle, name, contentType string, data []byte) error { + wc := bucket.Object(name).NewWriter(ctx) + wc.ContentType = contentType + if _, err := wc.Write(data); err != nil { + _ = wc.Close() + return err + } + return wc.Close() +} + +// ogpImageURL は Firebase Storage のダウンロードURLを返す(Node版 getOgpUrl 相当、拡張子はwebp)。 +func ogpImageURL(bucketName, username string) string { + objectPath := "ogps%2F" + url.QueryEscape(username) + ".webp" + if host := os.Getenv("FIREBASE_STORAGE_EMULATOR_HOST"); host != "" && os.Getenv("FUNCTIONS_EMULATOR") != "" { + return fmt.Sprintf("http://%s/download/storage/v1/b/%s/o/%s?alt=media", host, bucketName, objectPath) + } + return fmt.Sprintf("https://firebasestorage.googleapis.com/v0/b/%s/o/%s?alt=media", bucketName, objectPath) +} diff --git a/docs/backend.md b/docs/backend.md index 8ce9a49..901fc87 100644 --- a/docs/backend.md +++ b/docs/backend.md @@ -68,7 +68,8 @@ Issue 活動のあるユーザーは intelligence/defence/total(戦闘力・ラ Go は `performance.StatusLogicVersion`、Node は `performance.js` の `STATUS_LOGIC_VERSION` (両者は必ず一致させる)。**計算式を変えたら必ずインクリメントする。** 現在は `1`(`IssuesEvent` 修正を含むロジック)。 -- `status` を書き込む処理(`status`/`sanpai`/`statusCacheBackfill`、および Node の `userOGP`)は +- `status` を書き込む処理(`status`/`sanpai`/`statusCacheBackfill`/`userOGP`。 + Go版は `statusGo`/`sanpaiGo`/`statusCacheBackfillGo`/`userOGPGo`)は 同時にユーザードキュメントのトップレベル `status_version` に現行バージョンを刻む。 `status` オブジェクト自体には含めない(API レスポンス形状は不変)。 - キャッシュを使うか再計算するかの判定は全経路で共通ヘルパで行う @@ -243,8 +244,10 @@ OGP/Twitterカード用のメタタグを書き換える。 分岐で `null.length` を参照して例外化する(意図せず到達しないコード)。 Firebase Hostingの `/u/*` リライト経由でのみ呼ばれる前提のため実運用では 到達しないが、Go版もこの場合は正常系として扱わず internal error(500)を返す。 -- `userOGP` で生成するOGP画像そのもの(Canvas/Chart.jsによる画像合成)は - 今回のGo移植の対象外(下記「Go移植を見送った機能」参照)。 +- `og:image`/`twitter:image` は Go版のOGP画像生成関数 `userOGPGo` を指す + (下記「`userOGP` エンドポイントのGo移植」参照)。`userOGPGo` は WebP(1200×630)を + 返すため、`og:image` の直後に `og:image:type=image/webp` と + `og:image:width=1200`/`og:image:height=630` のメタタグを注入している。 ## スケジュール関数のGo移植 @@ -287,22 +290,48 @@ OGP/Twitterカード用のメタタグを書き換える。 - デプロイ方法(Pub/Subトリガー・Cloud Scheduler設定)は `app/functions-go/README.md` を参照。 -## Go移植を見送った機能 - -- **`userOGP`(OGP画像生成)**: `canvas`/`chartjs-node-canvas` によるサーバーサイド - 画像合成(ユーザーアイコンの切り抜き・レーダーチャート描画・PNG合成)を行っており、 - Node.jsの画像処理ライブラリに強く依存している。Goで同等の結果を得るには - 画像合成・チャート描画を別のライブラリで一から実装し直す必要があり、 - 他のエンドポイントとは技術的な難易度・作業量が大きく異なる。加えて、 - Node版はCanvas(Cairo)とChart.jsが担う画像のレンダリング(アンチエイリアス・ - フォント描画・チャート描画アルゴリズム)に依存しており、Go側で別の描画 - ライブラリを使う場合、ピクセル単位で完全に同一の画像を出力することは - 現実的に不可能(見た目が近い画像は作れても「Node版と挙動が同一」とは - 言えない)。これは本移植プロジェクト全体の設計原則(挙動をNode版と - 同一にすることを優先する)と相性が悪く、他のエンドポイントより - 慎重な検討が必要と判断し、今回は見送った。また `userOGP` は生成結果を - Storageにキャッシュし、ブラウザがOGP画像を非同期に取得する経路 - (SNSクローラー向け)であり、`ogpRewrite`のようにページの初期表示を - ブロックしないため、レイテンシー改善の優先度も他のエンドポイントより低い。 - 必要になった場合に改めて設計を提案する。 +## `userOGP` エンドポイントのGo移植 (`userOGPGo`) + +OGP画像生成(`app/functions-go/userogp.go` + `internal/ogpimage`)を Go へ移植した +(`userOGPGo`)。Node版(`exports.userOGP` / `createOgp`)は `canvas` + `chartjs-node-canvas` +でベース画像にカードを合成していたが、Goネイティブ実装で再現している。画像描画ロジックは +再利用・単体テストしやすいよう `internal/ogpimage` パッケージに分離している +(パッケージ設計は `app/functions-go/internal/ogpimage/README.md` を参照)。 + +処理の流れは Node版と同じく「GCSに `ogps/{user}` のキャッシュがあればそのURLへ +リダイレクト、無ければ生成してアップロード後にリダイレクト」。生成時は +`screen_name` で Firestore を検索し、`status`(version判定つき)を解決してから +カードを合成する。 + +Node版との差分(意図的な改善): + +- **アセット同梱**: ベース画像(`base.png`, 2500×1313)と Noto Sans JP フォントを + `go:embed` でバイナリに同梱。Node版のように実行時にGCSから `base.png` を + ダウンロードする往復が不要になり高速化する。 +- **レーダーチャート**: `chartjs-node-canvas`(外部プロセス)ではなく Goネイティブ + 描画で再現(Chart.jsの radar 設定=5軸・min0/max150・stepSize10・色指定に対応)。 +- **表示名/アイコンの取得元**: Node版は GitHub API(`get_user`)から `name`/`avatar_url` + を取得していたが、Go版は Firestore の `display_name` / `image_path` を使用し、 + GitHub API への往復(レート制限リスク)を排除した。 +- **余白クロップ**: 元の `base.png` は上下左右に暗い背景の余白が広く、そのまま + OGPにするとカード=文字が小さく読みづらい。そこで **元解像度(2500×1313)で + レンダリング → カード領域(塗り実測bbox+小マージン)を OG比(1200:630)でクロップ + → 最後に一度だけ 1200×630 へ高品質縮小(CatmullRom)** する。縮小前にクロップ + することで再拡大による画質劣化を避けつつ、余白除去でカード/文字が相対的に + 大きくなる。カードの外接矩形は `base.png` から実測した定数で、差し替え時は + 再計測が必要(`internal/ogpimage/ogpimage.go` に注記)。 +- **出力形式**: PNG → **WebP(可逆VP8L, 純Go `nativewebp`)** に変更しファイルサイズを + 削減。キャッシュオブジェクトは `ogps/{user}.webp`(Content-Type: `image/webp`)。 + なお X(Twitter) は WebP の `og:image` でカードを生成しない場合があるため、 + X対応を優先する場合はエンコーダを差し替えてPNG出力に戻せる(変更は1箇所)。 +- キャッシュ運用(`status_version` による自己修復)は `status`/`statusGo` と同一。 +- デプロイ設定(メモリ512Mi・タイムアウト60s・`STORAGE_BUCKET_NAME`)は + `app/functions-go/README.md` / `.github/workflows/dev-deploy.yml` を参照。 + +### OGP画像のQC + +`internal/ogpimage` の `TestWriteQCArtifacts` は環境変数 `OGP_QC_OUT` が指すディレクトリに +サンプルOGP(PNG/WebP)を書き出す。CI(`dev-deploy`)ではこのテストを実行して生成物を +`actions/upload-artifact`(名前 `ogp-sample`)でアーティファクト化し、描画の破綻を +デプロイ前に目視確認できるようにしている。