ウォーターマークをつけるWebアプリ(改良版) の元になった「透かし入れアップローダ」を少し改良したので、紹介。
今回は…
-
AVIF,JPEG XLの読み込み
-
サーバ保存時にEXIF撮影日時を参照・反映
-
EXIFの埋め込み可・不可
AVIF,JPEG XLの読み込み
オリジナルファイルが1200px以上の場合はリサイズ処理が入りますので、オリジナルは劣化が少ない方がいいかと思いJPEG XLの読み込みを実装したのですが…Chromeだとネイティブで読み取れず、動作がめちゃ重いという…。
仕方ないのでAVIFも実装したのですが、これ両方とも使うことあるのかな?どうせだったらHEIF対応にすればよかったかも…と思いましたが、「使うことあるのかな?」が先に浮かんでしまったので未実装w
サーバ保存時にEXIF撮影日時を参照
/YYYY ディレクトリに「mmdd-元ファイル名」として保存。
これ前はEXIF読み込みまでやっておらず、アップロード日を元にやっていました。
EXIFの埋め込み可・不可
上記仕様でEXIF参照が必要なため、アップロードするファイルからEXIFを除去するか残すか選べます。
ただし、仕様が面倒なのでJPEGのみとなっていて、通常PNGのローカル保存の場合はEXIF残すとJPEGに変更となります。

スクリーンショット


仕様書
ほぼAIまかせで書いたので、コードの公開はしませんが、AIに読み込ませれば似たような感じに作ってくれるかと…。
もっと見る…
# photo_uploader 仕様書(LLM生成用)
> これは既存の `photo_album.php` を**「photo_uploader」**として1から再実装できるようにまとめた仕様です。
> 目的:画像(JPEG/PNG/HEIC/AVIF/JPEG XL 等)をブラウザで開き、**透かし文字**を重ねて、**ダウンロード or サーバ保存**できる簡易ツール。
> 実装対象:**単一PHPファイル**(UI+保存API)+フロントJS。外部ライブラリはCDN読み込み。
---
## 1. 想定環境・例示パス
- サーバ:Apache + PHP 7.4+(8.x推奨)。GDは**不要**(サーバ側で再エンコードしないため)。
- タイムゾーン:`Asia/Tokyo` をPHP開始時に指定。
- 認証:超簡易な**パスワード1つ**(例:`example_password`)でセッション認証。
- 保存先(例):`/var/www/html/photo_uploader/`
- 公開ディレクトリに `photo_uploader.php` を置く想定。
- 画像保存は `../gallery/YYYY/`(例:`../gallery/2025/`)に書き込み。
> 例は環境依存。LLMは**相対パス**と**権限チェック**が通るように作ること。
---
## 2. 大枠アーキテクチャ
- **1ファイル構成**:`photo_uploader.php`
- (A) **認証ページ**(パスワード入力 → セッション開始)
- (B) **UIページ**(HTML/CSS/JSで編集UIとCanvas表示)
- (C) **保存API**(同一ファイルにPOST:`imageData` 等を受けて保存)
- **フロント側処理**(JavaScript)
- 画像読込(input[type=file])
- AVIF/JPEG XLの**デコード**(ネイティブ→失敗時WASM)
- 透かしレンダリング(Canvas)
- ダウンロード(PNG or JPEG)
- サーバ保存(JPEGをDataURLでPOST)
- EXIFの読込/移植/ストリップの制御(後述)
- UI状態の `localStorage` 保存/復元
---
## 3. 主要機能
### 3.1 画像の読込
- `<input type="file" id="upload" accept="image/*,.jxl,image/jxl,.avif,image/avif">`
- 判定ロジック
- `file.type` と拡張子から **`isAvif`** / **`isJxl`** を判定。
- **ネイティブ表示可能**なら `Image.src = URL.createObjectURL(file)`。
- 失敗時は**WASMフォールバック**:
- JXL: `@jsquash/jxl`
- AVIF: `@jsquash/avif`
- 両者とも `decode(ArrayBuffer)` → `ImageData` 相当をCanvasに `putImageData` → `toDataURL('image/png')` → `Image` に流す。
> 目的:**どの形式でも最終的にブラウザで見られる状態(Image)**を得る。
### 3.2 EXIF(撮影日時・移植・ストリップ)
- **撮影日時の抽出(クライアント)**
- `exifr`(UMD)で `DateTimeOriginal → CreateDate → ModifyDate` を優先的に読み取り。
- 取得不能時は `file.lastModified` を使用。
- 取得した日時は `Date` として保持し、**サーバ保存時に `takenAt`(ISO8601)** としてPOST。
- **EXIF移植(JPEGのみ)**
- 元ファイルがJPEGの場合のみ、ダウンロード時/サーバ保存時に `piexifjs` で**元EXIFを新JPEGに移植**。
- AVIF/JXL/PNG由来では移植**しない**(形式差異と互換性のため)。
- **EXIFストリップ(共通設定)**
- UIに「**EXIFをカットする**」チェック(`#stripExif`)を用意。
- **AVIF/JXL読み込み時は非表示**(ストリップ対象外)。
- チェック**ON**:ダウンロードはPNG(EXIFなし)/サーバ保存はEXIFなしJPEG(移植せず)。
- チェック**OFF**:ダウンロードはJPEG(可能ならEXIF移植)/サーバ保存もJPEG(可能なら移植)。
- **状態は共通**で `localStorage("strip_exif")` に保存。
### 3.3 透かし(ウォーターマーク)
- UI項目:
- テキスト(`#watermarkText`)
- 文字色(白/黒の簡易トグル `#blackColor`)
- 縁取りの有無(`#outline`)
- フォント選択(`#fontFamily`)
- フォントサイズ(`#fontSize`)
- 画像リサイズ目標長(`#resizeLength`:長辺基準)
- 「文字位置を中央にする」(`#centerTextButton`)
- 操作:
- プレビューCanvas上で**ドラッグ移動**(マウス&タッチ対応)。
- 描画:
- 表示用Canvas(縮小比は後述)に画像→文字を描画。
- 保存時は非表示の本番Canvasに**等倍**で文字合成して出力。
### 3.4 ダウンロード
- ボタン:`#downloadButton`(ラベルは**EXIF設定に応じて**自動切替)
- **ON**(ストリップ)→「**PNGとしてダウンロード**」
- **OFF**(保持)→「**JPEGとしてダウンロード**」
- ファイル名:元名の拡張子を置換(`.png` or `.jpg`)。
- JPEG時は**可能ならEXIF移植**してからDataURLをリンククリック。
### 3.5 サーバ保存
- ボタン:`#uploadServerButton`(「JPEGでサーバ保存」)
- 送信内容(`multipart/form-data`):
- `imageData`: `data:image/jpeg;base64,...`(常に**JPEG**)
- `fileName`: 元ファイル名(UIで扱った元の名前)
- `takenAt`: ISO8601文字列(クライアントで取得・決定)
- サーバ処理(PHP):
- **認証必須**(未認証は `403`+メッセージ)。
- `imageData` を `base64_decode`。
- `takenAt` → `strtotime` 成功ならその時刻を採用、失敗時は `time()`。
- ディレクトリ:`../gallery/YYYY/` を作成(0777, 再帰)。
- ファイル名:
- `fileName` があればベース名を**英数_-以外は `_`**に置換し、**先頭に `MMDD_`** を付与、拡張子は `.jpg` 固定。
- なければ `MMDD-watermarked_YYYYmmdd_His.jpg`
- 重複は `_1`, `_2`, … を末尾に付与。
- **再エンコードせず** `file_put_contents($savePath, $decodedImage)` で保存(EXIF移植されたJPEGを維持)。
- レスポンス:保存した**ファイル名(テキスト)**。
---
## 4. UIレイアウトとレスポンシブ要件
### 4.1 配置
- `.layout`:フォームとプレビューのコンテナ
- **モバイル**(デフォルト):縦並び
- **900px以上**:横並び(左:`.form-area`、右:`#displayCanvas`)
- `.form-area`:カード風のフォーム。最大幅 ~460px。
- `#displayCanvas`:プレビュー用Canvas。枠線+ドロップシャドウ。
### 4.2 横並び/縦並びの別
- **フォーム内の二列**(`#fontSize` と `#resizeLength`)
- `.form-row` + `.form-col`
- **広い画面(PC)**:縦並び
- **スマホ(600px以下)**:横並び
### 4.3 プレビュー拡大率
- **基本倍率 `BASE_DISPLAY_SCALE = 0.3`**
- **900px以上**の画面では**1.5倍**(= 0.45)で表示
- 実装は `currentDisplayScale()` で判定し、`resize` イベントで再適用。
---
## 5. ローカルストレージ仕様(キーと意味)
| Key | 値例 | 説明 |
|---|---|---|
| `resize` | `"1200"` | リサイズ長辺ピクセル |
| `watermarkText` | `"Watermark"` | 透かし文字 |
| `textColor` | `"black"` / `"white"` | 文字色 |
| `watermark_size` | `"30"` | 文字サイズ(px) |
| `watermark_font` | `"Arial"` | フォント名 |
| `strip_exif` | `"1"` / `"0"` | EXIFをカット(1=ON) |
> 初期化:未保存なら `resize=1200` をセット。
> チェックの復元後、**ダウンロードボタンのラベル**も即時更新。
---
## 6. 依存ライブラリ(CDN / 動的読み込み)
- **EXIF読取**:`exifr`(UMD, `https://unpkg.com/exifr/dist/full.umd.js`)
- **EXIF移植(JPEGのみ)**:`piexifjs`(`https://unpkg.com/piexifjs`)
- **AVIFデコード**:`@jsquash/avif`(ESM, `https://unpkg.com/@jsquash/avif@1.3.0?module`)
- **JPEG XLデコード**:`@jsquash/jxl`(ESM, `https://unpkg.com/@jsquash/jxl@1.3.0?module`)
> `import()`(動的インポート)で必要時のみロード。
> 互換性:**モダンなエバーグリーンブラウザ**を対象。古い環境はフォールバック不要で可。
---
## 7. サーバAPI 仕様(同一ファイル内)
### 7.1 認証
- `POST password` でログイン。正しければ `$_SESSION['logged_in']=true`。
- 間違いはエラーメッセージ表示のみ。
### 7.2 保存エンドポイント
- **URL**:`photo_uploader.php`(同一)
- **Method**:`POST`
- **Headers**:`multipart/form-data`
- **Body**:
- `imageData`(必須):`data:image/jpeg;base64,...`
- `fileName`(任意):元名(拡張子不要でもよい)
- `takenAt`(任意):ISO8601(例:`2025-09-07T12:34:56.000Z`)
- **Response**(`text/plain`):
- 成功:保存**ファイル名**(例:`0906_IMG_1234.jpg`)
- 失敗:`403`(未ログイン)/`500`(保存失敗)
---
## 8. 例外・制約・既知の注意点
- **AVIF/JXL**:EXIFの移植は**対象外**(UIチェックも非表示)。
- **大画像**:WASMデコードは重い。UIは固まらないが、ユーザに待機が発生。
- **JPEG品質**:ダウンロードは `0.9`、サーバ保存は `0.7` を目安(調整可)。
- **ファイル名サニタイズ**:`[^a-zA-Z0-9_-]` を `_` 置換。
- **重複回避**:`_1, _2...` 連番。
- **タイムゾーン**:`takenAt` 解釈はサーバ側の `Asia/Tokyo` に依存。
- **安全**:同一オリジン前提。公開時は**強い認証**や**CSRF対策**を別途検討。
---
## 9. 受け入れテスト(Acceptance)
1. **ログイン**:正しいPWでUI表示、誤りで警告。
2. **JPEG読込**:透かし表示、ドラッグ移動、`strip_exif` OFFでJPEGダウンロード→**EXIF保持**。
3. **PNG読込**:`strip_exif` ONでPNGダウンロード(EXIFなし)。
4. **AVIF読込**:ネイティブ or WASMでプレビュー。`strip_exif` UIが**非表示**。保存はJPEGで成功。
5. **JXL読込**:AVIFと同様。
6. **撮影日→保存先**:`takenAt` 由来の `YYYY` ディレクトリに保存、ファイル名先頭に `MMDD_`。
7. **重複名**:`_1` 連番で回避。
8. **レスポンシブ**:
- 600px以下:フォームの `fontSize / resizeLength` が**横並び**
- 900px以上:フォームとプレビューが**左右**に分割、プレビューは**1.5倍**表示
9. **ボタン文言**:`strip_exif` の状態で**PNG/JPEG**表示が切替。
---
## 10. 実装ガイド(LLM向け手順)
1. **PHP冒頭**
- `session_start();`
- `date_default_timezone_set('Asia/Tokyo');`
- `if (POST imageData)` → 保存APIの処理(認証/デコード/ディレクトリ/ファイル名/`file_put_contents`)。
- `if (POST password)` → 認証ハンドリング。
- 認証チェック:未ログインは**ログインHTML**を返して `exit;`。
2. **UIページ(認証済み)**
- `<style>`:カードUI、`.layout` コンテナ、レスポンシブ(600/900pxブレーク)、`.form-row/.form-col`。
- `<body>`:
- `.layout` 内に **`.form-area`** と **`#displayCanvas`**
- `.form-area` にフォーム要素(上記)+ `#stripExifLabel`(チェック)
- 下部にボタン行(`#downloadButton`, `#uploadServerButton`)
- 非表示 `#saveCanvas`(保存用)
- `<script>`:
- 変数群:画像、座標、縮小率、ファイル状態、`currentTakenAt`。
- `loadScript()` 汎用ローダ。
- `updateDownloadButtonLabel()`:ボタン文言を切替。
- `extractTakenAt(file)`:EXIF or lastModified。
- AVIF/JXL用 `decode*WithWasm()`。
- `currentDisplayScale()` / `applyDisplayScale()`:900px以上で1.5倍。
- 画像ハンドラ `handleFileUpload()`:
- 種別判定、`stripExif` の表示切替、元JPEGならDataURL保持、ネイティブ→WASM。
- `resizeAndRedraw()`:長辺基準で縮小→`resizedImg`→プレビューCanvas設定→描画。
- 透かし描画系:`drawWatermarkOnDisplayCanvas()` / `drawWatermark()`。
- ドラッグ系:`startDragging` / `dragWatermark` / `stopDragging`。
- 設定保存:`localStorage`(上表)。
- ダウンロード:PNG/JPEG切替、JPEG時はEXIF移植試行。
- サーバ保存:JPEG生成→必要ならEXIF移植→`imageData`/`fileName`/`takenAt` をPOST。
- リスナー初期化/`resize` で `applyDisplayScale()`。
---
## 11. 将来拡張(任意)
- **保存形式選択**(WEBP/JPEG/PNG切替)
- **シャドウ/外枠/縁取りの詳細設定**
- **撮影日オーバーライド**(手動修正欄)
- **ドラッグ時のガイド(中央線/端寄せスナップ)**
- **複数画像の一括処理**
---
## 12. 用語メモ
- **EXIFをカット**:メタデータを**付与しない/除去**する挙動全般(このツールではPNG出力や再エンコード無移植で実現)。
- **移植**:元JPEGのEXIFを新規JPEGへ**コピー**すること(`piexifjs`でDataURLに挿入)。
---
## 13. まとめ(LLMへの指示要約)
- この仕様に沿って**単一の `photo_uploader.php`** を生成。
- **UI**・**描画**・**保存API**・**認証**を一体で実装。
- 外部ライブラリは**必要時のみ動的読込**。
- **AVIF/JXL**は**ネイティブ→WASM**の順でデコード。
- **撮影年ディレクトリ**と**`MMDD_`ファイル名**を**`takenAt`から決定**。
- **EXIFの挙動**(カット/移植/非表示条件)と**ボタン文言切替**を忠実に実装。
- **レスポンシブ要件**(600px/900px)と**プレビュー1.5倍**を満たすこと。