"...resnet50_tensorflow.git" did not exist on "452fa40350d8583b0ac0985774abb7397446e436"
Unverified Commit 5ffdbeb6 authored by LiangLiu's avatar LiangLiu Committed by GitHub
Browse files

deploy update (#355)

1、frontend vue+vite
2、share task & template
3、x264 rtc stream push
parent 39683e24
FROM lightx2v/lightx2v:25092201-cu128 AS base
FROM node:alpine3.21 AS frontend_builder
COPY lightx2v /opt/lightx2v
RUN cd /opt/lightx2v/deploy/server/frontend \
&& npm install \
&& npm run build
FROM lightx2v:25092201-cu128 AS base
RUN mkdir /workspace/LightX2V
WORKDIR /workspace/LightX2V
......@@ -8,3 +15,5 @@ COPY assets assets
COPY configs configs
COPY lightx2v lightx2v
COPY lightx2v_kernel lightx2v_kernel
COPY --from=frontend_builder /opt/lightx2v/deploy/server/frontend/dist lightx2v/deploy/server/frontend/dist
......@@ -2,6 +2,8 @@ import asyncio
import base64
import io
import os
import subprocess
import tempfile
import time
import traceback
from datetime import datetime
......@@ -136,21 +138,24 @@ def format_image_data(data, max_size=1280):
return output.getvalue()
def media_to_wav(data):
with tempfile.NamedTemporaryFile() as fin:
fin.write(data)
fin.flush()
cmd = ["ffmpeg", "-i", fin.name, "-f", "wav", "-acodec", "pcm_s16le", "-ar", "44100", "-ac", "2", "pipe:1"]
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
assert p.returncode == 0, f"media to wav failed: {p.stderr.decode()}"
return p.stdout
def format_audio_data(data):
if len(data) < 4:
raise ValueError("Audio file too short")
try:
waveform, sample_rate = torchaudio.load(io.BytesIO(data), num_frames=10)
logger.info(f"load audio: {waveform.size()}, {sample_rate}")
assert waveform.size(0) > 0, "audio is empty"
assert sample_rate > 0, "audio sample rate is not valid"
except Exception as e:
logger.warning(f"torchaudio failed to load audio, trying alternative method: {e}")
# check audio headers
audio_headers = [b"RIFF", b"ID3", b"\xff\xfb", b"\xff\xf3", b"\xff\xf2", b"OggS"]
if not any(data.startswith(header) for header in audio_headers):
logger.warning("Audio file doesn't have recognized header, but continuing...")
logger.info(f"Audio validation passed (alternative method), size: {len(data)} bytes")
data = media_to_wav(data)
waveform, sample_rate = torchaudio.load(io.BytesIO(data), num_frames=10)
logger.info(f"load audio: {waveform.size()}, {sample_rate}")
assert waveform.numel() > 0, "audio is empty"
assert sample_rate > 0, "audio sample rate is not valid"
return data
......
......@@ -97,7 +97,6 @@ class VAReader:
def start_ffmpeg_process_whep(self):
"""Start gstream process read audio from stream"""
ffmpeg_cmd = [
"ffmpeg",
"gst-launch-1.0",
"-q",
"whepsrc",
......
import queue
import random
import signal
import socket
import subprocess
......@@ -13,6 +12,12 @@ import torchaudio as ta
from loguru import logger
def pseudo_random(a, b):
x = str(time.time()).split(".")[1]
y = int(float("0." + x) * 1000000)
return a + (y % (b - a + 1))
class VARecorder:
def __init__(
self,
......@@ -23,13 +28,14 @@ class VARecorder:
self.livestream_url = livestream_url
self.fps = fps
self.sample_rate = sample_rate
self.audio_port = random.choice(range(32000, 40000))
self.audio_port = pseudo_random(32000, 40000)
self.video_port = self.audio_port + 1
logger.info(f"VARecorder audio port: {self.audio_port}, video port: {self.video_port}")
self.width = None
self.height = None
self.stoppable_t = None
self.realtime = True
# ffmpeg process for mix video and audio data and push to livestream
self.ffmpeg_process = None
......@@ -93,6 +99,7 @@ class VARecorder:
self.video_conn, _ = self.video_socket.accept()
logger.info(f"Video connection established from {self.video_conn.getpeername()}")
fail_time, max_fail_time = 0, 10
packet_secs = 1.0 / self.fps
while True:
try:
if self.video_queue is None:
......@@ -101,9 +108,18 @@ class VARecorder:
if data is None:
logger.info("Video thread received stop signal")
break
# Convert to numpy and scale to [0, 255], convert RGB to BGR for OpenCV/FFmpeg
frames = (data * 255).clamp(0, 255).to(torch.uint8).cpu().numpy()
self.video_conn.send(frames.tobytes())
if not self.realtime:
frames = (data * 255).clamp(0, 255).to(torch.uint8).cpu().numpy()
self.video_conn.send(frames.tobytes())
else:
for i in range(data.shape[0]):
t0 = time.time()
frame = (data[i] * 255).clamp(0, 255).to(torch.uint8).cpu().numpy()
self.video_conn.send(frame.tobytes())
time.sleep(max(0, packet_secs - (time.time() - t0)))
fail_time = 0
except: # noqa
logger.error(f"Send video data error: {traceback.format_exc()}")
......@@ -223,12 +239,22 @@ class VARecorder:
ffmpeg_cmd = [
"ffmpeg",
"-re",
"-fflags",
"nobuffer",
"-analyzeduration",
"0",
"-probesize",
"32",
"-flush_packets",
"1",
"-f",
"s16le",
"-ar",
str(self.sample_rate),
"-ac",
"1",
"-ch_layout",
"mono",
"-i",
f"tcp://127.0.0.1:{self.audio_port}",
"-f",
......@@ -278,6 +304,12 @@ class VARecorder:
except Exception as e:
logger.error(f"Failed to start FFmpeg: {e}")
def start(self, width: int, height: int):
self.set_video_size(width, height)
duration = 1.0
self.pub_livestream(torch.zeros((int(self.fps * duration), height, width, 3), dtype=torch.float16), torch.zeros(int(self.sample_rate * duration), dtype=torch.float16))
time.sleep(duration)
def set_video_size(self, width: int, height: int):
if self.width is not None and self.height is not None:
assert self.width == width and self.height == height, "Video size already set"
......@@ -291,6 +323,7 @@ class VARecorder:
self.start_ffmpeg_process_whip()
else:
self.start_ffmpeg_process_local()
self.realtime = False
self.audio_thread = threading.Thread(target=self.audio_worker)
self.video_thread = threading.Thread(target=self.video_worker)
self.audio_thread.start()
......
import ctypes
import queue
import threading
import time
import traceback
import numpy as np
import torch
import torchaudio as ta
from loguru import logger
from scipy.signal import resample
class VAX64Recorder:
def __init__(
self,
whip_shared_path: str,
livestream_url: str,
fps: float = 16.0,
sample_rate: int = 16000,
):
assert livestream_url.startswith("http"), "VAX64Recorder only support whip http livestream"
self.livestream_url = livestream_url
self.fps = fps
self.sample_rate = sample_rate
self.width = None
self.height = None
self.stoppable_t = None
# only enable whip shared api for whip http livestream
self.whip_shared_path = whip_shared_path
self.whip_shared_lib = None
self.whip_shared_handle = None
# queue for send data to whip shared api
self.queue = queue.Queue()
self.worker_thread = None
def worker(self):
try:
fail_time, max_fail_time = 0, 10
packet_secs = 1.0 / self.fps
audio_chunk = round(48000 * 2 / self.fps)
audio_samples = round(48000 / self.fps)
while True:
try:
if self.queue is None:
break
data = self.queue.get()
if data is None:
logger.info("Worker thread received stop signal")
break
audios, images = data
for i in range(images.shape[0]):
t0 = time.time()
cur_audio = audios[i * audio_chunk : (i + 1) * audio_chunk].flatten()
audio_ptr = cur_audio.ctypes.data_as(ctypes.POINTER(ctypes.c_int16))
self.whip_shared_lib.pushRawAudioFrame(self.whip_shared_handle, audio_ptr, audio_samples)
cur_video = images[i].flatten()
video_ptr = cur_video.ctypes.data_as(ctypes.POINTER(ctypes.c_uint8))
self.whip_shared_lib.pushRawVideoFrame(self.whip_shared_handle, video_ptr, self.width, self.height)
time.sleep(max(0, packet_secs - (time.time() - t0)))
fail_time = 0
except: # noqa
logger.error(f"Send audio data error: {traceback.format_exc()}")
fail_time += 1
if fail_time > max_fail_time:
logger.error(f"Audio push worker thread failed {fail_time} times, stopping...")
break
except: # noqa
logger.error(f"Audio push worker thread error: {traceback.format_exc()}")
finally:
logger.info("Audio push worker thread stopped")
def start_libx264_whip_shared_api(self):
self.whip_shared_lib = ctypes.CDLL(self.whip_shared_path)
# define function argtypes and restype
self.whip_shared_lib.initStream.argtypes = [ctypes.c_char_p, ctypes.c_int, ctypes.c_int, ctypes.c_int]
self.whip_shared_lib.initStream.restype = ctypes.c_void_p
self.whip_shared_lib.pushRawAudioFrame.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_int16), ctypes.c_int]
self.whip_shared_lib.pushRawVideoFrame.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint8), ctypes.c_int, ctypes.c_int]
self.whip_shared_lib.destroyStream.argtypes = [ctypes.c_void_p]
whip_url = ctypes.c_char_p(self.livestream_url.encode("utf-8"))
self.whip_shared_handle = ctypes.c_void_p(self.whip_shared_lib.initStream(whip_url, 1, 1, 0))
logger.info(f"WHIP shared API initialized with handle: {self.whip_shared_handle}")
def convert_data(self, audios, images):
# Convert audio data to 16-bit integer format
audio_datas = np.clip(np.round(audios * 32767), -32768, 32767).astype(np.int16)
# Convert to numpy and scale to [0, 255], convert RGB to BGR for OpenCV/FFmpeg
image_datas = (images * 255).clamp(0, 255).to(torch.uint8).cpu().numpy()
logger.info(f"image_datas: {image_datas.shape} {image_datas.dtype} {image_datas.min()} {image_datas.max()}")
reample_audios = resample(audio_datas, int(len(audio_datas) * 48000 / self.sample_rate))
stereo_audios = np.stack([reample_audios, reample_audios], axis=-1).astype(np.int16).reshape(-1)
return stereo_audios, image_datas
def start(self, width: int, height: int):
self.set_video_size(width, height)
def set_video_size(self, width: int, height: int):
if self.width is not None and self.height is not None:
assert self.width == width and self.height == height, "Video size already set"
return
self.width = width
self.height = height
self.start_libx264_whip_shared_api()
self.worker_thread = threading.Thread(target=self.worker)
self.worker_thread.start()
# Publish ComfyUI Image tensor and audio tensor to livestream
def pub_livestream(self, images: torch.Tensor, audios: np.ndarray):
N, height, width, C = images.shape
M = audios.reshape(-1).shape[0]
assert C == 3, "Input must be [N, H, W, C] with C=3"
logger.info(f"Publishing video [{N}x{width}x{height}], audio: [{M}]")
audio_frames = round(M * self.fps / self.sample_rate)
if audio_frames != N:
logger.warning(f"Video and audio frames mismatch, {N} vs {audio_frames}")
self.set_video_size(width, height)
audio_datas, image_datas = self.convert_data(audios, images)
self.queue.put((audio_datas, image_datas))
logger.info(f"Published {N} frames and {M} audio samples")
self.stoppable_t = time.time() + M / self.sample_rate + 3
def stop(self, wait=True):
if wait and self.stoppable_t:
t = self.stoppable_t - time.time()
if t > 0:
logger.warning(f"Waiting for {t} seconds to stop ...")
time.sleep(t)
self.stoppable_t = None
# Send stop signals to queues
if self.queue:
self.queue.put(None)
# Wait for threads to finish
if self.worker_thread and self.worker_thread.is_alive():
self.worker_thread.join(timeout=5)
if self.worker_thread.is_alive():
logger.warning("Worker thread did not stop gracefully")
# Destroy WHIP shared API
if self.whip_shared_lib and self.whip_shared_handle:
self.whip_shared_lib.destroyStream(self.whip_shared_handle)
self.whip_shared_handle = None
self.whip_shared_lib = None
logger.warning("WHIP shared API destroyed")
def __del__(self):
self.stop()
def create_simple_video(frames=10, height=480, width=640):
video_data = []
for i in range(frames):
frame = np.zeros((height, width, 3), dtype=np.float32)
stripe_height = height // 8
colors = [
[1.0, 0.0, 0.0], # 红色
[0.0, 1.0, 0.0], # 绿色
[0.0, 0.0, 1.0], # 蓝色
[1.0, 1.0, 0.0], # 黄色
[1.0, 0.0, 1.0], # 洋红
[0.0, 1.0, 1.0], # 青色
[1.0, 1.0, 1.0], # 白色
[0.5, 0.5, 0.5], # 灰色
]
for j, color in enumerate(colors):
start_y = j * stripe_height
end_y = min((j + 1) * stripe_height, height)
frame[start_y:end_y, :] = color
offset = int((i / frames) * width)
frame = np.roll(frame, offset, axis=1)
frame = torch.tensor(frame, dtype=torch.float32)
video_data.append(frame)
return torch.stack(video_data, dim=0)
if __name__ == "__main__":
sample_rate = 16000
fps = 16
width = 352
height = 352
recorder = VAX64Recorder(
whip_shared_path="/data/nvme0/liuliang1/lightx2v/test_deploy/test_whip_so/src/libagora_go_whip.so",
livestream_url="https://reverse.st-oc-01.chielo.org/10.5.64.49:8000/rtc/v1/whip/?app=subscribe&stream=ll1&eip=10.120.114.82:8000",
fps=fps,
sample_rate=sample_rate,
)
recorder.start(width, height)
time.sleep(5)
audio_path = "/data/nvme0/liuliang1/lightx2v/test_deploy/media_test/mangzhong.wav"
audio_array, ori_sr = ta.load(audio_path)
audio_array = ta.functional.resample(audio_array.mean(0), orig_freq=ori_sr, new_freq=16000)
audio_array = audio_array.numpy().reshape(-1)
secs = audio_array.shape[0] // sample_rate
interval = 1
space = 10
i = 0
while i < space:
t0 = time.time()
logger.info(f"space {i} / {space} s")
cur_audio_array = np.zeros(int(interval * sample_rate), dtype=np.float32)
num_frames = int(interval * fps)
images = create_simple_video(num_frames, height, width)
recorder.pub_livestream(images, cur_audio_array)
i += interval
time.sleep(interval - (time.time() - t0))
started = True
i = 0
while i < secs:
t0 = time.time()
start = int(i * sample_rate)
end = int((i + interval) * sample_rate)
cur_audio_array = audio_array[start:end]
num_frames = int(interval * fps)
images = create_simple_video(num_frames, height, width)
logger.info(f"{i} / {secs} s")
if started:
logger.warning(f"start pub_livestream !!!!!!!!!!!!!!!!!!!!!!!")
started = False
recorder.pub_livestream(images, cur_audio_array)
i += interval
time.sleep(interval - (time.time() - t0))
recorder.stop()
......@@ -9,7 +9,7 @@ from contextlib import asynccontextmanager
import uvicorn
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, Response
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi.staticfiles import StaticFiles
from loguru import logger
......@@ -73,9 +73,9 @@ async def http_exception_handler(request: Request, exc: HTTPException):
static_dir = os.path.join(os.path.dirname(__file__), "static")
app.mount("/static", StaticFiles(directory=static_dir), name="static")
# 添加icon目录的静态文件服务
icon_dir = os.path.join(os.path.dirname(__file__), "static", "icon")
app.mount("/icon", StaticFiles(directory=icon_dir), name="icon")
# 添加assets目录的静态文件服务
assets_dir = os.path.join(os.path.dirname(__file__), "static", "assets")
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
security = HTTPBearer()
......@@ -299,7 +299,6 @@ async def api_v1_task_submit(request: Request, user=Depends(verify_user_access))
@app.get("/api/v1/task/query")
async def api_v1_task_query(request: Request, user=Depends(verify_user_access)):
try:
# 检查是否有task_ids参数(批量查询)
if "task_ids" in request.query_params:
task_ids = request.query_params["task_ids"].split(",")
tasks = []
......@@ -492,16 +491,6 @@ async def api_v1_task_delete(request: Request, user=Depends(verify_user_access))
if task["status"] not in FinishedStatus:
return error_response("Only finished tasks can be deleted", 400)
# delete input files
for _, input_filename in task["inputs"].items():
await data_manager.delete_bytes(input_filename)
logger.info(f"Deleted input file: {input_filename}")
# delete output files
for _, output_filename in task["outputs"].items():
await data_manager.delete_bytes(output_filename)
logger.info(f"Deleted output file: {output_filename}")
# delete task record
success = await task_manager.delete_task(task_id, user["user_id"])
if success:
......@@ -639,7 +628,8 @@ async def api_v1_monitor_metrics():
@app.get("/api/v1/template/asset_url/{template_type}/{filename}")
async def api_v1_template_asset_url(template_type: str, filename: str, valid=Depends(verify_user_access)):
async def api_v1_template_asset_url(template_type: str, filename: str):
"""get template asset URL - no authentication required"""
try:
url = await data_manager.presign_template_url(template_type, filename)
if url is None:
......@@ -653,8 +643,8 @@ async def api_v1_template_asset_url(template_type: str, filename: str, valid=Dep
# Template API endpoints
@app.get("/assets/template/{template_type}/{filename}")
async def assets_template(template_type: str, filename: str, valid=Depends(verify_user_access_from_query)):
"""get template file"""
async def assets_template(template_type: str, filename: str):
"""get template file - no authentication required"""
try:
if not await data_manager.template_file_exists(template_type, filename):
return error_response(f"template file {template_type} {filename} not found", 404)
......@@ -695,8 +685,8 @@ async def assets_template(template_type: str, filename: str, valid=Depends(verif
@app.get("/api/v1/template/list")
async def api_v1_template_list(request: Request, valid=Depends(verify_user_access)):
"""get template file list (support pagination)"""
async def api_v1_template_list(request: Request):
"""get template file list (support pagination) - no authentication required"""
try:
# check page params
page = int(request.query_params.get("page", 1))
......@@ -749,8 +739,8 @@ async def api_v1_template_list(request: Request, valid=Depends(verify_user_acces
@app.get("/api/v1/template/tasks")
async def api_v1_template_tasks(request: Request, valid=Depends(verify_user_access)):
"""get template task list (support pagination)"""
async def api_v1_template_tasks(request: Request):
"""get template task list (support pagination) - no authentication required"""
try:
# check page params
page = int(request.query_params.get("page", 1))
......@@ -773,11 +763,11 @@ async def api_v1_template_tasks(request: Request, valid=Depends(verify_user_acce
template_data = json.loads(bytes_data)
template_data["task"]["model_cls"] = model_pipelines.outer_model_name(template_data["task"]["model_cls"])
all_categories.update(template_data["task"]["tags"])
if category is not None and category != "all" and category not in template_data["task"]["tags"]:
if category and category not in template_data["task"]["tags"]:
continue
if search is not None and search not in template_data["task"]["params"]["prompt"] + template_data["task"]["params"]["negative_prompt"] + template_data["task"][
"model_cls"
] + template_data["task"]["stage"] + template_data["task"]["task_type"] + ",".join(template_data["task"]["tags"]):
if search and search not in template_data["task"]["params"]["prompt"] + template_data["task"]["params"]["negative_prompt"] + template_data["task"]["model_cls"] + template_data["task"][
"stage"
] + template_data["task"]["task_type"] + ",".join(template_data["task"]["tags"]):
continue
all_templates.append(template_data["task"])
except Exception as e:
......@@ -800,6 +790,131 @@ async def api_v1_template_tasks(request: Request, valid=Depends(verify_user_acce
return error_response(str(e), 500)
@app.get("/api/v1/template/{template_id}")
async def api_v1_template_get(template_id: str, user=None):
try:
template_files = await data_manager.list_template_files("tasks")
template_files = [] if template_files is None else template_files
for template_file in template_files:
try:
bytes_data = await data_manager.load_template_file("tasks", template_file)
template_data = json.loads(bytes_data)
if template_data["task"]["task_id"] == template_id:
return template_data["task"]
except Exception as e:
logger.warning(f"Failed to load template file {template_file}: {e}")
continue
return error_response("Template not found", 404)
except Exception as e:
traceback.print_exc()
return error_response(str(e), 500)
@app.post("/api/v1/share/create")
async def api_v1_share_create(request: Request, user=Depends(verify_user_access)):
try:
params = await request.json()
task_id = params["task_id"]
valid_days = params.get("valid_days", 7)
auth_type = params.get("auth_type", "public")
auth_value = params.get("auth_value", "")
share_type = params.get("share_type", "task")
assert auth_type == "public", "Only public share is supported now"
if share_type == "template":
template = await api_v1_template_get(task_id, user)
assert isinstance(template, dict) and template["task_id"] == task_id, f"Template {task_id} not found"
else:
task = await task_manager.query_task(task_id, user["user_id"], only_task=True)
assert task, f"Task {task_id} not found"
if auth_type == "user_id":
assert auth_value != "", "Target user is required for auth_type = user_id"
target_user = await task_manager.query_user(auth_value)
assert target_user and target_user["user_id"] == auth_value, f"Target user {auth_value} not found"
share_id = await task_manager.create_share(task_id, user["user_id"], share_type, valid_days, auth_type, auth_value)
return {"share_id": share_id, "share_url": f"/share/{share_id}"}
except Exception as e:
traceback.print_exc()
return error_response(str(e), 500)
@app.get("/api/v1/share/{share_id}")
async def api_v1_share_get(share_id: str):
try:
share_data = await task_manager.query_share(share_id)
assert share_data, f"Share {share_id} not found or expired or deleted"
task_id = share_data["task_id"]
share_type = share_data["share_type"]
assert share_data["auth_type"] == "public", "Only public share is supported now"
if share_type == "template":
task = await api_v1_template_get(task_id, None)
assert isinstance(task, dict) and task["task_id"] == task_id, f"Template {task_id} not found"
else:
task = await task_manager.query_task(task_id, only_task=True)
assert task, f"Task {task_id} not found"
user_info = await task_manager.query_user(share_data["user_id"])
username = user_info.get("username", "用户") if user_info else "用户"
share_info = {
"task_id": task_id,
"share_type": share_type,
"user_id": share_data["user_id"],
"username": username,
"task_type": task["task_type"],
"model_cls": task["model_cls"],
"stage": task["stage"],
"prompt": task["params"].get("prompt", ""),
"negative_prompt": task["params"].get("negative_prompt", ""),
"inputs": task["inputs"],
"outputs": task["outputs"],
"create_t": task["create_t"],
"valid_days": share_data["valid_days"],
"valid_t": share_data["valid_t"],
"auth_type": share_data["auth_type"],
"auth_value": share_data["auth_value"],
"output_video_url": None,
"input_urls": {},
}
for input_name, input_filename in task["inputs"].items():
if share_type == "template":
template_type = "images" if "image" in input_name else "audios"
input_url = await data_manager.presign_template_url(template_type, input_filename)
else:
input_url = await data_manager.presign_url(input_filename)
share_info["input_urls"][input_name] = input_url
for output_name, output_filename in task["outputs"].items():
if share_type == "template":
assert "video" in output_name, "Only video output is supported for template share"
output_url = await data_manager.presign_template_url("videos", output_filename)
else:
output_url = await data_manager.presign_url(output_filename)
share_info["output_video_url"] = output_url
return share_info
except Exception as e:
traceback.print_exc()
return error_response(str(e), 500)
# 所有未知路由 fallback 到 index.html (必须在所有API路由之后)
@app.get("/{full_path:path}", response_class=HTMLResponse)
async def vue_fallback(full_path: str):
index_path = os.path.join(static_dir, "index.html")
if os.path.exists(index_path):
return FileResponse(index_path)
return HTMLResponse("<h1>Frontend not found</h1>", status_code=404)
# =========================
# Main Entry
# =========================
......
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LightX2V</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- 主要图标库 -->
<link href="https://cdn.bootcdn.net/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<!-- 备用图标库 -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"
media="print" onload="this.media='all'">
<link rel='stylesheet' href='https://cdn-uicons.flaticon.com/3.0.0/uicons-solid-rounded/css/uicons-solid-rounded.css'>
<link rel='stylesheet' href='https://cdn-uicons.flaticon.com/3.0.0/uicons-bold-rounded/css/uicons-bold-rounded.css'>
<link rel='stylesheet' href='https://cdn-uicons.flaticon.com/3.0.0/uicons-solid-rounded/css/uicons-solid-rounded.css'>
<link rel='stylesheet' href='https://cdn-uicons.flaticon.com/3.0.0/uicons-regular-rounded/css/uicons-regular-rounded.css'>
<link href="/src/style.css" rel="stylesheet">
</head>
<body>
<div id="app">
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
This diff is collapsed.
{
"name": "my-project",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@flaticon/flaticon-uicons": "^3.3.1",
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.2.0",
"@tailwindcss/vite": "^4.1.13",
"pinia": "^3.0.3",
"tailwindcss": "^4.1.13",
"vue": "^3.5.21",
"vue-i18n": "^11.1.12",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.1.7"
}
}
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import router from './router'
import { init, handleLoginCallback, handleClickOutside, validateToken } from './utils/other'
import { initLanguage } from './utils/i18n'
import { startHintRotation, stopHintRotation } from './utils/other'
import { currentUser,
isLoading,
applyMobileStyles,
isLoggedIn,
loginLoading,
initLoading,
pollingInterval,
pollingTasks,
showAlert
} from './utils/other'
import { useI18n } from 'vue-i18n'
import Loading from './components/Loading.vue'
const { t, locale } = useI18n()
let source = null
// 页面加载时应用移动端样式
onMounted(() => {
applyMobileStyles();
window.addEventListener('resize', applyMobileStyles);
});
// 组件卸载时移除事件监听器
onUnmounted(() => {
window.removeEventListener('resize', applyMobileStyles);
});
// 生命周期:页面加载
onMounted(async () => {
// 1. 初始化语言
isLoading.value = true
await initLanguage()
initLoading.value = true
// 2. 启动提示滚动
startHintRotation()
// 3. 添加全局点击事件监听器
document.addEventListener('click', handleClickOutside)
try {
// 检查是否有登录回调参数
const urlParams = new URLSearchParams(window.location.search)
const code = urlParams.get('code')
if (code) {
// 处理登录回调
isLoading.value = true
source = localStorage.getItem('loginSource')
await handleLoginCallback(code, source)
return
}
// 检查本地存储的登录状态
const savedToken = localStorage.getItem('accessToken')
const savedUser = localStorage.getItem('currentUser')
if (savedToken && savedUser) {
// 验证token是否过期
const isValidToken = await validateToken(savedToken)
if (isValidToken) {
currentUser.value = JSON.parse(savedUser)
isLoggedIn.value = true
await init();
console.log('用户已登录,初始化完成')
} else {
// Token已过期,清除本地存储
localStorage.removeItem('accessToken')
localStorage.removeItem('currentUser')
isLoggedIn.value = false
console.log('Token已过期')
showAlert('请重新登录', 'warning')
}
} else {
isLoggedIn.value = false
console.log('用户未登录')
}
} catch (error) {
console.error('初始化失败', error)
showAlert('初始化失败,请刷新页面重试', 'danger')
isLoggedIn.value = false
} finally {
loginLoading.value = false
initLoading.value = false
isLoading.value = false
}
// 6. 移动端样式适配
applyMobileStyles()
window.addEventListener('resize', applyMobileStyles)
})
// 生命周期:页面卸载
onUnmounted(() => {
// 清理轮询
if (pollingInterval.value) clearInterval(pollingInterval.value)
pollingTasks.value.clear()
// 清理提示滚动
stopHintRotation()
// 移除事件监听器
window.removeEventListener('resize', applyMobileStyles)
document.removeEventListener('click', handleClickOutside)
})
</script>
<template>
<router-view></router-view>
<!-- 全局路由跳转Loading覆盖层 -->
<div v-show="isLoading" class="fixed inset-0 bg-gradient-main flex items-center justify-center">
<Loading />
</div>
</template>
<script setup>
import { alert, getAlertClass, getAlertIconBgClass, getAlertIcon } from '../utils/other'
import { useI18n } from 'vue-i18n'
import { ref, onMounted, onUnmounted } from 'vue'
const { t, locale } = useI18n()
// 响应式变量控制Alert位置
const alertPosition = ref({ top: '1rem' })
// 防抖函数
let scrollTimeout = null
// 监听滚动事件,动态调整Alert位置
const handleScroll = () => {
// 清除之前的定时器
if (scrollTimeout) {
clearTimeout(scrollTimeout)
}
// 设置新的定时器,防抖处理
scrollTimeout = setTimeout(() => {
const scrollY = window.scrollY
const viewportHeight = window.innerHeight
// 如果用户滚动了超过50px,将Alert显示在视口内
if (scrollY > 50) {
// 计算Alert应该显示的位置,确保在视口内可见
// 距离滚动位置20px,但不超过视口底部200px
const alertTop = Math.min(scrollY + 20, scrollY + viewportHeight - 200)
alertPosition.value = { top: `${alertTop}px` }
} else {
// 在页面顶部时,显示在固定位置
alertPosition.value = { top: '1rem' }
}
}, 10) // 10ms防抖延迟
}
onMounted(() => {
window.addEventListener('scroll', handleScroll, { passive: true })
// 初始化时也调用一次,确保位置正确
handleScroll()
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
if (scrollTimeout) {
clearTimeout(scrollTimeout)
}
})
</script>
<template>
<!-- 增强的提示消息系统 -->
<div v-cloak>
<div v-if="alert.show"
class="fixed left-1/2 transform -translate-x-1/2 z-[9999] max-w-xs w-full px-4 transition-all duration-300 ease-out"
:style="alertPosition"
:class="getAlertClass(alert.type)">
<div
class="bg-gray-800/95 backdrop-blur-sm border border-gray-700/50 rounded-lg shadow-lg transition-all duration-300 ease-out">
<div class="flex items-center p-3">
<div class="flex-shrink-0 mr-3">
<div class="w-6 h-6 rounded-full flex items-center justify-center"
:class="getAlertIconBgClass(alert.type)">
<i :class="getAlertIcon(alert.type)" class="text-xs"></i>
</div>
</div>
<div class="flex-1">
<p class="text-xs font-medium text-gray-200">
{{ alert.message }}
</p>
</div>
<div class="flex-shrink-0 ml-2">
<button @click="alert.show = false"
class="w-5 h-5 rounded-full flex items-center justify-center text-gray-400 hover:text-gray-200 hover:bg-gray-700/50 transition-all duration-200">
<i class="fas fa-times text-xs"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<template>
<div class="p-6">
<h2 class="text-xl font-bold text-white mb-4">音频预览测试</h2>
<!-- 测试音频模板预览 -->
<div class="mb-6">
<h3 class="text-lg text-white mb-2">音频模板预览测试</h3>
<div class="space-y-2">
<div v-for="template in audioTemplates" :key="template.filename"
class="flex items-center gap-4 p-3 bg-dark-light rounded-lg">
<span class="text-white">{{ template.filename }}</span>
<button @click="previewAudioTemplate(template)"
class="px-3 py-1 bg-laser-purple text-white rounded hover:bg-laser-purple/80">
预览
</button>
</div>
<div v-if="audioTemplates.length === 0" class="text-gray-400">
暂无音频模板
</div>
</div>
</div>
<!-- 测试音频历史预览 -->
<div class="mb-6">
<h3 class="text-lg text-white mb-2">音频历史预览测试</h3>
<div class="space-y-2">
<div v-for="history in audioHistory" :key="history.filename"
class="flex items-center gap-4 p-3 bg-dark-light rounded-lg">
<span class="text-white">{{ history.filename }}</span>
<button @click="previewAudioHistory(history)"
class="px-3 py-1 bg-laser-purple text-white rounded hover:bg-laser-purple/80">
预览
</button>
</div>
<div v-if="audioHistory.length === 0" class="text-gray-400">
暂无音频历史
</div>
</div>
</div>
<!-- 调试信息 -->
<div class="mt-6 p-4 bg-gray-800 rounded-lg">
<h3 class="text-lg text-white mb-2">调试信息</h3>
<div class="text-sm text-gray-300">
<p>音频模板数量: {{ audioTemplates.length }}</p>
<p>音频历史数量: {{ audioHistory.length }}</p>
<div v-if="audioTemplates.length > 0">
<p>第一个音频模板:</p>
<pre class="text-xs bg-gray-900 p-2 rounded mt-1">{{ JSON.stringify(audioTemplates[0], null, 2) }}</pre>
</div>
<div v-if="audioHistory.length > 0">
<p>第一个音频历史:</p>
<pre class="text-xs bg-gray-900 p-2 rounded mt-1">{{ JSON.stringify(audioHistory[0], null, 2) }}</pre>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
audioTemplates,
audioHistory,
previewAudioTemplate,
previewAudioHistory
} from '../utils/other'
</script>
<script setup>
import { confirmDialog,
showConfirmDialog} from '../utils/other'
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
</script>
<template>
<!-- 自定义确认对话框 -->
<div v-cloak>
<div v-if="confirmDialog.show" class="fixed inset-0 z-50 flex items-center justify-center p-4">
<!-- 背景遮罩 -->
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" @click="confirmDialog.show = false">
</div>
<!-- 对话框内容 -->
<div class="relative bg-dark-light border border-laser-purple/30 rounded-xl shadow-2xl max-w-md w-full mx-4 transform transition-all duration-300 ease-out"
:class="confirmDialog.show ? 'scale-100 opacity-100' : 'scale-95 opacity-0'">
<!-- 头部 -->
<div class="flex items-center justify-between p-6 border-b border-gray-700">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-red-500/20 rounded-full flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-red-400 text-lg"></i>
</div>
<h3 class="text-lg font-semibold text-white">{{ confirmDialog.title }}</h3>
</div>
<button @click="confirmDialog.show = false"
class="text-gray-400 hover:text-gray-300 transition-colors">
<i class="fas fa-times text-lg"></i>
</button>
</div>
<!-- 内容 -->
<div class="p-6">
<p class="text-gray-300 leading-relaxed mb-6">{{ confirmDialog.message }}</p>
<!-- 警告信息 -->
<div v-if="confirmDialog.warning"
class="bg-red-500/10 border border-red-500/30 rounded-lg p-4 mb-6">
<div class="flex items-start gap-3">
<i class="fas fa-info-circle text-red-400 mt-0.5"></i>
<div class="text-sm text-red-300">
<p class="font-medium mb-2">{{ confirmDialog.warning.title }}</p>
<ul class="space-y-1 text-xs">
<li v-for="item in confirmDialog.warning.items" :key="item"
class="flex items-center gap-2">
<i class="fas fa-minus text-red-400 text-xs"></i>
{{ item }}
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- 底部按钮 -->
<div class="flex gap-3 p-6 pt-0">
<button @click="confirmDialog.cancel()"
class="flex-1 px-4 py-2.5 bg-gray-600 hover:bg-gray-500 text-white rounded-lg transition-all duration-200 font-medium">
{{ t('cancel') }}
</button>
<button @click="confirmDialog.confirm()"
class="flex-1 px-4 py-2.5 bg-red-500 hover:bg-red-600 text-white rounded-lg transition-all duration-200 font-medium flex items-center justify-center gap-2">
<i class="fas fa-trash text-sm"></i>
{{ confirmDialog.confirmText }}
</button>
</div>
</div>
</div>
</div>
</template>
<template>
<div class="relative inline-block text-left">
<Menu as="div">
<div>
<MenuButton
class="inline-flex w-full justify-center rounded-md bg-dark-light border border-laser-purple/30 px-4 py-3 text-sm font-medium text-white hover:bg-dark-light/80 hover:border-laser-purple/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-laser-purple/50 transition-all duration-200"
>
{{ selectedLabel || placeholder }}
<ChevronDownIcon
class="-mr-1 ml-2 h-5 w-5 text-laser-purple hover:text-gradient-primary transition-colors"
aria-hidden="true"
/>
</MenuButton>
</div>
<transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<MenuItems
class="absolute right-0 mt-2 w-56 origin-top-right divide-y divide-gray-700/50 rounded-md bg-dark-light border border-laser-purple/30 shadow-lg ring-1 ring-black/5 focus:outline-none z-50"
>
<div class="px-1 py-1">
<MenuItem v-for="item in items" :key="item.value" v-slot="{ active }">
<button
@click="selectItem(item)"
:class="[
active ? 'bg-laser-purple/20 text-white' : 'text-gray-300',
'group flex w-full items-center rounded-md px-3 py-2 text-sm transition-colors duration-200',
]"
>
<i v-if="item.icon" :class="[item.icon, 'mr-3 h-4 w-4 text-laser-purple']" aria-hidden="true"></i>
{{ item.label }}
<i v-if="selectedValue === item.value" class="fas fa-check ml-auto text-laser-purple"></i>
</button>
</MenuItem>
</div>
<div v-if="items.length === 0" class="px-1 py-1">
<div class="px-3 py-2 text-sm text-gray-500 text-center">
{{ emptyMessage }}
</div>
</div>
</MenuItems>
</transition>
</Menu>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
import { ChevronDownIcon } from '@heroicons/vue/20/solid'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// Props
const props = defineProps({
items: {
type: Array,
default: () => []
},
selectedValue: {
type: [String, Number],
default: ''
},
placeholder: {
type: String,
default: ''
},
emptyMessage: {
type: String,
default: ''
}
})
// Emits
const emit = defineEmits(['select-item'])
// Computed
const selectedLabel = computed(() => {
const selectedItem = props.items.find(item => item.value === props.selectedValue)
return selectedItem ? selectedItem.label : ''
})
// Methods
const selectItem = (item) => {
emit('select-item', item)
}
</script>
<template>
<!-- 浮动粒子背景 -->
<div class="floating-particles">
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
</div>
</template>
<style scoped type="text/tailwindcss">
.floating-particles {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
pointer-events: none;
}
.particle {
position: absolute;
width: 4px;
height: 4px;
background: rgba(193, 169, 255, 0.6);
border-radius: 50%;
animation: floatParticle 15s linear infinite;
}
.particle:nth-child(1) { left: 10%; animation-delay: 0s; }
.particle:nth-child(2) { left: 20%; animation-delay: 12s; }
.particle:nth-child(3) { left: 30%; animation-delay: 10s; }
.particle:nth-child(4) { left: 40%; animation-delay: 6s; }
.particle:nth-child(5) { left: 50%; animation-delay: 8s; }
.particle:nth-child(6) { left: 60%; animation-delay: 14s; }
.particle:nth-child(7) { left: 70%; animation-delay: 16s; }
.particle:nth-child(8) { left: 80%; animation-delay: 2s; }
.particle:nth-child(9) { left: 90%; animation-delay: 4s; }
@keyframes floatParticle {
0% {
transform: translateY(100vh) scale(0);
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
transform: translateY(-100px) scale(1);
opacity: 0;
}
}
</style>
This diff is collapsed.
<script setup>
import { showImageZoomModal, closeImageZoomModal, zoomedImageUrl } from '../utils/other'
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
</script>
<template>
<!-- 图片放大弹窗 -->
<div v-cloak>
<div v-if="showImageZoomModal" class="fixed inset-0 bg-black/80 z-60 flex items-center justify-center p-4"
@click="closeImageZoomModal">
<div class="relative max-w-4xl max-h-[90vh] bg-secondary rounded-xl overflow-hidden" @click.stop>
<div class="flex items-center justify-between p-4 border-b border-gray-700">
<h3 class="text-lg font-medium text-white">{{ t('imagePreview') }}</h3>
<button @click="closeImageZoomModal"
class="text-gray-400 hover:text-white transition-colors">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<div class="p-4">
<img :src="zoomedImageUrl" :alt="'图片预览'"
class="w-full h-auto max-h-[70vh] object-contain rounded-lg" />
</div>
</div>
</div>
</div>
</template>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment