Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions static/locales/test.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
"从左侧列表选择一个模型开始测试": "从左侧列表选择一个模型开始测试",
"提示词 (Prompt)": "提示词 (Prompt)",
"描述你想生成的内容...": "描述你想生成的内容...",
"源视频媒体 ID": "源视频媒体 ID",
"输入源视频的 mediaGenerationId": "输入源视频的 mediaGenerationId",
"请输入源视频的 mediaGenerationId": "请输入源视频的 mediaGenerationId",
"上传图片": "上传图片",
"点击或拖拽上传图片": "点击或拖拽上传图片",
"选择模型后开始生成": "选择模型后开始生成",
Expand All @@ -21,6 +24,8 @@
"Veo 3.1 文生视频 (T2V)": "Veo 3.1 文生视频 (T2V)",
"Veo 3.1 图生视频 (I2V)": "Veo 3.1 图生视频 (I2V)",
"Veo 3.1 多图视频 (R2V)": "Veo 3.1 多图视频 (R2V)",
"Omni 1.1 Flash 视频": "Omni 1.1 Flash 视频",
"视频续写 (Extend)": "视频续写 (Extend)",
"视频放大 (Upsample)": "视频放大 (Upsample)",
"使用内置候选模型列表:": "使用内置候选模型列表:",
"加载模型失败:": "加载模型失败:",
Expand Down Expand Up @@ -95,6 +100,9 @@
"从左侧列表选择一个模型开始测试": "Choose a model from the list on the left to begin testing",
"提示词 (Prompt)": "Prompt",
"描述你想生成的内容...": "Describe what you want to generate...",
"源视频媒体 ID": "Source video media ID",
"输入源视频的 mediaGenerationId": "Enter the source video's mediaGenerationId",
"请输入源视频的 mediaGenerationId": "Enter the source video's mediaGenerationId",
"上传图片": "Upload images",
"点击或拖拽上传图片": "Click or drag images here to upload",
"选择模型后开始生成": "Select a model to start generating",
Expand All @@ -106,6 +114,8 @@
"Veo 3.1 文生视频 (T2V)": "Veo 3.1 Text-to-Video (T2V)",
"Veo 3.1 图生视频 (I2V)": "Veo 3.1 Image-to-Video (I2V)",
"Veo 3.1 多图视频 (R2V)": "Veo 3.1 Multi-reference video (R2V)",
"Omni 1.1 Flash 视频": "Omni 1.1 Flash Videos",
"视频续写 (Extend)": "Video extension (Extend)",
"视频放大 (Upsample)": "Video upscaling (Upsample)",
"使用内置候选模型列表:": "Using built-in model suggestions:",
"加载模型失败:": "Failed to load models:",
Expand Down
91 changes: 70 additions & 21 deletions static/test.html
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,13 @@

.form-group { margin-bottom: 14px; }
.form-group label { display: block; font-size: 13px; color: #888; margin-bottom: 6px; }
.form-group textarea {
.form-group textarea, .form-group input[type="text"] {
width: 100%; background: #0f1117; border: 1px solid #2a2b3a; color: #e1e1e6;
padding: 10px 12px; border-radius: 8px; font-size: 14px; resize: vertical; min-height: 80px;
padding: 10px 12px; border-radius: 8px; font-size: 14px;
font-family: inherit;
}
.form-group textarea:focus { outline: none; border-color: #7c8aff; }
.form-group textarea { resize: vertical; min-height: 80px; }
.form-group textarea:focus, .form-group input[type="text"]:focus { outline: none; border-color: #7c8aff; }

.image-upload-area {
border: 2px dashed #2a2b3a; border-radius: 8px; padding: 20px; text-align: center;
Expand Down Expand Up @@ -148,6 +149,11 @@ <h3 id="selectedModelName"><span data-i18n="请选择模型">请选择模型</sp
<textarea id="promptInput" placeholder="描述你想生成的内容..." data-i18n-placeholder="描述你想生成的内容...">一只可爱的橘猫趴在窗台上晒太阳,窗外是樱花盛开的春天</textarea>
</div>

<div class="form-group" id="videoSourceGroup" style="display:none;">
<label for="videoMediaId"><span data-i18n="源视频媒体 ID">源视频媒体 ID</span></label>
<input type="text" id="videoMediaId" placeholder="输入源视频的 mediaGenerationId" data-i18n-placeholder="输入源视频的 mediaGenerationId" autocomplete="off">
</div>

<div class="form-group" id="imageUploadGroup" style="display:none;">
<label><span data-i18n="上传图片">上传图片</span> <span id="imageLimit" style="color:#666;font-size:12px;"></span></label>
<div class="image-upload-area" id="imageUploadArea">
Expand Down Expand Up @@ -175,15 +181,40 @@ <h3><span class="status-dot status-idle" id="statusDot"></span> <span id="status
<script>
I18n.ready.then(() => {

// Normalize legacy underscore ids so both catalog styles classify identically.
function normalizeModelId(modelId) {
return String(modelId).toLowerCase().replace(/_/g, "-");
}

// Model family determines reference inputs independently of output resolution.
function getModelFamily(modelId) {
const norm = normalizeModelId(modelId);
if (norm.includes("image")) return "image";
if (norm.includes("i2v") || norm.includes("interpolation")) return "i2v";
if (norm.includes("r2v")) return "r2v";
if (norm.includes("omni") || norm.includes("abra")) return "omni";
if (norm.includes("extend")) return "extend";
return "t2v";
}

function classifyModel(modelId) {
const family = getModelFamily(modelId);
const norm = normalizeModelId(modelId);
if (family !== "image" && (norm.includes("4k") || norm.includes("1080p"))) return "upsample";
return family;
}

// Model categories for sidebar
const MODEL_CATEGORIES = {
"Gemini 3.1 Flash 图片": { filter: m => m.startsWith("gemini-3.1-flash-image") },
"Nano Banana 2 Lite 图片": { filter: m => m.startsWith("gemini-3.1-flash-lite-image") },
"Gemini 3.0 Pro 图片": { filter: m => m.startsWith("gemini-3.0-pro-image") },
"Veo 3.1 文生视频 (T2V)": { filter: m => m.startsWith("veo_3_1_t2v") && !m.includes("4k") && !m.includes("1080p") },
"Veo 3.1 图生视频 (I2V)": { filter: m => m.startsWith("veo_3_1_i2v") && !m.includes("4k") && !m.includes("1080p") },
"Veo 3.1 多图视频 (R2V)": { filter: m => m.startsWith("veo_3_1_r2v") && !m.includes("4k") && !m.includes("1080p") },
"视频放大 (Upsample)": { filter: m => !m.includes("image") && (m.includes("4k") || m.includes("1080p")) },
"Gemini 3.1 Flash 图片": { filter: m => normalizeModelId(m).startsWith("gemini-3.1-flash-image") },
"Nano Banana 2 Lite 图片": { filter: m => normalizeModelId(m).startsWith("gemini-3.1-flash-lite-image") },
"Gemini 3.0 Pro 图片": { filter: m => normalizeModelId(m).startsWith("gemini-3.0-pro-image") },
"Veo 3.1 文生视频 (T2V)": { filter: m => classifyModel(m) === "t2v" },
"Veo 3.1 图生视频 (I2V)": { filter: m => classifyModel(m) === "i2v" },
"Veo 3.1 多图视频 (R2V)": { filter: m => classifyModel(m) === "r2v" },
"Omni 1.1 Flash 视频": { filter: m => classifyModel(m) === "omni" },
"视频续写 (Extend)": { filter: m => classifyModel(m) === "extend" },
"视频放大 (Upsample)": { filter: m => classifyModel(m) === "upsample" },
};

const FALLBACK_MODELS = {
Expand Down Expand Up @@ -271,6 +302,11 @@ <h3><span class="status-dot status-idle" id="statusDot"></span> <span id="status
"veo_3_1_r2v_fast_landscape_ultra_relaxed_8s": "Video generation - R2V fast ultra relaxed 8s landscape",
"veo_3_1_r2v_fast_landscape_ultra_4k": "Video upsample alias - generate R2V landscape then upscale to 4K",
"veo_3_1_r2v_fast_landscape_ultra_1080p": "Video upsample alias - generate R2V landscape then upscale to 1080P",
"omni-1.1-flash": "Video generation - Omni 1.1 Flash 8s landscape, optional reference images",
"omni-1.1-flash-4s-landscape": "Video generation - Omni 1.1 Flash 4s landscape",
"omni-1.1-flash-4s-portrait": "Video generation - Omni 1.1 Flash 4s portrait",
"omni-1.1-flash-8s-landscape": "Video generation - Omni 1.1 Flash 8s landscape",
"omni-1.1-flash-8s-portrait": "Video generation - Omni 1.1 Flash 8s portrait",
};

let ALL_MODELS = {};
Expand Down Expand Up @@ -315,20 +351,21 @@ <h3><span class="status-dot status-idle" id="statusDot"></span> <span id="status
}

function getModelType(modelId) {
if (modelId.includes("image")) return "image";
return "video";
return getModelFamily(modelId) === "image" ? "image" : "video";
}

function getModelMeta(modelId) {
// Determine supports_images, min/max from model name patterns
const isI2V = modelId.includes("i2v");
const isR2V = modelId.includes("r2v");
const isT2V = modelId.includes("t2v");
const isImage = getModelType(modelId) === "image";

if (isImage) return { type: "image", supportsImages: true, minImages: 0, maxImages: 5 };
if (isI2V) return { type: "video", supportsImages: true, minImages: 1, maxImages: 2 };
if (isR2V) return { type: "video", supportsImages: true, minImages: 0, maxImages: 3 };
// Determine supports_images, min/max from the model family
const family = getModelFamily(modelId);

if (family === "image") return { type: "image", supportsImages: true, minImages: 0, maxImages: 5 };
if (family === "i2v") return { type: "video", supportsImages: true, minImages: 1, maxImages: 2 };
if (family === "r2v" || family === "omni") {
return { type: "video", supportsImages: true, minImages: 0, maxImages: 3 };
}
if (family === "extend") {
return { type: "video", supportsImages: false, minImages: 0, maxImages: 0, requiresVideoSource: true };
}
return { type: "video", supportsImages: false, minImages: 0, maxImages: 0 };
}

Expand Down Expand Up @@ -406,6 +443,9 @@ <h3><span class="status-dot status-idle" id="statusDot"></span> <span id="status
uploadGroup.style.display = "none";
}

document.getElementById("videoSourceGroup").style.display = meta.requiresVideoSource ? "block" : "none";
document.getElementById("videoMediaId").value = "";

// Reset image previews
document.getElementById("imagePreviewList").innerHTML = "";
document.getElementById("imageHint").style.display = "block";
Expand Down Expand Up @@ -481,6 +521,12 @@ <h3><span class="status-dot status-idle" id="statusDot"></span> <span id="status
if (!prompt) { alert(I18n.t("请输入提示词")); return; }
if (!apiKey) { alert(I18n.t("请输入 API Key")); return; }
if (!baseUrl) { alert(I18n.t("请输入服务地址")); return; }
const videoMediaId = STATE.modelConfig.requiresVideoSource
? document.getElementById("videoMediaId").value.trim() : "";
if (STATE.modelConfig.requiresVideoSource && !videoMediaId) {
alert(I18n.t("请输入源视频的 mediaGenerationId"));
return;
}

STATE.generating = true;
const btn = document.getElementById("btnGenerate");
Expand All @@ -502,6 +548,9 @@ <h3><span class="status-dot status-idle" id="statusDot"></span> <span id="status
STATE.images.forEach(dataUrl => {
content.push({ type: "image_url", image_url: { url: dataUrl } });
});
if (videoMediaId) {
content.push({ type: "image_url", image_url: { url: `extend://${videoMediaId}` } });
}

const messages = [{ role: "user", content: content.length === 1 ? prompt : content }];

Expand Down
163 changes: 163 additions & 0 deletions tests/test_i18n_browser.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""Browser checks use local pages and mocked API responses, without real accounts."""

import asyncio
import base64
import json
import re
import threading
from concurrent.futures import ThreadPoolExecutor
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlsplit
Expand Down Expand Up @@ -599,3 +601,164 @@ def test_extension_language_preserves_settings_and_translates_status(browser_pag
"Settings saved. The background connection will reconnect automatically."
)
assert page.locator("#routeKey").input_value() == "保存"


def test_upscaled_video_models_keep_reference_uploads(browser_page):
page, base = browser_page
open_page(page, base + "/test")
image = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jNekAAAAASUVORK5CYII="
)
for model, limit in [
("veo_3_1_i2v_s_4k", "(1-2 images)"),
("veo_3_1_i2v_s_1080p", "(1-2 images)"),
("veo_3_1_r2v_fast_landscape_ultra_4k", "(0-3 images)"),
("veo_3_1_r2v_fast_landscape_ultra_1080p", "(0-3 images)"),
]:
page.locator(f'.model-item[data-model="{model}"]').click()
playwright_api.expect(page.locator("#imageUploadGroup")).to_be_visible()
playwright_api.expect(page.locator("#imageLimit")).to_have_text(limit)
page.locator("#imageFileInput").set_input_files(
{"name": "image.png", "mimeType": "image/png", "buffer": image}
)
playwright_api.expect(page.locator(".image-preview-item")).to_have_count(1)


def test_upsample_heading_keeps_both_translations(browser_page):
page, base = browser_page
open_page(page, base + "/test")
heading = page.locator('.section-title[data-category="视频放大 (Upsample)"]')
playwright_api.expect(heading).to_contain_text("Video upscaling (Upsample)")
page.locator("[data-language-select]").select_option("zh-CN")
playwright_api.expect(heading).to_contain_text("视频放大 (Upsample)")
page.locator("[data-language-select]").select_option("en")
playwright_api.expect(heading).to_contain_text("Video upscaling (Upsample)")


def test_extend_requires_source_and_sends_backend_compatible_reference(browser_page):
from src.api.routes import _extract_prompt_and_images_from_openai_messages
from src.core.models import ChatMessage

page, base = browser_page
models = [
"veo-3.1-fast-extend-8s-landscape",
"omni-1.1-flash-4s-landscape",
"veo-3.1-fast-8s-landscape",
]
page.route(
"**/v1/models",
lambda route: route.fulfill(
content_type="application/json",
body=json.dumps({"data": [{"id": model} for model in models]}),
),
)
requests = []

def generate_response(route):
requests.append(route.request.post_data_json)
route.fulfill(
content_type="text/event-stream",
body='data: {"choices":[{"delta":{"content":"completed"}}]}\n\n'
+ "data: [DONE]\n\n",
)

page.route("**/v1/chat/completions", generate_response)
open_page(page, base + "/test")
page.locator("#apiKey").fill("local-test-key")
page.locator("#apiKey").press("Tab")
page.locator(f'.model-item[data-model="{models[0]}"]').click()
source = page.locator("#videoMediaId")
playwright_api.expect(source).to_be_visible()
playwright_api.expect(page.locator("#imageUploadGroup")).to_be_hidden()
playwright_api.expect(page.locator('label[for="videoMediaId"]')).to_have_text(
"Source video media ID"
)
dialogs = []

def dismiss_dialog(dialog):
dialogs.append(dialog.message)
dialog.accept()

page.on("dialog", dismiss_dialog)
for language, message in [
("en", "Enter the source video's mediaGenerationId"),
("zh-CN", "请输入源视频的 mediaGenerationId"),
]:
page.locator("[data-language-select]").select_option(language)
source.fill(" ")
page.locator("#btnGenerate").click()
assert dialogs[-1] == message
assert requests == []
playwright_api.expect(page.locator("#btnGenerate")).to_be_enabled()

source.fill(" source-media-id ")
page.locator("#promptInput").fill("Continue the scene")
page.locator("[data-language-select]").select_option("en")
assert source.input_value() == " source-media-id "
page.locator("#btnGenerate").click()
playwright_api.expect(page.locator("#outputResult pre")).to_have_text("completed")
assert len(requests) == 1
body = requests[0]
assert body["model"] == models[0]
assert body["stream"] is True
messages = [ChatMessage(**message) for message in body["messages"]]
with ThreadPoolExecutor(max_workers=1) as executor:
prompt, images, media_id = executor.submit(
asyncio.run, _extract_prompt_and_images_from_openai_messages(messages)
).result(timeout=5)
assert (prompt, images, media_id) == ("Continue the scene", [], "source-media-id")

page.locator(f'.model-item[data-model="{models[1]}"]').click()
playwright_api.expect(source).to_be_hidden()
assert source.input_value() == ""
playwright_api.expect(page.locator("#imageUploadGroup")).to_be_visible()
playwright_api.expect(page.locator("#imageLimit")).to_have_text("(0-3 images)")
image = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jNekAAAAASUVORK5CYII="
)
page.locator("#imageFileInput").set_input_files(
{"name": "image.png", "mimeType": "image/png", "buffer": image}
)
playwright_api.expect(page.locator(".image-preview-item")).to_have_count(1)
page.locator("#btnGenerate").click()
playwright_api.expect(page.locator("#outputResult pre")).to_have_text("completed")
playwright_api.expect(page.locator("#btnGenerate")).to_be_enabled()
assert len(requests) == 2
content = requests[-1]["messages"][0]["content"]
assert content[0] == {"type": "text", "text": "Continue the scene"}
assert content[1]["image_url"]["url"].startswith("data:image/png;base64,")

page.locator(f'.model-item[data-model="{models[2]}"]').click()
playwright_api.expect(source).to_be_hidden()
playwright_api.expect(page.locator("#imageUploadGroup")).to_be_hidden()
page.locator("#btnGenerate").click()
playwright_api.expect(page.locator("#btnGenerate")).to_be_enabled()
assert len(requests) == 3
assert requests[-1]["messages"] == [
{"role": "user", "content": "Continue the scene"}
]


def test_model_tester_displays_all_published_models(browser_page):
from src.services.generation_handler import MODEL_CONFIG

page, base = browser_page
models = [
{"id": model, "description": config.get("display_name", model)}
for model, config in MODEL_CONFIG.items()
if config.get("listed", True)
]
page.route(
"**/v1/models",
lambda route: route.fulfill(
content_type="application/json", body=json.dumps({"data": models})
),
)
open_page(page, base + "/test")
page.locator("#apiKey").fill("local-test-key")
page.locator("#apiKey").press("Tab")
items = page.locator(".model-item")
playwright_api.expect(items).to_have_count(len(models))
assert sorted(
items.evaluate_all("items => items.map(item => item.dataset.model)")
) == sorted(model["id"] for model in models)