Raven10492 commited on
Commit
7dd9c94
·
verified ·
1 Parent(s): 0c34094

Upload 7 files

Browse files
backend/main.py CHANGED
@@ -1,75 +1,119 @@
1
- import uvicorn
2
- from fastapi import FastAPI, File, UploadFile, Form, HTTPException
3
- from fastapi.responses import FileResponse
4
- from fastapi.staticfiles import StaticFiles
5
- from typing import Optional
6
- import services.vertex_service as vertex_service
7
- import services.video_service as video_service
8
- import os
9
-
10
- app = FastAPI()
11
-
12
- @app.post("/api/generate_video")
13
- async def generate_video(
14
- api_key: str = Form(...),
15
- prompt: str = Form(...),
16
- negative_prompt: Optional[str] = Form(None),
17
- model: str = Form(...),
18
- aspect_ratio: str = Form(...),
19
- duration: int = Form(...),
20
- resolution: str = Form(...),
21
- sample_count: int = Form(...),
22
- seed: Optional[int] = Form(None),
23
- person_generation: str = Form(...),
24
- generate_audio: bool = Form(...),
25
- enhance_prompt: bool = Form(...),
26
- image: Optional[UploadFile] = File(None),
27
- video: Optional[UploadFile] = File(None),
28
- ):
29
- try:
30
- project_id = await vertex_service.get_project_id(api_key)
31
-
32
- params = {
33
- "prompt": prompt,
34
- "negativePrompt": negative_prompt,
35
- "model": model,
36
- "aspectRatio": aspect_ratio,
37
- "durationSeconds": duration,
38
- "resolution": resolution,
39
- "sampleCount": sample_count,
40
- "seed": seed,
41
- "personGeneration": person_generation,
42
- "generateAudio": generate_audio,
43
- "enhancePrompt": enhance_prompt,
44
- }
45
-
46
- if image:
47
- params["image"] = await image.read()
48
- params["image_mime_type"] = image.content_type
49
-
50
- if video:
51
- params["video"] = await video.read()
52
- params["video_mime_type"] = video.content_type
53
-
54
- model_id = params["model"]
55
- operation_name = await vertex_service.start_video_generation(project_id, api_key, params)
56
- video_data = await vertex_service.poll_video_status(project_id, model_id, operation_name, api_key)
57
-
58
- video_paths = await video_service.save_videos(video_data)
59
-
60
- video_urls = [f"/api/video/{os.path.basename(p)}" for p in video_paths]
61
- return {"video_urls": video_urls}
62
- except Exception as e:
63
- raise HTTPException(status_code=400, detail=str(e))
64
-
65
- @app.get("/api/video/{video_id}")
66
- async def get_video(video_id: str):
67
- video_path = os.path.join(video_service.VIDEO_DIR, video_id)
68
- if os.path.exists(video_path):
69
- return FileResponse(video_path)
70
- raise HTTPException(status_code=404, detail="Video not found")
71
-
72
- app.mount("/", StaticFiles(directory="../frontend", html=True), name="static")
73
-
74
- if __name__ == "__main__":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  uvicorn.run(app, host="0.0.0.0", port=8000)
 
1
+ import uvicorn
2
+ from fastapi import FastAPI, File, UploadFile, Form, HTTPException
3
+ from fastapi.responses import FileResponse, JSONResponse
4
+ from fastapi.staticfiles import StaticFiles
5
+ from typing import Optional
6
+ import services.vertex_service as vertex_service
7
+ import services.video_service as video_service
8
+ import services.image_service as image_service # Import the new service
9
+ import os
10
+ import shutil
11
+ from datetime import datetime
12
+
13
+ app = FastAPI()
14
+
15
+ @app.post("/api/generate_video")
16
+ async def generate_video(
17
+ api_key: str = Form(...),
18
+ prompt: str = Form(...),
19
+ negative_prompt: Optional[str] = Form(None),
20
+ model: str = Form(...),
21
+ aspect_ratio: str = Form(...),
22
+ duration: int = Form(...),
23
+ resolution: str = Form(...),
24
+ sample_count: int = Form(...),
25
+ seed: Optional[int] = Form(None),
26
+ person_generation: str = Form(...),
27
+ generate_audio: bool = Form(...),
28
+ enhance_prompt: bool = Form(...),
29
+ image: Optional[UploadFile] = File(None),
30
+ video: Optional[UploadFile] = File(None),
31
+ # Add new form fields for image processing
32
+ image_process_mode: Optional[str] = Form(None),
33
+ image_fill_color: Optional[str] = Form("#000000"),
34
+ ):
35
+ try:
36
+ project_id = await vertex_service.get_project_id(api_key)
37
+
38
+ params = {
39
+ "prompt": prompt,
40
+ "negativePrompt": negative_prompt,
41
+ "model": model,
42
+ "aspectRatio": aspect_ratio,
43
+ "durationSeconds": duration,
44
+ "resolution": resolution,
45
+ "sampleCount": sample_count,
46
+ "seed": seed,
47
+ "personGeneration": person_generation,
48
+ "generateAudio": generate_audio,
49
+ "enhancePrompt": enhance_prompt,
50
+ }
51
+
52
+ if image:
53
+ image_bytes = await image.read()
54
+ # 现在所有宽高比都支持图片处理
55
+ if image_process_mode and image_process_mode != 'none':
56
+ # Process the image if a mode is selected
57
+ processed_image_bytes = image_service.process_image(
58
+ image_bytes=image_bytes,
59
+ mode=image_process_mode,
60
+ fill_color=image_fill_color,
61
+ target_aspect_ratio=aspect_ratio # 传递目标宽高比
62
+ )
63
+ params["image"] = processed_image_bytes
64
+ params["image_mime_type"] = "image/jpeg" # Service always returns JPEG
65
+ else:
66
+ # Otherwise, use the original image
67
+ params["image"] = image_bytes
68
+ params["image_mime_type"] = image.content_type
69
+
70
+ if video:
71
+ params["video"] = await video.read()
72
+ params["video_mime_type"] = video.content_type
73
+
74
+ model_id = params["model"]
75
+ operation_name = await vertex_service.start_video_generation(project_id, api_key, params)
76
+ video_data = await vertex_service.poll_video_status(project_id, model_id, operation_name, api_key)
77
+
78
+ video_paths = await video_service.save_video(video_data)
79
+
80
+ # Create a list of URLs for the frontend
81
+ video_urls = [f"/api/video/{os.path.basename(p)}" for p in video_paths]
82
+
83
+ return {"video_urls": video_urls}
84
+ except Exception as e:
85
+ raise HTTPException(status_code=400, detail=str(e))
86
+
87
+ @app.get("/api/video/{video_id}")
88
+ async def get_video(video_id: str):
89
+ video_path = os.path.join(video_service.VIDEO_DIR, video_id)
90
+ if os.path.exists(video_path):
91
+ return FileResponse(video_path)
92
+ raise HTTPException(status_code=404, detail="Video not found")
93
+
94
+ @app.post("/api/save_video")
95
+ async def save_video_to_server(video_url: str = Form(...)):
96
+ try:
97
+ video_id = os.path.basename(video_url)
98
+ source_path = os.path.join(video_service.VIDEO_DIR, video_id)
99
+
100
+ if not os.path.exists(source_path):
101
+ raise HTTPException(status_code=404, detail="Video not found")
102
+
103
+ output_dir = "output"
104
+ os.makedirs(output_dir, exist_ok=True)
105
+
106
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
107
+ filename = f"{timestamp}.mp4"
108
+ destination_path = os.path.join(output_dir, filename)
109
+
110
+ shutil.copy(source_path, destination_path)
111
+
112
+ return JSONResponse(content={"message": "Video saved successfully", "path": destination_path})
113
+ except Exception as e:
114
+ raise HTTPException(status_code=500, detail=str(e))
115
+
116
+ app.mount("/", StaticFiles(directory="../frontend", html=True), name="static")
117
+
118
+ if __name__ == "__main__":
119
  uvicorn.run(app, host="0.0.0.0", port=8000)
backend/services/image_service.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image, ImageOps
2
+ import io
3
+
4
+ def parse_aspect_ratio(aspect_ratio_str: str) -> float:
5
+ """
6
+ Parse aspect ratio string like "16:9" or "9:16" to float.
7
+ """
8
+ parts = aspect_ratio_str.split(':')
9
+ if len(parts) == 2:
10
+ return float(parts[0]) / float(parts[1])
11
+ return 16.0 / 9.0 # fallback
12
+
13
+ def _parse_hex_color(fill_color: str) -> tuple:
14
+ """
15
+ Safely parse hex color like "#RRGGBB" to an RGB tuple for PIL.
16
+ Falls back to black if parsing fails.
17
+ """
18
+ if not fill_color:
19
+ return (0, 0, 0)
20
+ s = fill_color.strip()
21
+ try:
22
+ if s.startswith("#"):
23
+ s = s[1:]
24
+ if len(s) == 6:
25
+ r = int(s[0:2], 16)
26
+ g = int(s[2:4], 16)
27
+ b = int(s[4:6], 16)
28
+ return (r, g, b)
29
+ except Exception:
30
+ pass
31
+ return (0, 0, 0)
32
+
33
+ def process_image(image_bytes: bytes, mode: str, fill_color: str = "#000000", target_aspect_ratio: str = "16:9") -> bytes:
34
+ """
35
+ Processes an image to fit the specified aspect ratio.
36
+
37
+ Args:
38
+ image_bytes: The original image in bytes.
39
+ mode: The processing mode ('stretch_to_fill', 'compress_to_fit', 'fill').
40
+ fill_color: The hex color code for the 'fill' mode background.
41
+ target_aspect_ratio: The target aspect ratio string like "16:9" or "9:16".
42
+
43
+ Returns:
44
+ The processed image in bytes (JPEG format).
45
+ """
46
+ if not mode or mode == 'none':
47
+ return image_bytes
48
+
49
+ try:
50
+ target_ratio = parse_aspect_ratio(target_aspect_ratio)
51
+ image = Image.open(io.BytesIO(image_bytes))
52
+ # Normalize EXIF orientation to ensure width/height reflect actual display
53
+ try:
54
+ image = ImageOps.exif_transpose(image)
55
+ except Exception:
56
+ pass
57
+ original_width, original_height = image.size
58
+ original_aspect_ratio = original_width / original_height
59
+
60
+ # If aspect ratio is already correct, no processing needed
61
+ if abs(original_aspect_ratio - target_ratio) < 1e-6:
62
+ return image_bytes
63
+
64
+ if mode == 'stretch_to_fill':
65
+ if original_aspect_ratio > target_ratio: # Wider than target
66
+ # Height is relatively too short, stretch it
67
+ new_width = original_width
68
+ new_height = int(new_width / target_ratio)
69
+ processed_image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
70
+ else: # Taller than target
71
+ # Width is relatively too short, stretch it
72
+ new_height = original_height
73
+ new_width = int(new_height * target_ratio)
74
+ processed_image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
75
+
76
+ elif mode == 'compress_to_fit':
77
+ if original_aspect_ratio > target_ratio: # Wider than target
78
+ # Width is relatively too long, compress it
79
+ new_height = original_height
80
+ new_width = int(new_height * target_ratio)
81
+ processed_image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
82
+ else: # Taller than target
83
+ # Height is relatively too long, compress it
84
+ new_width = original_width
85
+ new_height = int(new_width / target_ratio)
86
+ processed_image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
87
+
88
+ elif mode == 'fill':
89
+ # Pad with color to meet target aspect ratio without scaling the source
90
+ if original_aspect_ratio > target_ratio:
91
+ # Wider than target -> pad top/bottom
92
+ new_width = original_width
93
+ new_height = int(round(new_width / target_ratio))
94
+ paste_x, paste_y = 0, (new_height - original_height) // 2
95
+ else:
96
+ # Taller than target -> pad left/right
97
+ new_height = original_height
98
+ new_width = int(round(new_height * target_ratio))
99
+ paste_x, paste_y = (new_width - original_width) // 2, 0
100
+
101
+ # Use parsed RGB to avoid mode issues and ensure consistent color
102
+ fill_rgb = _parse_hex_color(fill_color or "#000000")
103
+ background = Image.new('RGB', (new_width, new_height), fill_rgb)
104
+
105
+ # Preserve alpha if present so the background color shows through transparent regions
106
+ if image.mode in ('RGBA', 'LA') or (image.mode == 'P' and 'transparency' in image.info):
107
+ if image.mode != 'RGBA':
108
+ image = image.convert('RGBA')
109
+ background.paste(image, (paste_x, paste_y), image)
110
+ else:
111
+ background.paste(image, (paste_x, paste_y))
112
+ processed_image = background
113
+ else:
114
+ # If mode is unknown, return original image
115
+ return image_bytes
116
+
117
+
118
+ # Save the processed image to a byte buffer
119
+ byte_arr = io.BytesIO()
120
+ # Ensure image is in RGB mode before saving as JPEG
121
+ if processed_image.mode != 'RGB':
122
+ processed_image = processed_image.convert('RGB')
123
+ processed_image.save(byte_arr, format='JPEG')
124
+ return byte_arr.getvalue()
125
+
126
+ except Exception as e:
127
+ print(f"Error processing image: {e}")
128
+ # In case of error, return the original image bytes
129
+ return image_bytes
backend/services/vertex_service.py CHANGED
@@ -1,107 +1,107 @@
1
- import aiohttp
2
- import asyncio
3
- import base64
4
- import re
5
- import logging
6
- from typing import Dict, Any
7
-
8
- logging.basicConfig(level=logging.INFO)
9
-
10
- # A simple in-memory cache for project IDs
11
- project_id_cache = {}
12
-
13
- async def get_project_id(api_key: str) -> str:
14
- if api_key in project_id_cache:
15
- return project_id_cache[api_key]
16
-
17
- url = f"https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.6:streamGenerateContent?key={api_key}"
18
- headers = {"Content-Type": "application/json"}
19
- data = {}
20
-
21
- async with aiohttp.ClientSession() as session:
22
- async with session.post(url, headers=headers, json=data) as response:
23
- error_data = await response.json()
24
- message = error_data[0]["error"]["message"]
25
- match = re.search(r"projects/(\d+)/", message)
26
- if match:
27
- project_id = match.group(1)
28
- project_id_cache[api_key] = project_id
29
- return project_id
30
- else:
31
- raise Exception("Could not extract project ID")
32
-
33
- async def start_video_generation(project_id: str, api_key: str, params: Dict[str, Any]) -> str:
34
- location = "us-central1" # Or make this a parameter
35
- model_id = params.pop("model")
36
- url = f"https://{location}-aiplatform.googleapis.com/v1/projects/{project_id}/locations/{location}/publishers/google/models/{model_id}:predictLongRunning?key={api_key}"
37
-
38
- instances = [{"prompt": params.get("prompt")}]
39
- if "image" in params and params["image"]:
40
- instances[0]["image"] = {
41
- "bytesBase64Encoded": base64.b64encode(params["image"]).decode("utf-8"),
42
- "mimeType": params["image_mime_type"]
43
- }
44
- if "video" in params and params["video"]:
45
- instances[0]["video"] = {
46
- "bytesBase64Encoded": base64.b64encode(params["video"]).decode("utf-8"),
47
- "mimeType": params["video_mime_type"]
48
- }
49
-
50
- parameters = {
51
- "aspectRatio": params.get("aspectRatio"),
52
- "durationSeconds": params.get("durationSeconds"),
53
- "resolution": params.get("resolution"),
54
- "generateAudio": params.get("generateAudio"),
55
- "enhancePrompt": params.get("enhancePrompt"),
56
- "negativePrompt": params.get("negativePrompt"),
57
- "personGeneration": params.get("personGeneration"),
58
- "sampleCount": params.get("sampleCount"),
59
- "seed": params.get("seed"),
60
- "safetySetting": "block_none"
61
- }
62
-
63
- # Remove None values from parameters
64
- parameters = {k: v for k, v in parameters.items() if v is not None}
65
-
66
- payload = {
67
- "instances": instances,
68
- "parameters": parameters
69
- }
70
-
71
- logging.info(f"Sending video generation request to {url} with payload: {payload}")
72
- async with aiohttp.ClientSession() as session:
73
- async with session.post(url, json=payload) as response:
74
- data = await response.json()
75
- logging.info(f"Received response: {data}")
76
- response.raise_for_status()
77
- return data["name"]
78
-
79
- async def poll_video_status(project_id: str, model_id: str, operation_name: str, api_key: str) -> Dict[str, Any]:
80
- location = "us-central1"
81
- url = f"https://{location}-aiplatform.googleapis.com/v1/projects/{project_id}/locations/{location}/publishers/google/models/{model_id}:fetchPredictOperation?key={api_key}"
82
-
83
- payload = {"operationName": operation_name}
84
-
85
- async with aiohttp.ClientSession() as session:
86
- while True:
87
- logging.info(f"Polling status from {url} with payload: {payload}")
88
- async with session.post(url, json=payload) as response:
89
- data = await response.json()
90
- logging.info(f"Received polling response: {data}")
91
- response.raise_for_status()
92
- if data.get("done"):
93
- if "error" in data:
94
- raise Exception(data['error']['message'])
95
-
96
- response_data = data.get("response", {})
97
-
98
- # If videos are present, return them, even if some were filtered.
99
- if "videos" in response_data:
100
- return response_data
101
-
102
- # If no videos, but filtering reasons exist, then all were blocked.
103
- if "raiMediaFilteredReasons" in response_data:
104
- raise Exception(response_data['raiMediaFilteredReasons'][0])
105
-
106
- return response_data
107
  await asyncio.sleep(5) # Poll every 5 seconds
 
1
+ import aiohttp
2
+ import asyncio
3
+ import base64
4
+ import re
5
+ import logging
6
+ from typing import Dict, Any
7
+
8
+ logging.basicConfig(level=logging.INFO)
9
+
10
+ # A simple in-memory cache for project IDs
11
+ project_id_cache = {}
12
+
13
+ async def get_project_id(api_key: str) -> str:
14
+ if api_key in project_id_cache:
15
+ return project_id_cache[api_key]
16
+
17
+ url = f"https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.6:streamGenerateContent?key={api_key}"
18
+ headers = {"Content-Type": "application/json"}
19
+ data = {}
20
+
21
+ async with aiohttp.ClientSession() as session:
22
+ async with session.post(url, headers=headers, json=data) as response:
23
+ error_data = await response.json()
24
+ message = error_data[0]["error"]["message"]
25
+ match = re.search(r"projects/(\d+)/", message)
26
+ if match:
27
+ project_id = match.group(1)
28
+ project_id_cache[api_key] = project_id
29
+ return project_id
30
+ else:
31
+ raise Exception("Could not extract project ID")
32
+
33
+ async def start_video_generation(project_id: str, api_key: str, params: Dict[str, Any]) -> str:
34
+ location = "us-central1" # Or make this a parameter
35
+ model_id = params.pop("model")
36
+ url = f"https://{location}-aiplatform.googleapis.com/v1/projects/{project_id}/locations/{location}/publishers/google/models/{model_id}:predictLongRunning?key={api_key}"
37
+
38
+ instances = [{"prompt": params.get("prompt")}]
39
+ if "image" in params and params["image"]:
40
+ instances[0]["image"] = {
41
+ "bytesBase64Encoded": base64.b64encode(params["image"]).decode("utf-8"),
42
+ "mimeType": params["image_mime_type"]
43
+ }
44
+ if "video" in params and params["video"]:
45
+ instances[0]["video"] = {
46
+ "bytesBase64Encoded": base64.b64encode(params["video"]).decode("utf-8"),
47
+ "mimeType": params["video_mime_type"]
48
+ }
49
+
50
+ parameters = {
51
+ "aspectRatio": params.get("aspectRatio"),
52
+ "durationSeconds": params.get("durationSeconds"),
53
+ "resolution": params.get("resolution"),
54
+ "generateAudio": params.get("generateAudio"),
55
+ "enhancePrompt": params.get("enhancePrompt"),
56
+ "negativePrompt": params.get("negativePrompt"),
57
+ "personGeneration": params.get("personGeneration"),
58
+ "sampleCount": params.get("sampleCount"),
59
+ "seed": params.get("seed"),
60
+ "safetySetting": "block_none"
61
+ }
62
+
63
+ # Remove None values from parameters
64
+ parameters = {k: v for k, v in parameters.items() if v is not None}
65
+
66
+ payload = {
67
+ "instances": instances,
68
+ "parameters": parameters
69
+ }
70
+
71
+ logging.info(f"Sending video generation request to {url} with payload: {payload}")
72
+ async with aiohttp.ClientSession() as session:
73
+ async with session.post(url, json=payload) as response:
74
+ data = await response.json()
75
+ logging.info(f"Received response: {data}")
76
+ response.raise_for_status()
77
+ return data["name"]
78
+
79
+ async def poll_video_status(project_id: str, model_id: str, operation_name: str, api_key: str) -> Dict[str, Any]:
80
+ location = "us-central1"
81
+ url = f"https://{location}-aiplatform.googleapis.com/v1/projects/{project_id}/locations/{location}/publishers/google/models/{model_id}:fetchPredictOperation?key={api_key}"
82
+
83
+ payload = {"operationName": operation_name}
84
+
85
+ async with aiohttp.ClientSession() as session:
86
+ while True:
87
+ logging.info(f"Polling status from {url} with payload: {payload}")
88
+ async with session.post(url, json=payload) as response:
89
+ data = await response.json()
90
+ logging.info(f"Received polling response: {data}")
91
+ response.raise_for_status()
92
+ if data.get("done"):
93
+ if "error" in data:
94
+ raise Exception(data['error']['message'])
95
+
96
+ response_data = data.get("response", {})
97
+
98
+ # If videos are present, return them, even if some were filtered.
99
+ if "videos" in response_data:
100
+ return response_data
101
+
102
+ # If no videos, but filtering reasons exist, then all were blocked.
103
+ if "raiMediaFilteredReasons" in response_data:
104
+ raise Exception(response_data['raiMediaFilteredReasons'][0])
105
+
106
+ return response_data
107
  await asyncio.sleep(5) # Poll every 5 seconds
backend/services/video_service.py CHANGED
@@ -1,37 +1,48 @@
1
- import os
2
- import uuid
3
- import base64
4
- import aiohttp
5
- from typing import Dict, Any, List
6
-
7
- VIDEO_DIR = "/tmp/generated_videos"
8
-
9
- async def save_videos(video_data: Dict[str, Any]) -> List[str]:
10
- os.makedirs(VIDEO_DIR, exist_ok=True)
11
- video_paths = []
12
-
13
- if "videos" not in video_data:
14
- raise Exception("No video data found in response")
15
-
16
- for video_item in video_data["videos"]:
17
- video_filename = f"{uuid.uuid4()}.mp4"
18
- video_path = os.path.join(VIDEO_DIR, video_filename)
19
-
20
- if "gcsUri" in video_item:
21
- gcs_uri = video_item["gcsUri"]
22
- async with aiohttp.ClientSession() as session:
23
- async with session.get(gcs_uri) as response:
24
- response.raise_for_status()
25
- with open(video_path, "wb") as f:
26
- f.write(await response.read())
27
- elif "bytesBase64Encoded" in video_item:
28
- video_bytes = base64.b64decode(video_item["bytesBase64Encoded"])
29
- with open(video_path, "wb") as f:
30
- f.write(video_bytes)
31
- else:
32
- # Skip this video if it has no content, but don't fail the whole request
33
- continue
34
-
35
- video_paths.append(video_path)
36
-
37
- return video_paths
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import uuid
3
+ import base64
4
+ import aiohttp
5
+ from typing import Dict, Any, List
6
+
7
+ VIDEO_DIR = "/tmp/generated_videos"
8
+
9
+ async def save_video(video_data: Dict[str, Any]) -> List[str]:
10
+ """
11
+ Saves all videos from a response object and returns a list of their local paths.
12
+ """
13
+ os.makedirs(VIDEO_DIR, exist_ok=True)
14
+ saved_paths = []
15
+
16
+ video_list = video_data.get("videos", [])
17
+ if not video_list:
18
+ raise Exception("No 'videos' array found in the response data.")
19
+
20
+ for video_item in video_list:
21
+ video_filename = f"{uuid.uuid4()}.mp4"
22
+ video_path = os.path.join(VIDEO_DIR, video_filename)
23
+
24
+ saved = False
25
+ try:
26
+ if "gcsUri" in video_item:
27
+ gcs_uri = video_item["gcsUri"]
28
+ async with aiohttp.ClientSession() as session:
29
+ async with session.get(gcs_uri) as response:
30
+ response.raise_for_status()
31
+ with open(video_path, "wb") as f:
32
+ f.write(await response.read())
33
+ saved_paths.append(video_path)
34
+ saved = True
35
+ elif "bytesBase64Encoded" in video_item:
36
+ video_bytes = base64.b64decode(video_item["bytesBase64Encoded"])
37
+ with open(video_path, "wb") as f:
38
+ f.write(video_bytes)
39
+ saved_paths.append(video_path)
40
+ saved = True
41
+ except Exception as e:
42
+ # Log the error but continue trying to process other videos
43
+ print(f"Warning: Failed to save a video. Error: {e}")
44
+
45
+ if not saved_paths:
46
+ raise Exception("Failed to save any videos from the provided data.")
47
+
48
+ return saved_paths
frontend/app.js CHANGED
@@ -1,70 +1,828 @@
1
- document.getElementById("video-form").addEventListener("submit", async (event) => {
2
- event.preventDefault();
3
-
4
- const form = event.target;
5
- const formData = new FormData();
6
-
7
- formData.append("api_key", form["api-key"].value);
8
- formData.append("prompt", form["prompt"].value);
9
- formData.append("negative_prompt", form["negative-prompt"].value);
10
- formData.append("model", form["model"].value);
11
- formData.append("aspect_ratio", form["aspect-ratio"].value);
12
- formData.append("duration", form["duration"].value);
13
- formData.append("resolution", form["resolution"].value);
14
- formData.append("sample_count", form["sample-count"].value);
15
- if (form["seed"].value) {
16
- formData.append("seed", form["seed"].value);
17
- }
18
- formData.append("person_generation", form["person-generation"].value);
19
- formData.append("generate_audio", form["generate-audio"].checked);
20
- formData.append("enhance_prompt", form["enhance-prompt"].checked);
21
-
22
- const imageFile = form["image-prompt"].files[0];
23
- if (imageFile) {
24
- formData.append("image", imageFile);
25
- }
26
-
27
- const videoFile = form["video-prompt"].files[0];
28
- if (videoFile) {
29
- formData.append("video", videoFile);
30
- }
31
-
32
- const loader = document.getElementById("loader");
33
- const videoResults = document.getElementById("video-results");
34
- const errorContainer = document.getElementById("error-container");
35
-
36
- loader.classList.remove("hidden");
37
- videoResults.innerHTML = ""; // Clear previous videos
38
- errorContainer.classList.add("hidden");
39
-
40
- try {
41
- const response = await fetch("/api/generate_video", {
42
- method: "POST",
43
- body: formData,
44
- });
45
-
46
- if (!response.ok) {
47
- const errorData = await response.json();
48
- throw new Error(errorData.detail || "An unknown error occurred");
49
- }
50
-
51
- const data = await response.json();
52
-
53
- if (data.video_urls && data.video_urls.length > 0) {
54
- data.video_urls.forEach(videoUrl => {
55
- const videoElement = document.createElement("video");
56
- videoElement.src = videoUrl;
57
- videoElement.controls = true;
58
- videoResults.appendChild(videoElement);
59
- });
60
- } else {
61
- throw new Error("No videos were generated.");
62
- }
63
-
64
- } catch (error) {
65
- errorContainer.textContent = error.message;
66
- errorContainer.classList.remove("hidden");
67
- } finally {
68
- loader.classList.add("hidden");
69
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  });
 
1
+ document.addEventListener("DOMContentLoaded", () => {
2
+ // --- DOM Element Selection ---
3
+ const generateBtn = document.getElementById("generate-btn");
4
+ const saveVideoBtn = document.getElementById("save-video-btn");
5
+ const loader = document.getElementById("loader");
6
+ const errorContainer = document.getElementById("error-container");
7
+ const placeholder = document.querySelector(".video-container .placeholder");
8
+
9
+ // Form Inputs
10
+ const apiKeyInput = document.getElementById("api-key");
11
+ const promptInput = document.getElementById("prompt");
12
+ const negativePromptInput = document.getElementById("negative-prompt");
13
+ const modelSelect = document.getElementById("model");
14
+ const aspectRatioSelect = document.getElementById("aspect-ratio");
15
+ const resolutionSelect = document.getElementById("resolution");
16
+ const personGenSelect = document.getElementById("person-generation");
17
+ const seedInput = document.getElementById("seed");
18
+ const sampleCountInput = document.getElementById("sample-count");
19
+ const durationInput = document.getElementById("duration");
20
+ const generateAudioSwitch = document.getElementById("generate-audio");
21
+ const threadCountInput = document.getElementById("thread-count");
22
+ const enhancePromptSwitch = document.getElementById("enhance-prompt");
23
+ const imagePromptInput = document.getElementById("image-prompt");
24
+ const videoPromptInput = document.getElementById("video-prompt");
25
+ const imageProcessModeSelect = document.getElementById("image-process-mode");
26
+ const imageFillColorGroup = document.getElementById("image-fill-color-group");
27
+ const imageFillColorPicker = document.getElementById("image-fill-color-picker");
28
+ const imageFillColorInput = document.getElementById("image-fill-color");
29
+
30
+ // Carousel Elements
31
+ const videoCarousel = document.getElementById("video-carousel");
32
+ const prevBtn = document.getElementById("prev-btn");
33
+ const nextBtn = document.getElementById("next-btn");
34
+ const carouselDots = document.getElementById("carousel-dots");
35
+
36
+ // Prompt Saving Elements
37
+ const savePromptBtn = document.getElementById("save-prompt-btn");
38
+ const savedPromptsToggle = document.getElementById("saved-prompts-toggle");
39
+ const savedPromptsList = document.getElementById("saved-prompts-list");
40
+
41
+ // Key Saving Elements
42
+ const saveKeyBtn = document.getElementById("save-key-btn");
43
+ const savedKeysToggle = document.getElementById("saved-keys-toggle");
44
+ const savedKeysList = document.getElementById("saved-keys-list");
45
+
46
+ // --- State ---
47
+ let currentIndex = 0;
48
+ let videoItems = [];
49
+ const SETTINGS_KEY = 'veoGeneratorSettings';
50
+ let currentPasteTarget = null; // 'image' or 'video'
51
+ let savedVideos = []; // To track URLs of saved videos
52
+ const SAVED_PROMPTS_KEY = 'veoGeneratorSavedPrompts';
53
+ const SAVED_KEYS_KEY = 'veoGeneratorSavedKeys';
54
+
55
+ const PRESET_KEYS = [
56
+ ];
57
+
58
+ // --- Functions ---
59
+
60
+ // --- Saved Prompts Logic ---
61
+ function updateSaveButtonState() {
62
+ const currentPrompt = promptInput.value.trim();
63
+ const prompts = getSavedPrompts();
64
+ if (currentPrompt && prompts.includes(currentPrompt)) {
65
+ savePromptBtn.innerHTML = feather.icons['check-circle'].toSvg();
66
+ savePromptBtn.title = "提示词已保存";
67
+ savePromptBtn.disabled = true;
68
+ } else {
69
+ savePromptBtn.innerHTML = feather.icons['plus-circle'].toSvg();
70
+ savePromptBtn.title = "保存当前提示词";
71
+ savePromptBtn.disabled = false;
72
+ }
73
+ }
74
+
75
+ function getSavedPrompts() {
76
+ return JSON.parse(localStorage.getItem(SAVED_PROMPTS_KEY)) || [];
77
+ }
78
+
79
+ function savePrompts(prompts) {
80
+ localStorage.setItem(SAVED_PROMPTS_KEY, JSON.stringify(prompts));
81
+ }
82
+
83
+ function renderSavedPrompts() {
84
+ const prompts = getSavedPrompts();
85
+ savedPromptsList.innerHTML = ''; // Clear the list first
86
+
87
+ if (prompts.length === 0) {
88
+ savedPromptsList.innerHTML = `<div class="saved-prompts-list-empty">没有已保存的提示词</div>`;
89
+ return;
90
+ }
91
+
92
+ prompts.forEach((promptText, index) => {
93
+ const item = document.createElement('div');
94
+ item.className = 'saved-prompt-item';
95
+ item.dataset.index = index;
96
+
97
+ const text = document.createElement('span');
98
+ text.className = 'saved-prompt-text';
99
+ text.textContent = promptText;
100
+ text.title = promptText; // Show full prompt on hover
101
+ text.addEventListener('click', () => {
102
+ promptInput.value = promptText;
103
+ saveSettings(); // Also save to main settings
104
+ savedPromptsList.classList.add('hidden');
105
+ updateSaveButtonState();
106
+ });
107
+
108
+ const deleteBtn = document.createElement('button');
109
+ deleteBtn.className = 'delete-prompt-btn';
110
+ deleteBtn.innerHTML = feather.icons['trash-2'].toSvg({ width: 14, height: 14 });
111
+ deleteBtn.title = '删除此提示词';
112
+ deleteBtn.addEventListener('click', (e) => {
113
+ e.stopPropagation(); // Prevent item click event
114
+ deletePrompt(index);
115
+ });
116
+
117
+ item.appendChild(text);
118
+ item.appendChild(deleteBtn);
119
+ savedPromptsList.appendChild(item);
120
+ });
121
+ }
122
+
123
+ function addNewPrompt() {
124
+ const currentPrompt = promptInput.value.trim();
125
+ if (!currentPrompt) {
126
+ // Silently return if prompt is empty
127
+ return;
128
+ }
129
+
130
+ let prompts = getSavedPrompts();
131
+ if (prompts.includes(currentPrompt)) {
132
+ // Silently return if prompt already exists
133
+ return;
134
+ }
135
+
136
+ prompts.unshift(currentPrompt); // Add to the beginning
137
+ savePrompts(prompts);
138
+ renderSavedPrompts();
139
+ updateSaveButtonState();
140
+ }
141
+
142
+ function deletePrompt(indexToDelete) {
143
+ let prompts = getSavedPrompts();
144
+ prompts.splice(indexToDelete, 1);
145
+ savePrompts(prompts);
146
+ renderSavedPrompts();
147
+ updateSaveButtonState();
148
+ }
149
+
150
+ savePromptBtn.addEventListener('click', addNewPrompt);
151
+
152
+ savedPromptsToggle.addEventListener('click', () => {
153
+ const isHidden = savedPromptsList.classList.toggle('hidden');
154
+ if (!isHidden) {
155
+ renderSavedPrompts(); // Re-render when opening
156
+ }
157
+ });
158
+
159
+ // Hide dropdown if clicked outside
160
+ document.addEventListener('click', (event) => {
161
+ if (!savedPromptsToggle.contains(event.target) && !savedPromptsList.contains(event.target)) {
162
+ savedPromptsList.classList.add('hidden');
163
+ }
164
+ });
165
+
166
+ // --- Saved Keys Logic ---
167
+ function getSavedKeys() {
168
+ return JSON.parse(localStorage.getItem(SAVED_KEYS_KEY)) || [];
169
+ }
170
+
171
+ function saveKeys(keys) {
172
+ localStorage.setItem(SAVED_KEYS_KEY, JSON.stringify(keys));
173
+ }
174
+
175
+ function getAllKeys() {
176
+ const customKeys = getSavedKeys();
177
+ // Add a 'preset' flag to distinguish them
178
+ const presetKeys = PRESET_KEYS.map(k => ({ ...k, preset: true }));
179
+ return [...presetKeys, ...customKeys];
180
+ }
181
+
182
+ function renderSavedKeys() {
183
+ const keys = getAllKeys();
184
+ savedKeysList.innerHTML = ''; // Clear the list
185
+
186
+ if (keys.length === 0) {
187
+ savedKeysList.innerHTML = `<div class="saved-keys-list-empty">没有已保存的 Key</div>`;
188
+ return;
189
+ }
190
+
191
+ keys.forEach((keyData, index) => {
192
+ const item = document.createElement('div');
193
+ item.className = 'saved-key-item';
194
+ if (keyData.preset) {
195
+ item.classList.add('preset');
196
+ }
197
+
198
+ const alias = document.createElement('span');
199
+ alias.className = 'saved-key-alias';
200
+ alias.textContent = keyData.alias;
201
+ alias.title = keyData.alias;
202
+
203
+ const value = document.createElement('span');
204
+ value.className = 'saved-key-value';
205
+ // Show partial key for preview
206
+ value.textContent = `...${keyData.value.slice(-6)}`;
207
+ value.title = keyData.value;
208
+
209
+ const textContainer = document.createElement('div');
210
+ textContainer.style.display = 'flex';
211
+ textContainer.style.alignItems = 'center';
212
+ textContainer.style.overflow = 'hidden';
213
+ textContainer.style.flexGrow = '1';
214
+ textContainer.appendChild(alias);
215
+ textContainer.appendChild(value);
216
+
217
+ item.addEventListener('click', () => {
218
+ apiKeyInput.value = keyData.value;
219
+ saveSettings();
220
+ savedKeysList.classList.add('hidden');
221
+ updateSaveKeyButtonState();
222
+ });
223
+
224
+ const deleteBtn = document.createElement('button');
225
+ deleteBtn.className = 'delete-key-btn';
226
+ deleteBtn.innerHTML = feather.icons['trash-2'].toSvg({ width: 14, height: 14 });
227
+ deleteBtn.title = '删除此 Key';
228
+ deleteBtn.addEventListener('click', (e) => {
229
+ e.stopPropagation();
230
+ // We need to find the index in the *custom* keys array
231
+ const customKeys = getSavedKeys();
232
+ const customIndexToDelete = customKeys.findIndex(k => k.value === keyData.value);
233
+ if (customIndexToDelete > -1) {
234
+ deleteKey(customIndexToDelete);
235
+ }
236
+ });
237
+
238
+ item.appendChild(textContainer);
239
+ item.appendChild(deleteBtn);
240
+ savedKeysList.appendChild(item);
241
+ });
242
+ }
243
+
244
+ function addNewKey() {
245
+ const currentKey = apiKeyInput.value.trim();
246
+ if (!currentKey) return;
247
+
248
+ const allKeys = getAllKeys();
249
+ if (allKeys.some(k => k.value === currentKey)) {
250
+ return; // Key already exists
251
+ }
252
+
253
+ const alias = prompt("为这个新的 Key 输入一个别名:", "");
254
+ if (alias === null || alias.trim() === "") {
255
+ return; // User cancelled or entered empty alias
256
+ }
257
+
258
+ let customKeys = getSavedKeys();
259
+ customKeys.unshift({ alias: alias.trim(), value: currentKey });
260
+ saveKeys(customKeys);
261
+ renderSavedKeys();
262
+ updateSaveKeyButtonState();
263
+ }
264
+
265
+ function deleteKey(indexToDelete) {
266
+ let customKeys = getSavedKeys();
267
+ customKeys.splice(indexToDelete, 1);
268
+ saveKeys(customKeys);
269
+ renderSavedKeys();
270
+ updateSaveKeyButtonState();
271
+ }
272
+
273
+ function updateSaveKeyButtonState() {
274
+ const currentKey = apiKeyInput.value.trim();
275
+ const allKeys = getAllKeys();
276
+ if (currentKey && allKeys.some(k => k.value === currentKey)) {
277
+ saveKeyBtn.innerHTML = feather.icons['check-circle'].toSvg();
278
+ saveKeyBtn.title = "Key 已保存";
279
+ saveKeyBtn.disabled = true;
280
+ } else {
281
+ saveKeyBtn.innerHTML = feather.icons['plus-circle'].toSvg();
282
+ saveKeyBtn.title = "保存当前 Key";
283
+ saveKeyBtn.disabled = false;
284
+ }
285
+ }
286
+
287
+ saveKeyBtn.addEventListener('click', addNewKey);
288
+
289
+ savedKeysToggle.addEventListener('click', () => {
290
+ const isHidden = savedKeysList.classList.toggle('hidden');
291
+ if (!isHidden) {
292
+ renderSavedKeys();
293
+ }
294
+ });
295
+
296
+ document.addEventListener('click', (event) => {
297
+ if (!savedKeysToggle.contains(event.target) && !savedKeysList.contains(event.target)) {
298
+ savedKeysList.classList.add('hidden');
299
+ }
300
+ });
301
+
302
+ // --- Settings Persistence ---
303
+ function saveSettings() {
304
+ const settings = {
305
+ apiKey: apiKeyInput.value,
306
+ prompt: promptInput.value,
307
+ negativePrompt: negativePromptInput.value,
308
+ model: modelSelect.value,
309
+ aspectRatio: aspectRatioSelect.value,
310
+ resolution: resolutionSelect.value,
311
+ personGeneration: personGenSelect.value,
312
+ seed: seedInput.value,
313
+ sampleCount: sampleCountInput.value,
314
+ duration: durationInput.value,
315
+ generateAudio: generateAudioSwitch.checked,
316
+ enhancePrompt: enhancePromptSwitch.checked,
317
+ threadCount: threadCountInput.value,
318
+ imageProcessMode: imageProcessModeSelect.value,
319
+ imageFillColor: imageFillColorInput.value,
320
+ };
321
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
322
+ }
323
+
324
+ function loadSettings() {
325
+ const savedSettings = localStorage.getItem(SETTINGS_KEY);
326
+ if (savedSettings) {
327
+ const settings = JSON.parse(savedSettings);
328
+ apiKeyInput.value = settings.apiKey || '';
329
+ promptInput.value = settings.prompt || '';
330
+ negativePromptInput.value = settings.negativePrompt || '';
331
+ modelSelect.value = settings.model || 'veo-3.0-generate-preview';
332
+ aspectRatioSelect.value = settings.aspectRatio || '16:9';
333
+ resolutionSelect.value = settings.resolution || '1080p';
334
+ personGenSelect.value = settings.personGeneration || 'allow_all';
335
+ seedInput.value = settings.seed || '';
336
+ sampleCountInput.value = settings.sampleCount || '1';
337
+ durationInput.value = settings.duration || '8';
338
+ generateAudioSwitch.checked = settings.generateAudio !== false; // default to true if undefined
339
+ enhancePromptSwitch.checked = settings.enhancePrompt !== false; // default to true if undefined
340
+ threadCountInput.value = settings.threadCount || '1';
341
+ imageProcessModeSelect.value = settings.imageProcessMode || 'none';
342
+ imageFillColorInput.value = settings.imageFillColor || '#000000';
343
+ imageFillColorPicker.value = settings.imageFillColor || '#000000';
344
+ // Update UI state based on loaded settings
345
+ updateImageProcessingState();
346
+ updateSaveButtonState();
347
+ }
348
+ updateSaveKeyButtonState(); // Also update key button state on load
349
+ }
350
+
351
+ // Attach listeners to save settings on change
352
+ const inputsToSave = [
353
+ apiKeyInput, promptInput, negativePromptInput, modelSelect, aspectRatioSelect,
354
+ resolutionSelect, personGenSelect, seedInput, sampleCountInput, durationInput,
355
+ generateAudioSwitch, threadCountInput, enhancePromptSwitch,
356
+ imageProcessModeSelect, imageFillColorInput
357
+ ];
358
+ inputsToSave.forEach(input => {
359
+ input.addEventListener('change', saveSettings);
360
+ // For text inputs, 'input' event is more responsive
361
+ if (input.type === 'textarea' || input.type === 'text' || input.type === 'password' || input.type === 'number') {
362
+ input.addEventListener('input', saveSettings);
363
+ }
364
+ });
365
+
366
+ promptInput.addEventListener('input', updateSaveButtonState);
367
+ apiKeyInput.addEventListener('input', updateSaveKeyButtonState);
368
+
369
+ // File Drop Zone Logic V2: With Preview and Delete
370
+ function setupFileDropZone(zoneId, inputId, previewId) {
371
+ const dropZone = document.getElementById(zoneId);
372
+ const fileInput = document.getElementById(inputId);
373
+ const previewContainer = document.getElementById(previewId);
374
+ let currentObjectURL = null;
375
+
376
+ const handleFile = (file) => {
377
+ if (currentObjectURL) {
378
+ URL.revokeObjectURL(currentObjectURL);
379
+ }
380
+
381
+ if (!file) {
382
+ previewContainer.innerHTML = '';
383
+ dropZone.classList.remove('has-file');
384
+ fileInput.value = ''; // Ensure file is cleared
385
+ return;
386
+ }
387
+
388
+ currentObjectURL = URL.createObjectURL(file);
389
+ previewContainer.innerHTML = ''; // Clear previous preview
390
+
391
+ let previewElement;
392
+ if (file.type.startsWith('image/')) {
393
+ previewElement = document.createElement('img');
394
+ previewElement.src = currentObjectURL;
395
+ } else if (file.type.startsWith('video/')) {
396
+ previewElement = document.createElement('video');
397
+ previewElement.src = currentObjectURL;
398
+ previewElement.muted = true;
399
+ previewElement.playsInline = true;
400
+ previewElement.setAttribute('preload', 'metadata'); // For poster frame
401
+ }
402
+
403
+ if(previewElement) {
404
+ previewContainer.appendChild(previewElement);
405
+ }
406
+
407
+ const deleteBtn = document.createElement('button');
408
+ deleteBtn.className = 'delete-file-btn';
409
+ deleteBtn.innerHTML = feather.icons['x'].toSvg({ width: 16, height: 16 });
410
+ deleteBtn.setAttribute('aria-label', 'Remove file');
411
+
412
+ deleteBtn.addEventListener('click', (e) => {
413
+ e.stopPropagation(); // VERY IMPORTANT: Prevents the click from bubbling up to the dropZone
414
+ e.preventDefault();
415
+ handleFile(null); // Clear the file
416
+ });
417
+
418
+ previewContainer.appendChild(deleteBtn);
419
+ dropZone.classList.add('has-file');
420
+ };
421
+
422
+ dropZone.addEventListener("click", (e) => {
423
+ // If the click is on the delete button, do nothing.
424
+ if (e.target.closest('.delete-file-btn')) {
425
+ return;
426
+ }
427
+ // If the click is on the file input itself, let the browser handle it.
428
+ if (e.target === fileInput) {
429
+ return;
430
+ }
431
+ // Otherwise, for any other part of the zone, trigger the file input.
432
+ fileInput.click();
433
+ });
434
+
435
+ dropZone.addEventListener("dragover", (e) => {
436
+ e.preventDefault();
437
+ dropZone.classList.add("dragover");
438
+ });
439
+
440
+ dropZone.addEventListener("dragleave", () => {
441
+ dropZone.classList.remove("dragover");
442
+ });
443
+
444
+ dropZone.addEventListener("drop", (e) => {
445
+ e.preventDefault();
446
+ dropZone.classList.remove("dragover");
447
+ if (e.dataTransfer.files.length) {
448
+ fileInput.files = e.dataTransfer.files;
449
+ handleFile(fileInput.files[0]);
450
+ }
451
+ });
452
+
453
+ fileInput.addEventListener("change", () => {
454
+ if (fileInput.files.length > 0) {
455
+ handleFile(fileInput.files[0]);
456
+ } else {
457
+ handleFile(null);
458
+ }
459
+ });
460
+ }
461
+
462
+ setupFileDropZone("image-drop-zone", "image-prompt", "image-preview");
463
+ setupFileDropZone("video-drop-zone", "video-prompt", "video-preview");
464
+
465
+ // --- Image Processing UI Logic ---
466
+ function updateImageProcessingState() {
467
+ const isFillMode = imageProcessModeSelect.value === 'fill';
468
+ const processGroup = imageProcessModeSelect.closest('.form-group');
469
+
470
+ // 移除了基于宽高比的禁用限制,现在所有宽高比都可以使用图片处理功能
471
+ processGroup.classList.remove('disabled');
472
+ imageProcessModeSelect.disabled = false;
473
+
474
+ // Color picker is enabled only in fill mode
475
+ if (isFillMode) {
476
+ imageFillColorGroup.classList.remove('disabled');
477
+ imageFillColorPicker.disabled = false;
478
+ imageFillColorInput.disabled = false;
479
+ } else {
480
+ imageFillColorGroup.classList.add('disabled');
481
+ imageFillColorPicker.disabled = true;
482
+ imageFillColorInput.disabled = true;
483
+ }
484
+ }
485
+
486
+ aspectRatioSelect.addEventListener('change', updateImageProcessingState);
487
+ imageProcessModeSelect.addEventListener('change', updateImageProcessingState);
488
+
489
+ imageFillColorPicker.addEventListener('input', () => {
490
+ imageFillColorInput.value = imageFillColorPicker.value;
491
+ saveSettings(); // Save on color change
492
+ });
493
+
494
+ imageFillColorInput.addEventListener('input', () => {
495
+ const value = imageFillColorInput.value;
496
+ // A simple regex to check for valid hex color format
497
+ if (/^#[0-9a-fA-F]{6}$/.test(value)) {
498
+ imageFillColorPicker.value = value;
499
+ saveSettings(); // Save on text input change
500
+ }
501
+ });
502
+
503
+ // Carousel Logic
504
+ function updateCarousel() {
505
+ videoCarousel.style.transform = `translateX(-${currentIndex * 100}%)`;
506
+ updateSaveVideoButtonState(); // Update save button state when carousel changes
507
+
508
+ const dots = carouselDots.querySelectorAll('.carousel-dot');
509
+ dots.forEach((dot, index) => {
510
+ dot.classList.toggle('active', index === currentIndex);
511
+ });
512
+
513
+ const videos = videoCarousel.querySelectorAll('video');
514
+ videos.forEach((video, index) => {
515
+ if (index !== currentIndex) {
516
+ video.pause();
517
+ }
518
+ });
519
+ }
520
+
521
+ function resetCarousel() {
522
+ videoCarousel.innerHTML = '';
523
+ carouselDots.innerHTML = '';
524
+ videoItems = [];
525
+ currentIndex = 0;
526
+ prevBtn.classList.add('hidden');
527
+ nextBtn.classList.add('hidden');
528
+ placeholder.style.display = 'flex';
529
+ saveVideoBtn.disabled = true; // Disable button when there are no videos
530
+ savedVideos = []; // Reset saved videos tracker
531
+ }
532
+
533
+ prevBtn.addEventListener('click', () => {
534
+ currentIndex = (currentIndex > 0) ? currentIndex - 1 : videoItems.length - 1;
535
+ updateCarousel();
536
+ });
537
+
538
+ nextBtn.addEventListener('click', () => {
539
+ currentIndex = (currentIndex < videoItems.length - 1) ? currentIndex + 1 : 0;
540
+ updateCarousel();
541
+ });
542
+
543
+ // --- Video Handling ---
544
+ function addVideoToCarousel(videoUrl) {
545
+ const index = videoItems.length;
546
+ videoItems.push(videoUrl);
547
+
548
+ const item = document.createElement('div');
549
+ item.className = 'carousel-item';
550
+ const video = document.createElement('video');
551
+ video.src = videoUrl;
552
+ video.controls = true;
553
+ video.addEventListener('click', (e) => {
554
+ e.preventDefault();
555
+ if (video.paused) {
556
+ video.play();
557
+ } else {
558
+ video.pause();
559
+ }
560
+ });
561
+ item.appendChild(video);
562
+ videoCarousel.appendChild(item);
563
+
564
+ const dot = document.createElement('div');
565
+ dot.className = 'carousel-dot';
566
+ dot.addEventListener('click', () => {
567
+ currentIndex = index;
568
+ updateCarousel();
569
+ });
570
+ carouselDots.appendChild(dot);
571
+
572
+ if (videoItems.length > 1) {
573
+ prevBtn.classList.remove('hidden');
574
+ nextBtn.classList.remove('hidden');
575
+ }
576
+
577
+ // If it's the first video, hide loader and update display
578
+ if (videoItems.length === 1) {
579
+ loader.classList.add("hidden");
580
+ placeholder.style.display = "none";
581
+ updateCarousel();
582
+ saveVideoBtn.disabled = false; // Enable the save button
583
+ }
584
+ }
585
+
586
+
587
+ // --- Form Submission ---
588
+ generateBtn.addEventListener("click", async () => {
589
+ resetCarousel();
590
+ loader.classList.remove("hidden");
591
+ placeholder.style.display = "none";
592
+ errorContainer.classList.add("hidden");
593
+
594
+ const threadCount = parseInt(threadCountInput.value, 10) || 1;
595
+ const sampleCount = parseInt(sampleCountInput.value, 10) || 1;
596
+
597
+ const baseFormData = () => {
598
+ const formData = new FormData();
599
+ formData.append("api_key", apiKeyInput.value);
600
+ formData.append("prompt", promptInput.value);
601
+ formData.append("negative_prompt", negativePromptInput.value);
602
+ formData.append("model", modelSelect.value);
603
+ formData.append("aspect_ratio", aspectRatioSelect.value);
604
+ formData.append("duration", durationInput.value);
605
+ formData.append("resolution", resolutionSelect.value);
606
+ if (seedInput.value) {
607
+ formData.append("seed", seedInput.value);
608
+ }
609
+ formData.append("person_generation", personGenSelect.value);
610
+ formData.append("generate_audio", generateAudioSwitch.checked);
611
+ formData.append("enhance_prompt", enhancePromptSwitch.checked);
612
+
613
+ if (imagePromptInput.files[0]) {
614
+ formData.append("image", imagePromptInput.files[0]);
615
+ }
616
+ if (videoPromptInput.files[0]) {
617
+ formData.append("video", videoPromptInput.files[0]);
618
+ }
619
+
620
+ // 现在所有宽高比都支持图片处理参数
621
+ formData.append("image_process_mode", imageProcessModeSelect.value);
622
+ formData.append("image_fill_color", imageFillColorInput.value);
623
+
624
+ return formData;
625
+ };
626
+
627
+ if (threadCount > 1) {
628
+ const requests = [];
629
+ for (let i = 0; i < threadCount; i++) {
630
+ const formData = baseFormData();
631
+ formData.append("sample_count", String(sampleCount)); // 每个线程生成 sampleCount 个视频
632
+
633
+ const request = fetch("/api/generate_video", {
634
+ method: "POST",
635
+ body: formData,
636
+ })
637
+ .then(response => {
638
+ if (!response.ok) {
639
+ return response.json().then(err => Promise.reject(err));
640
+ }
641
+ return response.json();
642
+ })
643
+ .then(data => {
644
+ if (data.video_urls && data.video_urls.length > 0) {
645
+ // 将该线程返回的全部视频加入轮播
646
+ data.video_urls.forEach(addVideoToCarousel);
647
+ }
648
+ })
649
+ .catch(error => {
650
+ console.error(`Thread ${i + 1} failed:`, error.detail || error.message);
651
+ });
652
+ requests.push(request);
653
+ }
654
+
655
+ Promise.allSettled(requests).then(() => {
656
+ if (videoItems.length === 0) {
657
+ // All requests failed
658
+ errorContainer.textContent = "所有视频生成请求均失败。";
659
+ errorContainer.classList.remove("hidden");
660
+ resetCarousel();
661
+ }
662
+ loader.classList.add("hidden");
663
+ });
664
+
665
+ } else {
666
+ // --- Single Request Logic (Original Flow) ---
667
+ const formData = baseFormData();
668
+ formData.append("sample_count", sampleCountInput.value);
669
+
670
+ try {
671
+ const response = await fetch("/api/generate_video", {
672
+ method: "POST",
673
+ body: formData,
674
+ });
675
+
676
+ const data = await response.json();
677
+ if (!response.ok) {
678
+ throw new Error(data.detail || "发生未知错误。");
679
+ }
680
+
681
+ const urls = data.video_urls;
682
+ if (!urls || !Array.isArray(urls) || urls.length === 0) {
683
+ throw new Error("API响应格式不正确或未返回视频。");
684
+ }
685
+
686
+ urls.forEach(addVideoToCarousel);
687
+
688
+ if (videoItems.length > 0) {
689
+ currentIndex = 0;
690
+ updateCarousel();
691
+ } else {
692
+ resetCarousel(); // No videos were successfully added
693
+ }
694
+
695
+ } catch (error) {
696
+ errorContainer.textContent = error.message;
697
+ errorContainer.classList.remove("hidden");
698
+ resetCarousel();
699
+ } finally {
700
+ loader.classList.add("hidden");
701
+ }
702
+ }
703
+ });
704
+
705
+ // --- Save Video Logic ---
706
+ function updateSaveVideoButtonState() {
707
+ if (videoItems.length === 0) {
708
+ saveVideoBtn.disabled = true;
709
+ saveVideoBtn.classList.remove('saved');
710
+ saveVideoBtn.querySelector('span').textContent = '保存此视频到服务器';
711
+ return;
712
+ }
713
+
714
+ const currentVideoUrl = videoItems[currentIndex];
715
+ if (savedVideos.includes(currentVideoUrl)) {
716
+ saveVideoBtn.disabled = true;
717
+ saveVideoBtn.classList.add('saved');
718
+ saveVideoBtn.querySelector('span').textContent = '已保存至服务器';
719
+ } else {
720
+ saveVideoBtn.disabled = false;
721
+ saveVideoBtn.classList.remove('saved');
722
+ saveVideoBtn.querySelector('span').textContent = '保存此视频到服务器';
723
+ }
724
+ }
725
+
726
+ async function saveCurrentVideo() {
727
+ if (videoItems.length === 0) return;
728
+
729
+ const videoUrlToSave = videoItems[currentIndex];
730
+ if (savedVideos.includes(videoUrlToSave)) return;
731
+
732
+ // Temporarily disable to prevent double-clicking
733
+ saveVideoBtn.disabled = true;
734
+ const originalText = saveVideoBtn.querySelector('span').textContent;
735
+ saveVideoBtn.querySelector('span').textContent = '保存中...';
736
+
737
+ try {
738
+ const formData = new FormData();
739
+ formData.append('video_url', videoUrlToSave);
740
+
741
+ const response = await fetch('/api/save_video', {
742
+ method: 'POST',
743
+ body: formData,
744
+ });
745
+
746
+ const data = await response.json();
747
+
748
+ if (!response.ok) {
749
+ throw new Error(data.detail || '保存失败');
750
+ }
751
+
752
+ // Success
753
+ savedVideos.push(videoUrlToSave);
754
+ updateSaveVideoButtonState();
755
+
756
+ } catch (error) {
757
+ // Show a temporary error message on the button
758
+ saveVideoBtn.querySelector('span').textContent = '保存失败!';
759
+ console.error('Save video error:', error);
760
+ // Revert button state after a delay
761
+ setTimeout(() => {
762
+ saveVideoBtn.querySelector('span').textContent = originalText;
763
+ updateSaveVideoButtonState(); // Re-evaluates the correct state
764
+ }, 2000);
765
+ }
766
+ }
767
+
768
+ saveVideoBtn.addEventListener('click', saveCurrentVideo);
769
+
770
+
771
+
772
+ // --- Paste to Upload Logic ---
773
+ const imageDropZone = document.getElementById("image-drop-zone");
774
+ const videoDropZone = document.getElementById("video-drop-zone");
775
+
776
+ imageDropZone.addEventListener('mouseenter', () => currentPasteTarget = 'image');
777
+ imageDropZone.addEventListener('mouseleave', () => currentPasteTarget = null);
778
+
779
+ videoDropZone.addEventListener('mouseenter', () => currentPasteTarget = 'video');
780
+ videoDropZone.addEventListener('mouseleave', () => currentPasteTarget = null);
781
+
782
+ document.addEventListener('paste', (event) => {
783
+ if (!currentPasteTarget) return;
784
+
785
+ // Don't interfere with text inputs
786
+ const activeElement = document.activeElement;
787
+ if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) {
788
+ return;
789
+ }
790
+
791
+ const items = event.clipboardData.items;
792
+ if (!items) return;
793
+
794
+ for (let i = 0; i < items.length; i++) {
795
+ if (items[i].kind === 'file') {
796
+ const file = items[i].getAsFile();
797
+ if (!file) continue;
798
+
799
+ event.preventDefault(); // Prevent default paste behavior
800
+
801
+ if (currentPasteTarget === 'image' && file.type.startsWith('image/')) {
802
+ // Create a new FileList and assign it to the input
803
+ const dataTransfer = new DataTransfer();
804
+ dataTransfer.items.add(file);
805
+ imagePromptInput.files = dataTransfer.files;
806
+ // Manually trigger the change event to update the UI
807
+ imagePromptInput.dispatchEvent(new Event('change'));
808
+ break; // Handle only the first valid file
809
+ }
810
+
811
+ if (currentPasteTarget === 'video' && file.type.startsWith('video/')) {
812
+ const dataTransfer = new DataTransfer();
813
+ dataTransfer.items.add(file);
814
+ videoPromptInput.files = dataTransfer.files;
815
+ videoPromptInput.dispatchEvent(new Event('change'));
816
+ break; // Handle only the first valid file
817
+ }
818
+ }
819
+ }
820
+ });
821
+
822
+ // --- Initial Load ---
823
+ loadSettings();
824
+ // Set initial state for image processing UI
825
+ updateImageProcessingState();
826
+ updateSaveButtonState();
827
+ renderSavedKeys(); // Initial render of the key list
828
  });
frontend/index.html CHANGED
@@ -1,100 +1,222 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>VEO3 Video Generator</title>
7
- <link rel="stylesheet" href="/style.css">
8
- </head>
9
- <body>
10
- <header>
11
- <h1>VEO3 Video Generator</h1>
12
- </header>
13
- <main>
14
- <form id="video-form">
15
- <div class="form-grid">
16
- <div class="form-group">
17
- <label for="api-key">Vertex Express Key:</label>
18
- <input type="text" id="api-key" name="api-key" required>
19
- </div>
20
- <div class="form-group">
21
- <label for="model">Model:</label>
22
- <select id="model" name="model">
23
- <option value="veo-2.0-generate-001">veo-2.0-generate-001</option>
24
- <option value="veo-3.0-generate-001">veo-3.0-generate-001</option>
25
- <option value="veo-3.0-fast-generate-001">veo-3.0-fast-generate-001</option>
26
- <option value="veo-3.0-generate-preview" selected>veo-3.0-generate-preview</option>
27
- <option value="veo-3.0-fast-generate-preview">veo-3.0-fast-generate-preview</option>
28
- </select>
29
- </div>
30
- <div class="form-group full-width">
31
- <label for="prompt">Prompt:</label>
32
- <textarea id="prompt" name="prompt" rows="4" required></textarea>
33
- </div>
34
- <div class="form-group full-width">
35
- <label for="negative-prompt">Negative Prompt:</label>
36
- <textarea id="negative-prompt" name="negative-prompt" rows="2"></textarea>
37
- </div>
38
- <div class="form-group">
39
- <label for="aspect-ratio">Aspect Ratio:</label>
40
- <select id="aspect-ratio" name="aspect-ratio">
41
- <option value="16:9">16:9</option>
42
- <option value="9:16">9:16</option>
43
- </select>
44
- </div>
45
- <div class="form-group">
46
- <label for="duration">Duration (seconds):</label>
47
- <input type="number" id="duration" name="duration" value="8" min="5" max="8">
48
- </div>
49
- <div class="form-group">
50
- <label for="resolution">Resolution:</label>
51
- <select id="resolution" name="resolution">
52
- <option value="720p">720p</option>
53
- <option value="1080p">1080p</option>
54
- </select>
55
- </div>
56
- <div class="form-group">
57
- <label for="sample-count">Sample Count:</label>
58
- <input type="number" id="sample-count" name="sample-count" value="1" min="1" max="4">
59
- </div>
60
- <div class="form-group">
61
- <label for="seed">Seed:</label>
62
- <input type="number" id="seed" name="seed" min="0" max="4294967295">
63
- </div>
64
- <div class="form-group">
65
- <label for="person-generation">Person Generation:</label>
66
- <select id="person-generation" name="person-generation">
67
- <option value="allow_all">Allow All</option>
68
- <option value="allow_adult">Allow Adults</option>
69
- <option value="dont_allow">Don't Allow</option>
70
- </select>
71
- </div>
72
- <div class="form-group checkbox-group">
73
- <label for="generate-audio">Generate Audio:</label>
74
- <input type="checkbox" id="generate-audio" name="generate-audio" checked>
75
- </div>
76
- <div class="form-group checkbox-group">
77
- <label for="enhance-prompt">Enhance Prompt:</label>
78
- <input type="checkbox" id="enhance-prompt" name="enhance-prompt" checked>
79
- </div>
80
- <div class="form-group full-width">
81
- <label for="image-prompt">Image Prompt:</label>
82
- <input type="file" id="image-prompt" name="image-prompt" accept="image/jpeg,image/png">
83
- </div>
84
- <div class="form-group full-width">
85
- <label for="video-prompt">Video Prompt:</label>
86
- <input type="file" id="video-prompt" name="video-prompt" accept="video/mp4">
87
- </div>
88
- </div>
89
- <button type="submit">Generate Video</button>
90
- </form>
91
-
92
- <div id="video-container">
93
- <div id="loader" class="hidden"></div>
94
- <div id="video-results"></div>
95
- <div id="error-container" class="hidden"></div>
96
- </div>
97
- </main>
98
- <script src="/app.js"></script>
99
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  </html>
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>VEO Video Generator</title>
7
+ <link rel="stylesheet" href="style.css">
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
9
+ <script src="https://unpkg.com/feather-icons"></script>
10
+ </head>
11
+ <body>
12
+ <div class="app-container">
13
+ <header class="app-header">
14
+ <h1>VEO Video Generator</h1>
15
+ <div class="header-actions">
16
+ <button id="save-video-btn" class="save-button" disabled>
17
+ <i data-feather="save"></i>
18
+ <span>保存此视频到服务器</span>
19
+ </button>
20
+ <button id="generate-btn" class="generate-button">
21
+ <i data-feather="play"></i>
22
+ <span>生成视频</span>
23
+ </button>
24
+ </div>
25
+ </header>
26
+ <main class="main-content" id="video-form">
27
+ <div class="settings-panel-left">
28
+ <div class="panel">
29
+ <h2 class="panel-title">核心参数</h2>
30
+ <div class="form-group">
31
+ <label for="api-key">Vertex Key</label>
32
+ <div class="key-input-container">
33
+ <input type="password" id="api-key" name="api-key" class="form-input" placeholder="选择或输入您的 Key">
34
+ <div class="key-actions">
35
+ <button id="save-key-btn" class="icon-btn" title="保存当前 Key">
36
+ <i data-feather="plus-circle"></i>
37
+ </button>
38
+ <div class="saved-keys-dropdown">
39
+ <button id="saved-keys-toggle" class="icon-btn" title="查看已保存的 Key">
40
+ <i data-feather="key"></i>
41
+ </button>
42
+ <div id="saved-keys-list" class="saved-prompts-list hidden">
43
+ <!-- Saved keys will be dynamically inserted here -->
44
+ </div>
45
+ </div>
46
+ </div>
47
+ </div>
48
+ </div>
49
+ <div class="form-group">
50
+ <div class="prompt-header">
51
+ <label for="prompt">正向提示词 (Prompt)</label>
52
+ <div class="prompt-actions">
53
+ <button id="save-prompt-btn" class="icon-btn" title="保存当前提示词">
54
+ <i data-feather="plus-circle"></i>
55
+ </button>
56
+ <div class="saved-prompts-dropdown">
57
+ <button id="saved-prompts-toggle" class="icon-btn" title="查看已保存的提示词">
58
+ <i data-feather="bookmark"></i>
59
+ </button>
60
+ <div id="saved-prompts-list" class="saved-prompts-list hidden">
61
+ <!-- Saved prompts will be dynamically inserted here -->
62
+ </div>
63
+ </div>
64
+ </div>
65
+ </div>
66
+ <textarea id="prompt" name="prompt" rows="5" class="form-input" placeholder="例如:一只穿着宇航服的猫在月球上行走"></textarea>
67
+ </div>
68
+ <div class="form-group">
69
+ <label for="negative-prompt">反向提示词 (Negative Prompt)</label>
70
+ <textarea id="negative-prompt" name="negative-prompt" rows="3" class="form-input" placeholder="例如:模糊, 低质量, 水印"></textarea>
71
+ </div>
72
+ </div>
73
+
74
+ <div class="panel">
75
+ <h2 class="panel-title">模型与画面</h2>
76
+ <div class="grid-2-col" style="grid-template-columns: repeat(2, 1fr); align-items: end;">
77
+ <div class="form-group">
78
+ <label for="model">模型 (Model)</label>
79
+ <select id="model" name="model" class="form-select">
80
+ <option value="veo-3.0-generate-preview" selected>veo-3.0-generate-preview</option>
81
+ <option value="veo-3.0-fast-generate-preview">veo-3.0-fast-generate-preview</option>
82
+ <option value="veo-2.0-generate-001">veo-2.0-generate-001</option>
83
+ <option value="veo-3.0-generate-001">veo-3.0-generate-001</option>
84
+ <option value="veo-3.0-fast-generate-001">veo-3.0-fast-generate-001</option>
85
+ </select>
86
+ </div>
87
+ <div class="form-group">
88
+ <label for="resolution">分辨率 (Resolution)</label>
89
+ <select id="resolution" name="resolution" class="form-select">
90
+ <option value="1080p">1080p</option>
91
+ <option value="720p">720p</option>
92
+ </select>
93
+ </div>
94
+ <div class="form-group">
95
+ <label for="aspect-ratio">宽高比 (Aspect Ratio)</label>
96
+ <select id="aspect-ratio" name="aspect-ratio" class="form-select">
97
+ <option value="16:9">16:9 (横屏)</option>
98
+ <option value="9:16">9:16 (竖屏)</option>
99
+ </select>
100
+ </div>
101
+ <div class="form-group">
102
+ <label for="image-process-mode">图片处理 (16:9)</label>
103
+ <select id="image-process-mode" name="image-process-mode" class="form-select">
104
+ <option value="none" selected>不作处理</option>
105
+ <option value="fill">颜色填充</option>
106
+ <option value="stretch_to_fill">拉伸填充 (仅放大)</option>
107
+ <option value="compress_to_fit">压缩填充 (仅缩小)</option>
108
+ </select>
109
+ </div>
110
+ <div class="form-group">
111
+ <label for="person-generation">人物生成</label>
112
+ <select id="person-generation" name="person-generation" class="form-select">
113
+ <option value="allow_all">允许所有</option>
114
+ <option value="allow_adult">仅成人</option>
115
+ <option value="dont_allow">不允许</option>
116
+ </select>
117
+ </div>
118
+ <div class="form-group" id="image-fill-color-group">
119
+ <label for="image-fill-color">填充颜色</label>
120
+ <div class="color-picker-wrapper">
121
+ <input type="color" id="image-fill-color-picker" value="#000000">
122
+ <input type="text" id="image-fill-color" name="image-fill-color" class="form-input color-input" value="#000000">
123
+ </div>
124
+ </div>
125
+ </div>
126
+ </div>
127
+ <div class="panel">
128
+ <h2 class="panel-title">高级设置</h2>
129
+ <div class="grid-3-col">
130
+ <div class="form-group">
131
+ <label for="seed">随机种子 (Seed)</label>
132
+ <input type="number" id="seed" name="seed" class="form-input" placeholder="留空则随机" min="0" max="4294967295">
133
+ </div>
134
+ <div class="form-group">
135
+ <label for="sample-count">单个线程生成数量</label>
136
+ <input type="number" id="sample-count" name="sample-count" class="form-input" value="1" min="1" max="4">
137
+ </div>
138
+ <div class="form-group">
139
+ <label for="duration">时长 (秒)</label>
140
+ <input type="number" id="duration" name="duration" class="form-input" value="8" min="5" max="8">
141
+ </div>
142
+ </div>
143
+ <div class="grid-3-col" style="margin-top: 16px;">
144
+ <div class="form-group switch-group">
145
+ <label for="generate-audio">生成音频</label>
146
+ <label class="switch">
147
+ <input type="checkbox" id="generate-audio" name="generate-audio" checked>
148
+ <span class="switch-slider"></span>
149
+ </label>
150
+ </div>
151
+ <div class="form-group switch-group">
152
+ <label for="thread-count">线程数</label>
153
+ <input type="number" id="thread-count" name="thread-count" class="form-input" value="1" min="1" step="1">
154
+ </div>
155
+ <div class="form-group switch-group">
156
+ <label for="enhance-prompt">增强提示词</label>
157
+ <label class="switch">
158
+ <input type="checkbox" id="enhance-prompt" name="enhance-prompt" checked>
159
+ <span class="switch-slider"></span>
160
+ </label>
161
+ </div>
162
+ </div>
163
+ </div>
164
+ </div>
165
+ <div class="content-area">
166
+ <div class="video-panel">
167
+ <div id="video-container" class="video-container">
168
+ <div id="video-carousel" class="carousel">
169
+ <!-- Dynamically generated video items will go here -->
170
+ </div>
171
+ <div id="loader" class="loader hidden">
172
+ <div class="spinner"></div>
173
+ <p>正在生成视频...</p>
174
+ </div>
175
+ <div id="placeholder" class="placeholder">
176
+ <i data-feather="film" style="width: 64px; height: 64px;"></i>
177
+ <p>生成的视频将在此处显示</p>
178
+ </div>
179
+ <button id="prev-btn" class="carousel-btn prev hidden"><i data-feather="chevron-left"></i></button>
180
+ <button id="next-btn" class="carousel-btn next hidden"><i data-feather="chevron-right"></i></button>
181
+ <div id="carousel-dots" class="carousel-dots"></div>
182
+ <div id="error-container" class="error-message hidden"></div>
183
+ </div>
184
+ </div>
185
+ <div class="settings-panel-bottom">
186
+ <div class="panel">
187
+ <h2 class="panel-title">输入文件 (可选)</h2>
188
+ <div class="grid-2-col">
189
+ <div class="form-group file-drop-group">
190
+ <label for="image-prompt">图像提示 (Image Prompt)</label>
191
+ <div class="file-drop-zone" id="image-drop-zone">
192
+ <div class="file-drop-prompt">
193
+ <i data-feather="image"></i>
194
+ <p>拖放、粘贴图片文件至此或点击选择</p>
195
+ </div>
196
+ <div class="file-preview" id="image-preview"></div>
197
+ <input type="file" id="image-prompt" name="image-prompt" accept="image/jpeg,image/png" class="file-input">
198
+ </div>
199
+ </div>
200
+ <div class="form-group file-drop-group">
201
+ <label for="video-prompt">视频提示 (Video Prompt)</label>
202
+ <div class="file-drop-zone" id="video-drop-zone">
203
+ <div class="file-drop-prompt">
204
+ <i data-feather="video"></i>
205
+ <p>拖放、粘贴视频文件至此或点击选择</p>
206
+ </div>
207
+ <div class="file-preview" id="video-preview"></div>
208
+ <input type="file" id="video-prompt" name="video-prompt" accept="video/mp4" class="file-input">
209
+ </div>
210
+ </div>
211
+ </div>
212
+ </div>
213
+ </div>
214
+ </div>
215
+ </main>
216
+ </div>
217
+ <script src="app.js"></script>
218
+ <script>
219
+ feather.replace()
220
+ </script>
221
+ </body>
222
  </html>
frontend/style.css CHANGED
@@ -1,150 +1,905 @@
1
- @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap');
2
-
3
- body {
4
- font-family: 'Roboto', sans-serif;
5
- margin: 0;
6
- background-color: #f0f2f5;
7
- color: #333;
8
- line-height: 1.6;
9
- }
10
-
11
- header {
12
- background: linear-gradient(90deg, #4a90e2, #50e3c2);
13
- color: #fff;
14
- padding: 1.5rem;
15
- text-align: center;
16
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
17
- }
18
-
19
- h1 {
20
- margin: 0;
21
- font-weight: 700;
22
- }
23
-
24
- main {
25
- max-width: 900px;
26
- margin: 2rem auto;
27
- padding: 2rem;
28
- background-color: #fff;
29
- border-radius: 8px;
30
- box-shadow: 0 4px 6px rgba(0,0,0,0.1);
31
- }
32
-
33
- .form-grid {
34
- display: grid;
35
- grid-template-columns: repeat(2, 1fr);
36
- gap: 1.5rem;
37
- }
38
-
39
- .form-group {
40
- display: flex;
41
- flex-direction: column;
42
- }
43
-
44
- .full-width {
45
- grid-column: 1 / -1;
46
- }
47
-
48
- label {
49
- font-weight: bold;
50
- margin-bottom: 0.5rem;
51
- color: #555;
52
- }
53
-
54
- input[type="text"],
55
- input[type="number"],
56
- textarea,
57
- select {
58
- padding: 0.75rem;
59
- border: 1px solid #ccc;
60
- border-radius: 4px;
61
- font-size: 1rem;
62
- transition: border-color 0.3s;
63
- }
64
-
65
- input[type="text"]:focus,
66
- input[type="number"]:focus,
67
- textarea:focus,
68
- select:focus {
69
- border-color: #4a90e2;
70
- outline: none;
71
- }
72
-
73
- textarea {
74
- resize: vertical;
75
- }
76
-
77
- .checkbox-group {
78
- flex-direction: row;
79
- align-items: center;
80
- }
81
-
82
- .checkbox-group label {
83
- margin-bottom: 0;
84
- margin-right: 0.5rem;
85
- }
86
-
87
- button {
88
- grid-column: 1 / -1;
89
- padding: 1rem;
90
- background: linear-gradient(90deg, #50e3c2, #4a90e2);
91
- color: #fff;
92
- border: none;
93
- border-radius: 4px;
94
- cursor: pointer;
95
- font-size: 1.1rem;
96
- font-weight: bold;
97
- transition: opacity 0.3s;
98
- margin-top: 1rem;
99
- }
100
-
101
- button:hover {
102
- opacity: 0.9;
103
- }
104
-
105
- #video-container {
106
- margin-top: 2rem;
107
- text-align: center;
108
- }
109
-
110
- #video-results {
111
- display: flex;
112
- flex-wrap: wrap;
113
- gap: 1rem;
114
- justify-content: center;
115
- }
116
-
117
- #video-results video {
118
- width: calc(50% - 0.5rem);
119
- max-width: 400px;
120
- border-radius: 8px;
121
- box-shadow: 0 4px 6px rgba(0,0,0,0.1);
122
- }
123
-
124
- .hidden {
125
- display: none;
126
- }
127
-
128
- #loader {
129
- border: 8px solid #f3f3f3;
130
- border-top: 8px solid #4a90e2;
131
- border-radius: 50%;
132
- width: 60px;
133
- height: 60px;
134
- animation: spin 1s linear infinite;
135
- margin: 2rem auto;
136
- }
137
-
138
- @keyframes spin {
139
- 0% { transform: rotate(0deg); }
140
- 100% { transform: rotate(360deg); }
141
- }
142
-
143
- #error-container {
144
- color: #e74c3c;
145
- background-color: #fbeae5;
146
- border: 1px solid #e74c3c;
147
- padding: 1rem;
148
- border-radius: 4px;
149
- margin-top: 1rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  }
 
1
+ :root {
2
+ --bg-color-dark: #121212;
3
+ --bg-color-medium: #1e1e1e;
4
+ --bg-color-light: #2a2a2a;
5
+ --text-color-primary: #e0e0e0;
6
+ --text-color-secondary: #a0a0a0;
7
+ --accent-color: #00aaff;
8
+ --accent-color-hover: #0088cc;
9
+ --border-color: #383838;
10
+ --error-color: #ff4d4d;
11
+ --font-family: 'Inter', sans-serif;
12
+ }
13
+
14
+ * {
15
+ box-sizing: border-box;
16
+ margin: 0;
17
+ padding: 0;
18
+ }
19
+
20
+ body {
21
+ font-family: var(--font-family);
22
+ background-color: var(--bg-color-dark);
23
+ color: var(--text-color-primary);
24
+ font-size: 14px;
25
+ line-height: 1.5;
26
+ overflow: hidden;
27
+ }
28
+
29
+ .app-container {
30
+ display: flex;
31
+ flex-direction: column;
32
+ height: 100vh;
33
+ }
34
+
35
+ .app-header {
36
+ display: flex;
37
+ justify-content: space-between;
38
+ align-items: center;
39
+ padding: 12px 24px;
40
+ background-color: var(--bg-color-medium);
41
+ border-bottom: 1px solid var(--border-color);
42
+ flex-shrink: 0;
43
+ }
44
+
45
+ .app-header h1 {
46
+ font-size: 18px;
47
+ font-weight: 600;
48
+ }
49
+
50
+ .header-actions {
51
+ display: flex;
52
+ align-items: center;
53
+ }
54
+
55
+ .save-button {
56
+ background-color: #4a4a4a; /* Gray when disabled */
57
+ color: #888;
58
+ border: none;
59
+ padding: 10px 20px;
60
+ border-radius: 6px;
61
+ cursor: not-allowed;
62
+ font-size: 14px;
63
+ font-weight: 500;
64
+ transition: background-color 0.2s ease, color 0.2s ease, cursor 0.2s ease;
65
+ display: flex;
66
+ align-items: center;
67
+ gap: 8px;
68
+ margin-right: 10px; /* Add some space between buttons */
69
+ }
70
+
71
+ .save-button:not(:disabled) {
72
+ background-color: #28a745; /* Green when enabled */
73
+ color: white;
74
+ cursor: pointer;
75
+ }
76
+
77
+ .save-button:not(:disabled):hover {
78
+ background-color: #218838;
79
+ }
80
+
81
+ .save-button.saved {
82
+ background-color: #dc3545; /* Red when saved */
83
+ color: white;
84
+ cursor: default;
85
+ }
86
+
87
+ .generate-button {
88
+ background-color: var(--accent-color);
89
+ color: white;
90
+ border: none;
91
+ padding: 10px 20px;
92
+ border-radius: 6px;
93
+ cursor: pointer;
94
+ font-size: 14px;
95
+ font-weight: 500;
96
+ transition: background-color 0.2s ease;
97
+ display: flex;
98
+ align-items: center;
99
+ gap: 8px;
100
+ }
101
+
102
+ .generate-button:hover {
103
+ background-color: var(--accent-color-hover);
104
+ }
105
+
106
+ .main-content {
107
+ display: grid;
108
+ grid-template-columns: 50fr 50fr;
109
+ grid-template-rows: auto;
110
+ flex-grow: 1;
111
+ height: calc(100vh - 65px);
112
+ }
113
+
114
+ .settings-panel-left {
115
+ background-color: var(--bg-color-medium);
116
+ padding: 20px;
117
+ overflow-y: auto;
118
+ border-right: 1px solid var(--border-color);
119
+ display: flex;
120
+ flex-direction: column;
121
+ gap: 20px;
122
+ }
123
+
124
+ .content-area {
125
+ display: grid;
126
+ grid-template-rows: 55fr 45fr; /* 调整比例,为底部面板提供更多空间 */
127
+ overflow: hidden;
128
+ }
129
+
130
+ .video-panel {
131
+ background-color: var(--bg-color-dark);
132
+ display: flex;
133
+ justify-content: center;
134
+ align-items: center;
135
+ padding: 20px;
136
+ position: relative;
137
+ overflow: hidden;
138
+ }
139
+
140
+ .video-container {
141
+ width: 100%;
142
+ height: 100%;
143
+ background-color: #000;
144
+ border-radius: 8px;
145
+ display: flex;
146
+ justify-content: center;
147
+ align-items: center;
148
+ position: relative;
149
+ overflow: hidden;
150
+ }
151
+
152
+ #generated-video { /* This ID is now deprecated */
153
+ display: none;
154
+ }
155
+
156
+ .carousel {
157
+ display: flex;
158
+ transition: transform 0.5s ease-in-out;
159
+ height: 100%;
160
+ }
161
+
162
+ .carousel-item {
163
+ min-width: 100%;
164
+ height: 100%;
165
+ box-sizing: border-box;
166
+ display: flex;
167
+ justify-content: center;
168
+ align-items: center;
169
+ }
170
+
171
+ .carousel-item video {
172
+ width: 100%;
173
+ height: 100%;
174
+ object-fit: contain;
175
+ border-radius: 8px;
176
+ }
177
+
178
+ .carousel-btn {
179
+ position: absolute;
180
+ top: 50%;
181
+ transform: translateY(-50%);
182
+ background-color: rgba(0, 0, 0, 0.4);
183
+ color: white;
184
+ border: none;
185
+ cursor: pointer;
186
+ padding: 10px;
187
+ border-radius: 50%;
188
+ z-index: 10;
189
+ transition: background-color 0.2s;
190
+ display: flex;
191
+ align-items: center;
192
+ justify-content: center;
193
+ }
194
+
195
+ .carousel-btn:hover {
196
+ background-color: rgba(0, 0, 0, 0.7);
197
+ }
198
+
199
+ .carousel-btn.prev {
200
+ left: 16px;
201
+ }
202
+
203
+ .carousel-btn.next {
204
+ right: 16px;
205
+ }
206
+
207
+ .carousel-dots {
208
+ position: absolute;
209
+ bottom: 0px;
210
+ left: 50%;
211
+ transform: translateX(-50%);
212
+ display: flex;
213
+ gap: 8px;
214
+ z-index: 10;
215
+ }
216
+
217
+ .carousel-dot {
218
+ width: 10px;
219
+ height: 10px;
220
+ border-radius: 50%;
221
+ background-color: rgba(255, 255, 255, 0.4);
222
+ cursor: pointer;
223
+ transition: background-color 0.3s;
224
+ }
225
+
226
+ .carousel-dot.active {
227
+ background-color: white;
228
+ }
229
+
230
+ .placeholder {
231
+ display: flex;
232
+ flex-direction: column;
233
+ align-items: center;
234
+ gap: 16px;
235
+ color: var(--text-color-secondary);
236
+ }
237
+
238
+ .placeholder i {
239
+ color: var(--text-color-secondary);
240
+ }
241
+
242
+ .loader {
243
+ position: absolute;
244
+ top: 0;
245
+ left: 0;
246
+ width: 100%;
247
+ height: 100%;
248
+ background-color: rgba(0, 0, 0, 0.7);
249
+ display: flex;
250
+ flex-direction: column;
251
+ justify-content: center;
252
+ align-items: center;
253
+ z-index: 10;
254
+ color: white;
255
+ backdrop-filter: blur(5px);
256
+ }
257
+
258
+ .spinner {
259
+ border: 4px solid rgba(255, 255, 255, 0.3);
260
+ border-radius: 50%;
261
+ border-top: 4px solid var(--accent-color);
262
+ width: 50px;
263
+ height: 50px;
264
+ animation: spin 1s linear infinite;
265
+ margin-bottom: 16px;
266
+ }
267
+
268
+ @keyframes spin {
269
+ 0% { transform: rotate(0deg); }
270
+ 100% { transform: rotate(360deg); }
271
+ }
272
+
273
+ .hidden {
274
+ display: none !important;
275
+ }
276
+
277
+ .settings-panel-bottom {
278
+ background-color: var(--bg-color-medium);
279
+ padding: 20px;
280
+ border-top: 1px solid var(--border-color);
281
+ display: flex; /* Make it a flex container */
282
+ flex-direction: column;
283
+ }
284
+
285
+ .error-message {
286
+ position: absolute;
287
+ bottom: 20px;
288
+ left: 20px;
289
+ right: 20px;
290
+ background-color: var(--error-color);
291
+ color: white;
292
+ padding: 12px;
293
+ border-radius: 6px;
294
+ font-size: 14px;
295
+ z-index: 20;
296
+ }
297
+
298
+ /* Form Styles */
299
+ .panel {
300
+ background-color: var(--bg-color-light);
301
+ border-radius: 8px;
302
+ padding: 20px;
303
+ border: 1px solid var(--border-color);
304
+ transition: all 0.3s ease;
305
+ display: flex; /* Added */
306
+ flex-direction: column; /* Added */
307
+ flex-grow: 1; /* Added to make panel fill its parent */
308
+ overflow: visible; /* Allow dropdowns to overflow */
309
+ }
310
+
311
+ .panel:hover {
312
+ border-color: #4a4a4a;
313
+ }
314
+
315
+ .panel-title {
316
+ font-size: 16px;
317
+ font-weight: 600;
318
+ margin-bottom: 16px;
319
+ padding-bottom: 10px;
320
+ border-bottom: 1px solid var(--border-color);
321
+ }
322
+
323
+ .form-group {
324
+ margin-bottom: 16px;
325
+ }
326
+
327
+ .grid-2-col > .form-group,
328
+ .grid-3-col > .form-group {
329
+ margin-bottom: 0;
330
+ }
331
+
332
+ .form-group:last-child {
333
+ margin-bottom: 0;
334
+ }
335
+
336
+ .form-group label {
337
+ display: block;
338
+ font-size: 14px;
339
+ font-weight: 500;
340
+ margin-bottom: 8px;
341
+ color: var(--text-color-secondary);
342
+ }
343
+
344
+ .form-input, .form-select, .form-textarea {
345
+ width: 100%;
346
+ background-color: var(--bg-color-medium);
347
+ border: 1px solid var(--border-color);
348
+ border-radius: 6px;
349
+ padding: 10px;
350
+ color: var(--text-color-primary);
351
+ font-size: 14px;
352
+ transition: border-color 0.2s, box-shadow 0.2s;
353
+ }
354
+
355
+ /* Thread count input specific styling */
356
+ .switch-group .form-input {
357
+ width: 90px;
358
+ text-align: center;
359
+ padding: 8px;
360
+ flex-shrink: 0;
361
+ }
362
+
363
+ .form-input:focus, .form-select:focus, .form-textarea:focus {
364
+ outline: none;
365
+ border-color: var(--accent-color);
366
+ box-shadow: 0 0 0 2px rgba(0, 170, 255, 0.2);
367
+ }
368
+
369
+ textarea.form-input {
370
+ resize: vertical;
371
+ }
372
+
373
+ .grid-2-col {
374
+ display: grid;
375
+ grid-template-columns: 1fr 1fr;
376
+ gap: 16px;
377
+ flex-grow: 1; /* Added */
378
+ }
379
+
380
+ .grid-3-col {
381
+ display: grid;
382
+ grid-template-columns: 1fr 1fr 1fr;
383
+ gap: 16px;
384
+ }
385
+
386
+ /* Switch */
387
+ .switch-group {
388
+ display: flex;
389
+ justify-content: space-between;
390
+ align-items: center;
391
+ padding: 4px 0; /* Reduced vertical padding */
392
+ }
393
+
394
+ .grid-2-col .switch-group {
395
+ background-color: var(--bg-color-medium);
396
+ border: 1px solid var(--border-color);
397
+ border-radius: 6px;
398
+ padding: 10px;
399
+ margin-bottom: 0;
400
+ }
401
+
402
+ .grid-3-col .switch-group {
403
+ background-color: var(--bg-color-medium);
404
+ border: 1px solid var(--border-color);
405
+ border-radius: 6px;
406
+ padding: 10px;
407
+ margin-bottom: 0;
408
+ }
409
+
410
+ .switch-group > label {
411
+ margin-bottom: 0; /* Override default label margin */
412
+ }
413
+
414
+ .switch {
415
+ position: relative;
416
+ display: inline-block;
417
+ width: 44px;
418
+ height: 24px;
419
+ }
420
+
421
+ .switch input {
422
+ opacity: 0;
423
+ width: 0;
424
+ height: 0;
425
+ }
426
+
427
+ .switch-slider {
428
+ position: absolute;
429
+ cursor: pointer;
430
+ top: 0;
431
+ left: 0;
432
+ right: 0;
433
+ bottom: 0;
434
+ background-color: var(--border-color);
435
+ transition: .4s;
436
+ border-radius: 24px;
437
+ }
438
+
439
+ .switch-slider:before {
440
+ position: absolute;
441
+ content: "";
442
+ height: 18px;
443
+ width: 18px;
444
+ left: 3px;
445
+ bottom: 3px;
446
+ background-color: white;
447
+ transition: .4s;
448
+ border-radius: 50%;
449
+ }
450
+
451
+ input:checked + .switch-slider {
452
+ background-color: var(--accent-color);
453
+ }
454
+
455
+ input:checked + .switch-slider:before {
456
+ transform: translateX(20px);
457
+ }
458
+
459
+
460
+ /* File Drop Zone */
461
+ .file-drop-group {
462
+ display: flex;
463
+ flex-direction: column;
464
+ height: 100%; /* Ensure the group takes full height of grid cell */
465
+ }
466
+
467
+ .file-drop-zone {
468
+ border: 2px dashed var(--border-color);
469
+ border-radius: 8px;
470
+ padding: 20px;
471
+ text-align: center;
472
+ cursor: pointer;
473
+ transition: border-color 0.3s, background-color 0.3s;
474
+ position: relative;
475
+ display: flex;
476
+ flex-direction: column;
477
+ align-items: center;
478
+ justify-content: center;
479
+ gap: 8px;
480
+ flex-grow: 1; /* Allow zone to grow and fill space */
481
+ }
482
+
483
+ .file-drop-zone.dragover {
484
+ border-color: var(--accent-color);
485
+ background-color: rgba(0, 170, 255, 0.1);
486
+ }
487
+
488
+ .file-drop-zone i {
489
+ color: var(--text-color-secondary);
490
+ margin-bottom: 8px;
491
+ }
492
+
493
+ .file-drop-zone p {
494
+ color: var(--text-color-secondary);
495
+ font-size: 12px;
496
+ }
497
+
498
+ .file-drop-prompt {
499
+ display: flex;
500
+ flex-direction: column;
501
+ align-items: center;
502
+ justify-content: center;
503
+ gap: 8px;
504
+ transition: opacity 0.3s ease;
505
+ }
506
+
507
+ .file-input {
508
+ position: absolute;
509
+ top: 0;
510
+ left: 0;
511
+ width: 100%;
512
+ height: 100%;
513
+ opacity: 0;
514
+ cursor: pointer;
515
+ }
516
+
517
+ .file-preview {
518
+ position: absolute;
519
+ top: 0;
520
+ left: 0;
521
+ width: 100%;
522
+ height: 100%;
523
+ padding: 8px;
524
+ display: none; /* Initially hidden */
525
+ justify-content: center;
526
+ align-items: center;
527
+ }
528
+
529
+ .file-preview img,
530
+ .file-preview video {
531
+ max-width: 100%;
532
+ max-height: 100%;
533
+ border-radius: 4px;
534
+ object-fit: cover;
535
+ }
536
+
537
+ .delete-file-btn {
538
+ position: absolute;
539
+ top: 12px;
540
+ right: 12px;
541
+ width: 24px;
542
+ height: 24px;
543
+ background-color: rgba(0, 0, 0, 0.6);
544
+ color: white;
545
+ border: none;
546
+ border-radius: 50%;
547
+ cursor: pointer;
548
+ display: flex;
549
+ justify-content: center;
550
+ align-items: center;
551
+ padding: 0;
552
+ z-index: 10;
553
+ transition: background-color 0.2s, transform 0.2s;
554
+ }
555
+
556
+ .delete-file-btn:hover {
557
+ background-color: rgba(255, 77, 77, 0.9);
558
+ transform: scale(1.1);
559
+ }
560
+
561
+ .file-drop-zone.has-file .file-drop-prompt {
562
+ opacity: 0;
563
+ pointer-events: none;
564
+ }
565
+
566
+ .file-drop-zone.has-file .file-preview {
567
+ display: flex;
568
+ }
569
+
570
+ /* Scrollbar styles */
571
+ ::-webkit-scrollbar {
572
+ width: 8px;
573
+ }
574
+
575
+ ::-webkit-scrollbar-track {
576
+ background: var(--bg-color-medium);
577
+ }
578
+
579
+ ::-webkit-scrollbar-thumb {
580
+ background: #555;
581
+ border-radius: 4px;
582
+ }
583
+
584
+ ::-webkit-scrollbar-thumb:hover {
585
+ background: #777;
586
+ }
587
+
588
+ @media (max-width: 820px) {
589
+ body {
590
+ overflow: auto; /* Allow scrolling on mobile */
591
+ }
592
+
593
+ .app-container {
594
+ height: auto;
595
+ min-height: 100vh;
596
+ }
597
+
598
+ .main-content {
599
+ display: flex;
600
+ flex-direction: column;
601
+ height: auto;
602
+ }
603
+
604
+ /* 移动端:重新排列顺序 - 视频面板优先 */
605
+ .main-content {
606
+ order: 0; /* 确保main-content在header之后 */
607
+ }
608
+
609
+ .content-area {
610
+ order: -1; /* 视频区域排在最前 */
611
+ display: flex;
612
+ flex-direction: column;
613
+ overflow: visible;
614
+ }
615
+
616
+ .settings-panel-left {
617
+ order: 1; /* 设置面板排在视频区域之后 */
618
+ border-right: none;
619
+ overflow-y: visible;
620
+ padding-bottom: 0;
621
+ }
622
+
623
+ .video-panel {
624
+ width: 100%;
625
+ aspect-ratio: 16 / 9; /* Ensure a consistent aspect ratio */
626
+ flex-shrink: 0; /* Prevent the panel from shrinking */
627
+ }
628
+
629
+ .settings-panel-bottom {
630
+ border-top: 1px solid var(--border-color); /* Keep the border for separation */
631
+ }
632
+
633
+ .grid-2-col, .grid-3-col {
634
+ grid-template-columns: 1fr; /* Stack columns */
635
+ }
636
+
637
+ .grid-2-col .switch-group {
638
+ /* Adjust switch group to not look weird when stacked */
639
+ justify-content: flex-start;
640
+ gap: 16px;
641
+ }
642
+ }
643
+ .color-picker-wrapper {
644
+ display: flex;
645
+ align-items: center;
646
+ gap: 8px;
647
+ }
648
+
649
+ .color-picker-wrapper input[type="color"] {
650
+ width: 36px;
651
+ height: 36px;
652
+ border: none;
653
+ padding: 0;
654
+ border-radius: 4px;
655
+ cursor: pointer;
656
+ background-color: transparent;
657
+ }
658
+
659
+ .color-picker-wrapper input[type="color"]::-webkit-color-swatch-wrapper {
660
+ padding: 0;
661
+ }
662
+
663
+ .color-picker-wrapper input[type="color"]::-webkit-color-swatch {
664
+ border: 1px solid var(--border-color);
665
+ border-radius: 4px;
666
+ }
667
+
668
+ .color-picker-wrapper .color-input {
669
+ flex-grow: 1;
670
+ }
671
+
672
+ .hidden {
673
+ display: none !important;
674
+ }
675
+ .form-group.disabled {
676
+ opacity: 0.5;
677
+ pointer-events: none;
678
+ }
679
+
680
+ /* Prompt Actions */
681
+ .prompt-header {
682
+ display: flex;
683
+ justify-content: space-between;
684
+ align-items: center;
685
+ margin-bottom: 8px;
686
+ }
687
+
688
+ .prompt-header label {
689
+ margin-bottom: 0; /* Override default */
690
+ }
691
+
692
+ .prompt-actions {
693
+ display: flex;
694
+ align-items: center;
695
+ gap: 8px;
696
+ }
697
+
698
+ .icon-btn {
699
+ background: none;
700
+ border: none;
701
+ color: var(--text-color-secondary);
702
+ cursor: pointer;
703
+ padding: 4px;
704
+ border-radius: 50%;
705
+ display: flex;
706
+ align-items: center;
707
+ justify-content: center;
708
+ transition: background-color 0.2s, color 0.2s;
709
+ }
710
+
711
+ .icon-btn:hover {
712
+ background-color: var(--bg-color-light);
713
+ color: var(--text-color-primary);
714
+ }
715
+
716
+ .icon-btn:disabled {
717
+ color: var(--accent-color);
718
+ opacity: 0.7;
719
+ cursor: default;
720
+ background-color: transparent;
721
+ }
722
+
723
+ .saved-prompts-dropdown {
724
+ position: relative;
725
+ }
726
+
727
+ .saved-prompts-list {
728
+ position: absolute;
729
+ top: 100%;
730
+ right: 0;
731
+ background-color: var(--bg-color-light);
732
+ border: 1px solid var(--border-color);
733
+ border-radius: 6px;
734
+ width: 280px;
735
+ max-height: 300px;
736
+ overflow-y: auto;
737
+ z-index: 100;
738
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
739
+ margin-top: 8px;
740
+ }
741
+
742
+ .saved-prompts-list.hidden {
743
+ display: none;
744
+ }
745
+
746
+ .saved-prompt-item {
747
+ display: flex;
748
+ justify-content: space-between;
749
+ align-items: center;
750
+ padding: 10px 12px;
751
+ cursor: pointer;
752
+ border-bottom: 1px solid var(--border-color);
753
+ transition: background-color 0.2s;
754
+ }
755
+
756
+ .saved-prompt-item:last-child {
757
+ border-bottom: none;
758
+ }
759
+
760
+ .saved-prompt-item:hover {
761
+ background-color: var(--bg-color-medium);
762
+ }
763
+
764
+ .saved-prompt-text {
765
+ white-space: nowrap;
766
+ overflow: hidden;
767
+ text-overflow: ellipsis;
768
+ flex-grow: 1;
769
+ margin-right: 12px;
770
+ font-size: 13px;
771
+ }
772
+
773
+ .delete-prompt-btn {
774
+ background: none;
775
+ border: none;
776
+ color: var(--text-color-secondary);
777
+ cursor: pointer;
778
+ padding: 2px;
779
+ border-radius: 4px;
780
+ flex-shrink: 0;
781
+ transition: color 0.2s;
782
+ }
783
+
784
+ .delete-prompt-btn:hover {
785
+ color: var(--error-color);
786
+ }
787
+
788
+ .saved-prompts-list-empty {
789
+ padding: 20px;
790
+ text-align: center;
791
+ color: var(--text-color-secondary);
792
+ font-style: italic;
793
+ }
794
+ /* --- Key Input Container --- */
795
+ .key-input-container {
796
+ position: relative;
797
+ display: flex;
798
+ align-items: center;
799
+ z-index: 2; /* Ensure this container is above its siblings */
800
+ }
801
+
802
+ .key-input-container .form-input {
803
+ padding-right: 80px; /* Make space for buttons */
804
+ }
805
+
806
+ .key-actions {
807
+ position: absolute;
808
+ right: 8px;
809
+ top: 50%;
810
+ transform: translateY(-50%);
811
+ display: flex;
812
+ align-items: center;
813
+ gap: 4px;
814
+ }
815
+
816
+ .saved-keys-dropdown {
817
+ position: relative;
818
+ }
819
+
820
+ /* Use the same styles as saved-prompts-list */
821
+ .saved-keys-list {
822
+ position: absolute;
823
+ bottom: 120%; /* Position above the button */
824
+ right: 0;
825
+ width: 280px;
826
+ background-color: #3c3c3c;
827
+ border-radius: 8px;
828
+ border: 1px solid #555;
829
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
830
+ z-index: 101;
831
+ max-height: 300px;
832
+ overflow-y: auto;
833
+ padding: 6px;
834
+ }
835
+
836
+ .saved-keys-list.hidden {
837
+ display: none;
838
+ }
839
+
840
+ .saved-key-item {
841
+ display: flex;
842
+ justify-content: space-between;
843
+ align-items: center;
844
+ padding: 8px 12px;
845
+ border-radius: 6px;
846
+ cursor: pointer;
847
+ transition: background-color 0.2s ease;
848
+ }
849
+
850
+ .saved-key-item:hover {
851
+ background-color: #4a4a4a;
852
+ }
853
+
854
+ .saved-key-alias {
855
+ font-weight: 500;
856
+ color: #e0e0e0;
857
+ white-space: nowrap;
858
+ overflow: hidden;
859
+ text-overflow: ellipsis;
860
+ flex-grow: 1;
861
+ }
862
+
863
+ .saved-key-value {
864
+ font-size: 0.8em;
865
+ color: #999;
866
+ margin-left: 12px;
867
+ white-space: nowrap;
868
+ overflow: hidden;
869
+ text-overflow: ellipsis;
870
+ max-width: 100px; /* Adjust as needed */
871
+ }
872
+
873
+ .delete-key-btn {
874
+ background: none;
875
+ border: none;
876
+ color: #999;
877
+ cursor: pointer;
878
+ padding: 4px;
879
+ display: flex;
880
+ align-items: center;
881
+ justify-content: center;
882
+ border-radius: 4px;
883
+ transition: color 0.2s ease, background-color 0.2s ease;
884
+ }
885
+
886
+ .delete-key-btn:hover {
887
+ color: #ff6b6b;
888
+ background-color: #4a4a4a;
889
+ }
890
+
891
+ .saved-keys-list-empty {
892
+ padding: 16px;
893
+ text-align: center;
894
+ color: #888;
895
+ font-size: 0.9em;
896
+ }
897
+
898
+ .saved-key-item.preset {
899
+ /* Style for preset keys, e.g., slightly different color */
900
+ /* background-color: #414a41; */
901
+ }
902
+
903
+ .saved-key-item.preset .delete-key-btn {
904
+ display: none; /* Hide delete button for presets */
905
  }