flow2ui

这里写脚本的英文描述

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         flow2ui
// @namespace    http://tampermonkey.net/
// @version      3.3
// @description  这里写脚本的英文描述
// @match        https://labs.google/fx/tools/flow/project/*
// @grant        none
// @run-at       document-start
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    console.log('🚀 图片生成 WebSocket 客户端 v3.0');

    if (window.self !== window.top) return;

    let capturedImageData = null;
    let onImageCaptured = null;

    // 拦截 Blob URL 获取图片数据
    const origCreateObjectURL = URL.createObjectURL.bind(URL);
    URL.createObjectURL = function(blob) {
        const url = origCreateObjectURL(blob);
        if (blob && (blob.type?.startsWith('image/') || blob.type?.startsWith('video/') || blob.size > 100000)) {
            console.log('📥 拦截Blob:', blob.type, Math.round(blob.size / 1024) + 'KB');
            const reader = new FileReader();
            reader.onloadend = () => {
                capturedImageData = reader.result.split(',')[1];
                if (onImageCaptured) onImageCaptured(capturedImageData);
            };
            reader.readAsDataURL(blob);
        }
        return url;
    };

    function waitForImageData(timeout = 120000) {
        return new Promise(resolve => {
            if (capturedImageData) {
                const data = capturedImageData;
                capturedImageData = null;
                return resolve(data);
            }
            const timer = setTimeout(() => { onImageCaptured = null; resolve(null); }, timeout);
            onImageCaptured = data => {
                clearTimeout(timer);
                onImageCaptured = null;
                capturedImageData = null;
                resolve(data);
            };
        });
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

    function init() {
        console.log('🎯 初始化');

        // URL 模式检查
        if (!/^https:\/\/labs\.google\/fx\/tools\/flow\/project\/.+/.test(location.href)) {
            console.log('URL不匹配,不建立连接');
            return;
        }

        // XPath helpers
        const $x1 = (xpath, ctx = document) => document.evaluate(xpath, ctx, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
        const $x = (xpath, ctx = document) => {
            const r = [], q = document.evaluate(xpath, ctx, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
            for (let i = 0; i < q.snapshotLength; i++) r.push(q.snapshotItem(i));
            return r;
        };
        const sleep = ms => new Promise(r => setTimeout(r, ms));

        // 通用等待函数(先等待再检查,避免立即满足条件)
        async function waitUntil(conditionFn, timeout = 60000, interval = 1000) {
            const start = Date.now();
            while (Date.now() - start < timeout) {
                await sleep(interval);  // 先等待
                if (await conditionFn()) return true;  // 再检查
            }
            return false;
        }

        // base64 转 File
        function base64ToFile(base64Data, filename = 'image.jpg') {
            const byteString = atob(base64Data);
            const ab = new ArrayBuffer(byteString.length);
            const ia = new Uint8Array(ab);
            for (let i = 0; i < byteString.length; i++) ia[i] = byteString.charCodeAt(i);
            return new File([new Blob([ab], { type: 'image/jpeg' })], filename, { type: 'image/jpeg' });
        }

        // 上传文件到 input 并等待完成
        async function uploadFileToInput(base64Data, filename = 'image.jpg') {
            const fileInput = $x('//input[@type="file"]')[0];
            if (!fileInput) throw new Error('未找到文件输入框');

            const dt = new DataTransfer();
            dt.items.add(base64ToFile(base64Data, filename));
            fileInput.files = dt.files;
            fileInput.dispatchEvent(new Event('change', { bubbles: true }));

            await sleep(1000);
            const cropBtn = $x('//button[contains(., "Crop and Save")]')[0];
            if (!cropBtn) throw new Error('未找到Crop and Save按钮');
            cropBtn.click();

            const ok = await waitUntil(() => !$x1('//button[contains(., "Upload")]'));
            if (!ok) throw new Error('上传超时');
        }

        // 上传参考图
        async function uploadReferenceImage(base64Data) {
            await sleep(1000);
            const addBtn = $x('//textarea[@id="PINHOLE_TEXT_AREA_ELEMENT_ID"]/..//button/i[text()="add"]')[0];
            if (!addBtn) throw new Error('未找到add按钮');
            addBtn.click();
            await sleep(1000);
            await uploadFileToInput(base64Data, 'reference.jpg');
        }

        // 上传首尾帧
        async function uploadFrameImages(frameImages) {
            if (!frameImages?.length) throw new Error('首帧是必需的');

            // 首帧
            const addBtns = $x('//textarea[@id="PINHOLE_TEXT_AREA_ELEMENT_ID"]/..//button/i[text()="add"]');
            if (!addBtns[0]) throw new Error('未找到首帧上传按钮');
            addBtns[0].click();
            await sleep(1000);
            await uploadFileToInput(frameImages[0], 'first.jpg');
            console.log('✅ 首帧上传成功');

            // 尾帧
            if (frameImages.length > 1) {
                await sleep(1000);
                const addBtn2 = $x('//textarea[@id="PINHOLE_TEXT_AREA_ELEMENT_ID"]/..//button/i[text()="add"]')[0];
                if (addBtn2) {
                    addBtn2.click();
                    await sleep(1000);
                    await uploadFileToInput(frameImages[1], 'last.jpg');
                    console.log('✅ 尾帧上传成功');
                }
            }
        }

        let ws = null;
        let isExecuting = false;
        let clientId = null;
        let shouldConnect = true;
        let hideTimer = null;

        function sendWsMessage(data) {
            if (ws?.readyState !== WebSocket.OPEN) return false;
            data._id = Date.now() + '_' + Math.random().toString(36).substr(2, 9);
            ws.send(JSON.stringify(data));
            return true;
        }

        function sendStatus(msg) {
            console.log('📌', msg);
            sendWsMessage({ type: 'status', message: msg });
        }

        function sendResult(taskId, error) {
            sendWsMessage({ type: 'result', task_id: taskId, error });
        }

        async function executeTask(taskId, prompt, taskType, aspectRatio, resolution, referenceImages) {
            console.log('🚀 执行任务:', taskId, taskType, prompt.substring(0, 30) + '...');

            if (isExecuting) return;
            isExecuting = true;
            capturedImageData = null;

            try {
                // 选择任务类型
                const taskBtn = $x('//textarea[@id="PINHOLE_TEXT_AREA_ELEMENT_ID"]/..//button[1]')[0];
                taskBtn.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'touch', isPrimary: true }));
                taskBtn.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }));
                taskBtn.click();
                await sleep(300);
                $x(`//div[@role="option"]//*[contains(text(), '${taskType}')]`)[0]?.click();
                await sleep(300);

                // 上传图片
                if (taskType === 'Frames to Video') {
                    sendStatus('上传首尾帧...');
                    await uploadFrameImages(referenceImages);
                } else if (taskType !== 'Text to Video' && referenceImages?.length) {
                    const name = taskType === 'Ingredients to Video' ? '垫图' : '参考图';
                    for (let i = 0; i < referenceImages.length; i++) {
                        sendStatus(`上传${name} ${i + 1}/${referenceImages.length}...`);
                        await uploadReferenceImage(referenceImages[i]);
                        await sleep(500);
                    }
                }

                // 设置参数
                sendStatus('设置参数...');
                $x1('//textarea[@id="PINHOLE_TEXT_AREA_ELEMENT_ID"]/..//button[contains(., "Settings")]')?.click();
                await sleep(300);
                $x1('//button[contains(., "Aspect Ratio")]')?.click();
                await sleep(300);
                $x1(`//div[@role="option"]//span[contains(text(), "${aspectRatio}")]`)?.click();
                await sleep(300);
                $x1('//button[contains(., "Outputs per prompt")]')?.click();
                await sleep(300);
                $x1('//div[@role="option" and normalize-space()="1"]')?.click();

                // 输入prompt
                sendStatus('开始: ' + prompt.substring(0, 30));
                const input = $x1('//textarea[@id="PINHOLE_TEXT_AREA_ELEMENT_ID"]');
                if (!input) throw new Error('未找到输入框');
                input.click();
                await sleep(300);
                input.focus();
                document.execCommand('selectAll');
                document.execCommand('insertText', false, prompt);
                await sleep(300);
                $x1('(//textarea[@id="PINHOLE_TEXT_AREA_ELEMENT_ID"]/..//button)[last()]')?.click();
                sendStatus('等待生成...');

                // 等待生成完成
                const genOk = await waitUntil(() => {
                    const container = $x1('//div[@data-item-index][contains(., "Reuse prompt")]/div/div/div/div/div[1]');
                    if (!container) return false;
                    if ($x1(".//img | .//video", container)) return true;
                    const text = container.innerText;
                    if (text?.trim().endsWith('%')) sendStatus('进度 ' + text);
                    else if (text && !text.includes('\n')) throw new Error('生成失败: ' + text);
                    return false;
                }, 120000);
                if (!genOk) throw new Error('生成超时');

                // 下载
                sendStatus('下载中...');
                const taskContainer = $x1('//div[@data-item-index][contains(., "Reuse prompt")]/div/div/div/div');
                const downloadIconBtn = $x1(`//button[.//*[contains(text(),'download')]]`, taskContainer);
                if (!downloadIconBtn) throw new Error('未找到下载图标按钮');
                downloadIconBtn.click();
                await sleep(500);

                const resMap = {
                    "1080p": "Upscaled (1080p)", "720p": "Original size (720p)",
                    "4K": "Download 4K", "2K": "Download 2K", "1K": "Download 1K"
                };

                let base64Data = null;
                if (resolution.toUpperCase() === '1K') {
                    const img1k = $x1('//div[@data-item-index][contains(., "Reuse prompt")]/div/div/div/div/div[1]//img');
                    const response = await fetch(img1k.src);
                    const blob = await response.blob();

                    base64Data = await new Promise((resolve) => {
                      const reader = new FileReader();
                      reader.onloadend = () => {
                        resolve(reader.result);
                      };
                      reader.readAsDataURL(blob);
                    });
                } else {
                    const resolutionText = resMap[resolution];
                    if (!resolutionText) throw new Error('未知分辨率: ' + resolution);
                    const dlBtn = $x1(`//div[contains(text(), '${resolutionText}')]`);
                    if (!dlBtn) throw new Error('未找到 ' + resolutionText + ' 下载按钮');
                    dlBtn.click();

                    // 等待图片数据
                    sendStatus('获取数据...');
                    base64Data = await waitForImageData( 4 * 60 * 1000);
                }


                if (base64Data) {
                    sendStatus('发送数据...');
                    const chunkSize = 1024 * 1024;
                    const totalChunks = Math.ceil(base64Data.length / chunkSize);

                    if (totalChunks > 1) {
                        for (let i = 0; i < totalChunks; i++) {
                            sendWsMessage({
                                type: 'image_chunk',
                                task_id: taskId,
                                chunk_index: i,
                                total_chunks: totalChunks,
                                data: base64Data.slice(i * chunkSize, (i + 1) * chunkSize)
                            });
                            await sleep(100);
                        }
                    } else {
                        sendWsMessage({ type: 'image_data', task_id: taskId, data: base64Data });
                    }
                    sendStatus('完成 ✅');
                } else {
                    sendResult(taskId, '未获取到图片数据');
                }

            } catch (e) {
                console.error('❌ 执行错误:', e);
                sendResult(taskId, e.message);
            } finally {
                isExecuting = false;
            }
        }

        function connect() {
            console.log('连接 ws://localhost:12345');
            ws = new WebSocket('ws://localhost:12345');

            ws.onopen = () => {
                console.log('连接成功,发送注册');
                ws.send(JSON.stringify({
                    type: 'register',
                    page_url: window.location.href
                }));
            };

            ws.onmessage = async (e) => {
                const data = JSON.parse(e.data);

                if (data.type === 'register_success') {
                    clientId = data.client_id;
                    console.log('注册成功:', clientId);
                    updateButton('已连接', '#28a745');
                    return;
                }

                if (data.type === 'task') {
                    console.log('收到任务:', data.task_id);
                    await executeTask(
                        data.task_id,
                        data.prompt,
                        data.task_type || 'Create Image',
                        data.aspect_ratio || '16:9',
                        data.resolution || '4K',
                        data.reference_images || []
                    );
                }
            };

            ws.onclose = () => {
                console.log('断开');
                clientId = null;
                updateButton('已断开', '#dc3545');
                if (shouldConnect) setTimeout(connect, 3000);
            };

            ws.onerror = (err) => console.error('错误:', err);
        }

        // 页面可见性监听
        document.addEventListener('visibilitychange', () => {
            if (document.hidden) {
                hideTimer = setTimeout(() => {
                    shouldConnect = false;
                    ws?.close();
                }, 30000);
            } else {
                clearTimeout(hideTimer);
                shouldConnect = true;
                if (!ws || ws.readyState !== WebSocket.OPEN) connect();
            }
        });

        // UI 按钮
        const btn = document.createElement('div');
        btn.textContent = '连接中...';
        btn.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:99999;padding:10px 20px;background:#6c757d;color:white;border-radius:5px;cursor:pointer;font-size:14px;box-shadow:0 2px 10px rgba(0,0,0,0.2);';
        btn.onclick = () => ws?.readyState === WebSocket.OPEN ? ws.close() : connect();
        document.body.appendChild(btn);

        function updateButton(text, color) {
            btn.textContent = text;
            btn.style.background = color;
        }

        connect();
    }
})();