0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

全部ブラウザで完結する変換ツールを作ったら、CSSとSEOで盛大にハマった

0
Posted at

PDFを結合したいだけなのに

仕事の契約書を1つのPDFにまとめたい。ただそれだけなのに、検索して出てきた無料ツールに契約書をアップロードするのは、正直かなり抵抗があります。

  • そのファイルがサーバー上でいつ消えるのか、書いてあっても確かめようがない
  • 会社によっては、外部サービスへのアップロードがそもそも規約で禁止されている
  • フリマに出す写真に位置情報(EXIF)が残っているのを消したいのに、消すために写真をアップロードするという矛盾

「変換のためにファイルを預ける」という前提を、そもそも無くせないだろうか。

ブラウザの上でWebAssemblyもCanvasも動く今なら、変換処理を全部ブラウザの中でやってしまえば、ファイルを送る必要がないはずです。そう考えて作ったのが「なんでも工房」です。

なんでも工房のトップページ

動画・音声の変換、PDFの結合分割・書き込み、画像の編集、QRコード生成など19個のツールが入っていますが、サーバー側の処理コードは1行も存在しません。GitHub Pagesのような静的配信でも動きます。

例えば画像圧縮では、スライダーを動かすたびに実際の圧縮をかけ直して、圧縮後の見た目と実ファイルサイズをその場で表示しています。サーバー往復が無いので、こういう「結果を見ながら決める」UIが軽く作れるのは、この構成の副産物でした。

画像圧縮の before/after プレビュー

この記事では、実際にどう作ったかと、作る過程で踏み抜いた落とし穴を書きます。


全体像:何をどのライブラリで処理しているか

機能 使っているもの
動画・音声の変換 ffmpeg.wasm
PDFの読み込み・描画 pdf.js (pdfjs-dist)
PDFの生成・書き出し pdf-lib / jsPDF
画像編集全般 Canvas API(ライブラリなし)
GIFアニメ生成 gifenc
EXIF解析 exifr
QRコード生成・読み取り qrcode / jsQR
ホスティング Vercel(静的配信のみ)

ビルドはVite + 素のTypeScriptです。フレームワークは使っていません。

ポイントは、重いライブラリを全部 dynamic import にしていることです。

{
  id: "pdf-merge-split",
  label: "PDF結合・分割",
  load: () => import("./tools/pdfMergeSplit").then((m) => m.buildPdfMergeSplitTool),
}

pdf.jsもffmpeg.wasmも巨大なので、トップページを開いただけで全部読み込むと目も当てられません。ツールを選んだ瞬間に初めて取りに行く形にしています。


ffmpeg.wasm をブラウザで動かす

一番「本当に動くのか?」と思われるのが動画変換だと思います。結論から言うと、普通に動きます。

読み込み

import { FFmpeg } from "@ffmpeg/ffmpeg";
import { toBlobURL } from "@ffmpeg/util";

const CORE_BASE_URL = "https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm";

const ffmpeg = new FFmpeg();
await ffmpeg.load({
  coreURL: await toBlobURL(`${CORE_BASE_URL}/ffmpeg-core.js`, "text/javascript"),
  wasmURL: await toBlobURL(`${CORE_BASE_URL}/ffmpeg-core.wasm`, "application/wasm"),
});

ここで大事なのが toBlobURL です。CDN上のURLをそのまま coreURL に渡すと、内部でWorkerを生成する際にクロスオリジンの制約に引っかかります。一度fetchしてBlob URLにしてから渡すことで回避しています。

もう1つ、マルチスレッド版(core-mt)ではなく通常版を使っている点も地味に重要です。マルチスレッド版は SharedArrayBuffer を使うため、Cross-Origin-Opener-PolicyCross-Origin-Embedder-Policy のヘッダ設定が必須になります。静的ホスティングでヘッダをいじりたくなかったので、シングルスレッド版を選びました。速度と引き換えに、配信環境を選ばない構成になっています。

インスタンスは使い回す

初回ロードが重いので、シングルトンにします。このとき、ロード中のPromiseも保持するのがポイントです。

let instance: FFmpeg | null = null;
let loadingPromise: Promise<FFmpeg> | null = null;

export function getFFmpeg(): Promise<FFmpeg> {
  if (instance) return Promise.resolve(instance);
  if (loadingPromise) return loadingPromise;   // ← これが無いと二重ロードする

  loadingPromise = (async () => {
    const ffmpeg = new FFmpeg();
    await ffmpeg.load({ /* ... */ });
    instance = ffmpeg;
    return ffmpeg;
  })();

  return loadingPromise;
}

instance のチェックだけだと、ロード完了前にもう一度呼ばれた場合に2回ロードが走ります。

実行と進捗

await ffmpeg.writeFile(inputName, await fetchFile(file));

ffmpeg.on("progress", ({ progress }) => {
  onProgress(Math.min(1, Math.max(0, progress)));
});

const exitCode = await ffmpeg.exec(["-i", inputName, ...args, outputName]);
if (exitCode !== 0) throw new Error(`変換に失敗しました(終了コード: ${exitCode})`);

const data = await ffmpeg.readFile(outputName);

引数はコマンドラインのffmpegとほぼ同じです。動画圧縮なら:

["-c:v", "libx264", "-preset", "veryfast", "-crf", String(crf), "-c:a", "aac", "-b:a", "128k"]

進捗が取れるのは体験上かなり重要でした。数十秒〜数分かかる処理で、何も出ないと固まったと思われます。

⚠️ 落とし穴:仮想FSにファイルが残り続ける

ffmpeg.wasm は内部に仮想ファイルシステムを持っています。writeFile した入力も exec が吐いた出力も、明示的に消すまで残ります。変換を繰り返すとメモリを食い潰すので、finally で必ず消します。

try {
  // ... exec して readFile
} finally {
  await ffmpeg.deleteFile(inputName).catch(() => {});
  await ffmpeg.deleteFile(outputName).catch(() => {});
}

画像編集は Canvas API だけでほぼ足りる

画像系のツールは外部ライブラリを一切使っていません。getImageDataglobalCompositeOperation を知っていれば、だいたい何とかなります。

背景透過:色の距離でアルファを0にする

クリックした色に近い画素を透明にする、いわゆる「同色透明化」です。

const maxDist = Math.sqrt(3 * 255 * 255);       // RGB空間の最大距離
const threshold = (tolerance / 100) * maxDist;  // 許容範囲スライダーの値

const d = imageData.data;
for (let i = 0; i < d.length; i += 4) {
  const dr = d[i]     - picked.r;
  const dg = d[i + 1] - picked.g;
  const db = d[i + 2] - picked.b;
  if (Math.sqrt(dr * dr + dg * dg + db * db) <= threshold) {
    d[i + 3] = 0;   // アルファだけ0にする
  }
}

やっていることは単純なRGBのユークリッド距離です。厳密にやるならLab色空間で比較すべきですが、実用上はこれで十分でした。

モザイク:縮めて、拡大する

モザイクの実装は驚くほど簡単です。一度小さく縮小して、補間を切って引き伸ばすだけ。

function applyMosaicRect(ctx, x, y, w, h, blockSize) {
  const small = document.createElement("canvas");
  const sw = Math.max(1, Math.round(w / blockSize));
  const sh = Math.max(1, Math.round(h / blockSize));
  small.width = sw;
  small.height = sh;

  // 縮小して情報を落とす
  small.getContext("2d").drawImage(ctx.canvas, x, y, w, h, 0, 0, sw, sh);

  // 補間なしで引き伸ばす → ブロック状になる
  ctx.imageSmoothingEnabled = false;
  ctx.drawImage(small, 0, 0, sw, sh, x, y, w, h);
  ctx.imageSmoothingEnabled = true;
}

imageSmoothingEnabled = false を戻し忘れると、以降の描画が全部ガビガビになるので注意。

ちなみに「ぼかし」のほうはもっと簡単で、ctx.filter = "blur(10px)" が使えます。

ペンでなぞった所だけモザイクをかける

ここは少し工夫が要りました。ペンでなぞった軌跡にだけモザイクをかけたいのですが、ストロークごとに部分モザイクを計算すると、ブロックの境界がズレて汚くなります。

解決策は、先に「全面モザイク版」のcanvasを作っておき、それをパターンとしてストロークで塗るという方法です。

// mosaicFull は画像全体にモザイクをかけた canvas(スライダー変更時に作り直す)
const pattern = penCtx.createPattern(mosaicFull, "no-repeat");

penCtx.strokeStyle = pattern;   // ← 色ではなく画像で線を引く
penCtx.lineWidth = penSize;
penCtx.lineCap = "round";
penCtx.beginPath();
penCtx.moveTo(from.x, from.y);
penCtx.lineTo(to.x, to.y);
penCtx.stroke();

createPatternno-repeat で作ると原点合わせでそのまま重なるので、なぞった部分だけモザイク版が露出する形になります。ブロックの位置は全面版と完全に一致するため、何度なぞっても継ぎ目が出ません。

ペンでなぞった部分だけにモザイクがかかる

消しゴム

ctx.globalCompositeOperation = "destination-out";
ctx.stroke();   // 描いた部分が透明になる

これだけです。

元に戻す

操作の直前に ImageData を積むだけ。素朴ですが十分実用的でした。

history.push(ctx.getImageData(0, 0, canvas.width, canvas.height));
if (history.length > 20) history.shift();   // 上限を決めないとメモリを食う

ハマった落とし穴

ここからが本題かもしれません。実装そのものより、こっちで時間を溶かしました。

1. <input type="range"> に代入した値は step に丸められる

画質スライダーで「0.35」のような細かい値を扱いたかったのですが、どうしても保持できませんでした。

原因は仕様です。range要素に .value を代入すると、HTMLの値サニタイズ処理で step の倍数に丸められます。step が 0.1 なら 0.35 は 0.4 になります。

input.step = "0.1";
input.value = "0.35";
console.log(input.value);   // "0.4"

対策は、真の値を別に持ち、range要素は表示専用として扱うこと。

let currentValue = 0.35;             // こっちが真の値
input.value = String(currentValue);  // range側は丸められてもよい

return { getValue: () => currentValue };   // range.value は読まない

2. CSSはスライダーの現在値を知れない

「ここまで色が満ちるメーター」をCSSだけで作ろうとして気付きました。CSSからは現在値が見えないので、JS側からCSS変数で渡すしかありません。

const pct = ((value - min) / (max - min)) * 100;
input.style.setProperty("--fill", `${pct}%`);
.slider-input::-webkit-slider-runnable-track {
  background-color: rgba(124, 108, 240, 0.18);
  background-image: linear-gradient(90deg, #7c6cf0, #ff8fb8);
  background-repeat: no-repeat;
  background-size: var(--fill) 100%;   /* ← ここ */
}

注意点が2つ。値が変わる経路すべてで更新すること(ドラッグ・数値入力・±ボタン…どれか1つ忘れると、そこだけ塗りが追従しません)。そして min/max で正規化すること。min=2, max=60 のスライダーで値が8のとき、塗りは 8% ではなく (8-2)/(60-2) = 10.3% です。

3. 1つの要素に ::after は1つしか作れない

これが一番ひどい事故でした。

ツールチップを [data-tooltip]::after で実装していたところに、ボタンの光沢アニメーションを .convert-button::after で追加しました。同じ要素の同じ擬似要素を2つのルールが奪い合う形になり、詳細度と記述順で1プロパティずつ勝敗が決まる、という地獄が発生します。

/* ツールチップ */
[data-tooltip]::after { content: attr(data-tooltip); left: 50%; }

/* 光沢(これを後から足した) */
.convert-button::after { content: ""; left: -60%; width: 40%; }
.convert-button:hover::after { left: 130%; }   /* ← 詳細度が高いので勝つ */

結果、一部のボタンでツールチップが完全に消え、別のボタンではホバー時にツールチップが画面の右方向にすっ飛んでいきました。

対策はシンプルで、::after はツールチップ専用と決め、装飾は ::before を使う。要素あたり ::before::after は1つずつしか無い、という当たり前の事実を意識して用途を割り当てるべきでした。

4. overflow: hidden は「自分の外に出る子」も切り取る

上の光沢アニメーションを要素内に収めるため、ボタンに overflow: hidden を付けました。すると今度はツールチップが表示されなくなりました

ツールチップはボタンの に出ます。ボタンは position: relative なので、絶対配置されたツールチップの基準(包含ブロック)になります。つまり overflow: hidden の対象は「ボタンの内側」ではなく「ボタンを基準に配置された全て」なので、外にはみ出すツールチップごと切られます

最終的に、ツールチップを持たないボタンにだけ光沢を適用する形にしました。

.convert-button:not([data-tooltip]) { overflow: hidden; }
.convert-button:not([data-tooltip])::before { /* 光沢 */ }

このとき、「透明度が1になっているから表示されているはず」と確認したのが失敗でした。opacity は 1 でも、クリップされていれば見えません。スクリーンショットを撮ってピクセルを数えるまで、直ったと勘違いしていました。視覚的な効果は、計算値ではなく実際の描画で確認すべきです。

5. overflow-y だけ指定しても、両方の軸がクリップされる

縦だけスクロールさせたくて overflow-y: auto を指定したら、横方向にはみ出す要素まで切られました。

これも仕様で、片方の軸を visible 以外にすると、もう片方が visible のままでは居られず auto に計算されるというルールがあります。片軸だけクリップ、はできません。

6. opacity: 0 の要素もレイアウト領域を占める

ある画面幅のときだけ、意味不明な横スクロールバーが出る現象に悩まされました。全要素の getBoundingClientRect() を調べても、はみ出している要素が1つも見つからない。

犯人は非表示状態のツールチップでした。opacity: 0 は「見えない」だけで、レイアウト上の場所は占め続けます。そして擬似要素なので querySelectorAll では引っかからず、原因特定に時間がかかりました。

display: none にして、ホバー時だけ display: block にすることで解決しています(フェードは諦めました)。

なお、overflow-x: clip で蓋をする方法も試しましたが、Chromeは body や html に指定した clip をビューポートのスクロールに反映してくれませんでした。小手先で隠すより、レイアウトから消すのが確実です。

7. クリックで出した入力欄からフォーカスが奪われる

画像上をクリックしてテキスト入力欄を出す機能で、入力欄が一瞬出てすぐ消えるという現象が起きました。

canvas.addEventListener("pointerdown", (e) => {
  showTextInput();
  textInput.focus();   // ← focusしているのに奪われる
});

原因は、pointerdown ハンドラの後に走るブラウザ標準の「クリックした要素にフォーカスを移す」処理です。こちらの focus() の後に実行されるので、フォーカスがcanvasに戻ってしまいます。

canvas.addEventListener("pointerdown", (e) => {
  e.preventDefault();   // ← デフォルトのフォーカス移動を止める
  showTextInput();
  textInput.focus();
});

Googleに1ページしか登録されていなかった

最後に、技術的には初歩的ですが、個人開発では致命的だった話を。

19個のツールを作り、サイトマップも入れ、Search Consoleにも登録しました。しばらくして「ページ」を見たら、インデックス登録済みページ数: 1

原因はルーティングでした。

https://example.com/#/app/pdf-merge-split

Googleは # 以降を無視します。ハッシュはあくまで「同一ページ内の位置」を指す仕様なので、19個のツールが全部「同じ1ページ」として扱われていたわけです。

これが何を意味するかというと、「PDF 結合 無料」で検索した人に対して、Googleは「PDF結合のページ」を出したいのに、そんなページは存在せず、全機能が雑多に載ったトップページ1枚しか無い。当然、どの検索語でも中途半端な評価になります。

やったこと

1. 実パスに変更

変更前: /#/app/pdf-merge-split
変更後: /tools/pdf-merge-split

location.hash を捨てて history.pushState に。古いURLは replaceState で新URLに転送し、ブックマークを死なせないようにしました。

2. ビルド時に各ページのHTMLを実ファイルとして生成

Viteのプラグインで、dist/index.html をテンプレートにして、ツールごとに title / description / canonical を差し替えたHTMLを吐き出します。

function seoPrerender(): Plugin {
  return {
    name: "seo-prerender",
    apply: "build",
    closeBundle() {
      const template = readFileSync("dist/index.html", "utf8");
      for (const route of TOOL_SEO) {
        const html = buildRouteHtml(template, route);   // titleなどを差し替え
        writeFileSync(`dist/${route.path}.html`, html, "utf8");
      }
      // sitemap.xml も同じリストから生成する
    },
  };
}

ルート情報を1つの配列で持ち、そこからページ生成もサイトマップ生成も行うようにしています。こうしておくと「サイトマップには載っているのにページが無い」という食い違いが原理的に起きません。

3. <button><a href> に変える

これを見落としていました。トップページの機能カードが <button> + クリックハンドラだったので、Googleがたどれるリンクが1本も無い状態でした。サイトマップだけでも一応発見はされますが、リンクで繋がっているほうが評価は上がります。

const card = document.createElement("a");
card.href = toolPath(f.id);
card.addEventListener("click", (e) => {
  // 修飾キー付きのクリックはブラウザに任せる(新しいタブで開けるように)
  if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
  e.preventDefault();
  navigate(toolPath(f.id));
});

結果、インデックス対象は 1 → 20 になりました。

⚠️ 落とし穴:rewrites が効かないことがある

SPAでパスルーティングをする場合、未知のURLを index.html に転送する設定が定番です。

{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }

ところが、この設定がまったく効きませんでした。同じ vercel.jsoncleanUrls は効いているのに、rewrites だけ無視され、未知のパスは全て NOT_FOUND を返す。記法を公式ドキュメントどおりの /:path* に変えても同じでした。

原因究明に時間をかけるより、そもそも rewrites に依存しない設計にする方が早いと判断しました。アプリが遷移しうるURLを全て実ファイルとして出力してしまえば、フォールバックは要りません。カテゴリページもASCIIのスラッグを振って実HTMLを生成し、noindex, follow を付けて出力しています。

副次的な効果として、存在しないURLがちゃんと404を返すようになりました。SPAフォールバックだと何を入れても200で中身が空、という「ソフト404」になりがちなので、SEO的にはむしろこちらのほうが健全です。


まとめ:ブラウザだけでどこまでできるか

できること

  • 一般的なサイズの動画・PDF・画像なら、実用的な速度で処理できる
  • 画像編集はライブラリ無しでほぼ何でもできる
  • サーバー処理が無いので、ホスティング費用は実質ゼロ

苦しいこと

  • 巨大な動画はメモリと時間を食う。処理時間は端末性能に完全に依存する
  • ffmpeg.wasm の初回ロードが重い(数十MB)
  • 当然ながら、処理中はそのタブを閉じられない

それでも最大の利点は、ファイルが手元から出ないことです。

「アップロードしないから安全です」と書く必要すらなく、実際に通信が発生していないので、DevToolsのNetworkタブを見てもらえば一目で分かります。信頼を説明ではなく構造で担保できるのは、この作り方の一番の強みだと思っています。

同じような「アップロードしたくない」を感じている人の参考になれば幸いです。


この記事はZennにも投稿しています。
https://zenn.dev/tamatamatama/articles/b08993f901c556

0
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?