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つの壁に直面します。
State(状態)の乖離:
単純に `input.value = "text"` と書き換えても、React内部のStateは更新されません。画面上は文字が入っていても、アプリケーション側は「空」と認識し、送信ボタンが活性化しません。DOMの動的生成と隠蔽:
画面上に見えているボタンと同じクラス名やラベルを持つ「不可視のボタン」がDOMツリー内に多数存在します。単純なセレクタ指定では、誤って無効な要素を操作してしまい、処理が中断します。外部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に対して「どのページで」「どんな権限で」動くかを指示します。
作成方法:
テキストエディタ(メモ帳、VS Codeなど)を開きます。
下記のコードをコピーして貼り付けます。
ファイル名を `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行に及ぶ処理が記述されています。
作成方法:
新規ファイルをテキストエディタで開きます。
下記の長いコードをすべてコピーして貼り付けます。
ファイル名を `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にこの機能を認識させます。
Chromeブラウザのアドレスバーに `chrome://extensions/` と入力してEnterキーを押します。
画面右上にある 「デベロッパーモード」 のスイッチをONにします。
左上に現れた 「パッケージ化されていない拡張機能を読み込む」 ボタンをクリックします。
ファイル選択ウィンドウが開くので、先ほど作成したフォルダ `vids-auto-operator` を選択(ファイルではなくフォルダそのものを選択)します。
これで一覧に "Google Vids Auto Clip-to-Video (Stable)" が表示されればインストール完了です。
4. 使い方:完全放置のためのオペレーション

インストールが完了したら、Google Vidsの編集画面を開いてみてください(既に開いている場合はF5キーでリロード)。
画面右下に、黒背景のコントロールパネル「Vids Auto Clip-to-Video」が表示されているはずです。
基本的な操作手順
プロンプトの入力:
パネル中央のテキストエリアに、シーンごとに動画生成AIへ渡したい指示(プロンプト)を入力します。1行目:シーン1に対する指示
2行目:シーン2に対する指示
3行目以降も同様...
※ シーン数より行数が少ない場合、残りのシーンは処理されません。逆に多い場合は、ある分だけ処理されます。
オプション設定:
`Include base prompt`: チェックを入れると、コード内で定義された基本プロンプト("Convert this image...")を各行の先頭に自動付与します。スタイルを統一したい場合に便利です。
実行:
緑色の 「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` 関数では、以下のフィルタリングロジックを実装し、誤クリックを排除しました。
コンテナの限定: タイムラインやフィルムストリップではなく、キャンバス(Workspace)内の要素に探索範囲を限定。
ソースの検証: `src` 属性や `xlink:href` が `blob:` または `https:` で始まっているかを確認(アイコン用リソースを除外)。
可視性の厳密なチェック: 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
音声解説
ここから先は
¥ 100
この記事が気に入ったらチップで応援してみませんか?
