GitHub
Draft

Story Canvas Editor 仕様

Story Canvas Editor のレンダリング仕様。キャンバス座標系、要素の描画順序、 テキストレンダリング、エクスポート処理を厳密に定義します。

1. キャンバス仕様

1.1 キャンバスサイズ

1080px
高さ 1920px
アスペクト比 9:16(縦型)

1.2 座標系

エディタ表示時は、実際のキャンバスサイズと表示サイズの比率(canvasScale)を計算し、座標変換を行います。

canvasScale = displayWidth / CANVAS_WIDTH
displayX = canvasX * canvasScale
displayY = canvasY * canvasScale

2. 要素型定義

2.1 基底型 CanvasElement

すべてのキャンバス要素が持つ共通プロパティ。

プロパティ 説明
id string 一意の識別子(UUID形式推奨)
type 'image' | 'text' 要素の種別
x number 左上X座標(0-1080)
y number 左上Y座標(0-1920)
width number 幅(px)、最小値: 50
height number 高さ(px)、最小値: 50
rotation number 回転角度(度)、現在未実装
zIndex number 描画順序(大きいほど前面)

2.2 ImageElement

画像要素。CanvasElement を拡張。

プロパティ 説明
type 'image' 固定値
r2_key string R2ストレージのキー
content_type string MIMEタイプ(image/jpeg, image/png等)
preview string プレビュー用Blob URL
dominantColors string[] 抽出した主要色(RGB形式)

2.3 TextElement

テキスト要素。CanvasElement を拡張。

プロパティ 説明
type 'text' 固定値
content string テキスト内容
fontSize number フォントサイズ(px)、範囲: 24-96、デフォルト: 48
fontWeight 'normal' | 'bold' フォントウェイト、デフォルト: 'bold'
color string テキスト色(CSS color形式)、デフォルト: '#ffffff'
backgroundColor string? 背景色(CSS color形式)、デフォルト: 'rgba(0,0,0,0.5)'

3. 背景仕様

3.1 背景タイプ

3.2 プリセット背景

ID 名前
black #000000
white #ffffff
gray グレー #374151
blue linear-gradient(135deg, #667eea 0%, #764ba2 100%)
sunset 夕焼け linear-gradient(135deg, #fa709a 0%, #fee140 100%)
ocean linear-gradient(135deg, #667eea 0%, #00d4ff 100%)
forest linear-gradient(135deg, #11998e 0%, #38ef7d 100%)
night linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%)
pink ピンク linear-gradient(135deg, #f093fb 0%, #f5576c 100%)

3.3 背景レンダリング

function drawBackground(ctx: CanvasRenderingContext2D, background: string): void {
  if (background.startsWith('linear-gradient')) {
    // 135度の対角グラデーション(左上→右下)
    const gradient = ctx.createLinearGradient(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);

    // CSS linear-gradientから色を抽出
    const colors = background.match(/#[a-fA-F0-9]{6}/g) || [];
    const stops = background.match(/(\d+)%/g)?.map(s => parseInt(s) / 100) || [];

    colors.forEach((color, i) => {
      const stop = stops[i] ?? (i / (colors.length - 1));
      gradient.addColorStop(stop, color);
    });

    ctx.fillStyle = gradient;
  } else {
    ctx.fillStyle = background;
  }
  ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
}

4. 描画順序

4.1 レイヤー構造

  1. 背景レイヤー: 最背面(zIndex関係なく常に最初に描画)
  2. 要素レイヤー: zIndex の昇順で描画(小さい値が背面)

4.2 描画アルゴリズム

function render(ctx: CanvasRenderingContext2D, state: EditorState): void {
  // 1. キャンバスをクリア
  ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);

  // 2. 背景を描画
  drawBackground(ctx, state.background);

  // 3. 要素をzIndex順にソート
  const sortedElements = [...state.elements].sort((a, b) => a.zIndex - b.zIndex);

  // 4. 各要素を順番に描画
  for (const element of sortedElements) {
    if (element.type === 'image') {
      drawImageElement(ctx, element);
    } else if (element.type === 'text') {
      drawTextElement(ctx, element);
    }
  }
}

5. 画像要素レンダリング

5.1 描画仕様

5.2 描画アルゴリズム

async function drawImageElement(
  ctx: CanvasRenderingContext2D,
  element: ImageElement
): Promise<void> {
  return new Promise((resolve) => {
    const img = new Image();
    img.crossOrigin = 'anonymous';

    img.onload = () => {
      ctx.drawImage(
        img,
        element.x,      // 描画先X
        element.y,      // 描画先Y
        element.width,  // 描画幅
        element.height  // 描画高さ
      );
      resolve();
    };

    img.onerror = () => {
      console.error('Failed to load image:', element.preview);
      resolve();
    };

    img.src = element.preview;
  });
}

6. テキスト要素レンダリング

6.1 描画仕様

フォントファミリー sans-serif
テキスト配置(水平) center(要素の中央)
テキスト配置(垂直) middle(要素の中央)
行の高さ fontSize × 1.2
テキスト折り返し 要素幅 - 20px(左右10pxパディング)

6.2 テキスト折り返しアルゴリズム

日本語テキストは文字単位で折り返し(単語分割なし)。

function wrapText(
  ctx: CanvasRenderingContext2D,
  text: string,
  maxWidth: number
): string[] {
  const lines: string[] = [];
  let currentLine = '';

  // 文字単位でループ(日本語対応)
  for (const char of text) {
    const testLine = currentLine + char;
    const metrics = ctx.measureText(testLine);

    if (metrics.width > maxWidth && currentLine !== '') {
      lines.push(currentLine);
      currentLine = char;
    } else {
      currentLine = testLine;
    }
  }

  if (currentLine !== '') {
    lines.push(currentLine);
  }

  return lines;
}

6.3 描画アルゴリズム

function drawTextElement(
  ctx: CanvasRenderingContext2D,
  element: TextElement
): void {
  const PADDING = 10;
  const LINE_HEIGHT_RATIO = 1.2;

  // 1. 背景を描画(設定されている場合)
  if (element.backgroundColor) {
    ctx.fillStyle = element.backgroundColor;
    ctx.fillRect(element.x, element.y, element.width, element.height);
  }

  // 2. フォント設定
  ctx.fillStyle = element.color;
  ctx.font = `${element.fontWeight} ${element.fontSize}px sans-serif`;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';

  // 3. テキスト折り返し
  const maxWidth = element.width - (PADDING * 2);
  const lines = wrapText(ctx, element.content, maxWidth);

  // 4. 行の高さと開始Y座標を計算
  const lineHeight = element.fontSize * LINE_HEIGHT_RATIO;
  const totalHeight = lines.length * lineHeight;
  const startY = element.y + (element.height - totalHeight) / 2 + lineHeight / 2;

  // 5. 各行を描画
  const centerX = element.x + element.width / 2;
  lines.forEach((line, index) => {
    ctx.fillText(line, centerX, startY + index * lineHeight);
  });
}

7. 色抽出アルゴリズム

7.1 概要

画像から主要な色を抽出し、背景色パレットとして提案。

7.2 アルゴリズム

async function extractDominantColors(imageUrl: string): Promise<string[]> {
  const SAMPLE_SIZE = 50;        // サンプリング解像度
  const QUANTIZE_STEP = 32;      // 色の量子化ステップ(256/32 = 8段階)
  const MAX_COLORS = 5;          // 返す色の最大数

  // 1. 画像を読み込み
  const img = await loadImage(imageUrl);

  // 2. 小さいキャンバスにリサンプリング
  const canvas = document.createElement('canvas');
  canvas.width = SAMPLE_SIZE;
  canvas.height = SAMPLE_SIZE;
  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0, SAMPLE_SIZE, SAMPLE_SIZE);

  // 3. ピクセルデータを取得
  const imageData = ctx.getImageData(0, 0, SAMPLE_SIZE, SAMPLE_SIZE).data;
  const colorCounts: Map<string, number> = new Map();

  // 4. 各ピクセルを量子化してカウント
  for (let i = 0; i < imageData.length; i += 4) {
    const alpha = imageData[i + 3];
    if (alpha < 128) continue;  // 半透明以下はスキップ

    const r = Math.round(imageData[i] / QUANTIZE_STEP) * QUANTIZE_STEP;
    const g = Math.round(imageData[i + 1] / QUANTIZE_STEP) * QUANTIZE_STEP;
    const b = Math.round(imageData[i + 2] / QUANTIZE_STEP) * QUANTIZE_STEP;

    const key = `rgb(${r},${g},${b})`;
    colorCounts.set(key, (colorCounts.get(key) || 0) + 1);
  }

  // 5. 頻度でソートして上位を返す
  return [...colorCounts.entries()]
    .sort((a, b) => b[1] - a[1])
    .slice(0, MAX_COLORS)
    .map(([color]) => color);
}

8. 表示時間の自動計算

8.1 計算式

function calculateDuration(elements: CanvasElement[]): string {
  let seconds = 3;  // 基本時間

  const textElements = elements.filter(e => e.type === 'text');
  const imageElements = elements.filter(e => e.type === 'image');

  // テキスト要素1つにつき +2秒
  seconds += textElements.length * 2;

  // テキストの長さに応じて追加(20文字ごとに +1秒)
  for (const el of textElements) {
    seconds += Math.ceil(el.content.length / 20);
  }

  // 画像が2枚以上の場合 +2秒
  if (imageElements.length >= 2) {
    seconds += 2;
  }

  // 範囲制限: 3-15秒
  seconds = Math.max(3, Math.min(15, seconds));

  return `PT${seconds}S`;  // ISO 8601 duration形式
}

8.2 例

要素構成 計算 結果
画像1枚のみ 3 PT3S
画像1枚 + テキスト(10文字) 3 + 2 + 1 = 6 PT6S
画像2枚 + テキスト(30文字) 3 + 2 + 2 + 2 = 9 PT9S
テキスト2つ(各50文字) 3 + 4 + 6 = 13 PT13S

9. エクスポート仕様

9.1 出力フォーマット

形式 JPEG
品質 0.9(90%)
サイズ 1080 × 1920 px
ファイル名 story.jpg

9.2 エクスポート処理

async function exportCanvas(state: EditorState): Promise<Blob> {
  // 1. オフスクリーンキャンバスを作成
  const canvas = document.createElement('canvas');
  canvas.width = CANVAS_WIDTH;   // 1080
  canvas.height = CANVAS_HEIGHT; // 1920
  const ctx = canvas.getContext('2d');

  // 2. 全要素を描画
  await render(ctx, state);

  // 3. JPEGとしてエクスポート
  return new Promise((resolve) => {
    canvas.toBlob(
      (blob) => resolve(blob),
      'image/jpeg',
      0.9  // 品質90%
    );
  });
}

10. エディタUI仕様

10.1 選択状態の表示

10.2 要素の初期配置

要素 初期位置 初期サイズ
画像 (140, 560) - キャンバス中央 800 × 800 px
テキスト (240, 860) - キャンバス中央 600 × 200 px

10.3 制約