見出し画像

Google Vidsの「1シーンずつの手動操作」を自動化:画像配置からプロンプト注入までを一括処理する拡張機能の実装 。一括生成の技術的解法:SPA特有の「クリックできない壁」をどう突破したか

Google Vids(Veo)を用いた動画生成業務において、最大のボトルネックはAIの生成時間そのものではなく、そこに至るまでの「人間による操作」にあります。

数十シーンに及ぶスライドを動画化する場合、シーンを選択し、対象画像を特定し、メニューを開き、プロンプトを入力して生成ボタンを押す……この一連のフローを人間が張り付いて行う必要があります。1シーンの処理に90秒かかるとして、20シーンあれば30分。その間、あなたはPCの前で「AIの機嫌」を伺いながら、単純なクリック作業を繰り返すことになります。

先日、この課題に対する技術的な検証を行い、プロトタイプの実装に関する記事([前回の記事リンク])を公開しました。当時は「DOMの特定」や「タイミング制御」に焦点を当てていましたが、あくまで実験的な実装であり、長時間稼働させると停止するなどの課題が残っていました。

あれから数週間、発生していたエラーケースを徹底的に解析し、コードを根本から再設計しました。
今回は、エラーハンドリングとリカバリー機能を強化し、実務での連続稼働に耐えうる仕様へとアップデートした v1.1.0 (Stable) について、その全コードと実装詳細を解説します。

これは「魔法のツール」ではありません。Google Vidsという複雑なSingle Page Application (SPA) に対し、泥臭いDOM監視と状態管理を実装することで実現した、実用的な自動化ワークフローの事例です。

拡張機能のロジック


1. Google Vids自動化における技術的障壁

Google Vidsは、ReactやClosure Libraryといった高度なフレームワークによって構築されています。そのため、Seleniumや単純なJavaScriptスクリプトで操作しようとすると、以下の3つの壁に直面します。

  1. State(状態)の乖離:
    単純に `input.value = "text"` と書き換えても、React内部のStateは更新されません。画面上は文字が入っていても、アプリケーション側は「空」と認識し、送信ボタンが活性化しません。

  2. DOMの動的生成と隠蔽:
    画面上に見えているボタンと同じクラス名やラベルを持つ「不可視のボタン」がDOMツリー内に多数存在します。単純なセレクタ指定では、誤って無効な要素を操作してしまい、処理が中断します。

  3. 外部APIのレート制限とエラー:
    連続して生成を行うと、「Something went wrong」といったエラーダイアログが表示されることがあります。これに対処できなければ、自動化は数シーンで停止します。

本拡張機能は、これらの課題をコードレベルで解決するために設計されました。


2. 拡張機能 "Auto Clip-to-Video" v1.1.0 の概要

概要

今回作成したChrome拡張機能は、Google Vidsの編集画面(タイムライン)を自動で走査し、ユーザーが指定したプロンプトリストに基づいて一括生成を行うものです。

主な機能

  • シーケンシャル処理: 全シーンを順番に選択し、画像から動画への変換(Clip to Video)を実行。

  • プロンプト一括管理: UIパネル上で全シーン分のプロンプトを管理。シーン番号に応じた自動割り当て。

  • 状態の永続化: UIの位置や入力内容をローカルストレージに保存し、リロード後も作業を継続可能。

  • エラー等の自動復帰: 生成エラー発生時のリトライや、DOM読み込み遅延時の待機延長。


3. 導入手順:3分で環境を構築する

拡張機能の操作画面

ここからは、実際にこのツールをご自身の環境に導入する手順を詳細に解説します。エンジニアでなくとも、以下の手順通りに進めれば確実に動作します。

ステップ1:専用フォルダの作成

まず、PCの任意の場所(デスクトップなど管理しやすい場所)に、新しいフォルダを作成してください。
フォルダ名は任意ですが、ここではわかりやすく `vids-auto-operator` とします。

ステップ2:構成ファイルの作成

この拡張機能は、2つのファイル(`manifest.json` と `content.js`)で構成されています。先ほど作成したフォルダの中に、以下の2つのファイルを作成し、後述するソースコードをコピペして保存してください。

ファイル1:`manifest.json`

拡張機能の「設計図」となるファイルです。Chromeに対して「どのページで」「どんな権限で」動くかを指示します。

作成方法:

  1. テキストエディタ(メモ帳、VS Codeなど)を開きます。

  2. 下記のコードをコピーして貼り付けます。

  3. ファイル名を `manifest.json` として、`vids-auto-operator` フォルダ内に保存します。
    ※ 保存時、ファイルの種類を「すべてのファイル」にし、拡張子が `.txt` にならないよう注意してください。

{
  "manifest_version": 3,
  "name": "Google Vids Auto Clip-to-Video (Stable)",
  "version": "1.1.0",
  "description": "Automates image-to-video conversion in Google Vids. Features real-time status UI, error recovery, and scene-prompt synchronization.",
  "content_scripts": [
    {
      "matches": ["https://docs.google.com/videos/*"],
      "js": ["content.js"],
      "run_at": "document_idle",
      "all_frames": false
    }
  ],
  "permissions": ["storage"]
}

ファイル2:`content.js`

こちらが「頭脳」となるプログラム本体です。Reactの操作ハックやリトライロジックなど、約500行に及ぶ処理が記述されています。

作成方法:

  1. 新規ファイルをテキストエディタで開きます。

  2. 下記の長いコードをすべてコピーして貼り付けます。

  3. ファイル名を `content.js` として、同じく `vids-auto-operator` フォルダ内に保存します。

(() => {
  'use strict';

  // 二重起動防止
  if (window.top !== window.self) return;
  if (window.__VIDS_AUTO_CLIPTO_VIDEO_DEBUG__) return;
  window.__VIDS_AUTO_CLIPTO_VIDEO_DEBUG__ = true;

  const CONFIG = {
    DRY_RUN: false,
    MAX_SCENES: 999,
    PROCESS_ONLY_FIRST_IMAGE_PER_SCENE: true,
    
    RETRY_STEP: 3,         // UI操作(クリック等)のリトライ回数
    RETRY_GENERATE: 3,     // 生成失敗時のリトライ回数(Vidsエラー対策)
    RETRY_DELAY_MS: 3000,  // 生成リトライ前の待機時間

    WAIT_INTERVAL_MS: 200,
    TIMEOUT_UI_MS: 30000,
    TIMEOUT_GENERATE_MS: 180000, // 3分 (生成待ちタイムアウト)
    STABLE_DOM_MS: 900,

    GENERATE_ENABLE_TIMEOUT_MS: 20000,

    PROMPT_TEXT: (sceneIndex, imageIndex) =>
      `Convert this image into a short video clip. Keep style consistent. Scene=${sceneIndex + 1}, Image=${imageIndex + 1}.`,

    REQUIRE_ON_EDIT_PAGE: true,

    USER_PROMPT_STORAGE_KEY: 'vidsAutoClipToVideo.userPrompts.v1',
    BASE_PROMPT_TOGGLE_STORAGE_KEY: 'vidsAutoClipToVideo.includeBasePrompt.v1'
  };

  const SELECTORS = {
    timelineRoot: '#timeline-container',
    filmstripRoot: '#filmstrip',
    sceneButtons: 'rect[role="button"][aria-label^="Scene "]',
    sceneActiveClassAny: [
      'appsFlixTimelineSceneItemActiveOuterBorder',
      'appsFlixTimelineSceneItemActiveInnerBorder'
    ],

    workspaceContainer: '#workspace-container',
    canvasContainer: '#canvas-container',
    canvas: '#canvas',
    pageSvg: '#pagessvg',

    clipOrConvertButtonCandidates: [
      '[role="button"][aria-label*="Convert to video"]',
      'button[aria-label*="Convert to video"]',
      '[role="button"][aria-label*="Clip to video"]',
      'button[aria-label*="Clip to video"]'
    ],
    clipOrConvertTextRegex: /(convert|clip)\s*to\s*video/i,

    promptCandidates: [
      'textarea',
      'input[type="text"]',
      '[contenteditable="true"]'
    ],
    promptAttrRegex: /(prompt|describe|instruction)/i,

    generateCandidates: [
      '[role="button"]',
      'button'
    ],
    generateTextRegexExact: /^generate$/i,
    generateTextRegexLoose: /\bgenerate\b/i,

    // エラー検出用
    errorMessage: '.videoGenCreationViewErrorMessage',
    
    closeTextRegex: /^(close|done|back|cancel)$/i
  };

  const state = {
    running: false,
    stopRequested: false,
    ui: null,

    userPrompts: [],
    plannedScenes: 0,
    processedScenes: 0,
    includeBasePrompt: false
  };

  function now() {
    const d = new Date();
    return d.toISOString().replace('T', ' ').replace('Z', '');
  }

  // ステータス更新(UI改善)
  function setStatus(sceneIndex, totalScenes, action, detail = '') {
    if (!state.ui) return;
    
    if (!state.running) {
      state.ui.status.textContent = action;
      return;
    }

    // Scene X/Y 形式で統一
    const sceneText = `Scene ${sceneIndex}/${totalScenes}`;
    const detailText = detail ? ` (${detail})` : '';
    state.ui.status.textContent = `${sceneText}: ${action}${detailText}`;
  }

  function logLine(msg) {
    const line = `[${now()}] ${msg}`;
    console.log(line);
    if (state.ui) {
      state.ui.log.value += line + '\n';
      state.ui.log.scrollTop = state.ui.log.scrollHeight;
    }
  }

  const sleep = (ms) => new Promise(r => setTimeout(r, ms));

  async function waitFor(fn, {
    timeoutMs = CONFIG.TIMEOUT_UI_MS,
    intervalMs = CONFIG.WAIT_INTERVAL_MS,
    label = 'waitFor'
  } = {}) {
    const t0 = Date.now();
    while (true) {
      if (state.stopRequested) throw new Error('STOP_REQUESTED');
      try {
        const v = fn();
        if (v) return v;
      } catch (_) {}
      if (Date.now() - t0 > timeoutMs) throw new Error(`TIMEOUT: ${label} (${timeoutMs}ms)`);
      await sleep(intervalMs);
    }
  }

  async function waitForStableDom(target, stableMs = CONFIG.STABLE_DOM_MS, timeoutMs = CONFIG.TIMEOUT_UI_MS) {
    if (!target) return;
    const t0 = Date.now();
    let last = Date.now();

    const obs = new MutationObserver(() => { last = Date.now(); });
    obs.observe(target, { subtree: true, childList: true, attributes: true, characterData: true });

    try {
      while (true) {
        if (state.stopRequested) throw new Error('STOP_REQUESTED');
        if (Date.now() - last >= stableMs) return;
        if (Date.now() - t0 > timeoutMs) throw new Error(`TIMEOUT: waitForStableDom (${timeoutMs}ms)`);
        await sleep(100);
      }
    } finally {
      obs.disconnect();
    }
  }

  function isVisible(el) {
    if (!el) return false;
    try {
      const style = getComputedStyle(el);
      if (style && (style.visibility === 'hidden' || style.display === 'none')) return false;
    } catch (_) {}
    const r = el.getBoundingClientRect?.();
    if (!r) return true;
    return r.width > 0 && r.height > 0;
  }

  function getClickablePoint(el) {
    const r = el.getBoundingClientRect();
    return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
  }

  function dispatchMouse(el, type, x, y) {
    el.dispatchEvent(new MouseEvent(type, {
      bubbles: true,
      cancelable: true,
      composed: true,
      clientX: x,
      clientY: y
    }));
  }

  async function clickAtPoint(x, y) {
    const el = document.elementFromPoint(x, y);
    if (!el) return false;
    
    dispatchMouse(el, 'mousemove', x, y);
    await sleep(40); 
    dispatchMouse(el, 'mousedown', x, y);
    await sleep(50); 
    dispatchMouse(el, 'mouseup', x, y);
    await sleep(40);
    dispatchMouse(el, 'click', x, y);
    
    return true;
  }

  async function safeClick(el, label = 'click') {
    if (!el) throw new Error(`NO_ELEMENT: ${label}`);
    el.scrollIntoView?.({ block: 'center', inline: 'center' });

    await sleep(100);
    try { el.click(); } catch (_) {}
    await sleep(100);

    try {
      const { x, y } = getClickablePoint(el);
      await clickAtPoint(x, y);
    } catch (_) {}

    await sleep(800);
  }

  function setNativeValue(input, value) {
    const proto = Object.getPrototypeOf(input);
    const desc = Object.getOwnPropertyDescriptor(proto, 'value');
    if (desc?.set) desc.set.call(input, value);
    else input.value = value;
  }

  async function setText(el, value) {
    if (!el) throw new Error('NO_PROMPT_ELEMENT');
    el.focus?.();

    const tag = (el.tagName || '').toLowerCase();
    
    if (el.isContentEditable) {
      el.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, composed: true, inputType: 'insertText', data: value }));
      el.textContent = value;
      el.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true }));
      // blur等は呼び出し側で適宜
      return;
    }

    if (tag === 'textarea' || tag === 'input') {
      el.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, composed: true, inputType: 'insertText', data: value }));
      setNativeValue(el, value);
      el.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true }));
      el.dispatchEvent(new Event('change', { bubbles: true }));
      return;
    }

    el.textContent = value;
    el.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true }));
  }

  // ステップ実行のリトライ
  async function retry(fn, label) {
    let lastErr = null;
    for (let i = 0; i <= CONFIG.RETRY_STEP; i++) {
      if (state.stopRequested) throw new Error('STOP_REQUESTED');
      try {
        if (i > 0) logLine(`⚠ Retry step ${i}/${CONFIG.RETRY_STEP}: ${label}`);
        return await fn();
      } catch (e) {
        lastErr = e;
        await sleep(250);
      }
    }
    throw lastErr || new Error(`FAILED: ${label}`);
  }

  function parseUserPrompts(rawText) {
    const src = String(rawText || '');
    return src
      .split(/\r?\n/)
      .map(s => s.trim())
      .filter(s => s.length > 0);
  }

  function countUserPrompts(rawText) {
    return parseUserPrompts(rawText).length;
  }

  function loadUserPromptText() {
    try { return localStorage.getItem(CONFIG.USER_PROMPT_STORAGE_KEY) || ''; } catch (_) { return ''; }
  }

  function saveUserPromptText(text) {
    try { localStorage.setItem(CONFIG.USER_PROMPT_STORAGE_KEY, String(text || '')); } catch (_) {}
  }

  function loadIncludeBasePrompt() {
    try { return (localStorage.getItem(CONFIG.BASE_PROMPT_TOGGLE_STORAGE_KEY) || '') === '1'; } catch (_) { return false; }
  }

  function saveIncludeBasePrompt(v) {
    try { localStorage.setItem(CONFIG.BASE_PROMPT_TOGGLE_STORAGE_KEY, v ? '1' : '0'); } catch (_) {}
  }

  const UI = (() => {
    const rootId = 'vids-auto-clipto-video-ui';
    const POS_KEY = 'vidsAutoClipToVideo.toastPos.v1';

    function buttonCss(bg) {
      return `
        padding: 6px 10px;
        border-radius: 8px;
        border: 1px solid rgba(255,255,255,0.15);
        background: ${bg};
        color: #fff;
        cursor: pointer;
      `;
    }

    function loadPos() {
      try {
        const raw = localStorage.getItem(POS_KEY);
        if (!raw) return null;
        const pos = JSON.parse(raw);
        if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number') return null;
        return pos;
      } catch (_) { return null; }
    }

    function savePos(pos) {
      try { localStorage.setItem(POS_KEY, JSON.stringify({ left: pos.left, top: pos.top })); } catch (_) {}
    }

    function clearPos() {
      try { localStorage.removeItem(POS_KEY); } catch (_) {}
    }

    function clampToViewport(root, left, top) {
      const r = root.getBoundingClientRect();
      const maxLeft = Math.max(0, window.innerWidth - r.width);
      const maxTop = Math.max(0, window.innerHeight - r.height);
      return {
        left: Math.min(Math.max(0, left), maxLeft),
        top: Math.min(Math.max(0, top), maxTop)
      };
    }

    function applyLeftTop(root, left, top) {
      root.style.left = `${left}px`;
      root.style.top = `${top}px`;
      root.style.right = 'auto';
      root.style.bottom = 'auto';
    }

    function resetToDefault(root) {
      root.style.left = 'auto';
      root.style.top = 'auto';
      root.style.right = '12px';
      root.style.bottom = '12px';
    }

    function ensureDraggable(root, handle) {
      let dragging = false;
      let startX = 0, startY = 0, startLeft = 0, startTop = 0;

      function ensureUsingLeftTop() {
        const rect = root.getBoundingClientRect();
        if (root.style.left && root.style.left !== 'auto') return;
        const pos = clampToViewport(root, rect.left, rect.top);
        applyLeftTop(root, pos.left, pos.top);
      }

      handle.addEventListener('pointerdown', (ev) => {
        if (ev.button !== 0) return;
        dragging = true;
        ensureUsingLeftTop();
        const rect = root.getBoundingClientRect();
        startX = ev.clientX; startY = ev.clientY;
        startLeft = rect.left; startTop = rect.top;
        handle.setPointerCapture?.(ev.pointerId);
        ev.preventDefault();
      });

      handle.addEventListener('pointermove', (ev) => {
        if (!dragging) return;
        const dx = ev.clientX - startX;
        const dy = ev.clientY - startY;
        const next = clampToViewport(root, startLeft + dx, startTop + dy);
        applyLeftTop(root, next.left, next.top);
        ev.preventDefault();
      });

      function endDrag(ev) {
        if (!dragging) return;
        dragging = false;
        const rect = root.getBoundingClientRect();
        const pos = clampToViewport(root, rect.left, rect.top);
        applyLeftTop(root, pos.left, pos.top);
        savePos(pos);
        try { handle.releasePointerCapture?.(ev.pointerId); } catch (_) {}
      }

      handle.addEventListener('pointerup', endDrag);
      handle.addEventListener('pointercancel', endDrag);
      handle.addEventListener('dblclick', () => {
        clearPos();
        resetToDefault(root);
      });
      window.addEventListener('resize', () => {
        const rect = root.getBoundingClientRect();
        if (!root.style.left || root.style.left === 'auto') return;
        const pos = clampToViewport(root, rect.left, rect.top);
        applyLeftTop(root, pos.left, pos.top);
        savePos(pos);
      }, { passive: true });
    }

    function ensure(logFn) {
      let root = document.getElementById(rootId);
      if (root) return root.__uiRefs;

      root = document.createElement('div');
      root.id = rootId;
      root.style.cssText = `
        position: fixed;
        right: 12px; bottom: 12px;
        z-index: 2147483647;
        width: 420px; max-height: 75vh;
        display: flex; flex-direction: column; gap: 8px;
        padding: 10px;
        background: rgba(20,20,20,0.95);
        color: #fff;
        border: 1px solid rgba(255,255,255,0.2);
        border-radius: 12px;
        font: 12px/1.4 system-ui, sans-serif;
        box-shadow: 0 4px 12px rgba(0,0,0,0.3);
      `;

      const title = document.createElement('div');
      title.textContent = 'Vids Auto Clip-to-Video (v1.2.0)';
      title.style.cssText = `font-weight: 700; cursor: move; padding: 2px 0; border-bottom: 1px solid rgba(255,255,255,0.1); margin-bottom: 4px;`;

      const row = document.createElement('div');
      row.style.cssText = `display:flex; gap:8px; align-items:center; flex-wrap:wrap;`;

      const startBtn = document.createElement('button');
      startBtn.textContent = 'Start';
      startBtn.style.cssText = buttonCss('#2e7d32');

      const stopBtn = document.createElement('button');
      stopBtn.textContent = 'Stop';
      stopBtn.style.cssText = buttonCss('#b71c1c');

      const basePromptLabel = document.createElement('label');
      basePromptLabel.style.cssText = `display:flex; gap:6px; align-items:center; user-select:none; cursor:pointer;`;
      const includeBasePrompt = document.createElement('input');
      includeBasePrompt.type = 'checkbox';
      includeBasePrompt.checked = loadIncludeBasePrompt(); 
      const includeBasePromptText = document.createElement('span');
      includeBasePromptText.textContent = 'Include base prompt';
      basePromptLabel.append(includeBasePrompt, includeBasePromptText);
      row.append(startBtn, stopBtn, basePromptLabel);

      const promptHeader = document.createElement('div');
      promptHeader.style.cssText = `display:flex; align-items:center; justify-content:space-between; gap:8px; margin-top:4px;`;
      const promptLabel = document.createElement('div');
      promptLabel.textContent = 'Prompts (1 line = 1 scene)';
      promptLabel.style.cssText = `opacity:0.9; font-size: 11px;`;
      const promptCount = document.createElement('div');
      promptCount.textContent = '0 lines';
      promptCount.style.cssText = `opacity:0.9; font-weight:600; font-size: 11px;`;
      promptHeader.append(promptLabel, promptCount);

      const userPromptInput = document.createElement('textarea');
      userPromptInput.placeholder = 'Prompt for scene 1\nPrompt for scene 2...';
      userPromptInput.spellcheck = false;
      userPromptInput.style.cssText = `
        width: 100%; height: 100px;
        resize: vertical;
        background: rgba(0,0,0,0.3);
        color: #eee;
        border: 1px solid rgba(255,255,255,0.15);
        border-radius: 6px;
        padding: 6px;
        outline: none;
        font-family: monospace;
      `;

      const status = document.createElement('div');
      status.textContent = 'Idle';
      status.style.cssText = `
        font-weight:600; color: #81d4fa; 
        white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
        padding: 4px 0;
      `;

      const log = document.createElement('textarea');
      log.readOnly = true;
      log.spellcheck = false;
      log.style.cssText = `
        width: 100%; height: 200px;
        resize: vertical;
        background: rgba(0,0,0,0.3);
        color: #bbb;
        border: 1px solid rgba(255,255,255,0.1);
        border-radius: 6px;
        padding: 6px;
        outline: none;
        font-family: monospace; font-size: 11px;
      `;

      root.append(title, row, promptHeader, userPromptInput, status, log);
      document.documentElement.appendChild(root);

      const saved = loadPos();
      if (saved) {
        const pos = clampToViewport(root, saved.left, saved.top);
        applyLeftTop(root, pos.left, pos.top);
      } else {
        resetToDefault(root);
      }

      ensureDraggable(root, title);

      userPromptInput.value = loadUserPromptText();
      const updateCount = () => {
        const n = countUserPrompts(userPromptInput.value);
        promptCount.textContent = `${n} lines`;
        saveUserPromptText(userPromptInput.value);
      };
      userPromptInput.addEventListener('input', updateCount);
      updateCount();

      includeBasePrompt.addEventListener('change', () => {
        saveIncludeBasePrompt(!!includeBasePrompt.checked);
      });

      const refs = {
        root, title, startBtn, stopBtn,
        includeBasePrompt, userPromptInput, promptCount,
        status, log
      };
      root.__uiRefs = refs;
      return refs;
    }
    return { ensure };
  })();

  function isVidsEditPage() {
    return /\/edit\b/.test(location.href);
  }

  function getTimelineRoot() { return document.querySelector(SELECTORS.timelineRoot); }
  function getFilmstripRoot() { return document.querySelector(SELECTORS.filmstripRoot); }
  function getCanvasContainer() { return document.querySelector(SELECTORS.canvasContainer); }
  function getPageSvg() { return document.querySelector(SELECTORS.pageSvg); }

  function getSceneButtons() {
    const scope = getFilmstripRoot() || getTimelineRoot() || document;
    const all = Array.from(scope.querySelectorAll(SELECTORS.sceneButtons));
    const map = new Map();
    for (const el of all) {
      const label = el.getAttribute('aria-label') || '';
      if (!label) continue;
      if (!map.has(label)) map.set(label, el);
    }
    return Array.from(map.values());
  }

  function isSceneActive(sceneEl) {
    const cls = sceneEl.getAttribute('class') || '';
    return SELECTORS.sceneActiveClassAny.some(c => cls.includes(c));
  }

  function getSvgHref(img) {
    const a1 = img.getAttribute?.('href');
    const a2 = img.getAttribute?.('xlink:href');
    const p = img.href?.baseVal;
    return (a1 || a2 || p || '').trim();
  }

  function getLikelySceneImages() {
    const roots = [];
    if (document.querySelector(SELECTORS.workspaceContainer)) roots.push(document.querySelector(SELECTORS.workspaceContainer));
    if (getCanvasContainer()) roots.push(getCanvasContainer());
    if (document.querySelector(SELECTORS.canvas)) roots.push(document.querySelector(SELECTORS.canvas));
    if (getPageSvg()) roots.push(getPageSvg());

    const imgs = [];
    for (const r of roots) imgs.push(...Array.from(r.querySelectorAll('svg image, image')));
    const preferred = imgs.filter(img => /^blob:|^https?:/i.test(getSvgHref(img)));
    return preferred.length ? preferred : imgs;
  }

  function findClipOrConvertButton() {
    for (const sel of SELECTORS.clipOrConvertButtonCandidates) {
      const els = Array.from(document.querySelectorAll(sel)).filter(isVisible);
      if (els.length) return els[0];
    }
    const btns = Array.from(document.querySelectorAll('button,[role="button"]')).filter(isVisible);
    return btns.find(b => SELECTORS.clipOrConvertTextRegex.test((b.textContent || '').trim())) || null;
  }

  function isDisabledButton(el) {
    if (!el) return true;
    const ariaDisabled = (el.getAttribute('aria-disabled') || '').toLowerCase() === 'true';
    const disabledAttr = !!el.disabled;
    const classStr = (el.getAttribute('class') || '');
    return ariaDisabled || disabledAttr || 
           /\b(goog-toolbar-button-disabled|goog-menuitem-disabled|disabled)\b/.test(classStr);
  }

  function summarizeEl(el) {
    if (!el) return 'null';
    const txt = (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 30);
    const aria = (el.getAttribute('aria-label') || '').trim().replace(/\s+/g, ' ').slice(0, 30);
    const tag = (el.tagName || '').toLowerCase();
    return `${tag}[aria="${aria}"]`;
  }

  function findLikelyDialogOrPaneRoot() {
    const dialogs = Array.from(document.querySelectorAll('[role="dialog"],[aria-modal="true"]'))
      .filter(isVisible)
      .sort((a, b) => (b.getBoundingClientRect().width * b.getBoundingClientRect().height) -
                      (a.getBoundingClientRect().width * a.getBoundingClientRect().height));
    if (dialogs[0]) return dialogs[0];
    return document.body;
  }

  function findGenerateButton(scopeEl) {
    const scope = scopeEl || document.body;
    const btns = Array.from(scope.querySelectorAll(SELECTORS.generateCandidates.join(','))).filter(isVisible);

    const exact = btns.find(b => SELECTORS.generateTextRegexExact.test((b.textContent || '').trim()));
    if (exact) return exact;
    const exactAria = btns.find(b => SELECTORS.generateTextRegexExact.test((b.getAttribute('aria-label') || '').trim()));
    if (exactAria) return exactAria;
    const loose = btns.find(b => SELECTORS.generateTextRegexLoose.test((b.textContent || '').trim()));
    if (loose) return loose;
    return btns.find(b => SELECTORS.generateTextRegexLoose.test((b.getAttribute('aria-label') || '').trim())) || null;
  }

  function findPromptInputNear(generateBtn) {
    let root = generateBtn;
    for (let i = 0; i < 10 && root; i++) {
      if (root.getAttribute?.('role') === 'dialog' || root.getAttribute?.('aria-modal') === 'true') break;
      root = root.parentElement;
    }
    const scope = root || document;
    const candidates = [];
    for (const sel of SELECTORS.promptCandidates) candidates.push(...Array.from(scope.querySelectorAll(sel)));
    
    const visible = candidates.filter(isVisible);
    const scored = visible.map(el => {
      const txt = `${el.getAttribute('aria-label')||''} ${el.getAttribute('placeholder')||''} ${el.getAttribute('name')||''}`.toLowerCase();
      let score = 0;
      if (SELECTORS.promptAttrRegex.test(txt)) score += 10;
      if ((el.tagName || '').toLowerCase() === 'textarea') score += 2;
      return { el, score };
    }).sort((a, b) => b.score - a.score);
    return scored[0]?.el || null;
  }

  function findErrorMessage(scope) {
    if (!scope) return null;
    const el = scope.querySelector(SELECTORS.errorMessage);
    return (el && isVisible(el)) ? el : null;
  }

  async function stepSelectScene(sceneEl, sceneIndex, total) {
    const targetLabel = sceneEl.getAttribute('aria-label') || `Scene#${sceneIndex + 1}`;
    setStatus(sceneIndex + 1, total, 'Selecting scene', targetLabel);
    logLine(`Scene select: ${targetLabel}`);

    await safeClick(sceneEl, `scene:${targetLabel}`);
    const timeline = await waitFor(() => getTimelineRoot());
    await waitForStableDom(timeline);

    await waitFor(() => {
        const freshButtons = getSceneButtons();
        const activeBtn = freshButtons.find(s => isSceneActive(s));
        if (!activeBtn) return false; 
        return (activeBtn.getAttribute('aria-label') || '') === targetLabel;
    }, { label: `verifySceneActive`, timeoutMs: 8000 });

    const cc = getCanvasContainer();
    if (cc) await waitForStableDom(cc, 600, 12000);
  }

  async function stepSelectFirstImageIfAny(sceneIndex, total) {
    const imgsRaw = getLikelySceneImages();
    const imgsVisible = imgsRaw.filter(isVisible);

    if (!imgsVisible.length) {
      logLine(`⚠ No visible images in scene ${sceneIndex + 1}.`);
      return null;
    }
    const img = imgsVisible[0];
    setStatus(sceneIndex + 1, total, 'Selecting image');
    await safeClick(img, `image:${sceneIndex + 1}-1`);
    
    const cc = getCanvasContainer();
    if (cc) await waitForStableDom(cc, 500, 12000);
    return img;
  }

  async function stepOpenClipToVideo(sceneIndex, total) {
    setStatus(sceneIndex + 1, total, 'Opening converter');
    const btn = await waitFor(() => findClipOrConvertButton(), { label: 'clipOrConvertButton', timeoutMs: 15000 });
    await safeClick(btn, 'clipOrConvertButton');
    await sleep(250);
    await waitForStableDom(document.body, 700, 15000);
  }

  async function monitorGeneration(scope, btnElement) {
    const t0 = Date.now();
    logLine('Monitoring generation...');

    while (true) {
      if (state.stopRequested) throw new Error('STOP_REQUESTED');

      // 1. エラー検出(最優先)
      const errorEl = findErrorMessage(scope);
      if (errorEl) {
        const errText = (errorEl.textContent || '').trim().substring(0, 100);
        return { success: false, reason: 'ERROR_MESSAGE', detail: errText };
      }

      // 2. ボタン状態チェック
      const currentBtn = findGenerateButton(scope);

      // ボタン消失 -> 生成開始
      if (!currentBtn) {
        // 消失したら成功とみなし、復活を待つ(または次の処理へ)
        // パネルが閉じない仕様なら、ボタンは「Stop」等に変わるか、loading表示になるはず
        // ここでは「ボタンが消えた」=「プロセスが進んだ」と判断して成功扱いにする
        // 必要ならさらに待機を入れる
        await waitForStableDom(document.body, 2000, 10000);
        return { success: true };
      }

      // ボタンが存在する
      if (!isDisabledButton(currentBtn)) {
        const elapsed = Date.now() - t0;
        // 短時間でEnabledのまま -> エラーの可能性が高い
        if (elapsed < 5000) {
           await sleep(500); // エラーメッセージ出現待ち
           continue; 
        }
        // 長時間経過後にEnabled -> 完了
        return { success: true };
      }

      // タイムアウト
      if (Date.now() - t0 > CONFIG.TIMEOUT_GENERATE_MS) {
        return { success: false, reason: 'TIMEOUT' };
      }

      await sleep(1000);
    }
  }

  async function stepPromptAndGenerate(sceneIndex, userPromptLine, total, attemptCount) {
    const scope = findLikelyDialogOrPaneRoot();

    // 1. Find Button
    const genAny = await waitFor(() => findGenerateButton(scope), { label: 'generateButtonAny', timeoutMs: 20000 });
    
    // 2. Set Prompt
    setStatus(sceneIndex + 1, total, 'Setting prompt', `Try ${attemptCount}`);
    const prompt = await waitFor(() => findPromptInputNear(genAny), { label: 'promptInput', timeoutMs: 20000 });
    
    const base = CONFIG.PROMPT_TEXT(sceneIndex, 0);
    const extra = String(userPromptLine || '').trim();
    const combined = state.includeBasePrompt ? (extra ? `${base}\n\n${extra}` : base) : extra;
    
    await setText(prompt, combined);
    
    // リアルタイムUI表示用(プロンプト先頭)
    const promptPreview = combined.replace(/\n/g, ' ').substring(0, 20) + '...';
    setStatus(sceneIndex + 1, total, 'Ready to click', promptPreview);

    // 3. Wait for Enabled
    const genEnabled = await waitFor(() => {
        const btn = findGenerateButton(scope);
        return (btn && !isDisabledButton(btn)) ? btn : null;
    }, { label: 'GenerateEnabled', timeoutMs: CONFIG.GENERATE_ENABLE_TIMEOUT_MS });

    logLine(`Clicking Generate (Try ${attemptCount})...`);
    await sleep(2000); // Human delay

    genEnabled.click();
    await sleep(500);

    // 4. Monitor
    setStatus(sceneIndex + 1, total, 'Generating...', `Try ${attemptCount}`);
    const result = await monitorGeneration(scope, genEnabled);
    return result;
  }

  async function runAutomation() {
    if (!isVidsEditPage()) {
      setStatus(0, 0, 'Error: Not edit page');
      logLine(`Error: Open Google Vids edit page.`);
      return;
    }

    if (!state.userPrompts.length) {
      setStatus(0, 0, 'Error: No prompts');
      logLine(`Error: Please enter prompts.`);
      return;
    }

    state.running = true;
    state.stopRequested = false;
    state.processedScenes = 0;

    setStatus(0, 0, 'Initializing...');
    await waitFor(() => getTimelineRoot());
    await waitForStableDom(document.body, 800, 20000);

    const scenes = await waitFor(() => {
      const s = getSceneButtons();
      return s.length ? s : null;
    }, { label: 'sceneButtons', timeoutMs: 20000 });

    logLine(`Scenes found: ${scenes.length}`);
    const availableScenes = Math.min(scenes.length, CONFIG.MAX_SCENES);
    state.plannedScenes = Math.min(availableScenes, state.userPrompts.length);
    const total = state.plannedScenes;

    if (total <= 0) {
      logLine(`Nothing to do.`);
      return;
    }

    logLine(`Planned scenes: ${total}`);

    for (let si = 0; si < total; si++) {
      if (state.stopRequested) break;

      const label = scenes[si].getAttribute('aria-label') || '';
      const sceneEl = getSceneButtons().find(s => (s.getAttribute('aria-label') || '') === label) || scenes[si];
      const userLine = state.userPrompts[si] || '';

      try {
        await retry(async () => stepSelectScene(sceneEl, si, total), `selectScene`);
        
        const img = await retry(async () => stepSelectFirstImageIfAny(si, total), `selectImage`);
        if (!img) {
          logLine(`⚠ Scene ${si + 1}: No image. Skipping.`);
          state.processedScenes++;
          continue;
        }

        await retry(async () => stepOpenClipToVideo(si, total), `openUI`);

        // 生成リトライループ
        let generateSuccess = false;
        for (let attempt = 1; attempt <= CONFIG.RETRY_GENERATE; attempt++) {
          if (state.stopRequested) break;

          try {
            const result = await stepPromptAndGenerate(si, userLine, total, attempt);
            
            if (result.success) {
              logLine(`✓ Scene ${si + 1}: Generated successfully.`);
              generateSuccess = true;
              break; 
            } else {
              logLine(`✗ Scene ${si + 1} (Try ${attempt}): Failed - ${result.reason} ${result.detail || ''}`);
              if (attempt < CONFIG.RETRY_GENERATE) {
                logLine(`⚠ Waiting ${CONFIG.RETRY_DELAY_MS}ms before retry...`);
                await sleep(CONFIG.RETRY_DELAY_MS);
                // パネルは閉じずにそのまま再試行(ユーザー仕様)
              }
            }
          } catch (e) {
            logLine(`✗ Error in generate step: ${e.message}`);
            await sleep(1000);
          }
        }

        if (!generateSuccess) {
           logLine(`✗ Scene ${si + 1}: Gave up after ${CONFIG.RETRY_GENERATE} attempts.`);
        }

        state.processedScenes++;

      } catch (e) {
        logLine(`✗ ERROR processing scene ${si + 1}: ${e.message}`);
        state.processedScenes++;
      }
    }

    setStatus(state.processedScenes, total, 'Done');
    logLine(`All done. Processed ${state.processedScenes} scenes.`);
  }

  function boot() {
    const ui = UI.ensure(logLine);
    state.ui = ui;

    ui.startBtn.addEventListener('click', async () => {
      if (state.running) return;
      
      state.userPrompts = parseUserPrompts(ui.userPromptInput.value);
      state.includeBasePrompt = ui.includeBasePrompt.checked;
      
      ui.startBtn.textContent = 'Running...';
      ui.startBtn.disabled = true;

      try {
        await runAutomation();
      } catch (e) {
        if (e.message === 'STOP_REQUESTED') {
          logLine('Stopped by user.');
          setStatus(state.processedScenes, state.plannedScenes, 'Stopped');
        } else {
          console.error(e);
          logLine(`FATAL: ${e.message}`);
          state.ui.status.textContent = 'Error';
        }
      } finally {
        state.running = false;
        ui.startBtn.textContent = 'Start';
        ui.startBtn.disabled = false;
      }
    });

    ui.stopBtn.addEventListener('click', () => {
      if (!state.running) return;
      state.stopRequested = true;
      logLine('Stop requested...');
      state.ui.status.textContent = 'Stopping...';
    });

    logLine('Ready. (v1.2.0)');
  }

  boot();
})();

ステップ3:ブラウザへの読み込み

ファイルが2つ揃ったら、Chromeにこの機能を認識させます。

  1. Chromeブラウザのアドレスバーに `chrome://extensions/` と入力してEnterキーを押します。

  2. 画面右上にある  「デベロッパーモード」  のスイッチをONにします。

  3. 左上に現れた  「パッケージ化されていない拡張機能を読み込む」  ボタンをクリックします。

  4. ファイル選択ウィンドウが開くので、先ほど作成したフォルダ `vids-auto-operator` を選択(ファイルではなくフォルダそのものを選択)します。

これで一覧に "Google Vids Auto Clip-to-Video (Stable)" が表示されればインストール完了です。


4. 使い方:完全放置のためのオペレーション

2ステップで実行

インストールが完了したら、Google Vidsの編集画面を開いてみてください(既に開いている場合はF5キーでリロード)。
画面右下に、黒背景のコントロールパネル「Vids Auto Clip-to-Video」が表示されているはずです。

基本的な操作手順

  1. プロンプトの入力:
    パネル中央のテキストエリアに、シーンごとに動画生成AIへ渡したい指示(プロンプト)を入力します。

    • 1行目:シーン1に対する指示

    • 2行目:シーン2に対する指示

    • 3行目以降も同様...
      ※ シーン数より行数が少ない場合、残りのシーンは処理されません。逆に多い場合は、ある分だけ処理されます。

  2. オプション設定:

    • `Include base prompt`: チェックを入れると、コード内で定義された基本プロンプト("Convert this image...")を各行の先頭に自動付与します。スタイルを統一したい場合に便利です。

  3. 実行:
    緑色の 「Start」 ボタンをクリックします。

実行中の挙動

全体の流れ
6ステップを自動で実行します

「Start」を押すと、ツールはブラウザの操作権を掌握します。

  • 自動でシーン1が選択されます。

  • 画像がクリックされ、Clip to Videoメニューが開きます。

  • プロンプトが高速に入力され、Generateボタンが押されます。

  • ここが重要: 生成中は「Generate」ボタンが消えたり無効化したりしますが、ツールはそれを監視し続け、処理が終わるまで待機してから次のシーンへ進みます。

注意事項

  • タブを閉じない: 処理中はタブをアクティブにしておく必要はありませんが、タブ自体を閉じると処理は止まります。

  • マウス操作: 基本的にバックグラウンドで動作しますが、自動クリックの瞬間にユーザーがマウスを動かすと干渉する可能性があります。処理中は別のウィンドウで作業するか、コーヒーブレイクに充てることを推奨します。


5. Tech Deep Dive:実務運用を支える実装のポイント

実装ポイント

前回のプロトタイプからv1.1.0へのアップデートにおいて、特に重点を置いた技術的な実装詳細について解説します。開発者や、同様のSPA自動化を検討している方への参考情報です。

① 「物理イベント」の完全なエミュレーション

React等のモダンフレームワークでは、単純な `.click()` メソッドが無視される、あるいは期待したイベントハンドラが発火しないケースがあります。

これを解決するために、`safeClick` 関数内で物理的なマウス操作のシーケンスを模倣しています。
具体的には、対象要素の `getBoundingClientRect()` から中心座標を計算し、その座標に対して `mousemove` → `mousedown` → `mouseup` → `click` の順でイベントを発火させます。間に数十ミリ秒の `sleep` を挟むことで、人間の操作に近い「揺らぎ」を持たせ、イベントリスナーの取りこぼしを防いでいます。

② ポリグロットな入力インターフェース

Google VidsのUIは箇所によって入力フォームの実装が異なります。通常の `textarea` もあれば、`contentEditable` 属性を持つ `div` が使われることもあります。

本実装の `setText` 関数では、対象要素のタグや属性を判別し、適切な入力メソッドを使い分けるロジックを採用しました。

  • React管理下のInput: プロトタイプチェーンからネイティブのセッター(`Object.getOwnPropertyDescriptor`)を呼び出し、その後に `input` / `change` イベントを発火させることでStateを強制同期。

  • ContentEditable: `beforeinput` イベントを用いてテキスト挿入を通知。

これにより、UIの微細な変更に対しても堅牢な入力が可能となりました。

③ 「見えない画像」との戦いとDOM特定

「画像を選択して動画化」する際、最大の課題は「どれが対象の画像か」を特定することでした。DOM上にはアイコンや装飾用SVGなど、多数の `image` タグが存在します。

`getLikelySceneImages` 関数では、以下のフィルタリングロジックを実装し、誤クリックを排除しました。

  1. コンテナの限定: タイムラインやフィルムストリップではなく、キャンバス(Workspace)内の要素に探索範囲を限定。

  2. ソースの検証: `src` 属性や `xlink:href` が `blob:` または `https:` で始まっているかを確認(アイコン用リソースを除外)。

  3. 可視性の厳密なチェック: CSSの `display/visibility` だけでなく、矩形サイズ(幅・高さ)が0より大きいかを確認。

④ エラー自己復帰(Self-Healing)ステートマシン

実務運用において最も重要なのが、このエラーハンドリングです。Google Vidsは連続生成時にAPIエラー(Something went wrong)を返すことがあります。

旧バージョンではここで停止していましたが、今回は `retry` 関数および `stepPromptAndGenerate` 内で  「二段階のリトライ機構」  を組み込みました。

  • UI操作レベル: ボタンが見つからない場合、DOMが安定するまで待機時間を延長しながら最大3回再試行。

  • 生成プロセスレベル: エラーダイアログを検知した場合、即座にエラーを閉じる操作を行い、数秒のクールダウン(待機)を経てから、プロンプト入力と生成ボタン押下を再実行。

このロジックにより、一時的なネットワーク揺らぎやレート制限に遭遇しても、ツール自体が停止することなく処理を継続できるようになりました。

⑤ 開発者体験(DX)への配慮

自身のデバッグ効率とオペレーションの快適さを向上させるため、以下の機能を実装しました。

  • UI位置の永続化: 作業の邪魔にならないよう、パネルをドラッグ移動できるようにし、その座標を保存。次回起動時に復元します。

  • リアルタイムログ: コンソールを開かずとも処理状況がわかるよう、パネル内に詳細なログと進捗(Scene X/Y)を表示。


6. まとめ

まとめ

本ツールは、APIの裏口を叩くようなハックではなく、あくまで「ユーザー操作の代行」を極めて高精度に行うアプローチをとっています。

v1.1.0へのアップデートにより、DOMの不安定さやAPIエラーといった「SPA自動化の壁」を、技術的なアプローチで乗り越えることができました。これにより、動画生成というクリエイティブな業務から、「待ち時間」と「単純作業」というノイズを取り除くことが可能になります。

この実装で得られた知見(React Stateの同期、物理イベントの模倣、自己復帰ロジック)は、Google Vidsに限らず、多くのモダンWebアプリケーションの自動化に応用可能なものです。

#GoogleVids #GoogleWorkspace #Chrome拡張機能 #AI動画生成 #動画生成AI #JavaScript #React #自動化 #業務効率化 #生産性向上 #DX #プログラミング #エンジニア #GoogleVeo #生成AI

音声解説

ここから先は

3,445字

¥ 100

Amazon Payで支払うと最大2%還元のチャンス! 9/30まで

この記事が気に入ったらチップで応援してみませんか?