ブラウザ上で写真をアップロードして、フォトフレームと合成して1枚の画像として保存できるWebアプリケーションを作成しました。
むかし、ゲーム関係のキャンペーン用サイトがありました。
相当むかしなのでイベントは終了し、探しましたが見つかりません。
以下はそのイベントサイトで作成した画像です。

カービィかわいいですね。
カービィのゲーム発売キャンペーンのような感じだったような。。うる覚えです。
「自分の写真」と「キャラクターが入ったフォトフレーム」を合体できるじゃありませんか。
カワイイなぁとも思ったのですが、そんなことよりも「こんなことできるんだ!」という技術的なことに驚いた記憶があります。
どうやるのか調べようと思ったのですが忙しくて忘れてて。そうこうしている間にページが無くなってしまいました。
こんな感じだったかな。。

今だとAIで簡単に合成できるんじゃないですか。
あ〜そうですね。AIに頼んだらすぐできそうですね。
もう需要ない?
でもAIだと課金もアレですし、キャラクターな権利関係もあったりもしたり?
あの楽しさ、気持ちを持っていかれた興奮が、段々とよみがえってきました。
これは作るしかない。
でもどうやって作るのか、さっぱりわかりません。
しかし今はAIがあります。
【共有】サンプルページ
ひとまずデザインは考えず、動作するものを用意しました。
また機能面で不満があったのを覚えていたのです。写真の位置調整についてブラッシュアップにて要件を追加しています。
- 基本の手順は一緒
- 拡大縮小にスライダーをつけたい
- 写真の位置調整をドラッグできるようにしたい
- SNSに訴求はいらない
写真フォトフレームを合成(サンプルページ)
https://astrowave.jp/photo_frame/index-with-images.html

フレームを選択すると、「写真アップロード」ボタンがactiveになります。

お気に入りの写真を放り込んでください。
どんな写真にするか自由です!

操作感がUPしているのが好印象ですぞ。
何回もできます!

楽しいです。やってよかった°(°´ω`°)°。

このフォトフレームは私ですか、うれしいです。
ふふふ( ̄ー ̄)ニヤ
【共有】AIにフレーム画像を作成してもらう方法
いまだに無課金だと画像生成はとても辛いですよね。
無能なサラリーマンはお金がないです。
chatGPTにお願いして画像「生成」してもらった時の、プロンプト文言のサンプルを以下に共有します。
プロンプト
画像はpng形式で、フォトフレームとして使うものです。 透明な中窓が欲しいです。
・画像サイズは1000pxの正方形(サイズいっぱい使ってください)
・中央の透明部分のフレームの形、サイズ感(長方形、面積30%)
・色やデザインの雰囲気(UFOの格納庫の洞窟の中)
・装飾(ロボット)
金属調とかイラスト風とか、ハート型で、グレイタイプの宇宙人とか。
課金の催促が来るまで生成を楽しんでくださいな。
【共有】ソースコード
index-with-images.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>フォトフレーム合成 - オリジナル画像を作成</title>
<meta name="description" content="お持ちの写真にフォトフレームを合成して、オリジナル画像を作成できます。">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<header class="header">
<h1>フォトフレーム合成</h1>
<p class="subtitle">写真にフレームを合成してオリジナル画像を作成</p>
</header>
<!-- ステップ1: フレーム選択 -->
<section class="step step-1 active">
<h2>ステップ1: フレームを選択</h2>
<div class="frames-grid" id="framesGrid">
<!-- フレームはJavaScriptで動的に生成 -->
</div>
<button class="btn btn-primary" id="nextToUpload" disabled>次へ:写真をアップロード</button>
</section>
<!-- ステップ2: 写真アップロード -->
<section class="step step-2">
<h2>ステップ2: 写真をアップロード</h2>
<div class="upload-area">
<input type="file" id="photoInput" accept="image/*" hidden>
<label for="photoInput" class="upload-label">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
<p>クリックして写真を選択</p>
<p class="upload-hint">または、ここに写真をドラッグ&ドロップ</p>
</label>
</div>
<button class="btn btn-secondary" id="backToFrame">戻る</button>
</section>
<!-- ステップ3: 編集 -->
<section class="step step-3">
<h2>ステップ3: 位置とサイズを調整</h2>
<div class="editor-container">
<div class="canvas-wrapper">
<canvas id="canvas"></canvas>
</div>
<div class="controls">
<div class="control-group">
<label>サイズ調整</label>
<div class="zoom-controls">
<button class="btn-icon" id="zoomOut">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="8" y1="12" x2="16" y2="12"></line>
</svg>
</button>
<input type="range" id="zoomRange" min="50" max="300" value="100" step="1">
<button class="btn-icon" id="zoomIn">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="16"></line>
<line x1="8" y1="12" x2="16" y2="12"></line>
</svg>
</button>
</div>
</div>
<p class="hint">💡 写真をドラッグして位置を調整できます</p>
</div>
</div>
<div class="button-group">
<button class="btn btn-secondary" id="backToUpload">写真を変更</button>
<button class="btn btn-primary" id="createImage">完成!</button>
</div>
</section>
<!-- ステップ4: 完成 -->
<section class="step step-4">
<h2>完成しました!</h2>
<div class="result-container">
<img id="resultImage" alt="完成画像">
<div class="result-actions">
<a class="btn btn-primary" id="downloadBtn" download="photo-frame.png">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
画像をダウンロード
</a>
<button class="btn btn-secondary" id="createAnother">もう一度作る</button>
</div>
</div>
</section>
<footer class="footer">
<p>© <script type="text/javascript">var date = new Date();var year = date.getFullYear();document.write(year);</script> astrowave</p>
</footer>
</div>
<!-- 実際の画像を使用するバージョン -->
<script src="app-with-images.js"></script>
</body>
</html>css
/* リセットとベース */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary-color: #4a90e2;
--primary-hover: #357abd;
--secondary-color: #6c757d;
--success-color: #28a745;
--border-color: #dee2e6;
--bg-light: #f8f9fa;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.15);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
color: #333;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
background: white;
border-radius: 16px;
box-shadow: var(--shadow-lg);
overflow: hidden;
}
/* ヘッダー */
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 40px 20px;
text-align: center;
}
.header h1 {
font-size: 2rem;
margin-bottom: 8px;
}
.subtitle {
font-size: 1rem;
opacity: 0.9;
}
/* ステップセクション */
.step {
display: none;
padding: 40px 20px;
animation: fadeIn 0.3s ease-in;
}
.step.active {
display: block;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.step h2 {
font-size: 1.5rem;
margin-bottom: 24px;
color: #333;
text-align: center;
}
/* フレームグリッド */
.frames-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.frame-item {
position: relative;
border: 3px solid transparent;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s ease;
aspect-ratio: 1;
background: var(--bg-light);
}
.frame-item:hover {
transform: translateY(-4px);
box-shadow: var(--shadow);
}
.frame-item.selected {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.2);
}
.frame-item img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.frame-item .checkmark {
position: absolute;
top: 8px;
right: 8px;
width: 28px;
height: 28px;
background: var(--primary-color);
border-radius: 50%;
display: none;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
}
.frame-item.selected .checkmark {
display: flex;
}
/* アップロードエリア */
.upload-area {
margin-bottom: 24px;
}
.upload-label {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
border: 3px dashed var(--border-color);
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
padding: 40px 20px;
background: var(--bg-light);
}
.upload-label:hover {
border-color: var(--primary-color);
background: rgba(74, 144, 226, 0.05);
}
.upload-label.drag-over {
border-color: var(--primary-color);
background: rgba(74, 144, 226, 0.1);
}
.upload-label svg {
color: var(--primary-color);
margin-bottom: 16px;
}
.upload-label p {
font-size: 1.1rem;
color: #666;
margin-bottom: 8px;
}
.upload-hint {
font-size: 0.9rem !important;
color: #999 !important;
}
/* エディター */
.editor-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
.canvas-wrapper {
position: relative;
border-radius: 12px;
overflow: hidden;
box-shadow: var(--shadow);
background: var(--bg-light);
}
#canvas {
display: block;
max-width: 100%;
height: auto;
cursor: move;
}
.controls {
width: 100%;
max-width: 500px;
}
.control-group {
margin-bottom: 16px;
}
.control-group label {
display: block;
font-weight: 600;
margin-bottom: 12px;
color: #555;
}
.zoom-controls {
display: flex;
align-items: center;
gap: 12px;
}
.zoom-controls input[type="range"] {
flex: 1;
height: 6px;
border-radius: 3px;
background: var(--border-color);
outline: none;
-webkit-appearance: none;
}
.zoom-controls input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--primary-color);
cursor: pointer;
transition: all 0.2s ease;
}
.zoom-controls input[type="range"]::-webkit-slider-thumb:hover {
background: var(--primary-hover);
transform: scale(1.1);
}
.zoom-controls input[type="range"]::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--primary-color);
cursor: pointer;
border: none;
transition: all 0.2s ease;
}
.zoom-controls input[type="range"]::-moz-range-thumb:hover {
background: var(--primary-hover);
transform: scale(1.1);
}
.hint {
text-align: center;
color: #666;
font-size: 0.9rem;
margin-top: 8px;
}
/* 結果表示 */
.result-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
#resultImage {
max-width: 100%;
height: auto;
border-radius: 12px;
box-shadow: var(--shadow);
}
.result-actions {
display: flex;
gap: 16px;
flex-wrap: wrap;
justify-content: center;
}
/* ボタン */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 32px;
font-size: 1rem;
font-weight: 600;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
color: white;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--primary-color);
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-hover);
transform: translateY(-2px);
box-shadow: var(--shadow);
}
.btn-secondary {
background: var(--secondary-color);
}
.btn-secondary:hover {
background: #5a6268;
transform: translateY(-2px);
box-shadow: var(--shadow);
}
.btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
border-radius: 8px;
background: var(--bg-light);
cursor: pointer;
transition: all 0.2s ease;
}
.btn-icon:hover {
background: var(--border-color);
}
.btn-icon svg {
color: var(--primary-color);
}
.button-group {
display: flex;
gap: 16px;
justify-content: center;
flex-wrap: wrap;
margin-top: 24px;
}
/* フッター */
.footer {
text-align: center;
padding: 24px;
background: var(--bg-light);
color: #666;
font-size: 0.9rem;
}
/* レスポンシブ */
@media (max-width: 768px) {
body {
padding: 10px;
}
.container {
border-radius: 12px;
}
.header {
padding: 24px 16px;
}
.header h1 {
font-size: 1.5rem;
}
.step {
padding: 24px 16px;
}
.frames-grid {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 12px;
}
.btn {
width: 100%;
}
.button-group {
flex-direction: column;
}
.result-actions {
flex-direction: column;
width: 100%;
}
.result-actions .btn {
width: 100%;
}
}script
/**
* フォトフレーム合成アプリケーション(実際の画像版)
* 実際のフレーム画像を使用するバージョン
* https://astrowave.jp/
*/
class PhotoFrameApp {
constructor() {
// 状態管理
this.state = {
selectedFrame: null,
selectedFrameImage: null, // フレーム画像のImageオブジェクト
uploadedPhoto: null,
photoScale: 1,
photoX: 0,
photoY: 0,
isDragging: false,
dragStartX: 0,
dragStartY: 0,
canvasSize: 600, // プレビューサイズ
bestFitScale: 1, // ベストフィットのスケール(スライダー100%の基準値)
photoOrientation: 1 // EXIF Orientation値(1=正常、6=90度回転など)
};
// フレーム画像のリスト
this.frames = [
{ id: 'frame1', name: 'フレーム1', image: 'frames/frame_01.png' },
{ id: 'frame2', name: 'フレーム2', image: 'frames/frame_02.png' },
{ id: 'frame3', name: 'フレーム3', image: 'frames/frame_03.png' },
{ id: 'frame4', name: 'フレーム4', image: 'frames/frame_04.png' },
{ id: 'frame5', name: 'フレーム5', image: 'frames/frame_05.png' },
{ id: 'frame6', name: 'フレーム6', image: 'frames/frame_06.png' },
];
this.init();
}
/**
* 初期化
*/
init() {
this.setupElements();
this.setupEventListeners();
this.renderFrames();
}
/**
* DOM要素の取得
*/
setupElements() {
// ステップ要素
this.steps = {
step1: document.querySelector('.step-1'),
step2: document.querySelector('.step-2'),
step3: document.querySelector('.step-3'),
step4: document.querySelector('.step-4')
};
// ボタン要素
this.buttons = {
nextToUpload: document.getElementById('nextToUpload'),
backToFrame: document.getElementById('backToFrame'),
backToUpload: document.getElementById('backToUpload'),
createImage: document.getElementById('createImage'),
createAnother: document.getElementById('createAnother'),
zoomIn: document.getElementById('zoomIn'),
zoomOut: document.getElementById('zoomOut'),
downloadBtn: document.getElementById('downloadBtn')
};
// その他の要素
this.framesGrid = document.getElementById('framesGrid');
this.photoInput = document.getElementById('photoInput');
this.uploadLabel = document.querySelector('.upload-label');
this.canvas = document.getElementById('canvas');
this.ctx = this.canvas.getContext('2d');
this.zoomRange = document.getElementById('zoomRange');
this.resultImage = document.getElementById('resultImage');
}
/**
* イベントリスナーの設定
*/
setupEventListeners() {
// ナビゲーションボタン
this.buttons.nextToUpload.addEventListener('click', () => this.goToStep(2));
this.buttons.backToFrame.addEventListener('click', () => this.goToStep(1));
this.buttons.backToUpload.addEventListener('click', () => this.goToStep(2));
this.buttons.createImage.addEventListener('click', () => this.generateFinalImage());
this.buttons.createAnother.addEventListener('click', () => this.reset());
// 写真アップロード
this.photoInput.addEventListener('change', (e) => this.handlePhotoUpload(e));
// ドラッグ&ドロップ
this.uploadLabel.addEventListener('dragover', (e) => {
e.preventDefault();
this.uploadLabel.classList.add('drag-over');
});
this.uploadLabel.addEventListener('dragleave', () => {
this.uploadLabel.classList.remove('drag-over');
});
this.uploadLabel.addEventListener('drop', (e) => {
e.preventDefault();
this.uploadLabel.classList.remove('drag-over');
const files = e.dataTransfer.files;
if (files.length > 0) {
this.handlePhotoFile(files[0]);
}
});
// キャンバス操作
this.canvas.addEventListener('mousedown', (e) => this.startDrag(e));
this.canvas.addEventListener('mousemove', (e) => this.drag(e));
this.canvas.addEventListener('mouseup', () => this.endDrag());
this.canvas.addEventListener('mouseleave', () => this.endDrag());
// タッチ操作
this.canvas.addEventListener('touchstart', (e) => this.startDrag(e));
this.canvas.addEventListener('touchmove', (e) => this.drag(e));
this.canvas.addEventListener('touchend', () => this.endDrag());
// ズーム操作
this.buttons.zoomIn.addEventListener('click', () => this.zoom(10));
this.buttons.zoomOut.addEventListener('click', () => this.zoom(-10));
this.zoomRange.addEventListener('input', (e) => {
this.zoomToValue(parseInt(e.target.value));
});
}
/**
* フレーム一覧を描画
*/
renderFrames() {
this.framesGrid.innerHTML = '';
this.frames.forEach(frame => {
const frameElement = document.createElement('div');
frameElement.className = 'frame-item';
frameElement.dataset.frameId = frame.id;
// 実際のフレーム画像を使用
const img = document.createElement('img');
img.src = frame.image;
img.alt = frame.name;
img.loading = 'lazy';
frameElement.appendChild(img);
const checkmark = document.createElement('div');
checkmark.className = 'checkmark';
checkmark.textContent = '✓';
frameElement.appendChild(checkmark);
frameElement.addEventListener('click', () => this.selectFrame(frame, frameElement));
this.framesGrid.appendChild(frameElement);
});
}
/**
* フレームを選択
*/
selectFrame(frame, element) {
// 既存の選択を解除
document.querySelectorAll('.frame-item').forEach(item => {
item.classList.remove('selected');
});
// 新しい選択
element.classList.add('selected');
this.state.selectedFrame = frame;
// フレーム画像を読み込み
const frameImg = new Image();
frameImg.onload = () => {
this.state.selectedFrameImage = frameImg;
this.buttons.nextToUpload.disabled = false;
};
frameImg.src = frame.image;
}
/**
* 写真アップロードの処理
*/
handlePhotoUpload(e) {
const file = e.target.files[0];
if (file) {
this.handlePhotoFile(file);
}
}
/**
* 写真ファイルの処理
*/
handlePhotoFile(file) {
if (!file.type.match('image.*')) {
alert('画像ファイルを選択してください。');
return;
}
// EXIF情報を読み取る
this.getOrientation(file, (orientation) => {
this.state.photoOrientation = orientation;
// 画像を読み込む
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
this.state.uploadedPhoto = img;
this.initializeCanvas();
this.goToStep(3);
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
});
}
/**
* キャンバスの初期化
*/
initializeCanvas() {
const size = this.state.canvasSize;
this.canvas.width = size;
this.canvas.height = size;
const img = this.state.uploadedPhoto;
const orientation = this.state.photoOrientation;
// Orientation 6,8の場合、画像の幅と高さが入れ替わる
let imgWidth = img.width;
let imgHeight = img.height;
if (orientation >= 5 && orientation <= 8) {
// 90度または270度回転の場合、幅と高さを入れ替え
[imgWidth, imgHeight] = [imgHeight, imgWidth];
}
// ベストフィットのスケールを計算(画像がキャンバスに収まる最大サイズ)
const bestFitScale = Math.min(size / imgWidth, size / imgHeight) * 0.8;
// スライダーの範囲を設定(ベストフィットを基準に)
// 最小: ベストフィットの50%
// 初期: ベストフィット(100として設定)
// 最大: ベストフィットの250%
this.zoomRange.min = 50; // 50% = ベストフィットの半分
this.zoomRange.value = 100; // 100% = ベストフィット
this.zoomRange.max = 250; // 250% = ベストフィットの2.5倍
// スライダー100%の時のスケールをbestFitScaleとして保存
this.state.bestFitScale = bestFitScale;
// 初期スケールをbestFitScaleに設定
this.state.photoScale = bestFitScale;
this.state.photoX = (size - imgWidth * bestFitScale) / 2;
this.state.photoY = (size - imgHeight * bestFitScale) / 2;
this.drawCanvas();
}
/**
* キャンバスに描画
*/
drawCanvas() {
const { canvasSize, uploadedPhoto, photoScale, photoX, photoY, selectedFrameImage, photoOrientation } = this.state;
// キャンバスをクリア
this.ctx.clearRect(0, 0, canvasSize, canvasSize);
// 背景を白で塗りつぶし
this.ctx.fillStyle = '#ffffff';
this.ctx.fillRect(0, 0, canvasSize, canvasSize);
// 写真を描画(EXIF Orientationを考慮)
if (uploadedPhoto) {
this.ctx.save();
// EXIF Orientationに基づいて画像を回転・反転
this.applyOrientation(uploadedPhoto, photoOrientation, photoX, photoY, photoScale);
this.ctx.restore();
}
// フレームを描画
if (selectedFrameImage && selectedFrameImage.complete) {
this.ctx.drawImage(selectedFrameImage, 0, 0, canvasSize, canvasSize);
}
}
/**
* ドラッグ開始
*/
startDrag(e) {
this.state.isDragging = true;
const rect = this.canvas.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
// キャンバスの表示サイズと実際のサイズの比率
const scaleX = this.canvas.width / rect.width;
const scaleY = this.canvas.height / rect.height;
const canvasX = (clientX - rect.left) * scaleX;
const canvasY = (clientY - rect.top) * scaleY;
this.state.dragStartX = canvasX - this.state.photoX;
this.state.dragStartY = canvasY - this.state.photoY;
this.canvas.style.cursor = 'grabbing';
}
/**
* ドラッグ中
*/
drag(e) {
if (!this.state.isDragging) return;
e.preventDefault();
const rect = this.canvas.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
// キャンバスの表示サイズと実際のサイズの比率
const scaleX = this.canvas.width / rect.width;
const scaleY = this.canvas.height / rect.height;
const canvasX = (clientX - rect.left) * scaleX;
const canvasY = (clientY - rect.top) * scaleY;
this.state.photoX = canvasX - this.state.dragStartX;
this.state.photoY = canvasY - this.state.dragStartY;
this.drawCanvas();
}
/**
* ドラッグ終了
*/
endDrag() {
this.state.isDragging = false;
this.canvas.style.cursor = 'move';
}
/**
* ズーム(ボタン用)
*/
zoom(delta) {
const currentValue = parseInt(this.zoomRange.value);
const newValue = Math.max(
parseInt(this.zoomRange.min),
Math.min(parseInt(this.zoomRange.max), currentValue + delta)
);
this.zoomRange.value = newValue;
this.zoomToValue(newValue);
}
/**
* スライダーの値に基づいてズーム(中央配置を維持)
*/
zoomToValue(value) {
const img = this.state.uploadedPhoto;
if (!img || !this.state.bestFitScale) return;
const prevScale = this.state.photoScale;
// スライダーの値(50〜250)をbestFitScaleを基準にした実際のスケールに変換
// value=100の時にbestFitScale、value=50の時にbestFitScale*0.5、value=250の時にbestFitScale*2.5
const newScale = this.state.bestFitScale * (value / 100);
// 画像の中心点を計算(現在のスケールでの中心位置)
const centerX = this.state.photoX + (img.width * prevScale) / 2;
const centerY = this.state.photoY + (img.height * prevScale) / 2;
// 新しいスケールで同じ中心点を維持するように位置を調整
this.state.photoScale = newScale;
this.state.photoX = centerX - (img.width * newScale) / 2;
this.state.photoY = centerY - (img.height * newScale) / 2;
this.drawCanvas();
}
/**
* 最終画像を生成
*/
generateFinalImage() {
// 高解像度の画像を生成
const finalCanvas = document.createElement('canvas');
const finalSize = 1200; // 高解像度
finalCanvas.width = finalSize;
finalCanvas.height = finalSize;
const finalCtx = finalCanvas.getContext('2d');
const scale = finalSize / this.state.canvasSize;
const { uploadedPhoto, photoScale, photoX, photoY, selectedFrameImage, photoOrientation } = this.state;
// 背景を白で塗りつぶし
finalCtx.fillStyle = '#ffffff';
finalCtx.fillRect(0, 0, finalSize, finalSize);
// 写真を描画(EXIF Orientationを考慮)
if (uploadedPhoto) {
finalCtx.save();
// EXIF Orientationに基づいて画像を回転・反転(高解像度版)
this.applyOrientationToContext(
finalCtx,
uploadedPhoto,
photoOrientation,
photoX * scale,
photoY * scale,
photoScale * scale
);
finalCtx.restore();
}
// フレームを描画
if (selectedFrameImage && selectedFrameImage.complete) {
finalCtx.drawImage(selectedFrameImage, 0, 0, finalSize, finalSize);
}
// 結果を表示
const dataURL = finalCanvas.toDataURL('image/png', 1.0);
this.resultImage.src = dataURL;
this.buttons.downloadBtn.href = dataURL;
// ファイル名を生成
const date = new Date();
const filename = `photo-frame-${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}${String(date.getDate()).padStart(2, '0')}-${String(date.getHours()).padStart(2, '0')}${String(date.getMinutes()).padStart(2, '0')}.png`;
this.buttons.downloadBtn.download = filename;
this.goToStep(4);
}
/**
* ステップ遷移
*/
goToStep(stepNumber) {
Object.values(this.steps).forEach(step => step.classList.remove('active'));
this.steps[`step${stepNumber}`].classList.add('active');
// スクロールをトップに
window.scrollTo({ top: 0, behavior: 'smooth' });
}
/**
* リセット
*/
reset() {
this.state = {
selectedFrame: null,
selectedFrameImage: null,
uploadedPhoto: null,
photoScale: 1,
photoX: 0,
photoY: 0,
isDragging: false,
dragStartX: 0,
dragStartY: 0,
canvasSize: 600,
bestFitScale: 1,
photoOrientation: 1
};
this.photoInput.value = '';
this.buttons.nextToUpload.disabled = true;
this.goToStep(1);
// 選択をクリア
document.querySelectorAll('.frame-item').forEach(item => {
item.classList.remove('selected');
});
}
/**
* EXIF Orientationを取得
* @param {File} file - 画像ファイル
* @param {Function} callback - コールバック関数 callback(orientation)
*/
getOrientation(file, callback) {
const reader = new FileReader();
reader.onload = (e) => {
const view = new DataView(e.target.result);
// JPEGファイルのチェック(0xFFD8で始まる)
if (view.getUint16(0, false) !== 0xFFD8) {
callback(1); // JPEGでない場合は正常な向き
return;
}
const length = view.byteLength;
let offset = 2;
// EXIFデータを探す
while (offset < length) {
const marker = view.getUint16(offset, false);
offset += 2;
// APP1マーカー(0xFFE1)がEXIF情報
if (marker === 0xFFE1) {
const exifLength = view.getUint16(offset, false);
offset += 2;
// "Exif"という文字列をチェック
if (view.getUint32(offset, false) !== 0x45786966) {
callback(1);
return;
}
offset += 6; // "Exif\0\0"をスキップ
// バイトオーダーをチェック
const tiffOffset = offset;
const littleEndian = view.getUint16(tiffOffset, false) === 0x4949;
// IFD(Image File Directory)のオフセットを取得
const ifdOffset = view.getUint32(tiffOffset + 4, littleEndian);
const tagCount = view.getUint16(tiffOffset + ifdOffset, littleEndian);
// タグを読み込んでOrientationを探す
for (let i = 0; i < tagCount; i++) {
const tagOffset = tiffOffset + ifdOffset + 2 + (i * 12);
const tag = view.getUint16(tagOffset, littleEndian);
// Orientationタグ(0x0112)
if (tag === 0x0112) {
const orientation = view.getUint16(tagOffset + 8, littleEndian);
callback(orientation);
return;
}
}
callback(1); // Orientationタグが見つからない場合
return;
}
// 次のマーカーへ
if (offset + 2 > length) break;
offset += view.getUint16(offset, false);
}
callback(1); // EXIFデータが見つからない場合
};
reader.readAsArrayBuffer(file);
}
/**
* EXIF Orientationに基づいて画像を描画(プレビュー用)
*/
applyOrientation(img, orientation, x, y, scale) {
this.applyOrientationToContext(this.ctx, img, orientation, x, y, scale);
}
/**
* EXIF Orientationに基づいて画像を描画(汎用)
* @param {CanvasRenderingContext2D} ctx - Canvas context
* @param {Image} img - 画像オブジェクト
* @param {number} orientation - EXIF Orientation値(1-8)
* @param {number} x - X座標
* @param {number} y - Y座標
* @param {number} scale - スケール
*/
applyOrientationToContext(ctx, img, orientation, x, y, scale) {
const w = img.width * scale;
const h = img.height * scale;
// Orientationに応じて変換を適用
switch (orientation) {
case 2:
// 水平反転
ctx.translate(x + w, y);
ctx.scale(-1, 1);
ctx.drawImage(img, 0, 0, w, h);
break;
case 3:
// 180度回転
ctx.translate(x + w, y + h);
ctx.rotate(Math.PI);
ctx.drawImage(img, 0, 0, w, h);
break;
case 4:
// 垂直反転
ctx.translate(x, y + h);
ctx.scale(1, -1);
ctx.drawImage(img, 0, 0, w, h);
break;
case 5:
// 90度回転 + 水平反転
ctx.translate(x + h, y);
ctx.rotate(Math.PI / 2);
ctx.scale(-1, 1);
ctx.drawImage(img, 0, 0, w, h);
break;
case 6:
// 90度回転(時計回り)
ctx.translate(x + h, y);
ctx.rotate(Math.PI / 2);
ctx.drawImage(img, 0, 0, w, h);
break;
case 7:
// 270度回転 + 水平反転
ctx.translate(x, y + w);
ctx.rotate(-Math.PI / 2);
ctx.scale(-1, 1);
ctx.drawImage(img, 0, 0, w, h);
break;
case 8:
// 270度回転(反時計回り)
ctx.translate(x, y + w);
ctx.rotate(-Math.PI / 2);
ctx.drawImage(img, 0, 0, w, h);
break;
default:
// Orientation 1(正常)または不明
ctx.drawImage(img, x, y, w, h);
}
}
}
// アプリケーション起動
document.addEventListener('DOMContentLoaded', () => {
new PhotoFrameApp();
});詳しくはAIに放り込んでください。
もし何かのイベントなどで利用してくれるなら、このブログで宣伝させてもらえると嬉しいです。
以上です。
【AI】イラストを描いてもらった
今回の記事のキャッチ画像で使わせてもらいます「Google ImageFX」で作成した画像です。誰でもgoogleアカウントでログインして使えます。
この記事にピッタリなイラストのための考えたリクエストは、「豪華な図書館でピンク色髪のメイドさんが食パンの真ん中をくり抜いた大きな穴から、目を覗き込んでカメラに向かって愛嬌を振り撒く。ダッチアングルのショルダーショット、実写的に。」です。


食パンとメイドさん。いいですね。
どういう状況なんでしょう。なんか。なんか(Ф∀Ф)
星間旅路のメロディ
「宇宙の静けさに包まれながら、漂流する過去の音楽を捜し求め、銀河の奥底でその旋律に耳を傾ける。」
「この電波はどこの星からきたのだろうか。」
どこかで聞いたことがあるような。



