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 WORKDIR /app
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
ENV LANG=C.UTF-8 ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8 ENV LC_ALL=C.UTF-8
ENV LD_LIBRARY_PATH=/usr/local/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH
# 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
RUN apt-get update && apt-get install -y vim tmux zip unzip wget git build-essential libibverbs-dev ca-certificates \ 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/* && 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 meson ruff pre-commit fastapi uvicorn requests -U
RUN pip install --no-cache-dir packaging ninja cmake scikit-build-core uv ruff pre-commit -U
RUN git clone https://github.com/vllm-project/vllm.git && cd vllm \ RUN git clone https://github.com/vllm-project/vllm.git && cd vllm \
&& python use_existing_torch.py && pip install -r requirements/build.txt \ && 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 ...@@ -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 \ RUN pip install --no-cache-dir diffusers transformers tokenizers accelerate safetensors opencv-python numpy imageio \
imageio-ffmpeg einops loguru qtorch ftfy easydict 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 git clone https://github.com/Dao-AILab/flash-attention.git --recursive
RUN cd flash-attention && python setup.py install && rm -rf build 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 ...@@ -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 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 WORKDIR /workspace
...@@ -11,7 +11,7 @@ RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|https://mirrors.tuna.tsinghua.ed ...@@ -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 && 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 \ 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/* && 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 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 ...@@ -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 \ RUN pip install --no-cache-dir diffusers transformers tokenizers accelerate safetensors opencv-python numpy imageio \
imageio-ffmpeg einops loguru qtorch ftfy easydict 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 git clone https://github.com/Dao-AILab/flash-attention.git --recursive
RUN cd flash-attention && python setup.py install && rm -rf build RUN cd flash-attention && python setup.py install && rm -rf build
......
# For rtc whep, build gstreamer whith whepsrc plugin FROM lightx2v/lightx2v:25091903-cu128 AS base
FROM registry.ms-sc-01.maoshanwangtech.com/ms-ccr/lightx2v:25080601-cu128-SageSm90 AS gstreamer-base
RUN apt update -y \ RUN mkdir /workspace/LightX2V
&& apt update -y \ WORKDIR /workspace/LightX2V
&& apt install -y libssl-dev flex bison \ ENV PYTHONPATH=/workspace/LightX2V
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
COPY assets assets COPY assets assets
COPY configs configs COPY configs configs
......
...@@ -102,6 +102,10 @@ ...@@ -102,6 +102,10 @@
"latents": "TENSOR", "latents": "TENSOR",
"output_video": "VIDEO" "output_video": "VIDEO"
}, },
"model_name_inner_to_outer": {
"seko_talk": "SekoTalk"
},
"model_name_outer_to_inner": {},
"monitor": { "monitor": {
"subtask_created_timeout": 1800, "subtask_created_timeout": 1800,
"subtask_pending_timeout": 1800, "subtask_pending_timeout": 1800,
......
...@@ -27,16 +27,16 @@ We strongly recommend using the Docker environment, which is the simplest and fa ...@@ -27,16 +27,16 @@ We strongly recommend using the Docker environment, which is the simplest and fa
#### 1. Pull Image #### 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 ```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: 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 ```bash
docker pull lightx2v/lightx2v:25090503-cu124 docker pull lightx2v/lightx2v:25091903-cu124
``` ```
#### 2. Run Container #### 2. Run Container
...@@ -51,10 +51,10 @@ For mainland China, if the network is unstable when pulling images, you can pull ...@@ -51,10 +51,10 @@ For mainland China, if the network is unstable when pulling images, you can pull
```bash ```bash
# cuda128 # cuda128
docker pull registry.cn-hangzhou.aliyuncs.com/yongyang/lightx2v:25090503-cu128 docker pull registry.cn-hangzhou.aliyuncs.com/yongyang/lightx2v:25091903-cu128
# cuda124 # 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 ### 🐍 Conda Environment Setup
......
...@@ -27,16 +27,16 @@ ...@@ -27,16 +27,16 @@
#### 1. 拉取镜像 #### 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 ```bash
docker pull lightx2v/lightx2v:25090503-cu128 docker pull lightx2v/lightx2v:25091903-cu128
``` ```
我们推荐使用`cuda128`环境,以获得更快的推理速度,若需要使用`cuda124`环境,可以使用带`-cu124`后缀的镜像版本: 我们推荐使用`cuda128`环境,以获得更快的推理速度,若需要使用`cuda124`环境,可以使用带`-cu124`后缀的镜像版本:
```bash ```bash
docker pull lightx2v/lightx2v:25090503-cu124 docker pull lightx2v/lightx2v:25091903-cu124
``` ```
#### 2. 运行容器 #### 2. 运行容器
...@@ -51,10 +51,10 @@ docker run --gpus all -itd --ipc=host --name [容器名] -v [挂载设置] --ent ...@@ -51,10 +51,10 @@ docker run --gpus all -itd --ipc=host --name [容器名] -v [挂载设置] --ent
```bash ```bash
# cuda128 # cuda128
docker pull registry.cn-hangzhou.aliyuncs.com/yongyang/lightx2v:25090503-cu128 docker pull registry.cn-hangzhou.aliyuncs.com/yongyang/lightx2v:25091903-cu128
# cuda124 # cuda124
docker pull registry.cn-hangzhou.aliyuncs.com/yongyang/lightx2v:25090503-cu124 docker pull registry.cn-hangzhou.aliyuncs.com/yongyang/lightx2v:25091903-cu124
``` ```
### 🐍 Conda 环境搭建 ### 🐍 Conda 环境搭建
......
...@@ -16,6 +16,8 @@ class Pipeline: ...@@ -16,6 +16,8 @@ class Pipeline:
self.model_lists = [] self.model_lists = []
self.types = {} self.types = {}
self.queues = set() 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() self.tidy_pipeline()
def init_dict(self, base, task, model_cls): def init_dict(self, base, task, model_cls):
...@@ -132,6 +134,14 @@ class Pipeline: ...@@ -132,6 +134,14 @@ class Pipeline:
item = item[k] item = item[k]
return item 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): def get_model_lists(self):
return self.model_lists return self.model_lists
...@@ -144,6 +154,12 @@ class Pipeline: ...@@ -144,6 +154,12 @@ class Pipeline:
def get_queues(self): def get_queues(self):
return self.queues 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__": if __name__ == "__main__":
pipeline = Pipeline(sys.argv[1]) pipeline = Pipeline(sys.argv[1])
......
import asyncio
import base64 import base64
import io import io
import os import os
...@@ -87,6 +88,72 @@ async def fetch_resource(url, timeout): ...@@ -87,6 +88,72 @@ async def fetch_resource(url, timeout):
return content 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): async def preload_data(inp, inp_type, typ, val):
try: try:
if typ == "url": if typ == "url":
...@@ -102,27 +169,10 @@ async def preload_data(inp, inp_type, typ, val): ...@@ -102,27 +169,10 @@ async def preload_data(inp, inp_type, typ, val):
# check if valid image bytes # check if valid image bytes
if inp_type == "IMAGE": if inp_type == "IMAGE":
image = Image.open(io.BytesIO(data)) data = await asyncio.to_thread(format_image_data, data)
logger.info(f"load image: {image.size}")
assert image.size[0] > 0 and image.size[1] > 0, "image is empty"
elif inp_type == "AUDIO": elif inp_type == "AUDIO":
if typ != "stream": if typ != "stream":
try: data = await asyncio.to_thread(format_audio_data, data)
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")
else: else:
raise Exception(f"cannot parse inp_type={inp_type} data") raise Exception(f"cannot parse inp_type={inp_type} data")
return data return data
...@@ -152,3 +202,21 @@ def check_params(params, raw_inputs, raw_outputs, types): ...@@ -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" assert stream_audio, "stream audio is not supported, please set env STREAM_AUDIO=1"
elif types[x] == "VIDEO": elif types[x] == "VIDEO":
assert stream_video, "stream video is not supported, please set env STREAM_VIDEO=1" 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 queue
import random
import signal import signal
import socket import socket
import subprocess import subprocess
...@@ -18,14 +19,13 @@ class VARecorder: ...@@ -18,14 +19,13 @@ class VARecorder:
livestream_url: str, livestream_url: str,
fps: float = 16.0, fps: float = 16.0,
sample_rate: int = 16000, sample_rate: int = 16000,
audio_port: int = 30200,
video_port: int = 30201,
): ):
self.livestream_url = livestream_url self.livestream_url = livestream_url
self.fps = fps self.fps = fps
self.sample_rate = sample_rate self.sample_rate = sample_rate
self.audio_port = audio_port self.audio_port = random.choice(range(32000, 40000))
self.video_port = video_port 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.width = None
self.height = None self.height = None
...@@ -116,6 +116,58 @@ class VARecorder: ...@@ -116,6 +116,58 @@ class VARecorder:
finally: finally:
logger.info("Video push worker thread stopped") 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): def start_ffmpeg_process_rtmp(self):
"""Start ffmpeg process that connects to our TCP sockets""" """Start ffmpeg process that connects to our TCP sockets"""
ffmpeg_cmd = [ ffmpeg_cmd = [
...@@ -240,7 +292,7 @@ class VARecorder: ...@@ -240,7 +292,7 @@ class VARecorder:
elif self.livestream_url.startswith("http"): elif self.livestream_url.startswith("http"):
self.start_ffmpeg_process_whip() self.start_ffmpeg_process_whip()
else: 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.audio_thread = threading.Thread(target=self.audio_worker)
self.video_thread = threading.Thread(target=self.video_worker) self.video_thread = threading.Thread(target=self.video_worker)
self.audio_thread.start() self.audio_thread.start()
...@@ -353,12 +405,13 @@ if __name__ == "__main__": ...@@ -353,12 +405,13 @@ if __name__ == "__main__":
recorder = VARecorder( recorder = VARecorder(
# livestream_url="rtmp://localhost/live/test", # 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, fps=fps,
sample_rate=sample_rate, 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, ori_sr = ta.load(audio_path)
audio_array = ta.functional.resample(audio_array.mean(0), orig_freq=ori_sr, new_freq=16000) audio_array = ta.functional.resample(audio_array.mean(0), orig_freq=ori_sr, new_freq=16000)
audio_array = audio_array.numpy().reshape(-1) audio_array = audio_array.numpy().reshape(-1)
......
...@@ -236,6 +236,11 @@ async def prepare_subtasks(task_id): ...@@ -236,6 +236,11 @@ async def prepare_subtasks(task_id):
await server_monitor.pending_subtasks_add(sub["queue"], sub["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") @app.get("/api/v1/model/list")
async def api_v1_model_list(user=Depends(verify_user_access)): async def api_v1_model_list(user=Depends(verify_user_access)):
try: try:
...@@ -254,6 +259,7 @@ async def api_v1_task_submit(request: Request, user=Depends(verify_user_access)) ...@@ -254,6 +259,7 @@ async def api_v1_task_submit(request: Request, user=Depends(verify_user_access))
return error_response(msg, 400) return error_response(msg, 400)
params = await request.json() params = await request.json()
keys = [params.pop("task"), params.pop("model_cls"), params.pop("stage")] 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" assert len(params["prompt"]) > 0, "valid prompt is required"
# get worker infos, model input names # get worker infos, model input names
...@@ -303,7 +309,7 @@ async def api_v1_task_query(request: Request, user=Depends(verify_user_access)): ...@@ -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) task, subtasks = await task_manager.query_task(task_id, user["user_id"], only_task=False)
if task is not None: if task is not None:
task["subtasks"] = await server_monitor.format_subtask(subtasks) task["subtasks"] = await server_monitor.format_subtask(subtasks)
task["status"] = task["status"].name format_task(task)
tasks.append(task) tasks.append(task)
return {"tasks": tasks} return {"tasks": tasks}
...@@ -313,7 +319,7 @@ async def api_v1_task_query(request: Request, user=Depends(verify_user_access)): ...@@ -313,7 +319,7 @@ async def api_v1_task_query(request: Request, user=Depends(verify_user_access)):
if task is None: if task is None:
return error_response(f"Task {task_id} not found", 404) return error_response(f"Task {task_id} not found", 404)
task["subtasks"] = await server_monitor.format_subtask(subtasks) task["subtasks"] = await server_monitor.format_subtask(subtasks)
task["status"] = task["status"].name format_task(task)
return task return task
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
...@@ -344,7 +350,7 @@ async def api_v1_task_list(request: Request, user=Depends(verify_user_access)): ...@@ -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) tasks = await task_manager.list_tasks(**query_params)
for task in tasks: for task in tasks:
task["status"] = task["status"].name format_task(task)
return {"tasks": tasks, "pagination": page_info} return {"tasks": tasks, "pagination": page_info}
except Exception as e: except Exception as e:
...@@ -457,12 +463,18 @@ async def api_v1_task_cancel(request: Request, user=Depends(verify_user_access)) ...@@ -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)): async def api_v1_task_resume(request: Request, user=Depends(verify_user_access)):
try: try:
task_id = request.query_params["task_id"] 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) 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) await prepare_subtasks(task_id)
return {"msg": "ok"} return {"msg": "ok"}
else: 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: except Exception as e:
traceback.print_exc() traceback.print_exc()
return error_response(str(e), 500) return error_response(str(e), 500)
...@@ -605,7 +617,7 @@ async def api_v1_worker_ping_subtask(request: Request, valid=Depends(verify_work ...@@ -605,7 +617,7 @@ async def api_v1_worker_ping_subtask(request: Request, valid=Depends(verify_work
queue = params.pop("queue") queue = params.pop("queue")
task = await task_manager.query_task(task_id) 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"} return {"msg": "delete"}
assert await task_manager.ping_subtask(task_id, worker_name, identity) 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 ...@@ -714,27 +726,18 @@ async def api_v1_template_list(request: Request, valid=Depends(verify_user_acces
if page <= total_pages: if page <= total_pages:
start_idx = (page - 1) * page_size start_idx = (page - 1) * page_size
end_idx = start_idx + 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]: async def handle_media(media_type, media_names, paginated_media_templates):
url = await data_manager.presign_template_url("images", image) 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: if url is None:
url = f"./assets/template/images/{image}" url = f"./assets/template/{media_type}/{media_name}"
paginated_image_templates.append({"filename": image, "url": url}) paginated_media_templates.append({"filename": media_name, "url": url})
for audio in all_audios[start_idx:end_idx]: await handle_media("images", all_images, paginated_image_templates)
url = await data_manager.presign_template_url("audios", audio) await handle_media("audios", all_audios, paginated_audio_templates)
if url is None: await handle_media("videos", all_videos, paginated_video_templates)
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})
return { return {
"templates": {"images": paginated_image_templates, "audios": paginated_audio_templates, "videos": paginated_video_templates}, "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 ...@@ -760,6 +763,7 @@ async def api_v1_template_tasks(request: Request, valid=Depends(verify_user_acce
page_size = min(page_size, 100) page_size = min(page_size, 100)
all_templates = [] all_templates = []
all_categories = set()
template_files = await data_manager.list_template_files("tasks") template_files = await data_manager.list_template_files("tasks")
template_files = [] if template_files is None else template_files 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 ...@@ -767,6 +771,8 @@ async def api_v1_template_tasks(request: Request, valid=Depends(verify_user_acce
try: try:
bytes_data = await data_manager.load_template_file("tasks", template_file) bytes_data = await data_manager.load_template_file("tasks", template_file)
template_data = json.loads(bytes_data) template_data = json.loads(bytes_data)
template_data["task"]["model_cls"] = model_pipelines.outer_model_name(template_data["task"]["model_cls"])
all_categories.update(template_data["task"]["tags"])
if category is not None and category != "all" and category not in template_data["task"]["tags"]: if category is not None and category != "all" and category not in template_data["task"]["tags"]:
continue continue
if search is not None and search not in template_data["task"]["params"]["prompt"] + template_data["task"]["params"]["negative_prompt"] + template_data["task"][ 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 ...@@ -787,7 +793,7 @@ async def api_v1_template_tasks(request: Request, valid=Depends(verify_user_acce
end_idx = start_idx + page_size end_idx = start_idx + page_size
paginated_templates = all_templates[start_idx:end_idx] 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: except Exception as e:
traceback.print_exc() traceback.print_exc()
......
...@@ -92,6 +92,11 @@ class WorkerClient: ...@@ -92,6 +92,11 @@ class WorkerClient:
if elapse > self.offline_timeout: if elapse > self.offline_timeout:
logger.warning(f"Worker {self.identity} {self.queue} offline timeout2: {elapse:.2f} s") logger.warning(f"Worker {self.identity} {self.queue} offline timeout2: {elapse:.2f} s")
return False 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 return True
...@@ -111,7 +116,7 @@ class ServerMonitor: ...@@ -111,7 +116,7 @@ class ServerMonitor:
self.fetching_timeout = self.config.get("fetching_timeout", 1000) self.fetching_timeout = self.config.get("fetching_timeout", 1000)
for queue in self.all_queues: 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_created_timeout = self.config["subtask_created_timeout"]
self.subtask_pending_timeout = self.config["subtask_pending_timeout"] self.subtask_pending_timeout = self.config["subtask_pending_timeout"]
self.worker_avg_window = self.config["worker_avg_window"] self.worker_avg_window = self.config["worker_avg_window"]
......
...@@ -221,7 +221,6 @@ ...@@ -221,7 +221,6 @@
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 14px 40px rgba(140, 110, 255, 0.55); box-shadow: 0 14px 40px rgba(140, 110, 255, 0.55);
} }
/* 修复布局问题 */ /* 修复布局问题 */
.task-type-btn { .task-type-btn {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
...@@ -577,6 +576,53 @@ ...@@ -577,6 +576,53 @@
padding: 0 1rem; 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 { .task-detail-panel {
max-width: none; max-width: none;
...@@ -755,14 +801,9 @@ ...@@ -755,14 +801,9 @@
@media (max-width: 640px) { @media (max-width: 640px) {
/* 通用移动端样式 */ /* 通用移动端样式 */
.mobile-bottom-nav { .mobile-bottom-nav {
position: fixed !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
width: 100% !important; width: 100% !important;
height: auto !important; height: auto !important;
padding: 0 !important; padding: 0 !important;
background: rgba(11, 10, 32, 0.95) !important;
backdrop-filter: blur(20px) !important; backdrop-filter: blur(20px) !important;
border-top: 1px solid rgba(154, 114, 255, 0.2) !important; border-top: 1px solid rgba(154, 114, 255, 0.2) !important;
z-index: 50 !important; z-index: 50 !important;
...@@ -784,10 +825,6 @@ ...@@ -784,10 +825,6 @@
flex-shrink: 0 !important; flex-shrink: 0 !important;
} }
.mobile-content {
margin-bottom: 5rem !important;
}
.sms-login-form .input-group { .sms-login-form .input-group {
flex-direction: row !important; flex-direction: row !important;
gap: 12px !important; gap: 12px !important;
...@@ -820,19 +857,7 @@ ...@@ -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 { .p-2.flex.flex-col.justify-center.h-full {
...@@ -879,10 +904,6 @@ ...@@ -879,10 +904,6 @@
flex-shrink: 0 !important; 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 { .flex-1.overflow-y-auto.p-10.content-area.main-scrollbar {
...@@ -951,11 +972,6 @@ ...@@ -951,11 +972,6 @@
gap: 1rem !important; gap: 1rem !important;
} }
/* 模板卡片在移动端调整 */
.bg-dark-light.rounded-xl.overflow-hidden {
margin: 0 !important;
}
/* 模态框在移动端调整 */ /* 模态框在移动端调整 */
.fixed.inset-0.z-50 { .fixed.inset-0.z-50 {
padding: 1rem !important; padding: 1rem !important;
...@@ -2444,7 +2460,7 @@ ...@@ -2444,7 +2460,7 @@
</div> </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="floating-particles">
<div class="particle"></div> <div class="particle"></div>
...@@ -2573,7 +2589,7 @@ ...@@ -2573,7 +2589,7 @@
</div> </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="floating-particles">
<div class="particle"></div> <div class="particle"></div>
...@@ -2603,10 +2619,6 @@ ...@@ -2603,10 +2619,6 @@
<!-- 顶部栏 --> <!-- 顶部栏 -->
<div class="top-bar"> <div class="top-bar">
<div class="top-bar-content"> <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"> <div class="top-bar-left">
<i class="fas fa-video text-gradient-icon mr-2 text-xl"></i> <i class="fas fa-video text-gradient-icon mr-2 text-xl"></i>
<span class="text-lg">LightX2V</span> <span class="text-lg">LightX2V</span>
...@@ -2793,7 +2805,7 @@ ...@@ -2793,7 +2805,7 @@
</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;"> style="overflow-x: visible;">
<div v-cloak> <div v-cloak>
<div v-if="filteredTasks.length === 0" <div v-if="filteredTasks.length === 0"
...@@ -2802,32 +2814,39 @@ ...@@ -2802,32 +2814,39 @@
<p class="text-gray-500 text-xs mt-1">{{ t('startToCreateYourFirstAIVideo') }}</p> <p class="text-gray-500 text-xs mt-1">{{ t('startToCreateYourFirstAIVideo') }}</p>
</div> </div>
<div v-else <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" <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">
@click="openTaskDetailModal(task)"
:title="viewTaskDetail">
<!-- 缩略图区域 --> <!-- 缩略图区域 -->
<div class="relative mb-3"> <div class="cursor-pointer bg-gray-800 relative flex flex-col"
<div class="w-full h-auto bg-dark-light rounded-lg overflow-hidden"> @click="openTaskDetailModal(task)"
:title="t('viewTaskDetails')">
<!-- 视频预览 -->
<!-- 成功任务:显示视频动图 --> <!-- 成功任务:显示视频动图 -->
<video v-if="task.status === 'SUCCEED'" <video v-if="task.status === 'SUCCEED' && task.outputs?.output_video"
:src="getTaskVideoUrl(task.task_id, 'output_video')" :src="getTaskFileUrlSync(task.task_id, 'output_video')"
:poster="getTaskImageUrl(task)" :poster="getTaskFileUrlSync(task.task_id, 'input_image')"
class="w-full h-auto object-cover group-hover:scale-105 transition-transform duration-300" class="w-full h-auto object-contain group-hover:scale-105 transition-transform duration-300"
playsinline webkit-playsinline preload="auto" playsinline webkit-playsinline
@mouseenter="playVideo($event)" @mouseenter="playVideo($event)"
@mouseleave="pauseVideo($event)" @mouseleave="pauseVideo($event)"
@loadeddata="onVideoLoaded($event)" @loadeddata="onVideoLoaded($event)"
@ended="onVideoEnded($event)"
@error="onVideoError($event)"></video> @error="onVideoError($event)"></video>
<!-- 其他状态:显示输入图片或占位符 --> <!-- 其他状态:显示输入图片或占位符 -->
<img v-else <img v-else="task.inputs?.input_image"
:src="getTaskImageUrl(task)" :alt="t('taskPreview')" :src="getTaskFileUrlSync(task.task_id, 'input_image')"
class="w-full h-auto object-cover bg-dark-light transition-transform duration-300 group-hover:scale-105" class="w-full h-auto object-contain group-hover:scale-105 transition-transform duration-300"
@error="handleThumbnailError" /> @error="handleThumbnailError" />
</div>
<!-- 移动端播放按钮 -->
<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"> <div class="absolute top-2 right-2">
...@@ -2837,43 +2856,43 @@ ...@@ -2837,43 +2856,43 @@
</span> </span>
</div> </div>
<!-- 悬停时显示的操作按钮 --> <!-- 悬停时显示的操作按钮(桌面端) -->
<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"> 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-3 pointer-events-auto"> <div class="flex space-x-2 sm:space-x-3 pointer-events-auto">
<button <button
v-if="['CREATED', 'PENDING', 'RUNNING','SUCCEED', 'FAILED', 'CANCEL'].includes(task.status)" v-if="['CREATED', 'PENDING', 'RUNNING','SUCCEED', 'FAILED', 'CANCEL'].includes(task.status)"
@click.stop="reuseTask(task)" @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')"> :title="t('reuseTask')">
<i class="fas fa-copy text-lg"></i> <i class="fas fa-copy text-sm sm:text-lg"></i>
</button> </button>
<button <button
v-if="['CREATED', 'PENDING', 'RUNNING'].includes(task.status)" v-if="['CREATED', 'PENDING', 'RUNNING'].includes(task.status)"
@click.stop="cancelTask(task.task_id)" @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" 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')"> :title="t('cancelTask')">
<i class="fas fa-times text-lg"></i> <i class="fas fa-times text-sm sm:text-lg"></i>
</button> </button>
<button <button
v-if="['SUCCEED', 'FAILED', 'CANCEL'].includes(task.status)" v-if="['SUCCEED', 'FAILED', 'CANCEL'].includes(task.status)"
@click.stop="resumeTask(task.task_id)" @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')"> :title="t('retryTask')">
<i class="fas fa-redo text-lg"></i> <i class="fas fa-redo text-sm sm:text-lg"></i>
</button> </button>
<button v-if="task.status === 'SUCCEED'" <button v-if="task.status === 'SUCCEED'"
@click.stop="downloadTaskOutput(task)" @click.stop="downloadFile(task.outputs?.output_video)"
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('downloadTask')"> :title="t('downloadTask')">
<i class="fas fa-download text-lg"></i> <i class="fas fa-download text-sm sm:text-lg"></i>
</button> </button>
<button <button
v-if="['SUCCEED', 'FAILED', 'CANCEL'].includes(task.status)" v-if="['SUCCEED', 'FAILED', 'CANCEL'].includes(task.status)"
@click.stop="deleteTask(task.task_id)" @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')"> :title="t('deleteTask')">
<i class="fas fa-trash text-lg"></i> <i class="fas fa-trash text-sm sm:text-lg"></i>
</button> </button>
</div> </div>
</div> </div>
...@@ -3038,7 +3057,7 @@ ...@@ -3038,7 +3057,7 @@
</div> </div>
</div> </div>
<div class="overflow-y-auto flex-1 max-h-[60vh] main-scrollbar"> <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" <div v-for="template in imageTemplates" :key="template.filename"
@click="selectImageTemplate(template)" @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"> class="break-inside-avoid mb-4 relative group cursor-pointer rounded-lg border border-gray-700 hover:border-laser-purple/50 transition-all">
...@@ -3199,6 +3218,7 @@ ...@@ -3199,6 +3218,7 @@
</div> </div>
</div> </div>
<!-- 任务创建面板 --> <!-- 任务创建面板 -->
<div class="max-w-4xl mx-auto" id="task-creator"> <div class="max-w-4xl mx-auto" id="task-creator">
<!-- 合并的创作区域 --> <!-- 合并的创作区域 -->
...@@ -3354,27 +3374,34 @@ ...@@ -3354,27 +3374,34 @@
<div class="flex justify-between items-center mb-2"> <div class="flex justify-between items-center mb-2">
<label class="block text-sm text-gray-400 flex items-center"> <label class="block text-sm text-gray-400 flex items-center">
{{ t('image') }} {{ 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> </label>
</div> </div>
<!-- 上传图片 --> <!-- 上传图片 -->
<div class="upload-area" <div class="upload-area"
@click="triggerImageUpload"> >
<!-- 默认上传界面 --> <!-- 默认上传界面 -->
<div v-if="!getCurrentImagePreview()" class="upload-content"> <div v-if="!getCurrentImagePreview()" class="upload-content">
<div class="upload-icon"> <p class="text-base text-white font-bold mb-4">{{ t('uploadImage') }}</p>
<i class="fas fa-image text-gradient-icon text-xl"></i>
</div>
<p class="text-xs text-gray-400 mb-4">{{ t('supportedImageFormats') }}</p> <p class="text-xs text-gray-400 mb-4">{{ t('supportedImageFormats') }}</p>
<div class="flex gap-2"> <div class="flex items-center justify-center space-x-6">
<div class="flex flex-col items-center space-y-2">
<button <button
class="btn-primary px-4 py-1.5 rounded-lg transition-all flex-1">{{ t('uploadImage') }}</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>
</div> </div>
...@@ -3385,7 +3412,7 @@ ...@@ -3385,7 +3412,7 @@
<!-- 悬停时显示的操作按钮,位置在中下方 --> <!-- 悬停时显示的操作按钮,位置在中下方 -->
<div <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"> <div class="flex space-x-3">
<button @click.stop="triggerImageUpload" <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" 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 @@ ...@@ -3411,45 +3438,73 @@
<div class="flex justify-between items-center mb-2"> <div class="flex justify-between items-center mb-2">
<label class="block text-sm text-gray-400 flex items-center"> <label class="block text-sm text-gray-400 flex items-center">
{{ t('audio') }} {{ 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> </label>
</div> </div>
<!-- 上传音频 --> <!-- 上传音频 -->
<div class="upload-area"> <div class="upload-area">
<!-- 默认上传界面 --> <!-- 默认上传界面 -->
<div v-if="!getCurrentAudioPreview()" class="upload-content" <div v-if="!getCurrentAudioPreview()" class="upload-content"
@click="triggerAudioUpload"> >
<div class="upload-icon"> <p class="text-base text-white font-bold mb-4">{{ t('uploadAudio') }}</p>
<i class="fas fa-microphone text-gradient-icon text-xl"></i>
</div>
<p class="text-xs text-gray-400 mb-4">{{ t('supportedAudioFormats') }}</p> <p class="text-xs text-gray-400 mb-4">{{ t('supportedAudioFormats') }}</p>
<div class="flex gap-2"> <div class="flex items-center justify-center space-x-6">
<div class="flex flex-col items-center space-y-2">
<button <button
class="btn-primary px-4 py-1.5 rounded-lg transition-all flex-1">{{ t('uploadAudio') }}</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> </div>
<!-- 音频预览 --> <!-- 音频预览 -->
<div v-if="getCurrentAudioPreview()" class="audio-preview group"> <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"> <source :src="getCurrentAudioPreviewUrl()" :type="getAudioMimeType()" preload="metadata">
</audio> </audio>
<!-- 悬停时显示的操作按钮,位置在中下方 --> <!-- 悬停时显示的操作按钮,位置在中下方 -->
<div <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"> <div class="flex space-x-3">
<button @click.stop="triggerAudioUpload" <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" 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')"> :title="t('reupload')">
<i class="fas fa-upload text-lg"></i> <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>
<button @click.stop="removeAudio" <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" 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 @@ ...@@ -3477,7 +3532,7 @@
</button> </button>
</label> </label>
<div class="text-xs text-gray-400"> <div class="text-xs text-gray-400">
{{ getCurrentForm().prompt?.length || 0 }} / 500 {{ getCurrentForm().prompt?.length || 0 }} / 1000
</div> </div>
</div> </div>
<div class="relative group cursor-pointer"> <div class="relative group cursor-pointer">
...@@ -3504,7 +3559,9 @@ ...@@ -3504,7 +3559,9 @@
<div class="flex justify-center mt-2"> <div class="flex justify-center mt-2">
<button @click="submitTask" :disabled="submitting" <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"> 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') }} {{ submitting ? t('submitting') : t('generateVideo') }}
</button> </button>
</div> </div>
...@@ -3549,13 +3606,13 @@ ...@@ -3549,13 +3606,13 @@
<!-- 分类筛选 --> <!-- 分类筛选 -->
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">
<button v-for="category in dynamicInspirationCategories" :key="category.id" <button v-for="category in InspirationCategories" :key="category"
@click="selectInspirationCategory(category.id)" @click="selectInspirationCategory(category)"
:class="selectedInspirationCategory === category.id :class="selectedInspirationCategory === category
? 'bg-laser-purple/20 text-white border-laser-purple/40' ? 'bg-laser-purple/20 text-white border-laser-purple/40'
: 'bg-dark-light text-gray-400 hover:text-white hover:bg-dark-light/80'" : '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"> class="px-4 py-2 rounded-lg border border-transparent transition-all duration-200 text-sm font-medium">
{{ category.name }} {{ category }}
</button> </button>
</div> </div>
</div> </div>
...@@ -3611,12 +3668,12 @@ ...@@ -3611,12 +3668,12 @@
</div> </div>
</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;"> 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" <div v-for="item in inspirationItems" :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"> 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" <div class="cursor-pointer bg-gray-800 relative flex flex-col"
@click="previewTemplateDetail(item)" @click="previewTemplateDetail(item)"
...@@ -3629,45 +3686,42 @@ ...@@ -3629,45 +3686,42 @@
preload="auto" playsinline webkit-playsinline preload="auto" playsinline webkit-playsinline
@mouseenter="playVideo($event)" @mouseleave="pauseVideo($event)" @mouseenter="playVideo($event)" @mouseleave="pauseVideo($event)"
@loadeddata="onVideoLoaded($event)" @loadeddata="onVideoLoaded($event)"
@ended="onVideoEnded($event)"
@error="onVideoError($event)"></video> @error="onVideoError($event)"></video>
<!-- 图片缩略图 --> <!-- 图片缩略图 -->
<img v-else="item?.inputs?.input_image" <img v-else
:src="getTemplateFileUrl(item.inputs.input_image,'images')" :src="getTemplateFileUrl(item.inputs.input_image,'images')"
:alt="item.params?.prompt || '模板图片'" :alt="item.params?.prompt || '模板图片'"
class="w-full h-auto object-contain group-hover:scale-105 transition-transform duration-300" class="w-full h-auto object-contain group-hover:scale-105 transition-transform duration-300"
@error="handleThumbnailError" /> @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 <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"> <div class="flex space-x-3 pointer-events-auto">
<button @click="applyTemplateImage(item)" <button @click.stop="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" 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')"> :title="t('applyImage')">
<i class="fas fa-image text-sm"></i> <i class="fas fa-image text-sm"></i>
</button> </button>
<button @click="applyTemplateAudio(item)" <button @click.stop="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" 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')"> :title="t('applyAudio')">
<i class="fas fa-music text-sm"></i> <i class="fas fa-music text-sm"></i>
</button> </button>
<button @click="useTemplate(item)" <button @click.stop="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" 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')"> :title="t('useTemplate')">
<i class="fas fa-clone text-sm"></i> <i class="fas fa-clone text-sm"></i>
</button> </button>
</div> </div>
</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>
</div> </div>
...@@ -3679,35 +3733,7 @@ ...@@ -3679,35 +3733,7 @@
</div> </div>
</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> <div v-cloak>
...@@ -3884,7 +3910,7 @@ ...@@ -3884,7 +3910,7 @@
<div v-if="showTemplateDetailModal" <div v-if="showTemplateDetailModal"
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
@click="closeTemplateDetailModal"> @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"> <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"> <h3 class="text-xl font-medium text-white flex items-center">
...@@ -3907,10 +3933,23 @@ ...@@ -3907,10 +3933,23 @@
</div> </div>
<!-- 弹窗内容 --> <!-- 弹窗内容 -->
<div class="flex h-[calc(100vh-120px)] overflow-y-auto main-scrollbar"> <div class="flex flex-col lg:flex-row h-[calc(100vh-120px)] overflow-y-auto main-scrollbar">
<!-- 左侧输入素材区域 --> <!-- 左侧视频播放区域 -->
<div class="w-2/5 border-r border-gray-700"> <div class="flex-1 lg:flex-1">
<div class="p-6 space-y-6"> <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 v-if="selectedTemplate?.inputs?.input_image" class="space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
...@@ -3920,7 +3959,7 @@ ...@@ -3920,7 +3959,7 @@
{{ t('applyImage') }} {{ t('applyImage') }}
</button> </button>
</div> </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'))"> @click="showImageZoom(getTemplateFileUrl(selectedTemplate.inputs.input_image,'images'))">
<img :src="getTemplateFileUrl(selectedTemplate.inputs.input_image,'images')" :alt="'输入图片'" <img :src="getTemplateFileUrl(selectedTemplate.inputs.input_image,'images')" :alt="'输入图片'"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" /> class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" />
...@@ -3967,20 +4006,6 @@ ...@@ -3967,20 +4006,6 @@
</div> </div>
</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> </div>
</div> </div>
...@@ -4013,7 +4038,7 @@ ...@@ -4013,7 +4038,7 @@
@click="closeTaskDetailModal"> @click="closeTaskDetailModal">
<!-- 任务完成时的大弹窗 --> <!-- 任务完成时的大弹窗 -->
<div v-if="modalTask?.status === 'SUCCEED'" <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"> <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"> <h3 class="text-xl font-medium text-white flex items-center">
...@@ -4027,10 +4052,10 @@ ...@@ -4027,10 +4052,10 @@
</div> </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="flex-1 p-6 order-1 lg:order-1">
<div class="h-full bg-black rounded-xl border border-laser-purple/50 overflow-hidden"> <div class="h-64 lg:h-full bg-black rounded-xl border border-laser-purple/50 overflow-hidden">
<video <video
v-if="selectedTaskFiles.outputs.output_video && selectedTaskFiles.outputs.output_video.url" v-if="selectedTaskFiles.outputs.output_video && selectedTaskFiles.outputs.output_video.url"
class="w-full h-full object-contain" controls preload="metadata" class="w-full h-full object-contain" controls preload="metadata"
...@@ -4054,7 +4079,7 @@ ...@@ -4054,7 +4079,7 @@
</div> </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"> <div class="flex flex-col space-y-3 items-center">
<button v-if="['CREATED', 'PENDING', 'RUNNING'].includes(modalTask?.status)" <button v-if="['CREATED', 'PENDING', 'RUNNING'].includes(modalTask?.status)"
...@@ -4226,7 +4251,7 @@ ...@@ -4226,7 +4251,7 @@
</div> </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> @click.stop>
<!-- 弹窗头部 --> <!-- 弹窗头部 -->
<div class="flex items-center justify-between p-6 border-b border-gray-700"> <div class="flex items-center justify-between p-6 border-b border-gray-700">
...@@ -4242,7 +4267,7 @@ ...@@ -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)" <div v-if="['CREATED', 'PENDING', 'RUNNING'].includes(modalTask?.status)"
class="max-w-4xl mx-auto task-running-panel"> class="max-w-4xl mx-auto task-running-panel">
...@@ -4283,7 +4308,7 @@ ...@@ -4283,7 +4308,7 @@
class="subtask-item"> class="subtask-item">
<div class="subtask-header"> <div class="subtask-header">
<span class="text-sm font-medium">{{ t('subtask') }} {{ index + 1 }}</span> <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) }} {{ getSubtaskStatusText(subtask.status) }}
</span> </span>
</div> </div>
...@@ -4551,6 +4576,36 @@ ...@@ -4551,6 +4576,36 @@
</div> </div>
</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>
</div> </div>
...@@ -4567,8 +4622,16 @@ ...@@ -4567,8 +4622,16 @@
const loginLoading = ref(false); const loginLoading = ref(false);
const initLoading = ref(false); const initLoading = ref(false);
const downloadLoading = 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 alert = ref({ show: false, message: '', type: 'info' });
// 短信登录相关数据 // 短信登录相关数据
const phoneNumber = ref(''); const phoneNumber = ref('');
const verifyCode = ref(''); const verifyCode = ref('');
...@@ -4611,7 +4674,7 @@ ...@@ -4611,7 +4674,7 @@
noHistoryTasks: '暂无历史任务', noHistoryTasks: '暂无历史任务',
templateDetail: '模板详情', templateDetail: '模板详情',
viewTemplateDetail: '查看模板详情', viewTemplateDetail: '查看模板详情',
viewTaskDetail: '查看任务详情', viewTaskDetails: '查看任务详情',
templateInfo: '模板信息', templateInfo: '模板信息',
useTemplate: '使用模板', useTemplate: '使用模板',
model: '模型', model: '模型',
...@@ -4629,12 +4692,13 @@ ...@@ -4629,12 +4692,13 @@
promptCopied: '提示词已复制到剪贴板', promptCopied: '提示词已复制到剪贴板',
outputVideo: '输出视频', outputVideo: '输出视频',
audio: '音频', audio: '音频',
optional: '(选填)',
// 页面标题 // 页面标题
pageTitle: 'LightX2V服务', pageTitle: 'LightX2V服务',
pleaseEnterThePromptForVideoGeneration: '请输入视频生成提示词', pleaseEnterThePromptForVideoGeneration: '请输入视频生成提示词',
describeTheContentStyleSceneOfTheVideo: '描述视频内容、风格、场景等...', describeTheContentStyleSceneOfTheVideo: '描述视频内容、风格、场景等...',
describeTheDigitalHumanImageBackgroundStyleActionRequirements: '描述数字人形象、背景风格、动作要求等...', describeTheDigitalHumanImageBackgroundStyleActionRequirements: '描述数字人表情、语气、动作等...',
describeTheContentActionRequirementsBasedOnTheImage: '描述基于图片的视频内容、动作要求等...', describeTheContentActionRequirementsBasedOnTheImage: '描述基于图片的视频内容、动作要求等...',
loginSubtitle: '一个强大的视频生成平台', loginSubtitle: '一个强大的视频生成平台',
...@@ -4766,19 +4830,30 @@ ...@@ -4766,19 +4830,30 @@
all: '全部', all: '全部',
// 任务操作 // 任务操作
reuseTask: '复用任务', reuseTask: '复用',
regenerateTask: '新生成', regenerateTask: '',
cancelTask: '取消任务', cancelTask: '取消',
retryTask: '重试任务', retryTask: '重试',
downloadTask: '下载视频', downloadTask: '下载视频',
downloadVideo: '下载视频', downloadVideo: '下载视频',
deleteTask: '删除任务', deleteTask: '删除',
// 任务创建 // 任务创建
createVideo: '创建视频', createVideo: '创建视频',
selectTemplate: '选择模板', selectTemplate: '选择模板',
uploadImage: '上传图片', uploadImage: '上传图片',
uploadAudio: '上传音频', uploadAudio: '上传音频',
recordAudio: '录音',
recording: '录音中...',
takePhoto: '拍照',
retake: '重拍',
usePhoto: '使用照片',
upload: '上传',
stopRecording: '停止录音',
recordingStarted: '开始录音',
recordingStopped: '录音已停止',
recordingCompleted: '录音完成',
recordingFailed: '录音失败',
enterPrompt: '输入提示词', enterPrompt: '输入提示词',
selectModel: '选择模型', selectModel: '选择模型',
startGeneration: '开始生成', startGeneration: '开始生成',
...@@ -4932,7 +5007,7 @@ ...@@ -4932,7 +5007,7 @@
noHistoryTasks: 'No history tasks', noHistoryTasks: 'No history tasks',
templateDetail: 'Template detail', templateDetail: 'Template detail',
viewTemplateDetail: 'View Template Detail', viewTemplateDetail: 'View Template Detail',
viewTaskDetail: 'View Task Detail', viewTaskDetails: 'View Task Details',
templateInfo: 'Template Info', templateInfo: 'Template Info',
useTemplate: 'Use Template', useTemplate: 'Use Template',
model: 'Model', model: 'Model',
...@@ -4940,12 +5015,13 @@ ...@@ -4940,12 +5015,13 @@
inputMaterials: 'Input Materials', inputMaterials: 'Input Materials',
inputImage: 'Input Image', inputImage: 'Input Image',
inputAudio: 'Input Audio', inputAudio: 'Input Audio',
optional: '(Optional)',
// 页面标题 // 页面标题
pageTitle: 'LightX2V Service', pageTitle: 'LightX2V Service',
pleaseEnterThePromptForVideoGeneration: 'Please enter the prompt for video generation', pleaseEnterThePromptForVideoGeneration: 'Please enter the prompt for video generation',
describeTheContentStyleSceneOfTheVideo: 'Describe the content, style, and scene of the video...', 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...', describeTheContentActionRequirementsBasedOnTheImage: 'Describe the content and action requirements based on the image...',
loginSubtitle: 'A powerful video generation platform', loginSubtitle: 'A powerful video generation platform',
whatDoYouWantToDo: 'What do you want to do today?', whatDoYouWantToDo: 'What do you want to do today?',
...@@ -5022,12 +5098,12 @@ ...@@ -5022,12 +5098,12 @@
videoGeneratingFailed: 'Video Generating Failed', videoGeneratingFailed: 'Video Generating Failed',
sorryYourVideoGenerationTaskFailed: 'Sorry Your Video Generation Task Failed', sorryYourVideoGenerationTaskFailed: 'Sorry Your Video Generation Task Failed',
thisTaskHasBeenCancelledYouCanRegenerateOrViewTheMaterialsYouUploadedBefore: 'This Task Has Been Cancelled You Can Regenerate Or View The Materials You Uploaded Before', thisTaskHasBeenCancelledYouCanRegenerateOrViewTheMaterialsYouUploadedBefore: 'This Task Has Been Cancelled You Can Regenerate Or View The Materials You Uploaded Before',
taskCancelled: 'Task Cancelled', taskCancelled: 'Cancelled',
regenerateTask: 'Regenerate Task', regenerateTask: 'Retry',
retryTask: 'Retry Task', retryTask: 'Retry',
downloadTask: 'Download Task', downloadTask: 'Download Video',
downloadVideo: 'Download Video', downloadVideo: 'Download Video',
deleteTask: 'Delete Task', deleteTask: 'Delete',
taskInfo: 'Task Info', taskInfo: 'Task Info',
taskID: 'Task ID', taskID: 'Task ID',
taskType: 'Task Type', taskType: 'Task Type',
...@@ -5046,13 +5122,13 @@ ...@@ -5046,13 +5122,13 @@
status: 'Status', status: 'Status',
// 任务操作 // 任务操作
reuseTask: 'Reuse Task', reuseTask: 'Reuse',
regenerateTask: 'Regenerate Task', regenerateTask: 'Retry',
retryTask: 'Retry Task', retryTask: 'Retry',
downloadTask: 'Download Task', downloadTask: 'Download Video',
downloadVideo: 'Download Video', downloadVideo: 'Download Video',
deleteTask: 'Delete Task', deleteTask: 'Delete',
cancelTask: 'Cancel Task', cancelTask: 'Cancel',
download: 'Download', download: 'Download',
delete: 'Delete', delete: 'Delete',
...@@ -5061,6 +5137,17 @@ ...@@ -5061,6 +5137,17 @@
selectTemplate: 'Select Template', selectTemplate: 'Select Template',
uploadImage: 'Upload Image', uploadImage: 'Upload Image',
uploadAudio: 'Upload Audio', 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', enterPrompt: 'Enter Prompt',
selectModel: 'Select Model', selectModel: 'Select Model',
startGeneration: 'Start Generation', startGeneration: 'Start Generation',
...@@ -5106,7 +5193,7 @@ ...@@ -5106,7 +5193,7 @@
uploadAudioFile: 'Upload Audio File', uploadAudioFile: 'Upload Audio File',
dragDropHere: 'Drag and drop files here or click to upload', dragDropHere: 'Drag and drop files here or click to upload',
supportedImageFormats: 'Supported jpg, png, webp image formats (< 10MB)', 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', maxFileSize: 'Max file size',
// 任务详情 // 任务详情
...@@ -5256,10 +5343,24 @@ ...@@ -5256,10 +5343,24 @@
const showTaskDetailModal = ref(false); const showTaskDetailModal = ref(false);
const modalTask = ref(null); 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 inspirationSearchQuery = ref('');
const selectedInspirationCategory = ref('all'); const selectedInspirationCategory = ref('all');
const inspirationItems = ref([]); const inspirationItems = ref([]);
const InspirationCategories = ref([]);
// 灵感广场分页相关变量 // 灵感广场分页相关变量
const inspirationPagination = ref(null); const inspirationPagination = ref(null);
...@@ -5661,103 +5762,10 @@ ...@@ -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缓存 // 通用URL缓存
const urlCache = ref(new Map()); 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缓存函数 // 通用URL缓存函数
const getCachedUrl = (key, urlGenerator) => { const getCachedUrl = (key, urlGenerator) => {
if (urlCache.value.has(key)) { if (urlCache.value.has(key)) {
...@@ -5860,8 +5868,21 @@ ...@@ -5860,8 +5868,21 @@
return response; 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 () => { const loginWithGitHub = async () => {
try { try {
beforeLogin();
console.log('starting login') console.log('starting login')
loginLoading.value = true; loginLoading.value = true;
const response = await fetch('./auth/login/github'); const response = await fetch('./auth/login/github');
...@@ -5875,6 +5896,9 @@ ...@@ -5875,6 +5896,9 @@
const loginWithGoogle = async () => { const loginWithGoogle = async () => {
try { try {
beforeLogin();
console.log('starting login')
loginLoading.value = true;
const response = await fetch('./auth/login/google'); const response = await fetch('./auth/login/google');
const data = await response.json(); const data = await response.json();
localStorage.setItem('loginSource', 'google'); localStorage.setItem('loginSource', 'google');
...@@ -5899,6 +5923,7 @@ ...@@ -5899,6 +5923,7 @@
} }
try { try {
beforeLogin();
const response = await fetch(`./auth/login/sms?phone_number=${phoneNumber.value}`); const response = await fetch(`./auth/login/sms?phone_number=${phoneNumber.value}`);
const data = await response.json(); const data = await response.json();
...@@ -5937,6 +5962,14 @@ ...@@ -5937,6 +5962,14 @@
// 登录成功后初始化数据 // 登录成功后初始化数据
await init(); await init();
// 登录成功后跳转到用户原本想访问的页面,如果没有则跳转到创建页面
if (currentView.value === 'login') {
currentView.value = 'create';
updateURL('create');
} else {
updateURL(currentView.value);
}
showAlert('登录成功', 'success'); showAlert('登录成功', 'success');
} else { } else {
showAlert(data.message || '验证码错误或已过期', 'danger'); showAlert(data.message || '验证码错误或已过期', 'danger');
...@@ -6038,6 +6071,14 @@ ...@@ -6038,6 +6071,14 @@
// 登录成功后初始化数据 // 登录成功后初始化数据
await init(); await init();
// 登录成功后跳转到用户原本想访问的页面,如果没有则跳转到创建页面
if (currentView.value === 'login') {
currentView.value = 'create';
updateURL('create');
} else {
updateURL(currentView.value);
}
// 清除URL中的code参数 // 清除URL中的code参数
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState({}, document.title, window.location.pathname);
} else { } else {
...@@ -6054,7 +6095,7 @@ ...@@ -6054,7 +6095,7 @@
localStorage.removeItem('accessToken'); localStorage.removeItem('accessToken');
localStorage.removeItem('currentUser'); localStorage.removeItem('currentUser');
clearAllCache(); clearAllCache();
switchToLoginView();
currentUser.value = {}; currentUser.value = {};
isLoggedIn.value = false; isLoggedIn.value = false;
models.value = []; models.value = [];
...@@ -6177,10 +6218,30 @@ ...@@ -6177,10 +6218,30 @@
if (response.ok) { if (response.ok) {
const blob = await response.blob(); 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); templateFileCache.value.set(cacheKey, file);
console.log('下载素材文件完成:', template.filename);
return file; return file;
} else { } else {
throw new Error('下载素材文件失败'); throw new Error('下载素材文件失败');
...@@ -6223,6 +6284,7 @@ ...@@ -6223,6 +6284,7 @@
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
setCurrentAudioPreview(e.target.result); setCurrentAudioPreview(e.target.result);
updateUploadedContentStatus();
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
...@@ -6367,6 +6429,91 @@ ...@@ -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 () => { const submitTask = async () => {
try { try {
const currentForm = getCurrentForm(); const currentForm = getCurrentForm();
...@@ -6383,12 +6530,16 @@ ...@@ -6383,12 +6530,16 @@
} }
if (!currentForm.prompt || currentForm.prompt.trim().length === 0) { if (!currentForm.prompt || currentForm.prompt.trim().length === 0) {
if (selectedTaskId.value === 's2v') {
currentForm.prompt = 'Make the character speak in a natural way according to the audio.';
} else {
showAlert('请输入提示词', 'warning'); showAlert('请输入提示词', 'warning');
return; return;
} }
}
if (currentForm.prompt.length > 500) { if (currentForm.prompt.length > 1000) {
showAlert('提示词长度不能超过500个字符', 'warning'); showAlert('提示词长度不能超过1000个字符', 'warning');
return; return;
} }
...@@ -6771,18 +6922,6 @@ ...@@ -6771,18 +6922,6 @@
return await getTaskFileUrlFromApi(taskId, fileKey); 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(仅从缓存获取,用于模板显示) // 同步获取任务文件URL(仅从缓存获取,用于模板显示)
const getTaskFileUrlSync = (taskId, fileKey) => { const getTaskFileUrlSync = (taskId, fileKey) => {
const cachedFile = getTaskFileFromCache(taskId, fileKey); const cachedFile = getTaskFileFromCache(taskId, fileKey);
...@@ -7164,38 +7303,6 @@ ...@@ -7164,38 +7303,6 @@
return statusMap[status] || 'bg-secondary'; 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) => { const viewSingleResult = async (taskId, key) => {
try { try {
downloadLoading.value = true; downloadLoading.value = true;
...@@ -7519,7 +7626,8 @@ ...@@ -7519,7 +7626,8 @@
'mp4': 'audio/mp4', 'mp4': 'audio/mp4',
'aac': 'audio/aac', 'aac': 'audio/aac',
'ogg': 'audio/ogg', 'ogg': 'audio/ogg',
'm4a': 'audio/mp4' 'm4a': 'audio/mp4',
'webm': 'audio/webm'
}; };
mimeType = mimeTypes[ext] || 'audio/mpeg'; mimeType = mimeTypes[ext] || 'audio/mpeg';
} }
...@@ -7774,7 +7882,7 @@ ...@@ -7774,7 +7882,7 @@
} else if (selectedTaskId.value === 'i2v') { } else if (selectedTaskId.value === 'i2v') {
return t('pleaseEnterThePromptForVideoGeneration') + ''+ t('describeTheContentActionRequirementsBasedOnTheImage'); return t('pleaseEnterThePromptForVideoGeneration') + ''+ t('describeTheContentActionRequirementsBasedOnTheImage');
} else if (selectedTaskId.value === 's2v') { } else if (selectedTaskId.value === 's2v') {
return t('pleaseEnterThePromptForVideoGeneration') + ''+ t('describeTheDigitalHumanImageBackgroundStyleActionRequirements'); return t('optional') + ' '+ t('pleaseEnterThePromptForVideoGeneration') + ''+ t('describeTheDigitalHumanImageBackgroundStyleActionRequirements');
} }
return t('pleaseEnterThePromptForVideoGeneration') + '...'; return t('pleaseEnterThePromptForVideoGeneration') + '...';
}; };
...@@ -7907,38 +8015,6 @@ ...@@ -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 // 监听currentPage变化,同步更新pageInput
watch(currentTaskPage, (newPage) => { watch(currentTaskPage, (newPage) => {
taskPageInput.value = newPage; taskPageInput.value = newPage;
...@@ -8021,6 +8097,9 @@ ...@@ -8021,6 +8097,9 @@
loadTemplateFilesFromCache(); loadTemplateFilesFromCache();
loadImageAudioTemplates(true); loadImageAudioTemplates(true);
// 4. 初始化路由(在数据加载完成后)
initRoute();
console.log('初始化完成:', { console.log('初始化完成:', {
currentUser: currentUser.value, currentUser: currentUser.value,
availableModels: models.value, availableModels: models.value,
...@@ -8077,7 +8156,7 @@ ...@@ -8077,7 +8156,7 @@
stage: currentStage || 'single_stage', stage: currentStage || 'single_stage',
imageFile: null, imageFile: null,
audioFile: null, audioFile: null,
prompt: '', prompt: 'Make the character speak in a natural way according to the audio.',
seed: Math.floor(Math.random() * 1000000) seed: Math.floor(Math.random() * 1000000)
}; };
break; break;
...@@ -8602,8 +8681,17 @@ ...@@ -8602,8 +8681,17 @@
await handleLoginCallback(code, source); await handleLoginCallback(code, source);
// handleGitHubCallback内部会设置isLoggedIn.value = true // handleGitHubCallback内部会设置isLoggedIn.value = true
} else { } else {
// 没有token且不是回调,显示登录 // 没有token且不是回调,默认显示登录
isLoggedIn.value = false; 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) { } catch (error) {
...@@ -8615,6 +8703,9 @@ ...@@ -8615,6 +8703,9 @@
loginLoading.value = false; loginLoading.value = false;
initLoading.value = false; initLoading.value = false;
} }
// 监听浏览器前进后退
window.addEventListener('popstate', handlePopState);
}); });
// 页面卸载时清理轮询 // 页面卸载时清理轮询
...@@ -8627,6 +8718,9 @@ ...@@ -8627,6 +8718,9 @@
// 清理提示滚动 // 清理提示滚动
stopHintRotation(); stopHintRotation();
// 清理路由事件监听器
window.removeEventListener('popstate', handlePopState);
}); });
// 提示词模板管理 // 提示词模板管理
...@@ -8634,13 +8728,33 @@ ...@@ -8634,13 +8728,33 @@
's2v': [ 's2v': [
{ {
id: 's2v_1', id: 's2v_1',
title: '商务演讲', title: '情绪表达',
prompt: '数字人进行商务演讲,表情自然,手势得体,背景为现代化的会议室,整体风格专业商务' prompt: '根据音频,人物进行情绪化表达,表情丰富,能体现音频中的情绪,手势根据情绪适当调整'
}, },
{ {
id: 's2v_2', id: 's2v_2',
title: '故事讲述',
prompt: '根据音频,人物进行故事讲述,表情丰富,能体现音频中的情绪,手势根据故事情节适当调整。'
},
{
id: 's2v_3',
title: '知识讲解',
prompt: '根据音频,人物进行知识讲解,表情严肃,整体风格专业得体,手势根据知识内容适当调整。'
},
{
id: 's2v_4',
title: '浮夸表演',
prompt: '根据音频,人物进行浮夸表演,表情夸张,动作浮夸,整体风格夸张搞笑。'
},
{
id: 's2v_5',
title: '商务演讲',
prompt: '根据音频,人物进行商务演讲,表情严肃,手势得体,整体风格专业商务。'
},
{
id: 's2v_6',
title: '产品介绍', title: '产品介绍',
prompt: '数字人介绍产品特点,语气亲切,动作自然,背景为产品展示区,突出产品的科技感和实用性' prompt: '数字人介绍产品特点,语气亲切热情,表情丰富,动作自然,能体现产品特点'
} }
], ],
't2v': [ 't2v': [
...@@ -8918,6 +9032,7 @@ ...@@ -8918,6 +9032,7 @@
// 设置音频预览 // 设置音频预览
setCurrentAudioPreview(history.data); setCurrentAudioPreview(history.data);
updateUploadedContentStatus();
// 更新表单 // 更新表单
const currentForm = getCurrentForm(); const currentForm = getCurrentForm();
...@@ -9132,6 +9247,7 @@ ...@@ -9132,6 +9247,7 @@
// 新增:视图切换方法 // 新增:视图切换方法
const switchToCreateView = () => { const switchToCreateView = () => {
currentView.value = 'create'; currentView.value = 'create';
updateURL('create');
// 如果之前有展开过创作区域,保持展开状态 // 如果之前有展开过创作区域,保持展开状态
if (isCreationAreaExpanded.value) { if (isCreationAreaExpanded.value) {
// 延迟一点时间确保DOM更新完成 // 延迟一点时间确保DOM更新完成
...@@ -9146,16 +9262,62 @@ ...@@ -9146,16 +9262,62 @@
const switchToProjectsView = () => { const switchToProjectsView = () => {
currentView.value = 'projects'; currentView.value = 'projects';
updateURL('projects');
// 刷新任务列表 // 刷新任务列表
refreshTasks(); refreshTasks();
}; };
const switchToInspirationView = () => { const switchToInspirationView = () => {
currentView.value = 'inspiration'; currentView.value = 'inspiration';
updateURL('inspiration');
// 加载灵感数据 // 加载灵感数据
loadInspirationData(); 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) => { const formatDate = (date) => {
if (!date) return ''; if (!date) return '';
...@@ -9179,6 +9341,7 @@ ...@@ -9179,6 +9341,7 @@
if (cachedData && cachedData.templates) { if (cachedData && cachedData.templates) {
console.log(`成功从缓存加载灵感模板数据${cacheKey}:`, cachedData.templates); console.log(`成功从缓存加载灵感模板数据${cacheKey}:`, cachedData.templates);
inspirationItems.value = cachedData.templates; inspirationItems.value = cachedData.templates;
InspirationCategories.value = cachedData.categories;
// 如果有分页信息也加载 // 如果有分页信息也加载
if (cachedData.pagination) { if (cachedData.pagination) {
inspirationPagination.value = cachedData.pagination; inspirationPagination.value = cachedData.pagination;
...@@ -9195,6 +9358,7 @@ ...@@ -9195,6 +9358,7 @@
const templates = data.templates || []; const templates = data.templates || [];
inspirationItems.value = data.templates || []; inspirationItems.value = data.templates || [];
InspirationCategories.value = data.categories || [];
inspirationPagination.value = data.pagination || null; inspirationPagination.value = data.pagination || null;
// 缓存模板数据 // 缓存模板数据
...@@ -9220,21 +9384,17 @@ ...@@ -9220,21 +9384,17 @@
console.warn('加载模板数据失败:', error); 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; return;
} }
// 更新分类 // 更新分类
selectedInspirationCategory.value = categoryId; selectedInspirationCategory.value = category;
// 重置页码为1 // 重置页码为1
inspirationCurrentPage.value = 1; inspirationCurrentPage.value = 1;
...@@ -9273,34 +9433,220 @@ ...@@ -9273,34 +9433,220 @@
}, 500); // 500ms 防抖延迟 }, 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 playVideo = (event) => {
const video = event.target; const video = event.target;
if (video.readyState >= 2) { // HAVE_CURRENT_DATA
// 检查视频是否已加载完成
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.currentTime = 0; // 从头开始播放
video.play().catch(e => { 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); console.log('视频播放失败:', e);
// 如果自动播放失败,可以尝试用户交互后播放 icon.className = 'fas fa-play text-sm';
currentPlayingVideo = null;
}); });
} else { } else {
// 如果视频还没加载完成,等待加载完成后再播放 // 视频未加载完成,显示loading并等待
console.log('视频还没加载完成,等待加载(移动端)');
icon.className = 'fas fa-spinner fa-spin text-sm';
currentLoadingVideo = video;
// 等待视频加载完成
video.addEventListener('loadeddata', () => { video.addEventListener('loadeddata', () => {
// 检查这个视频是否仍然是当前等待加载的视频
if (currentLoadingVideo === video) {
currentLoadingVideo = null;
video.currentTime = 0; video.currentTime = 0;
video.play().catch(e => console.log('视频播放失败:', e)); video.play().then(() => {
}, { once: true }); 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 {
const pauseVideo = (event) => {
const video = event.target;
video.pause(); video.pause();
video.currentTime = 0; 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 onVideoLoaded = (event) => {
const video = event.target; const video = event.target;
// 视频加载完成,准备播放 // 视频加载完成,准备播放
console.log('视频加载完成:', video.src); console.log('视频加载完成:', video.src);
// 更新视频加载状态(使用视频的实际src)
setVideoLoaded(video.src, true);
// 触发Vue的响应式更新
videoLoadedStates.value = new Map(videoLoadedStates.value);
}; };
const onVideoError = (event) => { const onVideoError = (event) => {
...@@ -9619,6 +9965,14 @@ ...@@ -9619,6 +9965,14 @@
loginLoading, loginLoading,
initLoading, initLoading,
downloadLoading, downloadLoading,
// 录音相关
isRecording,
recordingDuration,
startRecording,
stopRecording,
formatRecordingDuration,
loginWithGitHub, loginWithGitHub,
loginWithGoogle, loginWithGoogle,
// 短信登录相关 // 短信登录相关
...@@ -9762,12 +10116,10 @@ ...@@ -9762,12 +10116,10 @@
getTemplateFileUrlAsync, getTemplateFileUrlAsync,
loadTemplateFilesFromCache, loadTemplateFilesFromCache,
saveTemplateFilesToCache, saveTemplateFilesToCache,
getFirstImageKey,
loadFromCache, loadFromCache,
saveToCache, saveToCache,
clearAllCache, clearAllCache,
getStatusBadgeClass, getStatusBadgeClass,
downloadSingleResult,
viewSingleResult, viewSingleResult,
cancelTask, cancelTask,
resumeTask, resumeTask,
...@@ -9791,8 +10143,7 @@ ...@@ -9791,8 +10143,7 @@
getTaskInputUrl, getTaskInputUrl,
getTaskInputImage, getTaskInputImage,
getTaskInputAudio, getTaskInputAudio,
getTaskImageUrl, getTaskFileUrl,
getTaskVideoUrl,
getHistoryImageUrl, getHistoryImageUrl,
getUserAvatarUrl, getUserAvatarUrl,
getCurrentImagePreviewUrl, getCurrentImagePreviewUrl,
...@@ -9802,7 +10153,6 @@ ...@@ -9802,7 +10153,6 @@
handleImageLoad, handleImageLoad,
handleAudioError, handleAudioError,
handleAudioLoad, handleAudioLoad,
downloadTaskInput,
getTaskStatusDisplay, getTaskStatusDisplay,
getTaskStatusColor, getTaskStatusColor,
getTaskStatusIcon, getTaskStatusIcon,
...@@ -9848,14 +10198,17 @@ ...@@ -9848,14 +10198,17 @@
switchToCreateView, switchToCreateView,
switchToProjectsView, switchToProjectsView,
switchToInspirationView, switchToInspirationView,
switchToLoginView,
updateURL,
initRoute,
handlePopState,
openTaskDetailModal, openTaskDetailModal,
closeTaskDetailModal, closeTaskDetailModal,
// 灵感广场相关 // 灵感广场相关
inspirationSearchQuery, inspirationSearchQuery,
selectedInspirationCategory, selectedInspirationCategory,
inspirationItems, inspirationItems,
dynamicInspirationCategories, InspirationCategories,
filteredInspirationItems,
loadInspirationData, loadInspirationData,
selectInspirationCategory, selectInspirationCategory,
handleInspirationSearch, handleInspirationSearch,
...@@ -9887,8 +10240,12 @@ ...@@ -9887,8 +10240,12 @@
// 视频播放控制 // 视频播放控制
playVideo, playVideo,
pauseVideo, pauseVideo,
toggleVideoPlay,
pauseAllVideos,
updateVideoIcon,
onVideoLoaded, onVideoLoaded,
onVideoError onVideoError,
onVideoEnded
}; };
} }
}).mount('#app'); }).mount('#app');
......
...@@ -273,10 +273,10 @@ class LocalTaskManager(BaseTaskManager): ...@@ -273,10 +273,10 @@ class LocalTaskManager(BaseTaskManager):
task, subtasks = self.load(task_id, user_id) task, subtasks = self.load(task_id, user_id)
# the task is not finished # the task is not finished
if task["status"] not in FinishedStatus: if task["status"] not in FinishedStatus:
return False return "Active task cannot be resumed"
# the task is no need to resume # the task is no need to resume
if not all_subtask and task["status"] == TaskStatus.SUCCEED: if not all_subtask and task["status"] == TaskStatus.SUCCEED:
return False return "Succeed task cannot be resumed"
for sub in subtasks: for sub in subtasks:
if all_subtask or sub["status"] != TaskStatus.SUCCEED: if all_subtask or sub["status"] != TaskStatus.SUCCEED:
self.mark_subtask_change(records, sub, None, TaskStatus.CREATED) self.mark_subtask_change(records, sub, None, TaskStatus.CREATED)
......
...@@ -702,10 +702,10 @@ class PostgresSQLTaskManager(BaseTaskManager): ...@@ -702,10 +702,10 @@ class PostgresSQLTaskManager(BaseTaskManager):
task, subtasks = await self.load(conn, task_id, user_id) task, subtasks = await self.load(conn, task_id, user_id)
# the task is not finished # the task is not finished
if task["status"] not in FinishedStatus: if task["status"] not in FinishedStatus:
return False return "Active task cannot be resumed"
# the task is no need to resume # the task is no need to resume
if not all_subtask and task["status"] == TaskStatus.SUCCEED: if not all_subtask and task["status"] == TaskStatus.SUCCEED:
return False return "Succeed task cannot be resumed"
for sub in subtasks: for sub in subtasks:
if all_subtask or sub["status"] != TaskStatus.SUCCEED: if all_subtask or sub["status"] != TaskStatus.SUCCEED:
......
...@@ -23,15 +23,19 @@ from lightx2v.utils.utils import seed_all ...@@ -23,15 +23,19 @@ from lightx2v.utils.utils import seed_all
class BaseWorker: class BaseWorker:
@ProfilingContext4DebugL1("Init Worker Worker Cost:") @ProfilingContext4DebugL1("Init Worker Worker Cost:")
def __init__(self, args): def __init__(self, args):
args.save_video_path = ""
config = set_config(args) config = set_config(args)
config["mode"] = ""
logger.info(f"config:\n{json.dumps(config, ensure_ascii=False, indent=4)}") logger.info(f"config:\n{json.dumps(config, ensure_ascii=False, indent=4)}")
seed_all(config.seed) seed_all(config.seed)
self.rank = 0 self.rank = 0
self.world_size = 1
if config.parallel: if config.parallel:
self.rank = dist.get_rank() self.rank = dist.get_rank()
self.world_size = dist.get_world_size()
set_parallel_config(config) set_parallel_config(config)
seed_all(config.seed) 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) torch.set_grad_enabled(False)
self.runner = RUNNER_REGISTER[config.model_cls](config) self.runner = RUNNER_REGISTER[config.model_cls](config)
# fixed config # fixed config
...@@ -121,7 +125,7 @@ class BaseWorker: ...@@ -121,7 +125,7 @@ class BaseWorker:
async def save_output_video(self, tmp_video_path, output_video_path, data_manager): async def save_output_video(self, tmp_video_path, output_video_path, data_manager):
# save output video # 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() video_data = open(tmp_video_path, "rb").read()
await data_manager.save_bytes(video_data, output_video_path) await data_manager.save_bytes(video_data, output_video_path)
......
...@@ -85,7 +85,7 @@ def main(): ...@@ -85,7 +85,7 @@ def main():
help="The file of the source mask. Default None.", 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() args = parser.parse_args()
# set config # set config
......
...@@ -295,7 +295,9 @@ class DefaultRunner(BaseRunner): ...@@ -295,7 +295,9 @@ class DefaultRunner(BaseRunner):
save_to_video(self.gen_video, self.config.save_video_path, fps=fps, method="ffmpeg") 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} ✅") logger.info(f"✅ Video saved successfully to: {self.config.save_video_path} ✅")
if self.config.get("return_video", False):
return {"video": self.gen_video} return {"video": self.gen_video}
return {"video": None}
def run_pipeline(self, save_video=True): def run_pipeline(self, save_video=True):
if self.config["use_prompt_enhancer"]: if self.config["use_prompt_enhancer"]:
......
import gc import gc
import os import os
import warnings
from dataclasses import dataclass from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
...@@ -12,7 +11,6 @@ import torchvision.transforms.functional as TF ...@@ -12,7 +11,6 @@ import torchvision.transforms.functional as TF
from PIL import Image from PIL import Image
from einops import rearrange from einops import rearrange
from loguru import logger from loguru import logger
from torchvision.io import write_video
from torchvision.transforms import InterpolationMode from torchvision.transforms import InterpolationMode
from torchvision.transforms.functional import resize from torchvision.transforms.functional import resize
...@@ -28,9 +26,7 @@ from lightx2v.models.video_encoders.hf.wan.vae_2_2 import Wan2_2_VAE ...@@ -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.envs import *
from lightx2v.utils.profiler import * from lightx2v.utils.profiler import *
from lightx2v.utils.registry_factory import RUNNER_REGISTER from lightx2v.utils.registry_factory import RUNNER_REGISTER
from lightx2v.utils.utils import find_torch_model_path, load_weights, vae_to_comfyui_image from lightx2v.utils.utils import find_torch_model_path, load_weights, vae_to_comfyui_image_inplace
warnings.filterwarnings("ignore", category=UserWarning, module="torchvision.io._video_deprecation_warning")
def get_optimal_patched_size_with_sp(patched_h, patched_w, sp_size): def get_optimal_patched_size_with_sp(patched_h, patched_w, sp_size):
...@@ -475,10 +471,12 @@ class WanAudioRunner(WanRunner): # type:ignore ...@@ -475,10 +471,12 @@ class WanAudioRunner(WanRunner): # type:ignore
def init_run(self): def init_run(self):
super().init_run() super().init_run()
self.scheduler.set_audio_adapter(self.audio_adapter) self.scheduler.set_audio_adapter(self.audio_adapter)
self.gen_video_list = []
self.cut_audio_list = []
self.prev_video = None 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") @ProfilingContext4DebugL1("Init run segment")
def init_run_segment(self, segment_idx, audio_array=None): def init_run_segment(self, segment_idx, audio_array=None):
...@@ -510,22 +508,31 @@ class WanAudioRunner(WanRunner): # type:ignore ...@@ -510,22 +508,31 @@ class WanAudioRunner(WanRunner): # type:ignore
def end_run_segment(self): def end_run_segment(self):
self.gen_video = torch.clamp(self.gen_video, -1, 1).to(torch.float) self.gen_video = torch.clamp(self.gen_video, -1, 1).to(torch.float)
useful_length = self.segment.end_frame - self.segment.start_frame useful_length = self.segment.end_frame - self.segment.start_frame
self.gen_video_list.append(self.gen_video[:, :, :useful_length].cpu()) video_seg = self.gen_video[:, :, :useful_length].cpu()
self.cut_audio_list.append(self.segment.audio_array[: useful_length * self._audio_processor.audio_frame_rate]) audio_seg = self.segment.audio_array[: useful_length * self._audio_processor.audio_frame_rate]
if self.va_recorder: video_seg = vae_to_comfyui_image_inplace(video_seg)
cur_video = vae_to_comfyui_image(self.gen_video_list[-1])
self.va_recorder.pub_livestream(cur_video, self.cut_audio_list[-1])
if self.va_reader: # [Warning] Need check whether video segment interpolation works...
self.gen_video_list.pop() if "video_frame_interpolation" in self.config and self.vfi_model is not None:
self.cut_audio_list.pop() 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_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 # Update prev_video for next iteration
self.prev_video = self.gen_video self.prev_video = self.gen_video
# Clean up GPU memory after each segment del video_seg, audio_seg
del self.gen_video
torch.cuda.empty_cache() torch.cuda.empty_cache()
def get_rank_and_world_size(self): def get_rank_and_world_size(self):
...@@ -540,15 +547,16 @@ class WanAudioRunner(WanRunner): # type:ignore ...@@ -540,15 +547,16 @@ class WanAudioRunner(WanRunner): # type:ignore
output_video_path = self.config.get("save_video_path", None) output_video_path = self.config.get("save_video_path", None)
self.va_recorder = None self.va_recorder = None
if isinstance(output_video_path, dict): if isinstance(output_video_path, dict):
assert output_video_path["type"] == "stream", f"unexcept save_video_path: {output_video_path}" 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() rank, world_size = self.get_rank_and_world_size()
if rank == 2 % world_size: if output_video_path and rank == world_size - 1:
record_fps = self.config.get("target_fps", 16) record_fps = self.config.get("target_fps", 16)
audio_sr = self.config.get("audio_sr", 16000) audio_sr = self.config.get("audio_sr", 16000)
if "video_frame_interpolation" in self.config and self.vfi_model is not None: if "video_frame_interpolation" in self.config and self.vfi_model is not None:
record_fps = self.config["video_frame_interpolation"]["target_fps"] record_fps = self.config["video_frame_interpolation"]["target_fps"]
self.va_recorder = VARecorder( self.va_recorder = VARecorder(
livestream_url=output_video_path["data"], livestream_url=output_video_path,
fps=record_fps, fps=record_fps,
sample_rate=audio_sr, sample_rate=audio_sr,
) )
...@@ -583,8 +591,8 @@ class WanAudioRunner(WanRunner): # type:ignore ...@@ -583,8 +591,8 @@ class WanAudioRunner(WanRunner): # type:ignore
return super().run_main(total_steps) return super().run_main(total_steps)
rank, world_size = self.get_rank_and_world_size() rank, world_size = self.get_rank_and_world_size()
if rank == 2 % world_size: if rank == world_size - 1:
assert self.va_recorder is not None, "va_recorder is required for stream audio input for rank 0" assert self.va_recorder is not None, "va_recorder is required for stream audio input for rank 2"
self.va_reader.start() self.va_reader.start()
self.init_run() self.init_run()
...@@ -627,67 +635,17 @@ class WanAudioRunner(WanRunner): # type:ignore ...@@ -627,67 +635,17 @@ class WanAudioRunner(WanRunner): # type:ignore
self.va_recorder = None self.va_recorder = None
@ProfilingContext4DebugL1("Process after vae decoder") @ProfilingContext4DebugL1("Process after vae decoder")
def process_images_after_vae_decoder(self, save_video=True): def process_images_after_vae_decoder(self, save_video=False):
# Merge results if self.config.get("return_video", False):
gen_lvideo = torch.cat(self.gen_video_list, dim=2).float() audio_waveform = torch.from_numpy(self.cut_audio_final).unsqueeze(0).unsqueeze(0)
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} comfyui_audio = {"waveform": audio_waveform, "sample_rate": self._audio_processor.audio_sr}
return {"video": self.gen_video_final, "audio": comfyui_audio}
return {"video": comfyui_images, "audio": comfyui_audio} return {"video": None, "audio": None}
def init_modules(self): def init_modules(self):
super().init_modules() super().init_modules()
self.run_input_encoder = self._run_input_encoder_local_r2v_audio 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): def load_transformer(self):
"""Load transformer with LoRA support""" """Load transformer with LoRA support"""
base_model = WanAudioModel(self.config.model_path, self.config, self.init_device) base_model = WanAudioModel(self.config.model_path, self.config, self.init_device)
......
...@@ -31,6 +31,7 @@ def get_default_config(): ...@@ -31,6 +31,7 @@ def get_default_config():
"tgt_h": None, "tgt_h": None,
"tgt_w": None, "tgt_w": None,
"target_shape": None, "target_shape": None,
"return_video": False,
} }
return default_config return default_config
...@@ -73,6 +74,8 @@ def set_config(args): ...@@ -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.") 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 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 return config
......
...@@ -131,6 +131,39 @@ def vae_to_comfyui_image(vae_output: torch.Tensor) -> torch.Tensor: ...@@ -131,6 +131,39 @@ def vae_to_comfyui_image(vae_output: torch.Tensor) -> torch.Tensor:
return images 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( def save_to_video(
images: torch.Tensor, images: torch.Tensor,
output_path: str, 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