kintoneでオリジナルティキシールを作ろう!〜前編〜

こんにちは、ジョイゾーTech.チームの黒坂とナカです!

今回はCybozu Daysにて展示いたしました『kintoneオリジナルティキシール作成アプリ』の仕組みについて解説していきます。
改めまして、ブースにお立ち寄りいただいた皆様、オリジナルティキを作ってくださった皆様、本当にありがとうございました!

リアルティキの作成についてはnoteに記載しています!ぜひこちらもご覧ください。
神の像「ティキ」を動かそう!Cybozu Days 2025 JOYZO Tech.チーム
https://note.com/joyzojp/n/n3b6377a23024

弊社では初回開発無料の定額39万円でkintoneアプリを開発する定額型開発サービス「システム39」を提供しております。kintoneの導入やアプリ開発でお困りの方は、お気軽にご相談ください。
*Webでの打ち合わせも可能です。

   kintoneのアプリ開発はこちら <相談無料>    

kintoneオリジナルティキシール作成アプリ

kintoneオリジナルティキシール作成アプリの構成は大きく分けて4つとなっております。

①オリジナルティキ画像の作成
②AIによるティキ名&属性付与
③ティキの印刷機能や光るギミック
④ティキの世界観に沿った装飾

こちらの記事では「①オリジナルティキ画像の作成」と「②AIによるティキ名&属性付与」についてご紹介していきます。
後半の「③ティキの印刷機能や光るギミック」「④ティキの世界観に沿った装飾」もぜひ併せてご覧ください!
https://www.joyzo.co.jp/blog/26109

オリジナルティキ画像の作成

それではまず、ティキ画像の作成の仕組みについてご説明します。
ティキの画像を作成をするにあたって、アプリは下記2つを用意しました。

  • ティキギャラリー(ティキ画像の作成を行い画像や名前をレコードに保存するアプリ)
  • ティキパーツマスタ(ティキ画像を構成する輪郭・目などのパーツを持っているマスタアプリ)

ティキギャラリーアプリに、下記の2つの構築を行いました。

  • ティキパーツ選択画面の構築
  • 選んだパーツでティキ画像を保存する仕組みの構築

それぞれサクっとご紹介します!

ティキパーツ選択画面の構築

まずは実際にできたティキパーツ選択画面をお見せします。
画面の上半分にプレビュー画面、下にパーツを選べるタブを作りました。

この画面は新規レコード追加をきっかけに描画されます。
パーツの種類ごとにタブ分けを行い、タブにつけた名前とティキパーツマスタアプリで管理している『パーツ種別』が同じパーツを描画しています。

裏側の仕組みは下記の通りです。

①タブにパーツ種別と同じ値を定義づけ

// UI (HTML) の挿入部分から抜粋
<div class="tabs" id="tiki-tabs">
  <button data-type="輪郭" class="tab active">輪郭</button>
  <button data-type="からだ" class="tab">からだ</button>
  <button data-type="頭飾り" class="tab">頭飾り</button>
  <button data-type="目" class="tab">目</button>
  <button data-type="鼻" class="tab">鼻</button>
  <button data-type="口" class="tab">口</button>
</div>
// ...

②タブ切り替え時に定義づけしたパーツ種別を取得

// タブ切替のイベント処理
tabs.addEventListener('click', (e) => {
  const btn = e.target.closest('.tab'); 
  if (!btn) return;
  document.querySelectorAll('.tab').forEach(b =>  
    b.classList.remove('active'));
  btn.classList.add('active');
  // ★ ここでパーツ種別(data-type)を取得し、描画関数に渡している
  buildGrid(btn.dataset.type);
});

③種別に応じたパーツの描画

// グリッド描画関数 (buildGrid)
function buildGrid(jpType) {
  grid.innerHTML = '';
  // ★ 渡された種別(jpType)のリストだけを処理
  (parts[jpType] || []).forEach(p => { 
    // ... サムネイル(div)を生成 ...
    div.title = `${TYPE_LABEL[jpType]}:${p.name}`; // 種別の名前をタイトルに利用
    // ...
    grid.appendChild(div);
  });
  // ...
}

ちなみに、AIへの指示は、頭で思い描いたものをそのまま簡単な図にして指示しました。
個人的には言葉で伝えるより簡単で正確にできたと思います!

選んだパーツでティキ画像を保存する仕組みの構築

次に選んだパーツでティキ画像を保存する仕組みについてご紹介します。
動作の流れは下記の通りです。

  • ティキを作成
  • 作成者(自分の名前)を入力
  • 作成者の名前を元にティキに名前を自動生成&属性を付与
  • ティキを白黒線画に変換
  • 白黒線画ティキを保存

作成者の名前を元に自動生成は、この後に詳しく書いているので、ティキ作成のロジックとティキを白黒線画に変換→保存する部分について説明します。

※余談
本来はティキ作成→画像保存の予定でしたが、使用したプリンターが感熱式だったため、カラー画像を綺麗に印刷できないことが発覚。
期限が迫り来る中、白黒線画に変換できるように急遽ロジックを追加しました。

(↓印刷ができないと発覚した日の先輩とのやりとり)

ティキ作成

ティキを作成する部分の基本ロジックは、ティキパーツマスタから画像を取得→選択されたパーツをプレビューに描画です。

画像をマスタから取得→選択されたパーツを描画

/* ====== 画像取得ユーティリティ ====== */
async function fetchBlobUrlByFileKey(fileKey) {
  // kintoneのファイルAPIを叩き、ファイルキーに対応する画像データ(Blob)を取得
  // BlobをURLに変換し、キャッシュに保存して返す(★パーツ画像を取得する部分)
  if (blobCache.has(fileKey)) return blobCache.get(fileKey);
  const apiUrl = kintone.api.url('/k/v1/file', true) + `?fileKey=${encodeURIComponent(fileKey)}`;
  const resp = await fetch(apiUrl, { method: 'GET', headers: { 'X-Requested-With': 'XMLHttpRequest' } });
  if (!resp.ok) throw new Error(`File fetch failed: ${resp.status}`);
  const blob = await resp.blob();
  const url = URL.createObjectURL(blob);
  blobCache.set(fileKey, url);
  return url;
}

function loadImage(url) {
  // 画像URLからImageオブジェクトを生成するヘルパー関数
  return new Promise((res, rej) => {
    const img = new Image();
    img.crossOrigin = 'anonymous';
    img.onload = () => res(img);
    img.onerror = rej;
    img.src = url;
  });
}

// ★ 選択されたパーツをプレビュー(キャンバス)に描画する関数
async function drawOneLayer(jpType, part) {
  const layer = JP2EN[jpType]; // 日本語名(種別)をレイヤーID(英語名)に変換
  const cv = cvs[layer]; // 対応するキャンバス要素を取得
  const ctx = cv.getContext('2d');

  // 1. パーツのファイルキーを使ってkintoneから画像URLを取得
  const blobUrl = await fetchBlobUrlByFileKey(part.fileKey);
  // 2. 画像をロード
  const img = await loadImage(blobUrl);
  
  // 3. キャンバスをクリアし、取得した画像をキャンバスに描画
  ctx.clearRect(0, 0, CANVAS_W, CANVAS_H);
  ctx.drawImage(img, 0, 0, CANVAS_W, CANVAS_H);
}

プレビューの表示仕組みとしては、あらかじめパーツごとのレイヤー順を決めておき、全てのパーツを重ねることでティキを作成しています。
そのため、パーツは全てA4サイズの画像となっており、全てのパーツが重なった時に変に重ならないようにA4サイズの中で位置を調整しています。

ティキを白黒線画に変換→保存

『変換』と書いていますが、実は作成した画像をカスタマイズで白黒線画にするなどの変換はしていません。
作成画面で選択したカラーパーツと同じ形の白黒線画を使用することで、あたかも変換したように見せています。

具体的例で紹介していきます。

パーツマスタにある全てのパーツは『カラーパーツ』と『白黒線画パーツ』それぞれ1枚ずつ管理しています。
そして、同じパーツには同じparts IDを付与しています。

そして、ティキ作成の保存ボタンを押したタイミングで、あらかじめパーツマスタから取得していた同じPartsIDかつ、カラー種別が『モノクロ』のデータを取得し、印刷用画像を作成する仕組みになってます。

PartsIDでカラーパーツに該当するモノクロパーツをマッピング

// ====== パーツ取得 (fetchParts 関数内) ======

records.forEach(r => {
  // ... (省略) ...
  const partsId = r[PART_FIELD.code].value || '';
  const colorType = r[PART_FIELD.colorType]?.value;
  // ... (省略) ...
  if (colorType === 'カラー') {
    // ... (カラーパーツは通常リストに格納) ...
  } else if (colorType === 'モノクロ') {
    // ★ モノクロパーツは PartsID をキーにしてファイルキーをマップに格納
    monoFileKeyMap.set(partsId, file.fileKey);
  }
});

保存時にマッピングしたモノクロ画像を使って画像を生成

// ====== 合成(モノクロ) (composeMonochromeTikiBlob 関数内) ======

for (const jpType of jpOrder) {
  const part = selected[jpType]; // ユーザーが選択したカラーパーツ
  if (!part || !part.partsId) continue;

  // ★ 選択されたパーツの PartsID をキーにして、対応するモノクロパーツの fileKey を取得
  const monoFileKey = monoFileKeyMap.get(part.partsId); 
  if (!monoFileKey) {
    console.warn(`[Tiki Mono] PartsID: ${part.partsId} に対応するモノクロパーツが見つかりません。`);
    continue;
  }
    
  // モノクロパーツの画像を取得し描画
  const blobUrl = await fetchBlobUrlByFileKey(monoFileKey);
  // ... (画像を描画する処理) ...
}

後は、印刷用画像を添付ファイルに保存してティキの作成は完了です!

AIによるティキ名&属性付与

オリジナルティキを作成して保存ボタンを押すと、名前を入れるダイアログが表示されます。
名前を入れてOKをクリックすると、AIティキマスターが名前にあうティキ名と、マナ(属性)を授けてくれる、というAIを使った機能を実装しました。

この部分についての仕組みを紹介します。

AIティキマスター構成

AIティキマスターは、ChatGPT のAI機能をアプリやシステムから使えるサービス「OpenAI API」を使用しています。

①名前を入れるダイアログに名前を入れてOKをクリックする。
②kintoneがLambda関数URLに対してAI処理を依頼するリクエストを送る。
③LambdaがOpenAI APIへプロンプトなどのリクエストを送信する。
④OpenAI APIが生成した結果(ティキ名・説明・属性など)をLambdaへ返す。
⑤Lambdaが受け取った結果をkintoneへ返す。
⑥kintoneが結果を画面に反映し、ユーザーに表示する。

kintoneから直接OpenAI APIのURLを実行できない理由

kintone のカスタマイズはブラウザの中で動くため、外部サービスに直接アクセスしようとすると、ブラウザの安全機能(CORS)によってブロックされます。
OpenAI API はブラウザからの直接アクセスを許可する設定がないため、kintone からはそのまま呼び出せず、間に AWS Lambda などの中継サーバーが必要になります。

事前に必要なもの

OpenAI の API キー
AI にアクセスするための認証キーです。OpenAI の管理画面から取得します。

AWS アカウント(Lambda が使える状態)
OpenAI API を呼び出すための中継サーバーとして Lambda を利用します。

kintone の環境(JavaScript カスタマイズが可能なユーザー権限)
kintone から Lambda を呼び出すため、システム管理権限 または アプリ管理権限 が必要になります。

AWS Lambdaの設定

AWS Lambdaには以下のような関数を作成して、関数URLを取得します。

・関数名:generateTikiName
・ランタイム:Node.js 22.x
・アーキテクチャ:arm64
・関数URLを有効化:Enable
  認証タイプ:NONE
  オリジン間リソース共有(CORS)を設定:チェック

Lambdaの実行ファイル(index.mjs)は以下

import OpenAI from 'openai';

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

export const handler = async (event) => {
  try {
    const body = JSON.parse(event.body);
    const nameRule = '入力した名前を元にティキのキャラクター名をアルファベットのハワイ語っぽいもので1つだけ命名して。回答はJSONで返してください。JSONのキーはnameとmemoとattributeでnameは10文字以内のティキの名前を1つ、memoは30文字程度で名前の説明を書いて、attributeはティキの属性[水、風、炎、草、愛]の中から1つだけティキ名に近いものを選択してください。'
    
    const openAiResp = await openai.chat.completions.create({
      model: body.model,
      messages: [
        { role: 'system', content: nameRule },
        { role: 'user', content: body.name }
      ],
      response_format: body.responseFormat
    });
    return {
      statusCode: 200,
      headers: {
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': '*'
      },
      body: JSON.stringify({
        response: openAiResp.choices[0].message.content
      })
    };
  } catch(error) {
    console.error('OpenAI の処理に失敗しました:', error);
    return {
      statusCode: 500,
      headers: {
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': '*'
      },
      body: JSON.stringify({error: 'Internal Server Error'})
    };
  }
};

補足:この index.mjs では OpenAI のモジュールを import しているため、コードだけでは実行できません。
Lambda にデプロイする際に、openai パッケージ(node_modules)を一緒に含めてアップロードしてください。

さらに、環境変数にOpenAIのAPIキーを設定します。

なお、今回は構成をできるだけシンプルにするため、AWS Lambda の関数URLを使って kintone から呼び出す形にしています。
関数URLは設定が少なく、すぐに動かせるというメリットがありますが、アクセス元の制限など細かなセキュリティ設定はできません。

そのため、外部からのアクセス制御をしっかり行いたい場合や、特定のドメインやIPだけに限定したい場合は、API Gateway を使用して Lambda を公開する方法が推奨されます。

kintoneの設定

ティキギャラリーアプリには次のフィールドを作成しておきます。

  • 文字列(1行)フィールド
    • あなたの名前
    • ティキ名
    • 属性
    • 説明
  • スペースフィールド
    • request

JavaScriptカスタマイズを設定します。
実際のアプリでは、モバイル画面で作成したティキを保存したら名前を入れるダイアログが表示されて、OKをクリックしたらAPIを実行しますが、ここでは既に名前が入っているレコードの詳細画面で、ボタンをクリックしたらAPIを実行するコードにしておきます。

また、OpenAI APIから結果が返ってくるのに時間がかかるので「AIティキマスターがティキ名とマナを授けます」というダイアログを出すようにもしましたが、こちらも省略します。

(() => {
  'use strict';
  kintone.events.on('app.record.detail.show', (event) => {
    let record = event.record;
    // (requestというスペースフィールドにrequestButtonという名前で「命名する」ボタンを追加するコードをここに入れる。今は省略。)

    //ボタンを押すとLambda関数URLを呼び出して、命名するAPIを実⾏する
    requestButton.onclick = async () => {
      if (record['あなたの名前'].value === '') {
        window.alert('あなたの名前を入力してください。');
        return;
      }
      // (待ち時間にダイアログ表示はここに入れる。今は省略。)
      try {
        const [body] = await kintone.proxy(
          '(ここにLambda関数URLを入力)',
          'POST',
          { 'Content-Type': 'application/json' },
          { model: 'gpt-5-nano', // モデルはgpt-5-nanoを使用
            name: record['あなたの名前'].value,
            responseFormat: { type: 'json_object' }
          },
        );
        const parsedBody = JSON.parse(body);
        const responseJson = JSON.parse(parsedBody.response);
        // レコードに結果を反映する
        await kintone.api(kintone.api.url('/k/v1/record.json', true), 'PUT', {
          app: kintone.app.getId(),
          id: kintone.app.record.getId(),
          record: {
            'ティキ名': { value: responseJson.name },
            '説明': { value: responseJson.memo },
            '属性': { value: responseJson.attribute }
          }
        })
        location.reload();
      } catch (error) {
        console.error(error);
        window.alert('リクエストに失敗しました。');
        hibiscusContainer.style.display = 'none';
      }
    };
  return event;
  });
})();

「命名する」ボタンをクリックすると、OpenAI APIに入力した名前と「ハワイアンな名前をつけて」という指示を投げます。

しばらくすると、ハワイアンなティキ名、説明と属性がJSON形式で返ってくるので、その値でkintoneティキギャラリーアプリのレコードを更新して命名完了です。

「カオリ」という同じ名前を入れても、以下のようにいろいろな説明や属性が返ってくるので楽しかったです。

ティキ名説明属性
Kailoa香りと風を結ぶティキ。海風と共に旅を導く守護者。
Kailani海と天空をつなぐティキ。Kailaniという優しい響きの名。
Kaolei香りを運ぶ風のティキ、優しく人を愛で包む守り手、海の声を聴く。

ちなみに属性はなぜか「風」が多く、7割以上のティキに「風」が割り当てられていました。

ティキの印刷機能や光るギミック、ティキの世界観に沿った装飾といった『各種ギミック』の仕組みについては後編に続きます。ぜひご覧ください。

kintoneでオリジナルティキシールを作ろう!〜後編〜

弊社では初回開発無料の定額39万円でkintoneアプリを開発する定額型開発サービス「システム39」を提供しております。kintoneの導入やアプリ開発でお困りの方は、お気軽にご相談ください。
*Webでの打ち合わせも可能です。

   kintoneのアプリ開発はこちら <相談無料>    

同じカテゴリーの記事