flow2ui

这里写脚本的英文描述

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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();
    }
})();