プリクラ

https://github.com/kyiku/hackz-megalo-back

Next.js

TypeScript

AWS

DynamoDB

TailwindCSS

サーマルプリンターで遊んだやつです

Cookie777

紅茶ふりふり

推しアイデア

サーマルプリンターを使ったプリクラ

作った背景

サーマルプリンターで遊んでみたかった

推し技術

WebRTC P2P AWS ClayCode

プロジェクト詳細

はじめに

サーマルプリンターでプリクラ作ってみました。 AWS 19サービス使っていたり、プリクラだけに盛り盛りです。

プロダクト概要

PCがプリクラの大画面ディスプレイ、スマホがリモコン兼カメラという役割分担で、スマホを操作するとPC側の画面がリアルタイムで連動する。 image image image image image

技術スタック

フロントエンド

Next.js 16 / React 19 / TypeScript / Tailwind CSS v4 / Zustand v5 / WebRTC / MediaPipe / Web Audio API / WebUSB

バックエンド

AWS Lambda x19(Node.js 20, ARM64)/ AWS CDK v2 / Step Functions Express / sharp

AWSサービス(19個)

S3, DynamoDB, API Gateway (REST+WebSocket), Lambda, Step Functions, EventBridge, IoT Core, AppSync, CloudFront, CloudWatch, WAF, Amplify, Bedrock (Claude 3 Haiku), Bedrock (Stability AI), Rekognition, Comprehend, Polly, Transcribe, X-Ray

image

頑張ったこと

  • 今回初めてWebRTCに触れた。このストリーミングの制御に本当に時間がかかった。
  • QRコードで妥協せずClayCodeを取り入れているので、無駄に実装に時間がかかった。
  • AWSのサービスを19個使っていて、かつLambdaも26個ある。正直写真を撮って印刷するだけならローカルで完結できるのだが、それじゃ面白くないのでかなり盛った。

嬉しかったこと

  • AWSのサービスが結構多くなっていて、初めて使うリソースばっかりだったので、本当に難しかったけど動いた時は本当に嬉しかった。

  • 初めて2泊ともチームメンバーが全員寝れて、かなりコンディションがいい。

  • 今回のためにサーマルプリンターを買ったので、無駄にならなくてよかった。(約¥10000)

  • 今回が一番満足いくクオリティになったと思う!!!!!

ここから技術的な話です

ブラウザからプリンタを直接叩いた(WebUSB + ESC/POS)

WebUSB APIでMUNBYNのサーマルプリンタに直接バイナリコマンドを送り込んだ。ドライバも印刷ダイアログも一切なし

USBデバイスを開いた後にどのエンドポイントにデータを送るかも動的に探すようにしてて、USB構成→インターフェース→代替設定→エンドポイント一覧からdirection === 'out'のものを取ってくる。MUNBYN の機種が違っても動くはず。

ディザリングで写真をレシートに焼く

サーマルプリンタは白と黒しか出せない。ここでFloyd-Steinbergディザリングを実装した。

分配比率は右に7/16、左下に3/16、真下に5/16、右下に1/16。

このディザリングをフロント(WebUSBで直接印刷するとき)とバックエンド(Lambda上でprint-readyデータを作るとき)の両方に実装した。

印刷が真っ黒問題

ディスプレイで綺麗に見える写真がサーマルプリンタだと真っ黒になる。

RGB→グレースケール変換にはBT.601の輝度係数(R:0.299, G:0.587, B:0.114)を使い、明るさ+40とコントラストx1.3の補正をかけた。この数値は何十枚も印刷して決めた。レシートロール1本使い切った。最初は明るさ+20にしてたけどまだ暗くて、40まで上げてやっとちょうどよくなった。完全にアナログな調整だった。

あと画像データを一気にプリンタに送るとUSBコントローラのバッファが溢れて印刷が途中で止まったり化けたりする。576x768ピクセルのラスター画像だと約55KBになるので、これを4KBずつチャンク分割してawaitで送るようにした。awaitが重要で、同期的にループするとプリンタ側が消化しきれない。4KBという値も試行錯誤で決めた。2KBだと遅い、8KBだとたまにコケる。

WebRTC — スマホの映像をPC大画面にリアルタイムで映す

今回はPCをモニターとして、スマホをカメラにしたかったので、これをWebRTCのP2P映像ストリーミングで再現した。

シグナリング(SDP/ICE候補の交換)はAWS API Gateway WebSocket経由。スマホがjoin_room後500msの遅延を入れてからSDP Offerを送り、PC側がAnswerを返す。500ms待つのはPC側のWebSocket接続が安定するのを待つためで、即座に送るとPC側がまだ受け取れない状態のことがあった。

一番ハマったのがICE候補の到着順序問題。ローカルでは動くのにデプロイしたら動かなくなって、原因はICE候補がSDP Answerより先に届くケースだった。setRemoteDescriptionする前にaddIceCandidate呼ぶとInvalidStateErrorになる。ローカルだとSDP交換が一瞬で終わるから発生しないが、WebSocket経由のレイテンシが入ると確率が上がる。ICE候補をキューに溜めてお木、SDP設定後にフラッシュする仕組みを入れて解決した。

会場のネットワーク環境が読めないので、TURNサーバーもUDP/TCP/TURNSの4パターン用意した。ファイアウォールがUDP塞いでてもTCPで、443以外塞がれててもTURNSの443で突破できる。デモで「繋がりません…」が一番やばいので、ここは冗長にした。

スマホ↔PCのリアルタイム同期

WebRTCは映像の話だけど、スマホの操作とPC画面の連動はWebSocketで同期している。

WebSocket接続のZustandストアで一番気を使ったのが再接続ループの防止。closeイベントが来たら2秒後に再接続するため、既存接続を閉じるときset({ ws: null })oldWs.close()より先にやらないと、closeイベントのハンドラが「接続切れた!再接続!」って無限ループになる。先にnullにしておくことで、closeハンドラのif (current.ws !== ws) returnガードに引っかかって無視される。

あとnew WebSocket()した直後はまだCONNECTING状態なので、openイベントで初めてstoreにセットするようにした。これやらないとuseWebRtcのuseEffectがOPEN状態じゃないWebSocketで発火してしまう。

写真のプレビュー転送ではAPI Gateway WebSocketの32KBフレーム制限にぶつかった。写真はBase64で数百KBになるのでそのまま送れない。150pxにリサイズ+JPEG品質0.6に落として、さらに4枚一括じゃなく1枚ずつphotoIndex付きで送るようにした。PC側ではphotoIndexを見てスパースな配列を組み立てるため、順不同で届いても正しい位置に入る。

ニコニコ動画風AIやじコメント

撮影中にPC画面に流れるニコニコ動画みたいなコメント。全部AIがリアルタイムで生成。

3秒おきのフレーム自動アップロード

まず撮影中にスマホのカメラフレームを3秒おきにキャプチャしてS3にアップロード。320pxの低解像度+JPEG品質50%にしたのは、Rekognitionの顔検出にはこれで十分なのと帯域を食いすぎないようにするため。アップロード先はyaji-frames/{sessionId}/{timestamp}.jpgで、S3のライフサイクルルールで1日後に自動削除される。

2レーン並列

S3にフレームがアップされるとEventBridgeが即座にキックして、2つのレーンが並列で動く。

Fastレーン(~0.5秒): Rekognitionで顔の感情(HAPPY, SAD, ANGRY, SURPRISED...)を検出して、感情に対応するテンプレートからランダムにコメントを選ぶ。「いい笑顔www」「怒ってる?w」「びっくり!」みたいな。EventBridgeルールがyaji-frames/*のS3 PutObjectイベントを直接Lambdaに繋いでるから、アップロードから0.5秒くらいでコメントが出る。

面白いのが、EventBridgeからの入力形式と直接呼び出しの入力形式が違うこと。EventBridgeだとsource: 'aws.s3'+detail.object.key形式、直接だと{ sessionId, images }形式なので、ランタイムで判別するガードを入れた。

Deepレーン(~1-2秒): 同時にBedrock Claude 3 Haikuにフレーム画像を投げて、マルチモーダルで状況を理解したコメントを生成する。「ニコニコ動画風のやじコメントを1つだけ書いてください。短く面白く、ネットスラング可。」ってプロンプトを投げると、「ピースしてるw」「背景めっちゃいい感じ」のように文脈を理解したコメントが返ってくる。max_tokens: 50で短めに。DeepレーンはInvocationType: 'Event'の非同期呼び出しなのでAPI応答をブロックしない。

Fastが先に反応してDeepが少し遅れて面白いコメントを流す。この時間差でニコニコっぽいにぎやかさを出した。

WebSocketブロードキャスト

やじコメントのWebSocket配信では、DynamoDB GSI(sessionId-index)でセッションに紐づく全接続を取得してPromise.allで並列送信。切れた接続に送るとGoneExceptionが返るので、それをキャッチして接続レコードを自動削除するようにした。これやらないとゾンビ接続にいつまでも送り続けることになる。

Step Functions — 画像処理パイプライン

写真がS3にアップされた後、バックエンドではStep Functions Expressで5フェーズのパイプラインが走る。

Phase 1で顔検出とフィルター適用を並列実行して、Phase 2でコラージュ生成(顔の位置でスマートクロップ)、Phase 3でBedrockキャプション生成+Comprehend感情分析、Phase 4でディザリング+ClayCode QR合成、Phase 5でIoT Core MQTTに印刷ジョブ配信。

Step Functions ExpressはStandardより安いから選んだ。Standardだと1回$0.025かかるけどExpressは実行時間課金。パイプライン全体で15秒くらいなので5分タイムアウトに余裕で収まる。

CDKで書くときのポイントは、Parallelの出力が配列[$[0], $[1]]になること。Phase 1の顔検出結果$[0].facesとフィルター結果$[1].filteredImagesをPass stateのparametersで1つのオブジェクトに統合した。'faces.$': '$[0].faces'.$サフィックスがJSONPath評価の指定。

顔検出はオプショナルにしてて、Catchで{ faces: [] }を返すフォールバックを設定。Rekognitionが落ちても中央クロップで続行する。全体もParallelで包んでグローバルCatchを仕掛けてるので、途中のどのLambdaが例外投げてもpipeline-errorがセッションをfailedに更新してWebSocketでエラー通知する。

Stability AIスタイル転写

AIフィルター(アニメ、ポップアート、水彩画)はBedrock上のStability AI Style Transferを使った。フィルターごとにstyle_strengthcomposition_fidelitychange_strengthを微調整した。ポップアートはchange_strength: 0.90で派手に、水彩画はcomposition_fidelity: 0.90で元の雰囲気を残す方向に。

4枚並列で処理したいけどStability AIのスロットリングがあるのでpLimit(2)で同時実行数を2に制限した。素朴にPromise.allするとAPIに怒られる。あとStability AIがus-west-2でしか動かないのもハマった。東京リージョンのLambdaからクロスリージョンで呼んでる。

ClayCode

QRコードの代わりに、ClayCodeというOSSのビジュアルコードシステムを採用した。角丸の入れ子図形でデータを表現するので、レシートの白黒印刷との相性がいい。

image

こんな感じの2次元コードが発行される。このClayCodeという技術は好きな画像をコード内に入れることができるので、今回はハックちゅうのシルエットを入れて、その周りにコードを生成するようにしている。

[https://claycode.io/pages/scene_claycode.html](URL Here)

UR貼っておくので気になる方はぜひ触ってみてください。

MediaPipe ARフィルター — リアルタイム処理の工夫

撮影中にPC側のカメラ映像に犬耳やキラキラのARエフェクトをリアルタイムで重ねた。MediaPipeのFaceLandmarkerで468点の顔メッシュを取って、Canvas 2Dで描画。前回のハッカソンでもMediaPipe使ってて(瞬きでモールス信号のやつ)、あのときはEAR(目のアスペクト比)だけだったけど今回はFaceLandmarker丸ごと。

delegate: 'GPU'でWebGLアクセラレーションを使うとGPUありで10-15fps、CPUフォールバックだと3-5fpsくらい。CDNのWASMバージョンをnpmパッケージと同じ@0.10.32にピン留めしてて、ズレるとインターフェースが合わなくてクラッシュする。

描画ループの設計

ここが一番気を使った。requestAnimationFrameで描画ループを回していて、selectedEffectをuseEffectの依存配列に入れてない。代わりにselectedEffectRef.currentで読んでる。エフェクト切り替えのたびにuseEffectが再実行されると描画ループが作り直されてちらつくし、MediaPipeの再初期化が走る可能性がある。refで読めばループは走りっぱなしでエフェクトだけ切り替わる。

Canvas sizeの代入(canvas.width = vw)は内部バッファの再確保が走るので重い。canvasSizeRefでキャッシュしてサイズ変更時だけ代入するようにした。

performance.now()がまれに前回以下の値を返す問題もあって(ブラウザのセキュリティ制限で精度が下がってる場合)、MediaPipeのdetectForVideo()がタイムスタンプの逆行でエラーを吐く。lastTimestampRef + 1で強制的に単調増加させるガードを入れた。

エフェクトの配置は顔のランドマーク番号(額#10、左目#159、右目#386等)を使って、目の間の距離を基準にサイズをスケール。カメラからの距離に関わらず顔に追従する。キラキラエフェクトはMath.sin(Date.now() / 500)でゆらぎアニメーション。

スマホでエフェクトを選んだらWebSocket経由でar_syncイベントを送ってPC側にも即座に反映される。レイテンシは50-100ms。

AWS 19サービスの全容

レシート1枚にAWS 19サービス

CDK 8 Constructに分割

CDKのインフラコードを8つのConstruct(Storage, Api, Pipeline, Realtime, AppSyncApi, Cdn, Monitoring, Waf)に分けた。メインスタックのapp-stack.tsはIAM権限設定とConstruct間の配線だけで370行。27個のLambda関数それぞれに必要最小限のIAMポリシーを個別に設定してて、session-createにはDynamoDB読み書き+S3、filter-applyにはS3+Bedrock+WebSocket、yaji-comment-fastにはS3+Rekognition+WebSocket、みたいに細かく分けた。

各サービスがどう繋がってるか

S3: 画像ストレージ。Transfer Acceleration有効。6段階のライフサイクルルール(やじフレーム1日、中間データ7日、最終出力30日)で自動クリーンアップ。EventBridge統合でyaji-frames/*のPutObjectイベントを直接Lambdaに接続。

DynamoDB: セッションテーブル(PK: sessionId, SK: createdAt)と接続テーブル(PK: connectionId)の2つ。セッションテーブルにはdownloadCode-indexのGSIがあって、5桁コードからセッションを逆引きできる。コード生成時にGSIで衝突チェックして最大10回リトライ。TTL 30日で自動削除。Streamsでstats-updateをトリガー。

IoT Core: パイプライン完了時にMQTTで印刷ジョブをreceipt-purikura/print/{sessionId}トピックにpublish。Data-ATSエンドポイントはアカウント固有なので、CDKのAwsCustomResourceでデプロイ時にdescribeEndpoint APIを呼んで動的に取得して環境変数に注入した。

AppSync: DynamoDB Streams→stats-update Lambda→AppSync mutation→Subscription でリアルタイム統計ダッシュボード。セッションの状態が変わるたびに統計が更新される。

Cdn/WAF/Monitoring: 本番環境のみisProdフラグで有効化。dev環境のコストを抑えつつ、本番ではWAFのレート制限とCloudFrontキャッシュとSyntheticsの外形監視が入る。

音は全部プログラムで生成(Web Audio API)

BGM、カウントダウン音、シャッター音、ファンファーレ、全部音声ファイル0個でWeb Audio APIのオシレータから合成。

BGMはC4→E4→G4→E4のアルペジオをトライアングル波でループ。最後の音のonendedで再帰的に次ループを開始してて、setIntervalを使わないからタイミングのズレが蓄積しない。bgmGenerationカウンターでstop後の残留コールバックを弾いてる。

シャッター音はホワイトノイズにリニアフェードアウトをかけて「カシャッ」を再現。150msのランダムサンプル×減衰カーブで、バッファはキャッシュして使い回す。カウントダウンは3,2が660Hz、最後の1だけ880Hzで「ピッ、ピッ、ピーッ」のプリクラ感を出した。

exponentialRampToValueAtTimeで音量を指数的に減衰させてて、0にすると対数が-∞でエラーなので0.001まで下げてから止める。AudioContext.currentTimeベースのスケジューリングでsetTimeoutより正確にタイミング合わせられるのがWeb Audio APIの強み。

Web AudioのAutoplay Policyにもハマった。ユーザーインタラクション前にAudioContext作るとsuspendedになるので、毎回状態チェックしてresume()するようにした。

こだわり — ルームID生成

ルームIDの文字セットから0,O,I,1を除外した。crypto.getRandomValues()を使いつつ、256が文字数31で割り切れないことによるモジュロバイアスをlimit = floor(256/31)*31 = 248で切り捨てて回避した。

コスト

1セッション(4枚撮影→AI処理→印刷)で約36円。AIフィルターなしなら8円以下。

おわりに

各レイヤーにフォールバック仕込んでるので、Rekognitionが落ちても、Stability AIが遅くても、WebRTCが繋がらなくても、なんとか動き続ける。

Cookie777

@Cookie777