someone-in-the-world's picture
Remove redundant numpy->PIL->numpy round trip between interpolation and upscale
55d9178
Raw History Blame
19.6 kB
import os
import random
import tempfile
import threading
import time as _time
import gradio as gr
import numpy as np
import spaces
import torch
from diffusers.utils.export_utils import export_to_video
from logging_utils import (
LogUploader,
print_export_done,
print_export_error,
print_export_start,
print_frames_info,
print_infer_done,
print_infer_error,
print_infer_start,
print_stage_done,
print_stage_error,
print_stage_start,
print_startup_env,
)
from model.pipeline import (
DEFAULT_NEGATIVE_PROMPT,
DEFAULT_PROMPT,
FIXED_FPS,
MAX_DURATION,
MIN_DURATION,
estimate_generation_seconds,
get_num_frames,
load_pipeline,
resize_and_crop_to_match,
resize_image,
)
from postprocess.interpolation import ensure_weights_downloaded as ensure_rife_weights_downloaded
from postprocess.interpolation import interpolate_frames
from postprocess.upscale.upscale import ensure_weights_downloaded as ensure_upscale_weights_downloaded
MAX_SEED = np.iinfo(np.int32).max
FRAME_MULTIPLIER_CHOICES = [FIXED_FPS, FIXED_FPS * 2, FIXED_FPS * 4, FIXED_FPS * 8]
print_startup_env()
pipe = load_pipeline()
# RIFE/upscale weight fetches are CPU/network-only (no CUDA needed) — done here, at process
# startup, so a cold container doesn't pay for them out of the metered @spaces.GPU allocation
# on its first interpolation/upscale request.
ensure_rife_weights_downloaded()
ensure_upscale_weights_downloaded()
# Debug logging (LOG-2): gated entirely by LOG_HF_TOKEN/LOG_DATASET_REPO — a silent no-op when
# either is unset. Deliberately not the deploy HF_TOKEN: this uses its own write-scoped token so a
# leak has a much smaller blast radius (append-only on one dataset repo, not the whole Space repo).
_log_uploader = LogUploader(
token=os.environ.get("LOG_HF_TOKEN"),
repo_id=os.environ.get("LOG_DATASET_REPO"),
max_bytes=int(float(os.environ.get("LOG_STORAGE_CAP_GB", "10")) * 1024**3),
# Hub's commit endpoint rejects pushes once a directory holds >10000 files; stay well under
# that per-directory cap (each logged stem adds one file to data/, images/, and videos/).
max_files=int(os.environ.get("LOG_MAX_FILES", "8000")),
batch_interval=int(os.environ.get("LOG_BATCH_INTERVAL", "60")),
)
# SPACE_ID is set automatically by Hugging Face Spaces ("namespace/space_name") — used instead of a
# hardcoded handle so the privacy notice stays correct for anyone who duplicates this Space.
_SPACE_ID = os.environ.get("SPACE_ID")
_OPERATOR_MD = (
f"[the Space operator](https://huggingface.co/{_SPACE_ID.split('/')[0]})" if _SPACE_ID else "the Space operator"
)
_COMMUNITY_MD = (
f"[this Space's Community tab](https://huggingface.co/spaces/{_SPACE_ID}/discussions)"
if _SPACE_ID else "this Space's Community tab"
)
def _gpu_duration(
resized_image,
processed_last_image,
prompt,
negative_prompt,
steps,
num_frames,
guidance_scale,
seed,
frame_multiplier,
upscale_output,
progress,
):
duration = estimate_generation_seconds(
resized_image.width, resized_image.height, num_frames, int(steps), float(guidance_scale)
)
frame_factor = frame_multiplier // FIXED_FPS
out_frames = num_frames
if frame_factor > 1:
# Matches the reference project's RIFE-time heuristic: ~0.02s per extra interpolated frame.
extra_frames = (num_frames * frame_factor) - num_frames
duration += extra_frames * 0.02
out_frames += extra_frames
if upscale_output:
# Rough per-frame tiled-SR heuristic (4xLSDIRCompact, fp16); not yet empirically calibrated
# on the dev Space (same caveat as estimate_generation_seconds above), plus a flat allowance
# for first-call weight download/model load.
duration += 20 + out_frames * 0.3
return duration
def _apply_interpolation(raw_frames, frame_factor, progress):
if frame_factor <= 1:
return list(raw_frames), FIXED_FPS
def _report_interpolation_progress(done: int, total: int) -> None:
progress(0.7 + 0.2 * (done / total), desc=f"Interpolating frames ({done}/{total})...")
print_stage_start("interpolation")
t0 = _time.perf_counter()
try:
interpolated = interpolate_frames(
raw_frames, multiplier=int(frame_factor), progress_callback=_report_interpolation_progress
)
print_stage_done("interpolation", _time.perf_counter() - t0)
return interpolated, FIXED_FPS * frame_factor
except Exception as e:
# QF-2: don't discard a successfully generated base video over a post-processing failure.
print_stage_error("interpolation", e)
gr.Warning(f"Frame interpolation failed ({e}); returning the video without it.")
return list(raw_frames), FIXED_FPS
def _apply_upscale(frames, progress):
from postprocess.upscale import upscale_frames
def _report_upscale_progress(done: int, total: int) -> None:
progress(0.9 + 0.09 * (done / total), desc=f"Upscaling 4x ({done}/{total} frames)...")
progress(0.9, desc=f"Upscaling 4x (0/{len(frames)} frames)...")
print_stage_start("upscaling")
t0 = _time.perf_counter()
try:
upscaled = upscale_frames(frames, progress_callback=_report_upscale_progress)
print_stage_done("upscaling", _time.perf_counter() - t0)
return upscaled
except Exception as e:
# QF-2: a failed upscale still returns the pre-upscale (interpolated/base) result.
print_stage_error("upscaling", e)
gr.Warning(f"Upscaling failed ({e}); returning the video without it.")
return None
@spaces.GPU(duration=_gpu_duration)
def run_inference(
resized_image,
processed_last_image,
prompt,
negative_prompt,
steps,
num_frames,
guidance_scale,
seed,
frame_multiplier,
upscale_output,
progress=gr.Progress(),
):
print_infer_start(prompt, negative_prompt, seed, steps, guidance_scale, frame_multiplier, upscale_output)
t_start = _time.perf_counter()
def _report_generation_progress(pipe_, step_index, timestep, callback_kwargs):
progress(0.7 * (step_index + 1) / int(steps), desc=f"Generating ({step_index + 1}/{int(steps)} steps)...")
return callback_kwargs
print_stage_start("generation")
t0 = _time.perf_counter()
try:
result = pipe(
image=resized_image,
last_image=processed_last_image,
prompt=prompt,
negative_prompt=negative_prompt,
height=resized_image.height,
width=resized_image.width,
num_frames=num_frames,
guidance_scale=float(guidance_scale),
num_inference_steps=int(steps),
generator=torch.Generator(device="cuda").manual_seed(seed),
output_type="np",
callback_on_step_end=_report_generation_progress,
)
except Exception as e:
print_stage_error("generation", e)
raise
print_stage_done("generation", _time.perf_counter() - t0)
raw_frames = result.frames[0] # (T, H, W, C) float32 in [0, 1]
frame_factor = frame_multiplier // FIXED_FPS
final_frames, final_fps = _apply_interpolation(raw_frames, frame_factor, progress)
upscaled_frames = _apply_upscale(final_frames, progress) if upscale_output else None
if upscaled_frames is None:
print_infer_done(_time.perf_counter() - t_start, final_fps, len(final_frames))
return final_frames, final_fps
print_infer_done(_time.perf_counter() - t_start, final_fps, len(upscaled_frames))
return upscaled_frames, final_fps
def _spawn_log(
input_image,
output_frames,
output_fps,
prompt,
negative_prompt,
seed,
steps,
guidance_scale,
interpolation_enabled,
interpolation_multiplier,
upscale_enabled,
output_width,
output_height,
generation_duration_seconds,
success,
error_message="",
):
# Run on a background thread so committing to the Hub never adds latency to the request
# (LOG-6) — must be spawned here (in the CPU-facing function), not inside the @spaces.GPU
# run_inference call, since that runs in a subprocess that exits (killing any thread it spawns)
# as soon as run_inference returns.
threading.Thread(
target=_log_uploader.log_inference,
args=(
input_image, output_frames, output_fps, prompt, negative_prompt, seed, steps,
guidance_scale, interpolation_enabled, interpolation_multiplier, upscale_enabled,
output_width, output_height, generation_duration_seconds, success, error_message,
),
daemon=True,
).start()
def generate_video(
input_image,
last_image,
prompt,
negative_prompt,
duration_seconds,
steps,
guidance_scale,
seed,
randomize_seed,
frame_multiplier,
upscale_output,
progress=gr.Progress(),
):
if input_image is None:
raise gr.Error("Please upload an input image.")
resized_image = resize_image(input_image)
processed_last_image = resize_and_crop_to_match(last_image, resized_image) if last_image is not None else None
num_frames = get_num_frames(duration_seconds)
current_seed = random.randint(0, MAX_SEED) if randomize_seed else int(seed)
frame_factor = frame_multiplier // FIXED_FPS
interpolation_enabled = frame_factor > 1
interpolation_multiplier = frame_factor if interpolation_enabled else 1
t0 = _time.perf_counter()
try:
final_frames, final_fps = run_inference(
resized_image,
processed_last_image,
prompt,
negative_prompt,
steps,
num_frames,
guidance_scale,
current_seed,
frame_multiplier,
upscale_output,
progress,
)
except Exception as e:
import traceback as _tb
duration = _time.perf_counter() - t0
print_infer_error(e, duration)
print(f"[infer] traceback:\n{_tb.format_exc()}", flush=True)
if _log_uploader.enabled:
_spawn_log(
resized_image, None, None, prompt, negative_prompt, current_seed, steps,
guidance_scale, interpolation_enabled, interpolation_multiplier, upscale_output,
None, None, duration, False, str(e),
)
raise
duration = _time.perf_counter() - t0
print_frames_info("final_frames", final_frames, final_fps)
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmpfile:
video_path = tmpfile.name
print_export_start(video_path)
t_export = _time.perf_counter()
try:
export_to_video(final_frames, video_path, fps=final_fps, quality=6)
except Exception as e:
import traceback as _tb
print_export_error(e, _tb.format_exc())
raise
print_export_done(_time.perf_counter() - t_export)
if _log_uploader.enabled:
# Interpolation/upscale failures are already caught-and-warned (not raised) inside
# run_inference — a video was still produced, so this still counts as success=True.
# interpolation_enabled/upscale_output reflect what the user requested, not whether an
# internal fallback fired.
_spawn_log(
resized_image, final_frames, final_fps, prompt, negative_prompt, current_seed,
steps, guidance_scale, interpolation_enabled, interpolation_multiplier, upscale_output,
resized_image.width, resized_image.height, duration, True,
)
return video_path, video_path, current_seed
def preview_effect(input_image, duration_seconds, frame_multiplier, upscale_output):
"""QF-3: show the resulting fps/resolution/duration before the user hits Generate."""
num_frames = get_num_frames(duration_seconds)
frame_factor = frame_multiplier // FIXED_FPS
out_fps = FIXED_FPS * frame_factor if frame_factor > 1 else FIXED_FPS
out_frame_count = ((num_frames - 1) * frame_factor + 1) if frame_factor > 1 else num_frames
if input_image is not None:
w, h = resize_image(input_image).size
else:
w = h = None
if upscale_output and w is not None:
w, h = w * 4, h * 4
resolution = f"{w}×{h}px" if w is not None else "resolution depends on the uploaded image"
return f"**Output:** ~{out_frame_count} frames @ {out_fps} fps, {resolution}"
with gr.Blocks() as demo:
gr.Markdown("# High Quality Video Generation")
gr.Markdown("Turn a still image into a short video clip guided by a text prompt.")
gr.Markdown(
"- Service availability and response times may vary as this Space runs on shared GPU infrastructure.\n"
"- When using this Space, please comply with the "
"[Hugging Face Content Policy](https://huggingface.co/content-policy).\n"
"- This Space and its outputs are provided **\"as is\"**, without warranties of any kind. You are "
"solely responsible for the content you generate and for how you use or share it; the operator "
"accepts no liability for any loss, damage, or claim arising from use of this Space or its "
"outputs.\n"
f"- To monitor application performance and improve quality, input data (image/prompt) and generated "
f"outputs are logged solely for debugging purposes and retained in a private Hugging Face dataset "
f"accessible only to {_OPERATOR_MD}, automatically pruned once the dataset reaches its configured "
f"limit (oldest records deleted first). This processing is based on legitimate interest (GDPR Art. "
f"6(1)(f)) and the data is not shared with or sold to any third party. To request access to or "
f"deletion of your data, open a discussion on {_COMMUNITY_MD}. See the **Privacy Policy** below for "
"full details of your rights.\n"
"- No user account or identifying information (such as IP address or session data) is collected."
)
with gr.Accordion("Privacy Policy", open=False):
gr.Markdown(
f"**Space:** High Quality Video Generation \n"
f"**Operator:** {_OPERATOR_MD} \n"
f"**Last updated:** 2026-09-18\n"
"\n"
"#### What data is collected\n"
"When you submit a request, the following is logged: your uploaded input image, the generated "
"output video, your prompt and negative prompt, seed, inference "
"steps, guidance scale, frame-interpolation and upscaling settings, output resolution/fps/duration, "
"generation duration, and success/error status. No user account, IP address, or session data is "
"collected.\n"
"\n"
"#### Why it is collected\n"
"Solely for debugging and monitoring application performance (legitimate interest, GDPR Art. "
"6(1)(f)).\n"
"\n"
"#### Where it is stored\n"
f"In a private Hugging Face dataset accessible only to {_OPERATOR_MD}. The data is not shared with "
"or sold to any third party.\n"
"\n"
"#### How long it is kept\n"
"Entries are automatically pruned once the dataset reaches its configured storage cap (default "
"10GB) — the oldest entries are deleted first. The operator may also delete data manually at any "
"time.\n"
"\n"
"#### Your rights\n"
"Use of this Space and submission of images is entirely voluntary. Under GDPR you have the right "
"to:\n"
"- **Access** (Art. 15): request a copy of data held about you\n"
"- **Erasure** (Art. 17): request deletion of your data\n"
"- **Restriction** (Art. 18): request that processing be limited\n"
"- **Portability** (Art. 20): receive your data in a machine-readable format\n"
"- **Objection** (Art. 21): object to processing based on legitimate interest\n"
"\n"
f"To exercise any of these rights, open a discussion on {_COMMUNITY_MD}.\n"
"\n"
"#### Right to complain\n"
"You have the right to lodge a complaint with your national data protection authority, for example: "
"ICO (UK), CNIL (France), BfDI (Germany), or your local EU member state authority listed at "
"[edpb.europa.eu](https://www.edpb.europa.eu/about-edpb/about-edpb/members_en)."
)
with gr.Row():
with gr.Column():
input_image_component = gr.Image(type="pil", label="Input Image", sources=["upload", "clipboard"])
prompt_input = gr.Textbox(label="Prompt", value=DEFAULT_PROMPT)
duration_input = gr.Slider(
minimum=MIN_DURATION, maximum=MAX_DURATION, step=0.1, value=3.5, label="Duration (seconds)"
)
frame_multiplier_input = gr.Dropdown(
choices=FRAME_MULTIPLIER_CHOICES,
value=FIXED_FPS,
label="Video Fluidity (Frames per Second)",
info="Extra frames are generated with RIFE frame interpolation to smooth motion.",
)
upscale_checkbox = gr.Checkbox(
label="Upscale 4×",
value=True,
info="Upscales the finished video 4x on GPU after generation. Runs after frame "
"interpolation, within the same GPU allocation, and adds to the GPU quota used.",
)
with gr.Accordion("Advanced Settings", open=False):
last_image_component = gr.Image(
type="pil",
label="Last Image (Optional) — end frame to animate towards",
sources=["upload", "clipboard"],
)
negative_prompt_input = gr.Textbox(label="Negative Prompt", value=DEFAULT_NEGATIVE_PROMPT, lines=3)
steps_slider = gr.Slider(minimum=1, maximum=30, step=1, value=6, label="Inference Steps")
guidance_scale_input = gr.Slider(minimum=0.0, maximum=10.0, step=0.5, value=1.0, label="Guidance Scale")
seed_input = gr.Slider(label="Seed", minimum=0, maximum=MAX_SEED, step=1, value=42)
randomize_seed_checkbox = gr.Checkbox(label="Randomize seed", value=True)
effect_preview = gr.Markdown()
generate_button = gr.Button("Generate Video", variant="primary")
with gr.Column():
video_output = gr.Video(label="Generated Video", autoplay=True)
file_output = gr.File(label="Download Video")
generate_button.click(
fn=generate_video,
inputs=[
input_image_component,
last_image_component,
prompt_input,
negative_prompt_input,
duration_input,
steps_slider,
guidance_scale_input,
seed_input,
randomize_seed_checkbox,
frame_multiplier_input,
upscale_checkbox,
],
outputs=[video_output, file_output, seed_input],
)
preview_inputs = [input_image_component, duration_input, frame_multiplier_input, upscale_checkbox]
for component in preview_inputs:
component.change(fn=preview_effect, inputs=preview_inputs, outputs=effect_preview)
demo.load(fn=preview_effect, inputs=preview_inputs, outputs=effect_preview)
if __name__ == "__main__":
demo.queue().launch(show_error=True, ssr_mode=False)