"git@developer.sourcefind.cn:wuxk1/megatron-lm.git" did not exist on "bea16fa3319abf7901d6b434ec4becdac5a684f6"
Commit a1db6fa0 authored by LiangLiu's avatar LiangLiu Committed by GitHub
Browse files

update deploy (#323)



update deploy

---------
Co-authored-by: default avatarliuliang1 <liuliang1@sensetime.com>
Co-authored-by: default avatarqinxinyi <qinxinyi@sensetime.com>
Co-authored-by: default avatarYang Yong(雍洋) <yongyang1030@163.com>
parent 99158e75
FROM pytorch/pytorch:2.7.1-cuda12.8-cudnn9-devel AS base
FROM pytorch/pytorch:2.8.0-cuda12.8-cudnn9-devel AS base
WORKDIR /app
ENV DEBIAN_FRONTEND=noninteractive
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
# use tsinghua source
RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|https://mirrors.tuna.tsinghua.edu.cn/ubuntu/|g' /etc/apt/sources.list \
&& sed -i 's|http://security.ubuntu.com/ubuntu/|https://mirrors.tuna.tsinghua.edu.cn/ubuntu/|g' /etc/apt/sources.list
ENV LD_LIBRARY_PATH=/usr/local/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH
RUN apt-get update && apt-get install -y vim tmux zip unzip wget git build-essential libibverbs-dev ca-certificates \
curl iproute2 ffmpeg libsm6 libxext6 kmod ccache libnuma-dev \
curl iproute2 libsm6 libxext6 kmod ccache libnuma-dev libssl-dev flex bison libgtk-3-dev libpango1.0-dev \
libsoup2.4-dev libnice-dev libopus-dev libvpx-dev libx264-dev libsrtp2-dev libglib2.0-dev libdrm-dev\
&& apt-get clean && rm -rf /var/lib/apt/lists/*
RUN pip config set global.index-url https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
RUN pip install --no-cache-dir packaging ninja cmake scikit-build-core uv ruff pre-commit -U
RUN pip install --no-cache-dir packaging ninja cmake scikit-build-core uv meson ruff pre-commit fastapi uvicorn requests -U
RUN git clone https://github.com/vllm-project/vllm.git && cd vllm \
&& python use_existing_torch.py && pip install -r requirements/build.txt \
......@@ -28,6 +24,8 @@ RUN git clone https://github.com/sgl-project/sglang.git && cd sglang/sgl-kernel
RUN pip install --no-cache-dir diffusers transformers tokenizers accelerate safetensors opencv-python numpy imageio \
imageio-ffmpeg einops loguru qtorch ftfy easydict
RUN conda install conda-forge::ffmpeg=8.0.0 -y && ln -s /opt/conda/bin/ffmpeg /usr/bin/ffmpeg
RUN git clone https://github.com/Dao-AILab/flash-attention.git --recursive
RUN cd flash-attention && python setup.py install && rm -rf build
......@@ -42,4 +40,34 @@ RUN git clone https://github.com/KONAKONA666/q8_kernels.git
RUN cd q8_kernels && git submodule init && git submodule update && python setup.py install && rm -rf build
# cloud deploy
RUN pip install --no-cache-dir aio-pika asyncpg>=0.27.0 aioboto3>=12.0.0 PyJWT alibabacloud_dypnsapi20170525==1.2.2 redis==6.4.0 tos -U
RUN cd /opt \
&& wget https://mirrors.tuna.tsinghua.edu.cn/gnu//libiconv/libiconv-1.15.tar.gz \
&& tar zxvf libiconv-1.15.tar.gz \
&& cd libiconv-1.15 \
&& ./configure \
&& make \
&& make install
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
ENV PATH=/root/.cargo/bin:$PATH
RUN cd /opt \
&& git clone https://github.com/GStreamer/gstreamer.git -b 1.24.12 --depth 1 \
&& cd gstreamer \
&& meson setup builddir \
&& meson compile -C builddir \
&& meson install -C builddir \
&& ldconfig
RUN cd /opt \
&& git clone https://github.com/GStreamer/gst-plugins-rs.git -b gstreamer-1.24.12 --depth 1 \
&& cd gst-plugins-rs \
&& cargo build --package gst-plugin-webrtchttp --release \
&& install -m 644 target/release/libgstwebrtchttp.so $(pkg-config --variable=pluginsdir gstreamer-1.0)/
RUN ldconfig
WORKDIR /workspace
......@@ -11,7 +11,7 @@ RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|https://mirrors.tuna.tsinghua.ed
&& sed -i 's|http://security.ubuntu.com/ubuntu/|https://mirrors.tuna.tsinghua.edu.cn/ubuntu/|g' /etc/apt/sources.list
RUN apt-get update && apt-get install -y vim tmux zip unzip wget git build-essential libibverbs-dev ca-certificates \
curl iproute2 ffmpeg libsm6 libxext6 kmod ccache libnuma-dev \
curl iproute2 libsm6 libxext6 kmod ccache libnuma-dev \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
RUN pip config set global.index-url https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
......@@ -28,6 +28,8 @@ RUN git clone https://github.com/sgl-project/sglang.git && cd sglang/sgl-kernel
RUN pip install --no-cache-dir diffusers transformers tokenizers accelerate safetensors opencv-python numpy imageio \
imageio-ffmpeg einops loguru qtorch ftfy easydict
RUN conda install conda-forge::ffmpeg=8.0.0 -y && ln -s /opt/conda/bin/ffmpeg /usr/bin/ffmpeg
RUN git clone https://github.com/Dao-AILab/flash-attention.git --recursive
RUN cd flash-attention && python setup.py install && rm -rf build
......
# For rtc whep, build gstreamer whith whepsrc plugin
FROM registry.ms-sc-01.maoshanwangtech.com/ms-ccr/lightx2v:25080601-cu128-SageSm90 AS gstreamer-base
FROM lightx2v/lightx2v:25091903-cu128 AS base
RUN apt update -y \
&& apt update -y \
&& apt install -y libssl-dev flex bison \
libgtk-3-dev libpango1.0-dev libsoup2.4-dev \
libnice-dev libopus-dev libvpx-dev libx264-dev \
libsrtp2-dev libglib2.0-dev libdrm-dev
RUN cd /opt \
&& wget https://mirrors.tuna.tsinghua.edu.cn/gnu//libiconv/libiconv-1.15.tar.gz \
&& tar zxvf libiconv-1.15.tar.gz \
&& cd libiconv-1.15 \
&& ./configure \
&& make \
&& make install
RUN pip install meson
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
ENV PATH=/root/.cargo/bin:$PATH
RUN cd /opt \
&& git clone https://github.com/GStreamer/gstreamer.git -b 1.24.12 --depth 1 \
&& cd gstreamer \
&& meson setup builddir \
&& meson compile -C builddir \
&& meson install -C builddir \
&& ldconfig
RUN cd /opt \
&& git clone https://github.com/GStreamer/gst-plugins-rs.git -b gstreamer-1.24.12 --depth 1 \
&& cd gst-plugins-rs \
&& cargo build --package gst-plugin-webrtchttp --release \
&& install -m 644 target/release/libgstwebrtchttp.so $(pkg-config --variable=pluginsdir gstreamer-1.0)/
# Lightx2v deploy image
FROM registry.ms-sc-01.maoshanwangtech.com/ms-ccr/lightx2v:25080601-cu128-SageSm90
RUN mkdir /workspace/lightx2v
WORKDIR /workspace/lightx2v
ENV PYTHONPATH=/workspace/lightx2v
COPY requirements.txt requirements.txt
RUN pip install -r requirements.txt
RUN conda install conda-forge::ffmpeg=8.0.0 -y
RUN rm /usr/bin/ffmpeg && ln -s /opt/conda/bin/ffmpeg /usr/bin/ffmpeg
RUN apt update -y \
&& apt install -y libssl-dev \
libgtk-3-dev libpango1.0-dev libsoup2.4-dev \
libnice-dev libopus-dev libvpx-dev libx264-dev \
libsrtp2-dev libglib2.0-dev libdrm-dev
ENV LBDIR=/usr/local/lib/x86_64-linux-gnu
COPY --from=gstreamer-base /usr/local/bin/gst-* /usr/local/bin/
COPY --from=gstreamer-base $LBDIR $LBDIR
RUN ldconfig
ENV LD_LIBRARY_PATH=$LBDIR:$LD_LIBRARY_PATH
RUN gst-launch-1.0 --version
RUN gst-inspect-1.0 whepsrc
RUN mkdir /workspace/LightX2V
WORKDIR /workspace/LightX2V
ENV PYTHONPATH=/workspace/LightX2V
COPY assets assets
COPY configs configs
......
......@@ -102,6 +102,10 @@
"latents": "TENSOR",
"output_video": "VIDEO"
},
"model_name_inner_to_outer": {
"seko_talk": "SekoTalk"
},
"model_name_outer_to_inner": {},
"monitor": {
"subtask_created_timeout": 1800,
"subtask_pending_timeout": 1800,
......
......@@ -27,16 +27,16 @@ We strongly recommend using the Docker environment, which is the simplest and fa
#### 1. Pull Image
Visit LightX2V's [Docker Hub](https://hub.docker.com/r/lightx2v/lightx2v/tags), select a tag with the latest date, such as `25090503-cu128`:
Visit LightX2V's [Docker Hub](https://hub.docker.com/r/lightx2v/lightx2v/tags), select a tag with the latest date, such as `25091903-cu128`:
```bash
docker pull lightx2v/lightx2v:25090503-cu128
docker pull lightx2v/lightx2v:25091903-cu128
```
We recommend using the `cuda128` environment for faster inference speed. If you need to use the `cuda124` environment, you can use image versions with the `-cu124` suffix:
```bash
docker pull lightx2v/lightx2v:25090503-cu124
docker pull lightx2v/lightx2v:25091903-cu124
```
#### 2. Run Container
......@@ -51,10 +51,10 @@ For mainland China, if the network is unstable when pulling images, you can pull
```bash
# cuda128
docker pull registry.cn-hangzhou.aliyuncs.com/yongyang/lightx2v:25090503-cu128
docker pull registry.cn-hangzhou.aliyuncs.com/yongyang/lightx2v:25091903-cu128
# cuda124
docker pull registry.cn-hangzhou.aliyuncs.com/yongyang/lightx2v:25090503-cu124
docker pull registry.cn-hangzhou.aliyuncs.com/yongyang/lightx2v:25091903-cu124
```
### 🐍 Conda Environment Setup
......
......@@ -27,16 +27,16 @@
#### 1. 拉取镜像
访问 LightX2V 的 [Docker Hub](https://hub.docker.com/r/lightx2v/lightx2v/tags),选择一个最新日期的 tag,比如 `25090503-cu128`
访问 LightX2V 的 [Docker Hub](https://hub.docker.com/r/lightx2v/lightx2v/tags),选择一个最新日期的 tag,比如 `25091903-cu128`
```bash
docker pull lightx2v/lightx2v:25090503-cu128
docker pull lightx2v/lightx2v:25091903-cu128
```
我们推荐使用`cuda128`环境,以获得更快的推理速度,若需要使用`cuda124`环境,可以使用带`-cu124`后缀的镜像版本:
```bash
docker pull lightx2v/lightx2v:25090503-cu124
docker pull lightx2v/lightx2v:25091903-cu124
```
#### 2. 运行容器
......@@ -51,10 +51,10 @@ docker run --gpus all -itd --ipc=host --name [容器名] -v [挂载设置] --ent
```bash
# cuda128
docker pull registry.cn-hangzhou.aliyuncs.com/yongyang/lightx2v:25090503-cu128
docker pull registry.cn-hangzhou.aliyuncs.com/yongyang/lightx2v:25091903-cu128
# cuda124
docker pull registry.cn-hangzhou.aliyuncs.com/yongyang/lightx2v:25090503-cu124
docker pull registry.cn-hangzhou.aliyuncs.com/yongyang/lightx2v:25091903-cu124
```
### 🐍 Conda 环境搭建
......
......@@ -16,6 +16,8 @@ class Pipeline:
self.model_lists = []
self.types = {}
self.queues = set()
self.model_name_inner_to_outer = self.meta.get("model_name_inner_to_outer", {})
self.model_name_outer_to_inner = self.meta.get("model_name_outer_to_inner", {})
self.tidy_pipeline()
def init_dict(self, base, task, model_cls):
......@@ -132,6 +134,14 @@ class Pipeline:
item = item[k]
return item
def check_item_by_keys(self, keys):
item = self.data
for k in keys:
if k not in item:
return False
item = item[k]
return True
def get_model_lists(self):
return self.model_lists
......@@ -144,6 +154,12 @@ class Pipeline:
def get_queues(self):
return self.queues
def inner_model_name(self, name):
return self.model_name_outer_to_inner.get(name, name)
def outer_model_name(self, name):
return self.model_name_inner_to_outer.get(name, name)
if __name__ == "__main__":
pipeline = Pipeline(sys.argv[1])
......
import asyncio
import base64
import io
import os
......@@ -87,6 +88,72 @@ async def fetch_resource(url, timeout):
return content
# check, resize, read rotate meta info
def format_image_data(data, max_size=1280):
image = Image.open(io.BytesIO(data)).convert("RGB")
exif = image.getexif()
changed = False
w, h = image.size
assert w > 0 and h > 0, "image is empty"
logger.info(f"load image: {w}x{h}, exif: {exif}")
if w > max_size or h > max_size:
ratio = max_size / max(w, h)
w = int(w * ratio)
h = int(h * ratio)
image = image.resize((w, h))
logger.info(f"resize image to: {image.size}")
changed = True
orientation_key = 274
if orientation_key and orientation_key in exif:
orientation = exif[orientation_key]
if orientation == 2:
image = image.transpose(Image.FLIP_LEFT_RIGHT)
elif orientation == 3:
image = image.rotate(180, expand=True)
elif orientation == 4:
image = image.transpose(Image.FLIP_TOP_BOTTOM)
elif orientation == 5:
image = image.transpose(Image.FLIP_LEFT_RIGHT).rotate(90, expand=True)
elif orientation == 6:
image = image.rotate(270, expand=True)
elif orientation == 7:
image = image.transpose(Image.FLIP_LEFT_RIGHT).rotate(270, expand=True)
elif orientation == 8:
image = image.rotate(90, expand=True)
# reset orientation to 1
if orientation != 1:
logger.info(f"reset orientation from {orientation} to 1")
exif[orientation_key] = 1
changed = True
if not changed:
return data
output = io.BytesIO()
image.save(output, format=image.format or "JPEG", exif=exif.tobytes())
return output.getvalue()
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")
return data
async def preload_data(inp, inp_type, typ, val):
try:
if typ == "url":
......@@ -102,27 +169,10 @@ async def preload_data(inp, inp_type, typ, val):
# check if valid image bytes
if inp_type == "IMAGE":
image = Image.open(io.BytesIO(data))
logger.info(f"load image: {image.size}")
assert image.size[0] > 0 and image.size[1] > 0, "image is empty"
data = await asyncio.to_thread(format_image_data, data)
elif inp_type == "AUDIO":
if typ != "stream":
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}")
# 尝试使用其他方法验证音频文件
# 检查文件头是否为有效的音频格式
if len(data) < 4:
raise ValueError("Audio file too short")
# 检查常见的音频文件头
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 = await asyncio.to_thread(format_audio_data, data)
else:
raise Exception(f"cannot parse inp_type={inp_type} data")
return data
......@@ -152,3 +202,21 @@ def check_params(params, raw_inputs, raw_outputs, types):
assert stream_audio, "stream audio is not supported, please set env STREAM_AUDIO=1"
elif types[x] == "VIDEO":
assert stream_video, "stream video is not supported, please set env STREAM_VIDEO=1"
if __name__ == "__main__":
# https://github.com/recurser/exif-orientation-examples
exif_dir = "/data/nvme0/liuliang1/exif-orientation-examples"
out_dir = "/data/nvme0/liuliang1/exif-orientation-examples/outs"
os.makedirs(out_dir, exist_ok=True)
for base_name in ["Landscape", "Portrait"]:
for i in range(9):
fin_name = os.path.join(exif_dir, f"{base_name}_{i}.jpg")
fout_name = os.path.join(out_dir, f"{base_name}_{i}_formatted.jpg")
logger.info(f"format image: {fin_name} -> {fout_name}")
with open(fin_name, "rb") as f:
data = f.read()
data = format_image_data(data)
with open(fout_name, "wb") as f:
f.write(data)
import queue
import random
import signal
import socket
import subprocess
......@@ -18,14 +19,13 @@ class VARecorder:
livestream_url: str,
fps: float = 16.0,
sample_rate: int = 16000,
audio_port: int = 30200,
video_port: int = 30201,
):
self.livestream_url = livestream_url
self.fps = fps
self.sample_rate = sample_rate
self.audio_port = audio_port
self.video_port = video_port
self.audio_port = random.choice(range(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
......@@ -116,6 +116,58 @@ class VARecorder:
finally:
logger.info("Video push worker thread stopped")
def start_ffmpeg_process_local(self):
"""Start ffmpeg process that connects to our TCP sockets"""
ffmpeg_cmd = [
"/opt/conda/bin/ffmpeg",
"-re",
"-f",
"s16le",
"-ar",
str(self.sample_rate),
"-ac",
"1",
"-i",
f"tcp://127.0.0.1:{self.audio_port}",
"-f",
"rawvideo",
"-re",
"-pix_fmt",
"rgb24",
"-r",
str(self.fps),
"-s",
f"{self.width}x{self.height}",
"-i",
f"tcp://127.0.0.1:{self.video_port}",
"-ar",
"44100",
"-b:v",
"4M",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-tune",
"zerolatency",
"-g",
f"{self.fps}",
"-pix_fmt",
"yuv420p",
"-f",
"mp4",
self.livestream_url,
"-y",
"-loglevel",
"info",
]
try:
self.ffmpeg_process = subprocess.Popen(ffmpeg_cmd)
logger.info(f"FFmpeg streaming started with PID: {self.ffmpeg_process.pid}")
logger.info(f"FFmpeg command: {' '.join(ffmpeg_cmd)}")
except Exception as e:
logger.error(f"Failed to start FFmpeg: {e}")
def start_ffmpeg_process_rtmp(self):
"""Start ffmpeg process that connects to our TCP sockets"""
ffmpeg_cmd = [
......@@ -240,7 +292,7 @@ class VARecorder:
elif self.livestream_url.startswith("http"):
self.start_ffmpeg_process_whip()
else:
raise Exception(f"Unsupported livestream URL: {self.livestream_url}")
self.start_ffmpeg_process_local()
self.audio_thread = threading.Thread(target=self.audio_worker)
self.video_thread = threading.Thread(target=self.video_worker)
self.audio_thread.start()
......@@ -353,12 +405,13 @@ if __name__ == "__main__":
recorder = VARecorder(
# livestream_url="rtmp://localhost/live/test",
livestream_url="https://reverse.st-oc-01.chielo.org/10.5.64.49:8000/rtc/v1/whip/?app=live&stream=ll_test_video&eip=127.0.0.1:8000",
# livestream_url="https://reverse.st-oc-01.chielo.org/10.5.64.49:8000/rtc/v1/whip/?app=live&stream=ll_test_video&eip=127.0.0.1:8000",
livestream_url="/path/to/output_video.mp4",
fps=fps,
sample_rate=sample_rate,
)
audio_path = "/mtc/liuliang1/lightx2v/test_deploy/media_test/test_b_2min.wav"
audio_path = "/path/to/test_b_2min.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)
......
......@@ -236,6 +236,11 @@ async def prepare_subtasks(task_id):
await server_monitor.pending_subtasks_add(sub["queue"], sub["task_id"])
def format_task(task):
task["status"] = task["status"].name
task["model_cls"] = model_pipelines.outer_model_name(task["model_cls"])
@app.get("/api/v1/model/list")
async def api_v1_model_list(user=Depends(verify_user_access)):
try:
......@@ -254,6 +259,7 @@ async def api_v1_task_submit(request: Request, user=Depends(verify_user_access))
return error_response(msg, 400)
params = await request.json()
keys = [params.pop("task"), params.pop("model_cls"), params.pop("stage")]
keys[1] = model_pipelines.inner_model_name(keys[1])
assert len(params["prompt"]) > 0, "valid prompt is required"
# get worker infos, model input names
......@@ -303,7 +309,7 @@ async def api_v1_task_query(request: Request, user=Depends(verify_user_access)):
task, subtasks = await task_manager.query_task(task_id, user["user_id"], only_task=False)
if task is not None:
task["subtasks"] = await server_monitor.format_subtask(subtasks)
task["status"] = task["status"].name
format_task(task)
tasks.append(task)
return {"tasks": tasks}
......@@ -313,7 +319,7 @@ async def api_v1_task_query(request: Request, user=Depends(verify_user_access)):
if task is None:
return error_response(f"Task {task_id} not found", 404)
task["subtasks"] = await server_monitor.format_subtask(subtasks)
task["status"] = task["status"].name
format_task(task)
return task
except Exception as e:
traceback.print_exc()
......@@ -344,7 +350,7 @@ async def api_v1_task_list(request: Request, user=Depends(verify_user_access)):
tasks = await task_manager.list_tasks(**query_params)
for task in tasks:
task["status"] = task["status"].name
format_task(task)
return {"tasks": tasks, "pagination": page_info}
except Exception as e:
......@@ -457,12 +463,18 @@ async def api_v1_task_cancel(request: Request, user=Depends(verify_user_access))
async def api_v1_task_resume(request: Request, user=Depends(verify_user_access)):
try:
task_id = request.query_params["task_id"]
task = await task_manager.query_task(task_id, user_id=user["user_id"])
keys = [task["task_type"], task["model_cls"], task["stage"]]
if not model_pipelines.check_item_by_keys(keys):
return error_response(f"Model {keys} is not supported now, please submit a new task", 400)
ret = await task_manager.resume_task(task_id, user_id=user["user_id"], all_subtask=False)
if ret:
if ret is True:
await prepare_subtasks(task_id)
return {"msg": "ok"}
else:
return error_response(f"Task {task_id} resume failed", 400)
return error_response(f"Task {task_id} resume failed: {ret}", 400)
except Exception as e:
traceback.print_exc()
return error_response(str(e), 500)
......@@ -605,7 +617,7 @@ async def api_v1_worker_ping_subtask(request: Request, valid=Depends(verify_work
queue = params.pop("queue")
task = await task_manager.query_task(task_id)
if task["status"] != TaskStatus.RUNNING:
if task is None or task["status"] != TaskStatus.RUNNING:
return {"msg": "delete"}
assert await task_manager.ping_subtask(task_id, worker_name, identity)
......@@ -714,27 +726,18 @@ async def api_v1_template_list(request: Request, valid=Depends(verify_user_acces
if page <= total_pages:
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
all_images.sort(key=lambda x: x)
all_audios.sort(key=lambda x: x)
all_videos.sort(key=lambda x: x)
for image in all_images[start_idx:end_idx]:
url = await data_manager.presign_template_url("images", image)
if url is None:
url = f"./assets/template/images/{image}"
paginated_image_templates.append({"filename": image, "url": url})
for audio in all_audios[start_idx:end_idx]:
url = await data_manager.presign_template_url("audios", audio)
if url is None:
url = f"./assets/template/audios/{audio}"
paginated_audio_templates.append({"filename": audio, "url": url})
for video in all_videos[start_idx:end_idx]:
url = await data_manager.presign_template_url("videos", video)
if url is None:
url = f"./assets/template/videos/{video}"
paginated_video_templates.append({"filename": video, "url": url})
async def handle_media(media_type, media_names, paginated_media_templates):
media_names.sort(key=lambda x: x)
for media_name in media_names[start_idx:end_idx]:
url = await data_manager.presign_template_url(media_type, media_name)
if url is None:
url = f"./assets/template/{media_type}/{media_name}"
paginated_media_templates.append({"filename": media_name, "url": url})
await handle_media("images", all_images, paginated_image_templates)
await handle_media("audios", all_audios, paginated_audio_templates)
await handle_media("videos", all_videos, paginated_video_templates)
return {
"templates": {"images": paginated_image_templates, "audios": paginated_audio_templates, "videos": paginated_video_templates},
......@@ -760,6 +763,7 @@ async def api_v1_template_tasks(request: Request, valid=Depends(verify_user_acce
page_size = min(page_size, 100)
all_templates = []
all_categories = set()
template_files = await data_manager.list_template_files("tasks")
template_files = [] if template_files is None else template_files
......@@ -767,6 +771,8 @@ async def api_v1_template_tasks(request: Request, valid=Depends(verify_user_acce
try:
bytes_data = await data_manager.load_template_file("tasks", template_file)
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"]:
continue
if search is not None and search not in template_data["task"]["params"]["prompt"] + template_data["task"]["params"]["negative_prompt"] + template_data["task"][
......@@ -787,7 +793,7 @@ async def api_v1_template_tasks(request: Request, valid=Depends(verify_user_acce
end_idx = start_idx + page_size
paginated_templates = all_templates[start_idx:end_idx]
return {"templates": paginated_templates, "pagination": {"page": page, "page_size": page_size, "total": total_templates, "total_pages": total_pages}}
return {"templates": paginated_templates, "pagination": {"page": page, "page_size": page_size, "total": total_templates, "total_pages": total_pages}, "categories": list(all_categories)}
except Exception as e:
traceback.print_exc()
......
......@@ -92,6 +92,11 @@ class WorkerClient:
if elapse > self.offline_timeout:
logger.warning(f"Worker {self.identity} {self.queue} offline timeout2: {elapse:.2f} s")
return False
# fetching too long
elif self.status == WorkerStatus.FETCHING:
if elapse > self.fetching_timeout:
logger.warning(f"Worker {self.identity} {self.queue} fetching timeout: {elapse:.2f} s")
return False
return True
......@@ -111,7 +116,7 @@ class ServerMonitor:
self.fetching_timeout = self.config.get("fetching_timeout", 1000)
for queue in self.all_queues:
self.subtask_run_timeouts[queue] = self.config["subtask_running_timeouts"].get(queue, 60)
self.subtask_run_timeouts[queue] = self.config["subtask_running_timeouts"].get(queue, 3600)
self.subtask_created_timeout = self.config["subtask_created_timeout"]
self.subtask_pending_timeout = self.config["subtask_pending_timeout"]
self.worker_avg_window = self.config["worker_avg_window"]
......
......@@ -221,7 +221,6 @@
transform: translateY(-1px);
box-shadow: 0 14px 40px rgba(140, 110, 255, 0.55);
}
/* 修复布局问题 */
.task-type-btn {
padding: 0.75rem 1rem;
......@@ -577,6 +576,53 @@
padding: 0 1rem;
}
/* 移动端全屏显示 */
@media (max-width: 768px) {
#task-creator {
width: 100%;
padding: 0 0.5rem;
}
#inspiration-gallery {
width: 100%;
padding: 0 0.5rem;
}
/* 任务详情面板移动端全屏 */
.task-detail-panel {
width: 100%;
padding: 0 0.5rem;
}
/* 任务运行面板移动端全屏 */
.task-running-panel {
width: 100%;
padding: 0 0.5rem;
}
/* 任务失败面板移动端全屏 */
.task-failed-panel {
width: 100%;
padding: 0 0.5rem;
}
/* 任务取消面板移动端全屏 */
.task-cancelled-panel {
width: 100%;
padding: 0 0.5rem;
}
/* 移动端内容区域调整 */
.content-area {
padding: 0.5rem !important;
}
/* 移动端创作区域调整 */
.creation-area-container {
padding: 0.5rem;
}
}
/* 任务详情面板全屏 */
.task-detail-panel {
max-width: none;
......@@ -755,14 +801,9 @@
@media (max-width: 640px) {
/* 通用移动端样式 */
.mobile-bottom-nav {
position: fixed !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
width: 100% !important;
height: auto !important;
padding: 0 !important;
background: rgba(11, 10, 32, 0.95) !important;
backdrop-filter: blur(20px) !important;
border-top: 1px solid rgba(154, 114, 255, 0.2) !important;
z-index: 50 !important;
......@@ -784,10 +825,6 @@
flex-shrink: 0 !important;
}
.mobile-content {
margin-bottom: 5rem !important;
}
.sms-login-form .input-group {
flex-direction: row !important;
gap: 12px !important;
......@@ -820,19 +857,7 @@
}
/* 左侧功能区在移动端移动到下方 */
.relative.w-20.pl-5.flex.flex-col.z-10 {
position: fixed !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
width: 100% !important;
height: auto !important;
padding: 0 !important;
background: rgba(11, 10, 32, 0.95) !important;
backdrop-filter: blur(20px) !important;
border-top: 1px solid rgba(154, 114, 255, 0.2) !important;
z-index: 50 !important;
}
/* 功能导航在移动端水平排列 */
.p-2.flex.flex-col.justify-center.h-full {
......@@ -879,10 +904,6 @@
flex-shrink: 0 !important;
}
/* 主内容区域在移动端占满全屏 */
.flex-1.flex.flex-col.min-h-0 {
margin-bottom: 5rem !important; /* 为底部导航留出空间 */
}
/* 历史任务区域调整 */
.flex-1.overflow-y-auto.p-10.content-area.main-scrollbar {
......@@ -951,11 +972,6 @@
gap: 1rem !important;
}
/* 模板卡片在移动端调整 */
.bg-dark-light.rounded-xl.overflow-hidden {
margin: 0 !important;
}
/* 模态框在移动端调整 */
.fixed.inset-0.z-50 {
padding: 1rem !important;
......@@ -2444,7 +2460,7 @@
</div>
<!-- 登录页面 -->
<div v-if="isLoggedIn === false" class="login-container w-full">
<div v-if="isLoggedIn === false || currentView === 'login'" class="login-container w-full">
<!-- 浮动粒子背景 -->
<div class="floating-particles">
<div class="particle"></div>
......@@ -2573,7 +2589,7 @@
</div>
<!-- 主应用页面 -->
<div v-if="isLoggedIn === true" class="main-container">
<div v-if="isLoggedIn === true && currentView !== 'login'" class="main-container">
<!-- 浮动粒子背景 -->
<div class="floating-particles">
<div class="particle"></div>
......@@ -2603,10 +2619,6 @@
<!-- 顶部栏 -->
<div class="top-bar">
<div class="top-bar-content">
<!-- 左侧图标 -->
<!-- <div class="top-bar-left">
<img src="/icon/seko_logo_white.svg" alt="SekoTalk Logo" class="top-bar-logo">
</div> -->
<div class="top-bar-left">
<i class="fas fa-video text-gradient-icon mr-2 text-xl"></i>
<span class="text-lg">LightX2V</span>
......@@ -2793,41 +2805,48 @@
</div>
<!-- 可滚动的任务列表区域 -->
<div class="flex-1 overflow-y-auto history-tasks-scroll min-h-0 p-4"
<div class="flex-1 overflow-y-auto main-scrollbar min-h-0 p-4"
style="overflow-x: visible;">
<div v-cloak>
<div v-if="filteredTasks.length === 0"
class="flex-col items-center justify-center py-12 text-center">
<p class="text-gray-400 text-sm">{{ t('noHistoryTasks') }}</p>
<p class="text-gray-500 text-xs mt-1">{{ t('startToCreateYourFirstAIVideo') }}</p>
</div>
</div>
<div v-else
class="columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-6">
class="columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-3">
<!-- 任务卡片 -->
<div v-for="task in filteredTasks" :key="task.task_id"
class="cursor-pointer break-inside-avoid mb-6 group relative bg-dark-light rounded-xl overflow-hidden border border-gray-700/50 hover:border-laser-purple/40 transition-all duration-300 hover:shadow-laser/20"
class="break-inside-avoid mb-3 group relative bg-dark-light rounded-xl overflow-hidden border border-gray-700/50 hover:border-laser-purple/40 transition-all duration-300 hover:shadow-laser/20">
<!-- 缩略图区域 -->
<div class="cursor-pointer bg-gray-800 relative flex flex-col"
@click="openTaskDetailModal(task)"
:title="viewTaskDetail">
<!-- 缩略图区域 -->
<div class="relative mb-3">
<div class="w-full h-auto bg-dark-light rounded-lg overflow-hidden">
<!-- 成功任务:显示视频动图 -->
<video v-if="task.status === 'SUCCEED'"
:src="getTaskVideoUrl(task.task_id, 'output_video')"
:poster="getTaskImageUrl(task)"
class="w-full h-auto object-cover group-hover:scale-105 transition-transform duration-300"
playsinline webkit-playsinline
:title="t('viewTaskDetails')">
<!-- 视频预览 -->
<!-- 成功任务:显示视频动图 -->
<video v-if="task.status === 'SUCCEED' && task.outputs?.output_video"
:src="getTaskFileUrlSync(task.task_id, 'output_video')"
:poster="getTaskFileUrlSync(task.task_id, 'input_image')"
class="w-full h-auto object-contain group-hover:scale-105 transition-transform duration-300"
preload="auto" playsinline webkit-playsinline
@mouseenter="playVideo($event)"
@mouseleave="pauseVideo($event)"
@loadeddata="onVideoLoaded($event)"
@ended="onVideoEnded($event)"
@error="onVideoError($event)"></video>
<!-- 其他状态:显示输入图片或占位符 -->
<img v-else
:src="getTaskImageUrl(task)" :alt="t('taskPreview')"
class="w-full h-auto object-cover bg-dark-light transition-transform duration-300 group-hover:scale-105"
@error="handleThumbnailError" />
</div>
<!-- 其他状态:显示输入图片或占位符 -->
<img v-else="task.inputs?.input_image"
:src="getTaskFileUrlSync(task.task_id, 'input_image')"
class="w-full h-auto object-contain group-hover:scale-105 transition-transform duration-300"
@error="handleThumbnailError" />
<!-- 移动端播放按钮 -->
<button v-if="task.status === 'SUCCEED'"
@click.stop="toggleVideoPlay($event)"
class="md:hidden absolute bottom-3 left-1/2 transform -translate-x-1/2 w-10 h-10 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center text-white hover:bg-black/70 transition-colors z-20">
<i class="fas fa-play text-sm"></i>
</button>
<!-- 状态指示器 -->
<div class="absolute top-2 right-2">
......@@ -2837,43 +2856,43 @@
</span>
</div>
<!-- 悬停时显示的操作按钮 -->
<!-- 悬停时显示的操作按钮(桌面端) -->
<div
class="absolute bottom-2 left-1/2 transform -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none">
<div class="flex space-x-3 pointer-events-auto">
class="hidden md:block absolute bottom-2 left-1/2 transform -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none">
<div class="flex space-x-2 sm:space-x-3 pointer-events-auto">
<button
v-if="['CREATED', 'PENDING', 'RUNNING','SUCCEED', 'FAILED', 'CANCEL'].includes(task.status)"
@click.stop="reuseTask(task)"
class="w-10 h-10 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center text-white hover:bg-white/30 transition-colors"
class="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center text-white hover:bg-white/30 transition-colors"
:title="t('reuseTask')">
<i class="fas fa-copy text-lg"></i>
<i class="fas fa-copy text-sm sm:text-lg"></i>
</button>
<button
v-if="['CREATED', 'PENDING', 'RUNNING'].includes(task.status)"
@click.stop="cancelTask(task.task_id)"
class="w-10 h-10 rounded-full bg-laser-purple/80 backdrop-blur-sm flex items-center justify-center text-white hover:bg-laser-purple transition-colors"
:title="t('cancelTask')">
<i class="fas fa-times text-lg"></i>
<i class="fas fa-times text-sm sm:text-lg"></i>
</button>
<button
v-if="['SUCCEED', 'FAILED', 'CANCEL'].includes(task.status)"
@click.stop="resumeTask(task.task_id)"
class="w-10 h-10 rounded-full bg-laser-purple/80 backdrop-blur-sm flex items-center justify-center text-white hover:bg-laser-purple transition-colors"
class="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-laser-purple/80 backdrop-blur-sm flex items-center justify-center text-white hover:bg-laser-purple transition-colors"
:title="t('retryTask')">
<i class="fas fa-redo text-lg"></i>
<i class="fas fa-redo text-sm sm:text-lg"></i>
</button>
<button v-if="task.status === 'SUCCEED'"
@click.stop="downloadTaskOutput(task)"
class="w-10 h-10 rounded-full bg-laser-purple/80 backdrop-blur-sm flex items-center justify-center text-white hover:bg-laser-purple transition-colors"
@click.stop="downloadFile(task.outputs?.output_video)"
class="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-laser-purple/80 backdrop-blur-sm flex items-center justify-center text-white hover:bg-laser-purple transition-colors"
:title="t('downloadTask')">
<i class="fas fa-download text-lg"></i>
<i class="fas fa-download text-sm sm:text-lg"></i>
</button>
<button
v-if="['SUCCEED', 'FAILED', 'CANCEL'].includes(task.status)"
@click.stop="deleteTask(task.task_id)"
class="w-10 h-10 rounded-full bg-red-400/80 backdrop-blur-sm flex items-center justify-center text-white hover:bg-red-500 transition-colors"
class="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-red-400/80 backdrop-blur-sm flex items-center justify-center text-white hover:bg-red-500 transition-colors"
:title="t('deleteTask')">
<i class="fas fa-trash text-lg"></i>
<i class="fas fa-trash text-sm sm:text-lg"></i>
</button>
</div>
</div>
......@@ -2892,7 +2911,7 @@
</div>
</div>
</div>
</div>
</div>
</div>
</div>
......@@ -3038,7 +3057,7 @@
</div>
</div>
<div class="overflow-y-auto flex-1 max-h-[60vh] main-scrollbar">
<div v-if="imageTemplates.length > 0" class="columns-4 gap-4">
<div v-if="imageTemplates.length > 0" class="columns-2 sm:columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-4">
<div v-for="template in imageTemplates" :key="template.filename"
@click="selectImageTemplate(template)"
class="break-inside-avoid mb-4 relative group cursor-pointer rounded-lg border border-gray-700 hover:border-laser-purple/50 transition-all">
......@@ -3196,8 +3215,9 @@
</div>
</div>
</div>
</div>
</div>
<!-- 任务创建面板 -->
<div class="max-w-4xl mx-auto" id="task-creator">
......@@ -3354,27 +3374,34 @@
<div class="flex justify-between items-center mb-2">
<label class="block text-sm text-gray-400 flex items-center">
{{ t('image') }}
<button
@click.stop="showImageTemplates = true; mediaModalTab = 'history'; getImageHistory()"
class="text-xs hover:text-gradient-icon transition-colors pl-3"
:title="t('templates')">
<i class="fas fa-lightbulb mr-1"></i>
</button>
</label>
</div>
<!-- 上传图片 -->
<div class="upload-area"
@click="triggerImageUpload">
>
<!-- 默认上传界面 -->
<div v-if="!getCurrentImagePreview()" class="upload-content">
<div class="upload-icon">
<i class="fas fa-image text-gradient-icon text-xl"></i>
</div>
<p class="text-xs text-gray-400 mb-4">{{ t('supportedImageFormats') }}</p>
<div class="flex gap-2">
<button
class="btn-primary px-4 py-1.5 rounded-lg transition-all flex-1">{{ t('uploadImage') }}</button>
<p class="text-base text-white font-bold mb-4">{{ t('uploadImage') }}</p>
<p class="text-xs text-gray-400 mb-4">{{ t('supportedImageFormats') }}</p>
<div class="flex items-center justify-center space-x-6">
<div class="flex flex-col items-center space-y-2">
<button
class="w-12 h-12 flex items-center justify-center bg-white/15 text-white p-3 rounded-full transition-all duration-200 hover:scale-110 shadow-lg"
@click="triggerImageUpload"
:title="t('uploadImage')">
<i class="fas fa-upload text-lg"></i>
</button>
<span class="text-xs text-gray-300">{{ t('upload') }}</span>
</div>
<div class="flex flex-col items-center space-y-2">
<button
@click.stop="showImageTemplates = true; mediaModalTab = 'history'; getImageHistory()"
class="w-12 h-12 flex items-center justify-center bg-white/15 text-white p-3 rounded-full transition-all duration-200 hover:scale-110 shadow-lg"
:title="t('templates')">
<i class="fas fa-history text-lg"></i>
</button>
<span class="text-xs text-gray-300">{{ t('templates') }}</span>
</div>
</div>
</div>
......@@ -3385,7 +3412,7 @@
<!-- 悬停时显示的操作按钮,位置在中下方 -->
<div
class="absolute inset-x-0 bottom-4 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
class="absolute inset-x-0 bottom-4 flex items-center justify-center opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-300">
<div class="flex space-x-3">
<button @click.stop="triggerImageUpload"
class="w-12 h-12 flex items-center justify-center bg-white/15 text-white p-3 rounded-full transition-all duration-200 hover:scale-110 shadow-lg"
......@@ -3411,45 +3438,73 @@
<div class="flex justify-between items-center mb-2">
<label class="block text-sm text-gray-400 flex items-center">
{{ t('audio') }}
<button
@click.stop="showAudioTemplates = true; mediaModalTab = 'history'; getAudioHistory()"
class="text-xs hover:text-gradient-icon transition-colors pl-3"
:title="t('templates')">
<i class="fas fa-lightbulb mr-1"></i>
</button>
</label>
</div>
<!-- 上传音频 -->
<div class="upload-area">
<!-- 默认上传界面 -->
<div v-if="!getCurrentAudioPreview()" class="upload-content"
@click="triggerAudioUpload">
<div class="upload-icon">
<i class="fas fa-microphone text-gradient-icon text-xl"></i>
</div>
>
<p class="text-base text-white font-bold mb-4">{{ t('uploadAudio') }}</p>
<p class="text-xs text-gray-400 mb-4">{{ t('supportedAudioFormats') }}</p>
<div class="flex gap-2">
<button
class="btn-primary px-4 py-1.5 rounded-lg transition-all flex-1">{{ t('uploadAudio') }}</button>
<div class="flex items-center justify-center space-x-6">
<div class="flex flex-col items-center space-y-2">
<button
@click.stop="showAudioTemplates = true; mediaModalTab = 'history'; getAudioHistory()"
class="w-12 h-12 flex items-center justify-center bg-white/15 text-white p-3 rounded-full transition-all duration-200 hover:scale-110 shadow-lg"
:title="t('templates')">
<i class="fas fa-history text-lg"></i>
</button>
<span class="text-xs text-gray-300">{{ t('templates') }}</span>
</div>
<div class="flex flex-col items-center space-y-2">
<button
class="w-12 h-12 flex items-center justify-center bg-white/15 text-white p-3 rounded-full transition-all duration-200 hover:scale-110 shadow-lg"
@click="triggerAudioUpload"
:title="t('uploadAudio')">
<i class="fas fa-upload text-lg"></i>
</button>
<span class="text-xs text-gray-300">{{ t('upload') }}</span>
</div>
<div class="flex flex-col items-center space-y-2">
<button
@click.stop="isRecording ? stopRecording() : startRecording()"
class="w-12 h-12 flex items-center justify-center bg-white/15 text-white p-3 rounded-full transition-all duration-200 hover:scale-110 shadow-lg"
:class="{ 'bg-red-500/80': isRecording }"
:title="isRecording ? t('stopRecording') : t('recordAudio')">
<i class="fas fa-microphone text-lg" :class="{ 'animate-pulse': isRecording }"></i>
</button>
<span class="text-xs text-gray-300">{{ t('recordAudio') }}</span>
</div>
<div v-if="isRecording && recordingDuration > 0" class="flex flex-col items-center space-y-2">
<div class="text-white text-xs font-medium bg-black/20 px-2 py-1 rounded-full">
{{ formatRecordingDuration(recordingDuration) }}
</div>
<span class="text-xs text-gray-300">{{ t('recording') }}</span>
</div>
</div>
</div>
<!-- 音频预览 -->
<div v-if="getCurrentAudioPreview()" class="audio-preview group">
<audio controls class="w-full h-full">
<audio controls class="w-full h-full" @error="handleAudioError" @loadstart="console.log('音频开始加载')" @canplay="console.log('音频可以播放')">
<source :src="getCurrentAudioPreviewUrl()" :type="getAudioMimeType()" preload="metadata">
</audio>
<!-- 悬停时显示的操作按钮,位置在中下方 -->
<div
class="absolute inset-x-0 bottom-4 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300 bg-black/20">
class="absolute inset-x-0 bottom-4 flex items-center justify-center opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-300">
<div class="flex space-x-3">
<button @click.stop="triggerAudioUpload"
class="w-12 h-12 flex items-center justify-center bg-white/15 text-white p-3 rounded-full transition-all duration-200 hover:scale-110 shadow-lg"
:title="t('reupload')">
<i class="fas fa-upload text-lg"></i>
</button>
<button @click.stop="isRecording ? stopRecording() : startRecording()"
class="w-12 h-12 flex items-center justify-center bg-white/15 text-white p-3 rounded-full transition-all duration-200 hover:scale-110 shadow-lg"
:title="isRecording ? t('stopRecording') : t('recordAudio')"
:class="{ 'bg-red-500/80': isRecording }">
<i class="fas fa-microphone text-lg" :class="{ 'animate-pulse': isRecording }"></i>
</button>
<button @click.stop="removeAudio"
class="w-12 h-12 flex items-center justify-center bg-white/15 text-white p-3 rounded-full transition-all duration-200 hover:scale-110 shadow-lg"
......@@ -3477,7 +3532,7 @@
</button>
</label>
<div class="text-xs text-gray-400">
{{ getCurrentForm().prompt?.length || 0 }} / 500
{{ getCurrentForm().prompt?.length || 0 }} / 1000
</div>
</div>
<div class="relative group cursor-pointer">
......@@ -3504,7 +3559,9 @@
<div class="flex justify-center mt-2">
<button @click="submitTask" :disabled="submitting"
class="btn-primary flex items-center justify-center px-8 py-3 rounded-lg text-lg transition-all shadow-lg">
<i class="fas fa-play text-xl mr-2"></i>
<i class="fas fa-spinner fa-spin text-xl mr-2" :class="{ 'hidden': !submitting }"></i>
<i class="fas fa-play text-xl mr-2" :class="{ 'hidden': submitting }"></i>
{{ submitting ? t('submitting') : t('generateVideo') }}
</button>
</div>
......@@ -3549,13 +3606,13 @@
<!-- 分类筛选 -->
<div class="flex gap-2 flex-wrap">
<button v-for="category in dynamicInspirationCategories" :key="category.id"
@click="selectInspirationCategory(category.id)"
:class="selectedInspirationCategory === category.id
<button v-for="category in InspirationCategories" :key="category"
@click="selectInspirationCategory(category)"
:class="selectedInspirationCategory === category
? 'bg-laser-purple/20 text-white border-laser-purple/40'
: 'bg-dark-light text-gray-400 hover:text-white hover:bg-dark-light/80'"
class="px-4 py-2 rounded-lg border border-transparent transition-all duration-200 text-sm font-medium">
{{ category.name }}
{{ category }}
</button>
</div>
</div>
......@@ -3611,12 +3668,12 @@
</div>
</div>
<!-- 灵感内容网格 -->
<div class="flex-1 overflow-y-auto history-tasks-scroll min-h-0 p-4"
<div class="flex-1 overflow-y-auto main-scrollbar min-h-0 p-4"
style="overflow-x: visible;">
<div class="columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-6">
<div class="columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-3">
<!-- 灵感卡片 -->
<div v-for="item in filteredInspirationItems" :key="item.task_id"
class="break-inside-avoid mb-6 group relative bg-dark-light rounded-xl overflow-hidden border border-gray-700/50 hover:border-laser-purple/40 transition-all duration-300 hover:shadow-laser/20">
<div v-for="item in inspirationItems" :key="item.task_id"
class="break-inside-avoid mb-3 group relative bg-dark-light rounded-xl overflow-hidden border border-gray-700/50 hover:border-laser-purple/40 transition-all duration-300 hover:shadow-laser/20">
<!-- 视频缩略图区域 -->
<div class="cursor-pointer bg-gray-800 relative flex flex-col"
@click="previewTemplateDetail(item)"
......@@ -3629,45 +3686,42 @@
preload="auto" playsinline webkit-playsinline
@mouseenter="playVideo($event)" @mouseleave="pauseVideo($event)"
@loadeddata="onVideoLoaded($event)"
@ended="onVideoEnded($event)"
@error="onVideoError($event)"></video>
<!-- 图片缩略图 -->
<img v-else="item?.inputs?.input_image"
<img v-else
:src="getTemplateFileUrl(item.inputs.input_image,'images')"
:alt="item.params?.prompt || '模板图片'"
class="w-full h-auto object-contain group-hover:scale-105 transition-transform duration-300"
@error="handleThumbnailError" />
<!-- 悬浮操作按钮(下方居中) -->
<!-- 移动端播放按钮 -->
<button v-if="item?.outputs?.output_video"
@click.stop="toggleVideoPlay($event)"
class="md:hidden absolute bottom-3 left-1/2 transform -translate-x-1/2 w-10 h-10 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center text-white hover:bg-black/70 transition-colors z-20">
<i class="fas fa-play text-sm"></i>
</button>
<!-- 悬浮操作按钮(下方居中,仅桌面端) -->
<div
class="absolute bottom-3 left-1/2 transform -translate-x-1/2 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none z-10 w-full">
class="hidden md:flex absolute bottom-3 left-1/2 transform -translate-x-1/2 items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none z-10 w-full">
<div class="flex space-x-3 pointer-events-auto">
<button @click="applyTemplateImage(item)"
class="w-10 h-10 rounded-full bg-laser-purple/80 backdrop-blur-sm flex items-center justify-center text-white hover:bg-laser-purple transition-colors"
<button @click.stop="applyTemplateImage(item)"
class="w-10 h-10 rounded-full bg-laser-purple backdrop-blur-sm flex items-center justify-center text-white hover:bg-laser-purple transition-colors"
:title="t('applyImage')">
<i class="fas fa-image text-sm"></i>
</button>
<button @click="applyTemplateAudio(item)"
class="w-10 h-10 rounded-full bg-laser-purple/80 backdrop-blur-sm flex items-center justify-center text-white hover:bg-laser-purple transition-colors"
<button @click.stop="applyTemplateAudio(item)"
class="w-10 h-10 rounded-full bg-laser-purple backdrop-blur-sm flex items-center justify-center text-white hover:bg-laser-purple transition-colors"
:title="t('applyAudio')">
<i class="fas fa-music text-sm"></i>
</button>
<button @click="useTemplate(item)"
class="w-10 h-10 rounded-full bg-laser-purple/80 backdrop-blur-sm flex items-center justify-center text-white hover:bg-laser-purple transition-colors"
<button @click.stop="useTemplate(item)"
class="w-10 h-10 rounded-full bg-laser-purple backdrop-blur-sm flex items-center justify-center text-white hover:bg-laser-purple transition-colors"
:title="t('useTemplate')">
<i class="fas fa-clone text-sm"></i>
</button>
</div>
</div>
</div> <!-- 固定高度内容信息和标签,合并为一个组件,最多两行文字 -->
<div class="p-4 flex flex-col justify-between h-20">
<div class="flex flex-wrap gap-1">
<span v-for="tag in item.tags" :key="tag"
class="px-2 py-1 bg-gray-700/50 text-gray-300 text-xs rounded-full">
{{ tag }}
</span>
</div>
</div>
</div>
</div>
</div>
......@@ -3679,35 +3733,7 @@
</div>
</div>
<!-- 增强的提示消息系统 -->
<div v-cloak>
<div v-if="alert.show"
class="fixed top-4 left-1/2 transform -translate-x-1/2 z-[9999] max-w-xs w-full px-4"
: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>
<!-- 自定义确认对话框 -->
<div v-cloak>
......@@ -3884,7 +3910,7 @@
<div v-if="showTemplateDetailModal"
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
@click="closeTemplateDetailModal">
<div class="bg-secondary rounded-xl w-[80%] h-[100vh]" @click.stop>
<div class="bg-secondary rounded-xl w-[95%] sm:w-[90%] md:w-[80%] h-[95vh] sm:h-[100vh]" @click.stop>
<!-- 弹窗头部 -->
<div class="flex items-center justify-between p-6 border-b border-gray-700">
<h3 class="text-xl font-medium text-white flex items-center">
......@@ -3907,10 +3933,23 @@
</div>
<!-- 弹窗内容 -->
<div class="flex h-[calc(100vh-120px)] overflow-y-auto main-scrollbar">
<!-- 左侧输入素材区域 -->
<div class="w-2/5 border-r border-gray-700">
<div class="p-6 space-y-6">
<div class="flex flex-col lg:flex-row h-[calc(100vh-120px)] overflow-y-auto main-scrollbar">
<!-- 左侧视频播放区域 -->
<div class="flex-1 lg:flex-1">
<div class="p-4 lg:p-6 flex items-center justify-between">
<label class="text-sm font-medium text-white">{{ t('outputVideo') }}</label>
</div>
<div class="w-full h-[50vh] sm:h-[60vh] lg:h-[calc(70vh)] bg-gray-900 rounded-lg overflow-hidden mx-4 lg:mx-6">
<video
:src="getTemplateFileUrl(selectedTemplate.outputs.output_video,'videos')"
:poster="getTemplateFileUrl(selectedTemplate.inputs.input_image,'images')"
class="w-full h-full object-contain" controls
@loadeddata="onVideoLoaded($event)"></video>
</div>
</div>
<!-- 右侧输入素材区域 -->
<div class="w-full lg:w-2/5 lg:border-l border-gray-700 mt-4 lg:mt-0">
<div class="p-4 lg:p-6 space-y-4 lg:space-y-6">
<!-- 输入图片 -->
<div v-if="selectedTemplate?.inputs?.input_image" class="space-y-3">
<div class="flex items-center justify-between">
......@@ -3920,7 +3959,7 @@
{{ t('applyImage') }}
</button>
</div>
<div class="relative w-[50%] bg-gray-800 rounded-lg overflow-hidden cursor-pointer group"
<div class="relative w-full sm:w-3/4 lg:w-[50%] bg-gray-800 rounded-lg overflow-hidden cursor-pointer group"
@click="showImageZoom(getTemplateFileUrl(selectedTemplate.inputs.input_image,'images'))">
<img :src="getTemplateFileUrl(selectedTemplate.inputs.input_image,'images')" :alt="'输入图片'"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" />
......@@ -3967,20 +4006,6 @@
</div>
</div>
<!-- 右侧视频播放区域 -->
<div class="flex-1">
<div class="p-6 flex items-center justify-between">
<label class="text-sm font-medium text-white">{{ t('outputVideo') }}</label>
</div>
<div class="w-full h-[calc(70vh)] bg-gray-900 rounded-lg overflow-hidden">
<video
:src="getTemplateFileUrl(selectedTemplate.outputs.output_video,'videos')"
:poster="getTemplateFileUrl(selectedTemplate.inputs.input_image,'images')"
class="w-full h-full object-contain" controls
@loadeddata="onVideoLoaded($event)"></video>
</div>
</div>
</div>
</div>
</div>
......@@ -4013,7 +4038,7 @@
@click="closeTaskDetailModal">
<!-- 任务完成时的大弹窗 -->
<div v-if="modalTask?.status === 'SUCCEED'"
class="bg-secondary rounded-xl max-w-7xl w-full max-h-[95vh] overflow-hidden" @click.stop>
class="bg-secondary rounded-xl w-[95%] sm:w-[90%] md:w-[80%] h-[95vh] sm:h-[100vh]" @click.stop>
<!-- 弹窗头部 -->
<div class="flex items-center justify-between p-6 border-b border-gray-700">
<h3 class="text-xl font-medium text-white flex items-center">
......@@ -4027,10 +4052,10 @@
</div>
<!-- 左右布局内容 -->
<div class="flex h-[calc(100vh-120px)]">
<div class="flex flex-col lg:flex-row h-[calc(100vh-120px)] main-scrollbar overflow-y-auto">
<!-- 左侧视频播放器 -->
<div class="flex-1 p-6">
<div class="h-full bg-black rounded-xl border border-laser-purple/50 overflow-hidden">
<div class="flex-1 p-6 order-1 lg:order-1">
<div class="h-64 lg:h-full bg-black rounded-xl border border-laser-purple/50 overflow-hidden">
<video
v-if="selectedTaskFiles.outputs.output_video && selectedTaskFiles.outputs.output_video.url"
class="w-full h-full object-contain" controls preload="metadata"
......@@ -4054,7 +4079,7 @@
</div>
<!-- 右侧任务详情 -->
<div class="w-[28rem] overflow-y-auto border-l border-gray-700">
<div class="w-full lg:w-[28rem] border-t lg:border-t-0 lg:border-l border-gray-700 order-2 lg:order-2">
<!-- 任务操作按钮 -->
<div class="flex flex-col space-y-3 items-center">
<button v-if="['CREATED', 'PENDING', 'RUNNING'].includes(modalTask?.status)"
......@@ -4226,7 +4251,7 @@
</div>
<!-- 其他状态的小弹窗 -->
<div v-else class="bg-secondary rounded-xl max-w-4xl w-full max-h-[90vh] overflow-hidden"
<div v-else class="bg-secondary rounded-xl w-[95%] sm:w-[90%] md:w-[80%] h-[95vh] sm:h-[100vh]"
@click.stop>
<!-- 弹窗头部 -->
<div class="flex items-center justify-between p-6 border-b border-gray-700">
......@@ -4242,7 +4267,7 @@
<!-- 弹窗内容 -->
<div class="overflow-y-auto max-h-[calc(90vh-120px)] p-6">
<div class="overflow-y-auto max-h-[calc(90vh-120px)] p-6 main-scrollbar">
<!-- 任务进行中信息 -->
<div v-if="['CREATED', 'PENDING', 'RUNNING'].includes(modalTask?.status)"
class="max-w-4xl mx-auto task-running-panel">
......@@ -4283,7 +4308,7 @@
class="subtask-item">
<div class="subtask-header">
<span class="text-sm font-medium">{{ t('subtask') }} {{ index + 1 }}</span>
<span class="subtask-status" :class="subtask.status.toLowerCase()">
<span class="subtask-status">
{{ getSubtaskStatusText(subtask.status) }}
</span>
</div>
......@@ -4551,6 +4576,36 @@
</div>
</div>
<!-- 增强的提示消息系统 -->
<div v-cloak>
<div v-if="alert.show"
class="fixed top-4 left-1/2 transform -translate-x-1/2 z-[9999] max-w-xs w-full px-4"
: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>
</div>
</div>
......@@ -4567,8 +4622,16 @@
const loginLoading = ref(false);
const initLoading = ref(false);
const downloadLoading = ref(false);
// 录音相关状态
const isRecording = ref(false);
const mediaRecorder = ref(null);
const audioChunks = ref([]);
const recordingDuration = ref(0);
const recordingTimer = ref(null);
const alert = ref({ show: false, message: '', type: 'info' });
// 短信登录相关数据
const phoneNumber = ref('');
const verifyCode = ref('');
......@@ -4611,7 +4674,7 @@
noHistoryTasks: '暂无历史任务',
templateDetail: '模板详情',
viewTemplateDetail: '查看模板详情',
viewTaskDetail: '查看任务详情',
viewTaskDetails: '查看任务详情',
templateInfo: '模板信息',
useTemplate: '使用模板',
model: '模型',
......@@ -4629,12 +4692,13 @@
promptCopied: '提示词已复制到剪贴板',
outputVideo: '输出视频',
audio: '音频',
optional: '(选填)',
// 页面标题
pageTitle: 'LightX2V服务',
pleaseEnterThePromptForVideoGeneration: '请输入视频生成提示词',
describeTheContentStyleSceneOfTheVideo: '描述视频内容、风格、场景等...',
describeTheDigitalHumanImageBackgroundStyleActionRequirements: '描述数字人形象、背景风格、动作要求等...',
describeTheDigitalHumanImageBackgroundStyleActionRequirements: '描述数字人表情、语气、动作等...',
describeTheContentActionRequirementsBasedOnTheImage: '描述基于图片的视频内容、动作要求等...',
loginSubtitle: '一个强大的视频生成平台',
......@@ -4766,19 +4830,30 @@
all: '全部',
// 任务操作
reuseTask: '复用任务',
regenerateTask: '新生成',
cancelTask: '取消任务',
retryTask: '重试任务',
reuseTask: '复用',
regenerateTask: '',
cancelTask: '取消',
retryTask: '重试',
downloadTask: '下载视频',
downloadVideo: '下载视频',
deleteTask: '删除任务',
deleteTask: '删除',
// 任务创建
createVideo: '创建视频',
selectTemplate: '选择模板',
uploadImage: '上传图片',
uploadAudio: '上传音频',
recordAudio: '录音',
recording: '录音中...',
takePhoto: '拍照',
retake: '重拍',
usePhoto: '使用照片',
upload: '上传',
stopRecording: '停止录音',
recordingStarted: '开始录音',
recordingStopped: '录音已停止',
recordingCompleted: '录音完成',
recordingFailed: '录音失败',
enterPrompt: '输入提示词',
selectModel: '选择模型',
startGeneration: '开始生成',
......@@ -4932,7 +5007,7 @@
noHistoryTasks: 'No history tasks',
templateDetail: 'Template detail',
viewTemplateDetail: 'View Template Detail',
viewTaskDetail: 'View Task Detail',
viewTaskDetails: 'View Task Details',
templateInfo: 'Template Info',
useTemplate: 'Use Template',
model: 'Model',
......@@ -4940,12 +5015,13 @@
inputMaterials: 'Input Materials',
inputImage: 'Input Image',
inputAudio: 'Input Audio',
optional: '(Optional)',
// 页面标题
pageTitle: 'LightX2V Service',
pleaseEnterThePromptForVideoGeneration: 'Please enter the prompt for video generation',
describeTheContentStyleSceneOfTheVideo: 'Describe the content, style, and scene of the video...',
describeTheDigitalHumanImageBackgroundStyleActionRequirements: 'Describe the digital human image, background style, and action requirements...',
describeTheDigitalHumanImageBackgroundStyleActionRequirements: 'Describe the digital human expression, tone, and action...',
describeTheContentActionRequirementsBasedOnTheImage: 'Describe the content and action requirements based on the image...',
loginSubtitle: 'A powerful video generation platform',
whatDoYouWantToDo: 'What do you want to do today?',
......@@ -5022,12 +5098,12 @@
videoGeneratingFailed: 'Video Generating Failed',
sorryYourVideoGenerationTaskFailed: 'Sorry Your Video Generation Task Failed',
thisTaskHasBeenCancelledYouCanRegenerateOrViewTheMaterialsYouUploadedBefore: 'This Task Has Been Cancelled You Can Regenerate Or View The Materials You Uploaded Before',
taskCancelled: 'Task Cancelled',
regenerateTask: 'Regenerate Task',
retryTask: 'Retry Task',
downloadTask: 'Download Task',
taskCancelled: 'Cancelled',
regenerateTask: 'Retry',
retryTask: 'Retry',
downloadTask: 'Download Video',
downloadVideo: 'Download Video',
deleteTask: 'Delete Task',
deleteTask: 'Delete',
taskInfo: 'Task Info',
taskID: 'Task ID',
taskType: 'Task Type',
......@@ -5046,13 +5122,13 @@
status: 'Status',
// 任务操作
reuseTask: 'Reuse Task',
regenerateTask: 'Regenerate Task',
retryTask: 'Retry Task',
downloadTask: 'Download Task',
reuseTask: 'Reuse',
regenerateTask: 'Retry',
retryTask: 'Retry',
downloadTask: 'Download Video',
downloadVideo: 'Download Video',
deleteTask: 'Delete Task',
cancelTask: 'Cancel Task',
deleteTask: 'Delete',
cancelTask: 'Cancel',
download: 'Download',
delete: 'Delete',
......@@ -5061,6 +5137,17 @@
selectTemplate: 'Select Template',
uploadImage: 'Upload Image',
uploadAudio: 'Upload Audio',
recordAudio: 'Record Audio',
recording: 'Recording...',
takePhoto: 'Take Photo',
retake: 'Retake',
usePhoto: 'Use Photo',
upload: 'Upload',
stopRecording: 'Stop Recording',
recordingStarted: 'Recording started',
recordingStopped: 'Recording stopped',
recordingCompleted: 'Recording completed',
recordingFailed: 'Recording failed',
enterPrompt: 'Enter Prompt',
selectModel: 'Select Model',
startGeneration: 'Start Generation',
......@@ -5106,7 +5193,7 @@
uploadAudioFile: 'Upload Audio File',
dragDropHere: 'Drag and drop files here or click to upload',
supportedImageFormats: 'Supported jpg, png, webp image formats (< 10MB)',
supportedAudioFormats: 'Supported mp3, m4a, wav audio formats (< 120s)',
clearCharacterImageTip: 'Upload a clear character image',
maxFileSize: 'Max file size',
// 任务详情
......@@ -5256,10 +5343,24 @@
const showTaskDetailModal = ref(false);
const modalTask = ref(null);
// 视频加载状态跟踪
const videoLoadedStates = ref(new Map()); // 跟踪每个视频的加载状态
// 检查视频是否已加载完成
const isVideoLoaded = (videoSrc) => {
return videoLoadedStates.value.get(videoSrc) || false;
};
// 设置视频加载状态
const setVideoLoaded = (videoSrc, loaded) => {
videoLoadedStates.value.set(videoSrc, loaded);
};
// 灵感广场相关数据
const inspirationSearchQuery = ref('');
const selectedInspirationCategory = ref('all');
const inspirationItems = ref([]);
const InspirationCategories = ref([]);
// 灵感广场分页相关变量
const inspirationPagination = ref(null);
......@@ -5661,103 +5762,10 @@
};
});
// 动态生成灵感广场类别
const dynamicInspirationCategories = computed(() => {
const categories = [{ id: 'all', name: '全部' }];
// 从所有灵感项目中提取唯一的category
const uniqueCategories = new Set();
inspirationItems.value.forEach(item => {
if (item.tags && item.tags.length > 0) {
uniqueCategories.add(item.tags[0]);
}
});
// 将唯一的category转换为类别对象
Array.from(uniqueCategories).sort().forEach(category => {
categories.push({
id: category,
name: category
});
});
console.log(categories)
return categories;
});
// 任务图片URL缓存
const taskImageUrlCache = ref(new Map());
// 任务视频URL缓存
const taskVideoUrlCache = ref(new Map());
// 通用URL缓存
const urlCache = ref(new Map());
// 获取任务图片URL(带缓存)
const getTaskImageUrl = (task) => {
if (!task || !task.inputs) return null;
const cacheKey = task.task_id;
if (taskImageUrlCache.value.has(cacheKey)) {
return taskImageUrlCache.value.get(cacheKey);
}
const imageInputs = Object.keys(task.inputs).filter(key =>
key.includes('image') ||
task.inputs[key].toString().toLowerCase().match(/\.(jpg|jpeg|png|gif|bmp|webp)$/)
);
if (imageInputs.length > 0) {
const firstImageKey = imageInputs[0];
// 优先从缓存获取
const cachedUrl = getTaskFileUrlSync(task.task_id, firstImageKey);
if (cachedUrl) {
taskImageUrlCache.value.set(cacheKey, cachedUrl);
return cachedUrl;
}
// 缓存没有则生成URL
const token = localStorage.getItem('accessToken');
const url = token
? `./assets/task/input?task_id=${task.task_id}&name=${firstImageKey}&token=${encodeURIComponent(token)}`
: `./assets/task/input?task_id=${task.task_id}&name=${firstImageKey}`;
taskImageUrlCache.value.set(cacheKey, url);
return url;
}
return null;
};
// 获取任务视频URL(带缓存,同步版本)
const getTaskVideoUrl = (taskId, key) => {
const cacheKey = `${taskId}_${key}`;
if (taskVideoUrlCache.value.has(cacheKey)) {
return taskVideoUrlCache.value.get(cacheKey);
}
// 优先从缓存获取
const cachedUrl = getTaskFileUrlSync(taskId, key);
if (cachedUrl) {
taskVideoUrlCache.value.set(cacheKey, cachedUrl);
return cachedUrl;
}
// 缓存没有则生成URL
const token = localStorage.getItem('accessToken');
const url = token
? `./assets/task/result?task_id=${taskId}&name=${key}&token=${encodeURIComponent(token)}`
: `./assets/task/result?task_id=${taskId}&name=${key}`;
// 缓存API URL
setTaskFileToCache(taskId, key, {
url: url,
timestamp: Date.now()
});
taskVideoUrlCache.value.set(cacheKey, url);
return url;
};
// 通用URL缓存函数
const getCachedUrl = (key, urlGenerator) => {
if (urlCache.value.has(key)) {
......@@ -5860,8 +5868,21 @@
return response;
};
const beforeLogin = () => {
localStorage.removeItem('loginSource');
localStorage.removeItem('accessToken');
localStorage.removeItem('currentUser');
clearAllCache();
switchToLoginView();
currentUser.value = {};
isLoggedIn.value = false;
models.value = [];
tasks.value = [];
};
const loginWithGitHub = async () => {
try {
beforeLogin();
console.log('starting login')
loginLoading.value = true;
const response = await fetch('./auth/login/github');
......@@ -5875,6 +5896,9 @@
const loginWithGoogle = async () => {
try {
beforeLogin();
console.log('starting login')
loginLoading.value = true;
const response = await fetch('./auth/login/google');
const data = await response.json();
localStorage.setItem('loginSource', 'google');
......@@ -5899,6 +5923,7 @@
}
try {
beforeLogin();
const response = await fetch(`./auth/login/sms?phone_number=${phoneNumber.value}`);
const data = await response.json();
......@@ -5937,6 +5962,14 @@
// 登录成功后初始化数据
await init();
// 登录成功后跳转到用户原本想访问的页面,如果没有则跳转到创建页面
if (currentView.value === 'login') {
currentView.value = 'create';
updateURL('create');
} else {
updateURL(currentView.value);
}
showAlert('登录成功', 'success');
} else {
showAlert(data.message || '验证码错误或已过期', 'danger');
......@@ -6038,6 +6071,14 @@
// 登录成功后初始化数据
await init();
// 登录成功后跳转到用户原本想访问的页面,如果没有则跳转到创建页面
if (currentView.value === 'login') {
currentView.value = 'create';
updateURL('create');
} else {
updateURL(currentView.value);
}
// 清除URL中的code参数
window.history.replaceState({}, document.title, window.location.pathname);
} else {
......@@ -6054,7 +6095,7 @@
localStorage.removeItem('accessToken');
localStorage.removeItem('currentUser');
clearAllCache();
switchToLoginView();
currentUser.value = {};
isLoggedIn.value = false;
models.value = [];
......@@ -6177,10 +6218,30 @@
if (response.ok) {
const blob = await response.blob();
const file = new File([blob], template.filename, { type: blob.type });
// 根据文件扩展名确定正确的MIME类型
let mimeType = blob.type;
const extension = template.filename.toLowerCase().split('.').pop();
if (extension === 'wav') {
mimeType = 'audio/wav';
} else if (extension === 'mp3') {
mimeType = 'audio/mpeg';
} else if (extension === 'm4a') {
mimeType = 'audio/mp4';
} else if (extension === 'ogg') {
mimeType = 'audio/ogg';
} else if (extension === 'webm') {
mimeType = 'audio/webm';
}
console.log('文件扩展名:', extension, 'MIME类型:', mimeType);
const file = new File([blob], template.filename, { type: mimeType });
// 缓存文件对象
templateFileCache.value.set(cacheKey, file);
console.log('下载素材文件完成:', template.filename);
return file;
} else {
throw new Error('下载素材文件失败');
......@@ -6223,6 +6284,7 @@
const reader = new FileReader();
reader.onload = (e) => {
setCurrentAudioPreview(e.target.result);
updateUploadedContentStatus();
};
reader.readAsDataURL(file);
......@@ -6367,6 +6429,91 @@
}
};
// 开始录音
const startRecording = async () => {
try {
// 请求麦克风权限
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 44100
}
});
// 创建MediaRecorder
mediaRecorder.value = new MediaRecorder(stream, {
mimeType: 'audio/webm;codecs=opus'
});
audioChunks.value = [];
// 监听数据可用事件
mediaRecorder.value.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.value.push(event.data);
}
};
// 监听录音停止事件
mediaRecorder.value.onstop = () => {
const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' });
const audioFile = new File([audioBlob], 'recording.webm', { type: 'audio/webm' });
// 设置到表单
s2vForm.value.audioFile = audioFile;
// 创建预览URL
const audioUrl = URL.createObjectURL(audioBlob);
setCurrentAudioPreview(audioUrl);
updateUploadedContentStatus();
// 停止所有音频轨道
stream.getTracks().forEach(track => track.stop());
showAlert(t('recordingCompleted'), 'success');
};
// 开始录音
mediaRecorder.value.start(1000); // 每秒收集一次数据
isRecording.value = true;
recordingDuration.value = 0;
// 开始计时
recordingTimer.value = setInterval(() => {
recordingDuration.value++;
}, 1000);
showAlert(t('recordingStarted'), 'info');
} catch (error) {
console.error('录音失败:', error);
showAlert(t('recordingFailed'), 'danger');
}
};
// 停止录音
const stopRecording = () => {
if (mediaRecorder.value && isRecording.value) {
mediaRecorder.value.stop();
isRecording.value = false;
if (recordingTimer.value) {
clearInterval(recordingTimer.value);
recordingTimer.value = null;
}
showAlert(t('recordingStopped'), 'info');
}
};
// 格式化录音时长
const formatRecordingDuration = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const submitTask = async () => {
try {
const currentForm = getCurrentForm();
......@@ -6383,12 +6530,16 @@
}
if (!currentForm.prompt || currentForm.prompt.trim().length === 0) {
showAlert('请输入提示词', 'warning');
return;
if (selectedTaskId.value === 's2v') {
currentForm.prompt = 'Make the character speak in a natural way according to the audio.';
} else {
showAlert('请输入提示词', 'warning');
return;
}
}
if (currentForm.prompt.length > 500) {
showAlert('提示词长度不能超过500个字符', 'warning');
if (currentForm.prompt.length > 1000) {
showAlert('提示词长度不能超过1000个字符', 'warning');
return;
}
......@@ -6771,18 +6922,6 @@
return await getTaskFileUrlFromApi(taskId, fileKey);
};
// 获取任务的第一个图片键
const getFirstImageKey = (task) => {
if (!task || !task.inputs) return null;
const imageInputs = Object.keys(task.inputs).filter(key =>
key.includes('image') ||
task.inputs[key].toString().toLowerCase().match(/\.(jpg|jpeg|png|gif|bmp|webp)$/)
);
return imageInputs.length > 0 ? imageInputs[0] : null;
};
// 同步获取任务文件URL(仅从缓存获取,用于模板显示)
const getTaskFileUrlSync = (taskId, fileKey) => {
const cachedFile = getTaskFileFromCache(taskId, fileKey);
......@@ -7164,38 +7303,6 @@
return statusMap[status] || 'bg-secondary';
};
const downloadSingleResult = async (taskId, key, outputPath) => {
try {
downloadLoading.value = true;
const url = await getTaskFileUrl(taskId, key);
if (url) {
const response = await fetch(url);
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// 使用原始文件名,如果没有则使用outputPath
const filename = key || outputPath || `result_${taskId}`;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
showAlert('文件下载成功', 'success');
} else {
showAlert('获取结果失败', 'danger');
}
} else {
showAlert(t('getTaskResultFailedAlert'), 'danger');
}
} catch (error) {
showAlert(`${t('downloadTaskResultFailedAlert')}: ${error.message}`, 'danger');
} finally {
downloadLoading.value = false;
}
};
const viewSingleResult = async (taskId, key) => {
try {
downloadLoading.value = true;
......@@ -7519,7 +7626,8 @@
'mp4': 'audio/mp4',
'aac': 'audio/aac',
'ogg': 'audio/ogg',
'm4a': 'audio/mp4'
'm4a': 'audio/mp4',
'webm': 'audio/webm'
};
mimeType = mimeTypes[ext] || 'audio/mpeg';
}
......@@ -7774,7 +7882,7 @@
} else if (selectedTaskId.value === 'i2v') {
return t('pleaseEnterThePromptForVideoGeneration') + ''+ t('describeTheContentActionRequirementsBasedOnTheImage');
} else if (selectedTaskId.value === 's2v') {
return t('pleaseEnterThePromptForVideoGeneration') + ''+ t('describeTheDigitalHumanImageBackgroundStyleActionRequirements');
return t('optional') + ' '+ t('pleaseEnterThePromptForVideoGeneration') + ''+ t('describeTheDigitalHumanImageBackgroundStyleActionRequirements');
}
return t('pleaseEnterThePromptForVideoGeneration') + '...';
};
......@@ -7907,38 +8015,6 @@
}
};
const downloadTaskInput = async (taskId, inputName, fileName) => {
try {
const url = await getTaskInputUrl(taskId, inputName);
const response = await fetch(url);
if (!response || !response.ok) {
throw new Error(`下载失败: ${response ? response.status : '认证失败'}`);
}
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
// 创建下载链接
const link = document.createElement('a');
link.href = downloadUrl;
// 使用原始文件名,如果没有则使用inputName
const filename = inputName || fileName || `input_${taskId}`;
link.download = filename;
document.body.appendChild(link);
link.click();
// 清理
document.body.removeChild(link);
window.URL.revokeObjectURL(downloadUrl);
showAlert(t('fileDownloadSuccessAlert'), 'success');
} catch (error) {
console.error(t('downloadFailedAlert'), error);
showAlert(`${t('downloadFailedAlert')}: ${error.message}`, 'danger');
}
};
// 监听currentPage变化,同步更新pageInput
watch(currentTaskPage, (newPage) => {
taskPageInput.value = newPage;
......@@ -8021,6 +8097,9 @@
loadTemplateFilesFromCache();
loadImageAudioTemplates(true);
// 4. 初始化路由(在数据加载完成后)
initRoute();
console.log('初始化完成:', {
currentUser: currentUser.value,
availableModels: models.value,
......@@ -8077,7 +8156,7 @@
stage: currentStage || 'single_stage',
imageFile: null,
audioFile: null,
prompt: '',
prompt: 'Make the character speak in a natural way according to the audio.',
seed: Math.floor(Math.random() * 1000000)
};
break;
......@@ -8602,8 +8681,17 @@
await handleLoginCallback(code, source);
// handleGitHubCallback内部会设置isLoggedIn.value = true
} else {
// 没有token且不是回调,显示登录
// 没有token且不是回调,默认显示登录
isLoggedIn.value = false;
const urlParams = new URLSearchParams(window.location.search);
const view = urlParams.get('view');
if (view && ['create', 'projects', 'inspiration'].includes(view)) {
// 如果URL中指定了其他页面,仍然显示登录界面,但设置currentView用于登录后跳转
currentView.value = view;
} else {
// 默认显示登录界面
currentView.value = 'login';
}
}
}
} catch (error) {
......@@ -8615,6 +8703,9 @@
loginLoading.value = false;
initLoading.value = false;
}
// 监听浏览器前进后退
window.addEventListener('popstate', handlePopState);
});
// 页面卸载时清理轮询
......@@ -8627,6 +8718,9 @@
// 清理提示滚动
stopHintRotation();
// 清理路由事件监听器
window.removeEventListener('popstate', handlePopState);
});
// 提示词模板管理
......@@ -8634,13 +8728,33 @@
's2v': [
{
id: 's2v_1',
title: '商务演讲',
prompt: '数字人进行商务演讲,表情自然,手势得体,背景为现代化的会议室,整体风格专业商务'
title: '情绪表达',
prompt: '根据音频,人物进行情绪化表达,表情丰富,能体现音频中的情绪,手势根据情绪适当调整'
},
{
id: 's2v_2',
title: '故事讲述',
prompt: '根据音频,人物进行故事讲述,表情丰富,能体现音频中的情绪,手势根据故事情节适当调整。'
},
{
id: 's2v_3',
title: '知识讲解',
prompt: '根据音频,人物进行知识讲解,表情严肃,整体风格专业得体,手势根据知识内容适当调整。'
},
{
id: 's2v_4',
title: '浮夸表演',
prompt: '根据音频,人物进行浮夸表演,表情夸张,动作浮夸,整体风格夸张搞笑。'
},
{
id: 's2v_5',
title: '商务演讲',
prompt: '根据音频,人物进行商务演讲,表情严肃,手势得体,整体风格专业商务。'
},
{
id: 's2v_6',
title: '产品介绍',
prompt: '数字人介绍产品特点,语气亲切,动作自然,背景为产品展示区,突出产品的科技感和实用性'
prompt: '数字人介绍产品特点,语气亲切热情,表情丰富,动作自然,能体现产品特点'
}
],
't2v': [
......@@ -8918,6 +9032,7 @@
// 设置音频预览
setCurrentAudioPreview(history.data);
updateUploadedContentStatus();
// 更新表单
const currentForm = getCurrentForm();
......@@ -9132,6 +9247,7 @@
// 新增:视图切换方法
const switchToCreateView = () => {
currentView.value = 'create';
updateURL('create');
// 如果之前有展开过创作区域,保持展开状态
if (isCreationAreaExpanded.value) {
// 延迟一点时间确保DOM更新完成
......@@ -9146,16 +9262,62 @@
const switchToProjectsView = () => {
currentView.value = 'projects';
updateURL('projects');
// 刷新任务列表
refreshTasks();
};
const switchToInspirationView = () => {
currentView.value = 'inspiration';
updateURL('inspiration');
// 加载灵感数据
loadInspirationData();
};
const switchToLoginView = () => {
currentView.value = 'login';
updateURL('login');
};
// URL路由管理
const updateURL = (view) => {
const url = new URL(window.location);
url.searchParams.set('view', view);
window.history.pushState({ view }, '', url);
};
const initRoute = () => {
const urlParams = new URLSearchParams(window.location.search);
const view = urlParams.get('view');
if (view && ['create', 'projects', 'inspiration', 'login'].includes(view)) {
currentView.value = view;
// 注意:数据已经在init()中加载过了,这里不需要重复加载
// 只需要设置当前视图即可
} else {
// 默认显示登录界面
currentView.value = 'login';
updateURL('login');
}
};
// 监听浏览器前进后退
const handlePopState = (event) => {
if (event.state && event.state.view) {
currentView.value = event.state.view;
// 根据页面加载相应的数据
if (event.state.view === 'projects') {
refreshTasks();
} else if (event.state.view === 'inspiration') {
loadInspirationData();
}
// login页面不需要额外数据加载
} else {
// 处理直接访问URL的情况
initRoute();
}
};
// 日期格式化函数
const formatDate = (date) => {
if (!date) return '';
......@@ -9179,6 +9341,7 @@
if (cachedData && cachedData.templates) {
console.log(`成功从缓存加载灵感模板数据${cacheKey}:`, cachedData.templates);
inspirationItems.value = cachedData.templates;
InspirationCategories.value = cachedData.categories;
// 如果有分页信息也加载
if (cachedData.pagination) {
inspirationPagination.value = cachedData.pagination;
......@@ -9195,6 +9358,7 @@
const templates = data.templates || [];
inspirationItems.value = data.templates || [];
InspirationCategories.value = data.categories || [];
inspirationPagination.value = data.pagination || null;
// 缓存模板数据
......@@ -9220,21 +9384,17 @@
console.warn('加载模板数据失败:', error);
}
};
// 筛选灵感内容 - 现在数据直接从后端获取,不需要前端筛选
const filteredInspirationItems = computed(() => {
// 直接返回从后端获取的数据,后端已经根据分类和搜索条件进行了筛选
return inspirationItems.value || [];
});
// 选择分类
const selectInspirationCategory = async (categoryId) => {
const selectInspirationCategory = async (category) => {
// 如果点击的是当前分类,不重复请求
if (selectedInspirationCategory.value === categoryId) {
if (selectedInspirationCategory.value === category) {
return;
}
// 更新分类
selectedInspirationCategory.value = categoryId;
selectedInspirationCategory.value = category;
// 重置页码为1
inspirationCurrentPage.value = 1;
......@@ -9273,34 +9433,220 @@
}, 500); // 500ms 防抖延迟
};
// 全局视频播放管理
let currentPlayingVideo = null;
let currentLoadingVideo = null; // 跟踪正在等待加载的视频
// 更新视频播放按钮图标
const updateVideoIcon = (video, isPlaying) => {
// 查找视频容器中的播放按钮
const container = video.closest('.relative');
if (!container) return;
// 查找移动端播放按钮
const playButton = container.querySelector('button[class*="absolute"][class*="bottom-3"]');
if (playButton) {
const icon = playButton.querySelector('i');
if (icon) {
icon.className = isPlaying ? 'fas fa-pause text-sm' : 'fas fa-play text-sm';
}
}
};
// 处理视频播放结束
const onVideoEnded = (event) => {
const video = event.target;
console.log('视频播放完毕:', video.src);
// 重置视频到开始位置
video.currentTime = 0;
// 更新播放按钮图标为播放状态
updateVideoIcon(video, false);
// 如果播放完毕的是当前播放的视频,清除引用
if (currentPlayingVideo === video) {
currentPlayingVideo = null;
console.log('当前播放视频播放完毕');
}
};
// 视频播放控制
const playVideo = (event) => {
const video = event.target;
if (video.readyState >= 2) { // HAVE_CURRENT_DATA
video.currentTime = 0; // 从头开始播放
video.play().catch(e => {
console.log('视频播放失败:', e);
// 如果自动播放失败,可以尝试用户交互后播放
});
} else {
// 如果视频还没加载完成,等待加载完成后再播放
video.addEventListener('loadeddata', () => {
video.currentTime = 0;
video.play().catch(e => console.log('视频播放失败:', e));
}, { once: true });
// 检查视频是否已加载完成
if (video.readyState < 2) { // HAVE_CURRENT_DATA
console.log('视频还没加载完成,忽略鼠标悬停播放');
return;
}
// 如果当前有视频在播放,先暂停它
if (currentPlayingVideo && currentPlayingVideo !== video) {
currentPlayingVideo.pause();
currentPlayingVideo.currentTime = 0;
// 更新上一个视频的图标
updateVideoIcon(currentPlayingVideo, false);
console.log('暂停上一个视频');
}
// 视频已加载完成,可以播放
video.currentTime = 0; // 从头开始播放
video.play().then(() => {
// 播放成功,更新当前播放视频
currentPlayingVideo = video;
console.log('开始播放新视频');
}).catch(e => {
console.log('视频播放失败:', e);
currentPlayingVideo = null;
video.pause();
video.currentTime = 0;
});
};
const pauseVideo = (event) => {
const video = event.target;
// 检查视频是否已加载完成
if (video.readyState < 2) { // HAVE_CURRENT_DATA
console.log('视频还没加载完成,忽略鼠标离开暂停');
return;
}
video.pause();
video.currentTime = 0;
// 更新视频图标
updateVideoIcon(video, false);
// 如果暂停的是当前播放的视频,清除引用
if (currentPlayingVideo === video) {
currentPlayingVideo = null;
console.log('暂停当前播放视频');
}
};
// 移动端视频播放切换
const toggleVideoPlay = (event) => {
const button = event.target.closest('button');
const video = button.parentElement.querySelector('video');
const icon = button.querySelector('i');
if (video.paused) {
// 如果当前有视频在播放,先暂停它
if (currentPlayingVideo && currentPlayingVideo !== video) {
currentPlayingVideo.pause();
currentPlayingVideo.currentTime = 0;
// 更新上一个视频的图标
updateVideoIcon(currentPlayingVideo, false);
console.log('暂停上一个视频(移动端)');
}
// 如果当前有视频在等待加载,取消它的等待状态
if (currentLoadingVideo && currentLoadingVideo !== video) {
currentLoadingVideo = null;
console.log('取消上一个视频的加载等待(移动端)');
}
// 检查视频是否已加载完成
if (video.readyState >= 2) { // HAVE_CURRENT_DATA
// 视频已加载完成,直接播放
video.currentTime = 0;
video.play().then(() => {
icon.className = 'fas fa-pause text-sm';
currentPlayingVideo = video;
console.log('开始播放新视频(移动端)');
}).catch(e => {
console.log('视频播放失败:', e);
icon.className = 'fas fa-play text-sm';
currentPlayingVideo = null;
});
} else {
// 视频未加载完成,显示loading并等待
console.log('视频还没加载完成,等待加载(移动端)');
icon.className = 'fas fa-spinner fa-spin text-sm';
currentLoadingVideo = video;
// 等待视频加载完成
video.addEventListener('loadeddata', () => {
// 检查这个视频是否仍然是当前等待加载的视频
if (currentLoadingVideo === video) {
currentLoadingVideo = null;
video.currentTime = 0;
video.play().then(() => {
icon.className = 'fas fa-pause text-sm';
currentPlayingVideo = video;
console.log('开始播放新视频(移动端-延迟加载)');
}).catch(e => {
console.log('视频播放失败:', e);
icon.className = 'fas fa-play text-sm';
currentPlayingVideo = null;
});
} else {
// 这个视频的加载等待已被取消,重置图标
icon.className = 'fas fa-play text-sm';
console.log('视频加载完成但等待已被取消(移动端)');
}
}, { once: true });
};
} else {
video.pause();
video.currentTime = 0;
icon.className = 'fas fa-play text-sm';
// 如果暂停的是当前播放的视频,清除引用
if (currentPlayingVideo === video) {
currentPlayingVideo = null;
console.log('暂停当前播放视频(移动端)');
}
// 如果暂停的是当前等待加载的视频,清除引用
if (currentLoadingVideo === video) {
currentLoadingVideo = null;
console.log('取消当前等待加载的视频(移动端)');
}
}
};
// 暂停所有视频
const pauseAllVideos = () => {
if (currentPlayingVideo) {
currentPlayingVideo.pause();
currentPlayingVideo.currentTime = 0;
// 更新视频图标
updateVideoIcon(currentPlayingVideo, false);
currentPlayingVideo = null;
console.log('暂停所有视频');
}
// 清理等待加载的视频状态
if (currentLoadingVideo) {
// 重置等待加载的视频图标
const loadingContainer = currentLoadingVideo.closest('.relative');
if (loadingContainer) {
const loadingButton = loadingContainer.querySelector('button[class*="absolute"][class*="bottom-3"]');
if (loadingButton) {
const loadingIcon = loadingButton.querySelector('i');
if (loadingIcon) {
loadingIcon.className = 'fas fa-play text-sm';
}
}
}
currentLoadingVideo = null;
console.log('取消所有等待加载的视频');
}
};
const onVideoLoaded = (event) => {
const video = event.target;
// 视频加载完成,准备播放
console.log('视频加载完成:', video.src);
// 更新视频加载状态(使用视频的实际src)
setVideoLoaded(video.src, true);
// 触发Vue的响应式更新
videoLoadedStates.value = new Map(videoLoadedStates.value);
};
const onVideoError = (event) => {
......@@ -9619,6 +9965,14 @@
loginLoading,
initLoading,
downloadLoading,
// 录音相关
isRecording,
recordingDuration,
startRecording,
stopRecording,
formatRecordingDuration,
loginWithGitHub,
loginWithGoogle,
// 短信登录相关
......@@ -9762,12 +10116,10 @@
getTemplateFileUrlAsync,
loadTemplateFilesFromCache,
saveTemplateFilesToCache,
getFirstImageKey,
loadFromCache,
saveToCache,
clearAllCache,
getStatusBadgeClass,
downloadSingleResult,
viewSingleResult,
cancelTask,
resumeTask,
......@@ -9791,8 +10143,7 @@
getTaskInputUrl,
getTaskInputImage,
getTaskInputAudio,
getTaskImageUrl,
getTaskVideoUrl,
getTaskFileUrl,
getHistoryImageUrl,
getUserAvatarUrl,
getCurrentImagePreviewUrl,
......@@ -9802,7 +10153,6 @@
handleImageLoad,
handleAudioError,
handleAudioLoad,
downloadTaskInput,
getTaskStatusDisplay,
getTaskStatusColor,
getTaskStatusIcon,
......@@ -9848,14 +10198,17 @@
switchToCreateView,
switchToProjectsView,
switchToInspirationView,
switchToLoginView,
updateURL,
initRoute,
handlePopState,
openTaskDetailModal,
closeTaskDetailModal,
// 灵感广场相关
inspirationSearchQuery,
selectedInspirationCategory,
inspirationItems,
dynamicInspirationCategories,
filteredInspirationItems,
InspirationCategories,
loadInspirationData,
selectInspirationCategory,
handleInspirationSearch,
......@@ -9887,8 +10240,12 @@
// 视频播放控制
playVideo,
pauseVideo,
toggleVideoPlay,
pauseAllVideos,
updateVideoIcon,
onVideoLoaded,
onVideoError
onVideoError,
onVideoEnded
};
}
}).mount('#app');
......
......@@ -273,10 +273,10 @@ class LocalTaskManager(BaseTaskManager):
task, subtasks = self.load(task_id, user_id)
# the task is not finished
if task["status"] not in FinishedStatus:
return False
return "Active task cannot be resumed"
# the task is no need to resume
if not all_subtask and task["status"] == TaskStatus.SUCCEED:
return False
return "Succeed task cannot be resumed"
for sub in subtasks:
if all_subtask or sub["status"] != TaskStatus.SUCCEED:
self.mark_subtask_change(records, sub, None, TaskStatus.CREATED)
......
......@@ -702,10 +702,10 @@ class PostgresSQLTaskManager(BaseTaskManager):
task, subtasks = await self.load(conn, task_id, user_id)
# the task is not finished
if task["status"] not in FinishedStatus:
return False
return "Active task cannot be resumed"
# the task is no need to resume
if not all_subtask and task["status"] == TaskStatus.SUCCEED:
return False
return "Succeed task cannot be resumed"
for sub in subtasks:
if all_subtask or sub["status"] != TaskStatus.SUCCEED:
......
......@@ -23,15 +23,19 @@ from lightx2v.utils.utils import seed_all
class BaseWorker:
@ProfilingContext4DebugL1("Init Worker Worker Cost:")
def __init__(self, args):
args.save_video_path = ""
config = set_config(args)
config["mode"] = ""
logger.info(f"config:\n{json.dumps(config, ensure_ascii=False, indent=4)}")
seed_all(config.seed)
self.rank = 0
self.world_size = 1
if config.parallel:
self.rank = dist.get_rank()
self.world_size = dist.get_world_size()
set_parallel_config(config)
seed_all(config.seed)
# same as va_recorder rank and worker main ping rank
self.out_video_rank = self.world_size - 1
torch.set_grad_enabled(False)
self.runner = RUNNER_REGISTER[config.model_cls](config)
# fixed config
......@@ -121,7 +125,7 @@ class BaseWorker:
async def save_output_video(self, tmp_video_path, output_video_path, data_manager):
# save output video
if data_manager.name != "local" and self.rank == 0 and isinstance(tmp_video_path, str):
if data_manager.name != "local" and self.rank == self.out_video_rank and isinstance(tmp_video_path, str):
video_data = open(tmp_video_path, "rb").read()
await data_manager.save_bytes(video_data, output_video_path)
......
......@@ -85,7 +85,7 @@ def main():
help="The file of the source mask. Default None.",
)
parser.add_argument("--save_video_path", type=str, default="./output_lightx2v.mp4", help="The path to save video path/file")
parser.add_argument("--save_video_path", type=str, default=None, help="The path to save video path/file")
args = parser.parse_args()
# set config
......
......@@ -295,7 +295,9 @@ class DefaultRunner(BaseRunner):
save_to_video(self.gen_video, self.config.save_video_path, fps=fps, method="ffmpeg")
logger.info(f"✅ Video saved successfully to: {self.config.save_video_path} ✅")
return {"video": self.gen_video}
if self.config.get("return_video", False):
return {"video": self.gen_video}
return {"video": None}
def run_pipeline(self, save_video=True):
if self.config["use_prompt_enhancer"]:
......
import gc
import os
import warnings
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
......@@ -12,7 +11,6 @@ import torchvision.transforms.functional as TF
from PIL import Image
from einops import rearrange
from loguru import logger
from torchvision.io import write_video
from torchvision.transforms import InterpolationMode
from torchvision.transforms.functional import resize
......@@ -28,9 +26,7 @@ from lightx2v.models.video_encoders.hf.wan.vae_2_2 import Wan2_2_VAE
from lightx2v.utils.envs import *
from lightx2v.utils.profiler import *
from lightx2v.utils.registry_factory import RUNNER_REGISTER
from lightx2v.utils.utils import find_torch_model_path, load_weights, vae_to_comfyui_image
warnings.filterwarnings("ignore", category=UserWarning, module="torchvision.io._video_deprecation_warning")
from lightx2v.utils.utils import find_torch_model_path, load_weights, vae_to_comfyui_image_inplace
def get_optimal_patched_size_with_sp(patched_h, patched_w, sp_size):
......@@ -475,10 +471,12 @@ class WanAudioRunner(WanRunner): # type:ignore
def init_run(self):
super().init_run()
self.scheduler.set_audio_adapter(self.audio_adapter)
self.gen_video_list = []
self.cut_audio_list = []
self.prev_video = None
if self.config.get("return_video", False):
self.gen_video_final = torch.zeros((self.inputs["expected_frames"], self.config.tgt_h, self.config.tgt_w, 3), dtype=torch.float32, device="cpu")
else:
self.gen_video_final = None
self.cut_audio_final = None
@ProfilingContext4DebugL1("Init run segment")
def init_run_segment(self, segment_idx, audio_array=None):
......@@ -510,22 +508,31 @@ class WanAudioRunner(WanRunner): # type:ignore
def end_run_segment(self):
self.gen_video = torch.clamp(self.gen_video, -1, 1).to(torch.float)
useful_length = self.segment.end_frame - self.segment.start_frame
self.gen_video_list.append(self.gen_video[:, :, :useful_length].cpu())
self.cut_audio_list.append(self.segment.audio_array[: useful_length * self._audio_processor.audio_frame_rate])
video_seg = self.gen_video[:, :, :useful_length].cpu()
audio_seg = self.segment.audio_array[: useful_length * self._audio_processor.audio_frame_rate]
if self.va_recorder:
cur_video = vae_to_comfyui_image(self.gen_video_list[-1])
self.va_recorder.pub_livestream(cur_video, self.cut_audio_list[-1])
video_seg = vae_to_comfyui_image_inplace(video_seg)
# [Warning] Need check whether video segment interpolation works...
if "video_frame_interpolation" in self.config and self.vfi_model is not None:
target_fps = self.config["video_frame_interpolation"]["target_fps"]
logger.info(f"Interpolating frames from {self.config.get('fps', 16)} to {target_fps}")
video_seg = self.vfi_model.interpolate_frames(
video_seg,
source_fps=self.config.get("fps", 16),
target_fps=target_fps,
)
if self.va_reader:
self.gen_video_list.pop()
self.cut_audio_list.pop()
if self.va_recorder:
self.va_recorder.pub_livestream(video_seg, audio_seg)
elif self.config.get("return_video", False):
self.gen_video_final[self.segment.start_frame : self.segment.end_frame].copy_(video_seg)
self.cut_audio_final = np.concatenate([self.cut_audio_final, audio_seg], axis=0).astype(np.float32) if self.cut_audio_final is not None else audio_seg
# Update prev_video for next iteration
self.prev_video = self.gen_video
# Clean up GPU memory after each segment
del self.gen_video
del video_seg, audio_seg
torch.cuda.empty_cache()
def get_rank_and_world_size(self):
......@@ -540,18 +547,19 @@ class WanAudioRunner(WanRunner): # type:ignore
output_video_path = self.config.get("save_video_path", None)
self.va_recorder = None
if isinstance(output_video_path, dict):
assert output_video_path["type"] == "stream", f"unexcept save_video_path: {output_video_path}"
rank, world_size = self.get_rank_and_world_size()
if rank == 2 % world_size:
record_fps = self.config.get("target_fps", 16)
audio_sr = self.config.get("audio_sr", 16000)
if "video_frame_interpolation" in self.config and self.vfi_model is not None:
record_fps = self.config["video_frame_interpolation"]["target_fps"]
self.va_recorder = VARecorder(
livestream_url=output_video_path["data"],
fps=record_fps,
sample_rate=audio_sr,
)
output_video_path = output_video_path["data"]
logger.info(f"init va_recorder with output_video_path: {output_video_path}")
rank, world_size = self.get_rank_and_world_size()
if output_video_path and rank == world_size - 1:
record_fps = self.config.get("target_fps", 16)
audio_sr = self.config.get("audio_sr", 16000)
if "video_frame_interpolation" in self.config and self.vfi_model is not None:
record_fps = self.config["video_frame_interpolation"]["target_fps"]
self.va_recorder = VARecorder(
livestream_url=output_video_path,
fps=record_fps,
sample_rate=audio_sr,
)
def init_va_reader(self):
audio_path = self.config.get("audio_path", None)
......@@ -583,8 +591,8 @@ class WanAudioRunner(WanRunner): # type:ignore
return super().run_main(total_steps)
rank, world_size = self.get_rank_and_world_size()
if rank == 2 % world_size:
assert self.va_recorder is not None, "va_recorder is required for stream audio input for rank 0"
if rank == world_size - 1:
assert self.va_recorder is not None, "va_recorder is required for stream audio input for rank 2"
self.va_reader.start()
self.init_run()
......@@ -627,67 +635,17 @@ class WanAudioRunner(WanRunner): # type:ignore
self.va_recorder = None
@ProfilingContext4DebugL1("Process after vae decoder")
def process_images_after_vae_decoder(self, save_video=True):
# Merge results
gen_lvideo = torch.cat(self.gen_video_list, dim=2).float()
merge_audio = np.concatenate(self.cut_audio_list, axis=0).astype(np.float32)
comfyui_images = vae_to_comfyui_image(gen_lvideo)
# Apply frame interpolation if configured
if "video_frame_interpolation" in self.config and self.vfi_model is not None:
target_fps = self.config["video_frame_interpolation"]["target_fps"]
logger.info(f"Interpolating frames from {self.config.get('fps', 16)} to {target_fps}")
comfyui_images = self.vfi_model.interpolate_frames(
comfyui_images,
source_fps=self.config.get("fps", 16),
target_fps=target_fps,
)
if save_video and isinstance(self.config["save_video_path"], str):
if "video_frame_interpolation" in self.config and self.config["video_frame_interpolation"].get("target_fps"):
fps = self.config["video_frame_interpolation"]["target_fps"]
else:
fps = self.config.get("fps", 16)
if not dist.is_initialized() or dist.get_rank() == 0:
logger.info(f"🎬 Start to save video 🎬")
self._save_video_with_audio(comfyui_images, merge_audio, fps)
logger.info(f"✅ Video saved successfully to: {self.config.save_video_path} ✅")
# Convert audio to ComfyUI format
audio_waveform = torch.from_numpy(merge_audio).unsqueeze(0).unsqueeze(0)
comfyui_audio = {"waveform": audio_waveform, "sample_rate": self._audio_processor.audio_sr}
return {"video": comfyui_images, "audio": comfyui_audio}
def process_images_after_vae_decoder(self, save_video=False):
if self.config.get("return_video", False):
audio_waveform = torch.from_numpy(self.cut_audio_final).unsqueeze(0).unsqueeze(0)
comfyui_audio = {"waveform": audio_waveform, "sample_rate": self._audio_processor.audio_sr}
return {"video": self.gen_video_final, "audio": comfyui_audio}
return {"video": None, "audio": None}
def init_modules(self):
super().init_modules()
self.run_input_encoder = self._run_input_encoder_local_r2v_audio
def _save_video_with_audio(self, images, audio_array, fps):
output_path = self.config.get("save_video_path")
parent_dir = os.path.dirname(output_path)
if parent_dir and not os.path.exists(parent_dir):
os.makedirs(parent_dir, exist_ok=True)
sample_rate = self._audio_processor.audio_sr
if images.dtype != torch.uint8:
images = (images * 255).clamp(0, 255).to(torch.uint8)
write_video(
filename=output_path,
video_array=images,
fps=fps,
video_codec="libx264",
audio_array=torch.tensor(audio_array[None]),
audio_fps=sample_rate,
audio_codec="aac",
options={"preset": "medium", "crf": "23"}, # 可调整视频输出质量
)
def load_transformer(self):
"""Load transformer with LoRA support"""
base_model = WanAudioModel(self.config.model_path, self.config, self.init_device)
......
......@@ -31,6 +31,7 @@ def get_default_config():
"tgt_h": None,
"tgt_w": None,
"target_shape": None,
"return_video": False,
}
return default_config
......@@ -73,6 +74,8 @@ def set_config(args):
logger.warning(f"`num_frames - 1` has to be divisible by {config.vae_stride[0]}. Rounding to the nearest number.")
config.target_video_length = config.target_video_length // config.vae_stride[0] * config.vae_stride[0] + 1
assert not (config.save_video_path and config.return_video), "save_video_path and return_video cannot be set at the same time"
return config
......
......@@ -131,6 +131,39 @@ def vae_to_comfyui_image(vae_output: torch.Tensor) -> torch.Tensor:
return images
def vae_to_comfyui_image_inplace(vae_output: torch.Tensor) -> torch.Tensor:
"""
Convert VAE decoder output to ComfyUI Image format (inplace operation)
Args:
vae_output: VAE decoder output tensor, typically in range [-1, 1]
Shape: [B, C, T, H, W] or [B, C, H, W]
WARNING: This tensor will be modified in-place!
Returns:
ComfyUI Image tensor in range [0, 1]
Shape: [B, H, W, C] for single frame or [B*T, H, W, C] for video
Note: The returned tensor is the same object as input (modified in-place)
"""
# Handle video tensor (5D) vs image tensor (4D)
if vae_output.dim() == 5:
# Video tensor: [B, C, T, H, W]
B, C, T, H, W = vae_output.shape
# Reshape to [B*T, C, H, W] for processing (inplace view)
vae_output = vae_output.permute(0, 2, 1, 3, 4).contiguous().view(B * T, C, H, W)
# Normalize from [-1, 1] to [0, 1] (inplace)
vae_output.add_(1).div_(2)
# Clamp values to [0, 1] (inplace)
vae_output.clamp_(0, 1)
# Convert from [B, C, H, W] to [B, H, W, C] and move to CPU
vae_output = vae_output.permute(0, 2, 3, 1).cpu()
return vae_output
def save_to_video(
images: torch.Tensor,
output_path: str,
......
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