Spaces:
Running on Zero
Running on Zero
Upload app.py
Browse files
app.py
CHANGED
|
@@ -708,21 +708,11 @@ def _cover_crop(image_path: str, canvas_label: str) -> str:
|
|
| 708 |
return image_path
|
| 709 |
|
| 710 |
def _fit_keyframe(image_path, current_canvas):
|
| 711 |
-
"""
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
with _Image.open(image_path) as img:
|
| 715 |
-
aspect = img.width / img.height
|
| 716 |
-
label = _snap_canvas(aspect, current_canvas)
|
| 717 |
-
return _cover_crop(image_path, label), label
|
| 718 |
|
| 719 |
def _fit_keyframe_ui(image_path, current_canvas):
|
| 720 |
-
"""
|
| 721 |
-
|
| 722 |
-
Doing this on upload (rather than only server-side inside ``generate``) is the whole point: the
|
| 723 |
-
user immediately sees the keyframe cropped to the ratio the video will be rendered at, and the
|
| 724 |
-
Canvas dropdown moves to the snapped resolution instead of sitting on the 16:9 default.
|
| 725 |
-
"""
|
| 726 |
path = _as_path(image_path)
|
| 727 |
if not path:
|
| 728 |
return gr.update(), gr.update()
|
|
@@ -838,16 +828,13 @@ def generate(
|
|
| 838 |
|
| 839 |
from diffusers.utils import encode_video
|
| 840 |
|
| 841 |
-
# The
|
| 842 |
-
#
|
| 843 |
-
# I2V image. The *first* frame owns the canvas — a last frame is only cover-cropped to match it.
|
| 844 |
first, last = _as_path(image_path), _as_path(last_image_path)
|
| 845 |
if first:
|
| 846 |
-
first
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
elif last:
|
| 850 |
-
last, canvas = _fit_keyframe(last, canvas)
|
| 851 |
|
| 852 |
num_frames = snap_frames(duration)
|
| 853 |
|
|
@@ -869,7 +856,9 @@ def generate(
|
|
| 869 |
rewrite_prompt=bool(enhance_prompt),
|
| 870 |
)
|
| 871 |
condition_seconds = time.time() - conditioned
|
| 872 |
-
|
|
|
|
|
|
|
| 873 |
# `None` whenever no rewrite happened — either none was asked for, or the conditioner declined one.
|
| 874 |
enhanced = (plan or {}).get("refined_prompt") or ""
|
| 875 |
|
|
@@ -893,10 +882,55 @@ def generate(
|
|
| 893 |
path = os.path.join(OUTPUT_DIR, f"h3-flashgen-{int(time.time() * 1000)}.mp4")
|
| 894 |
encode_video(frames, fps=FPS, output_path=path, audio=audio, audio_sample_rate=sampling_rate)
|
| 895 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 896 |
# Reported, not assumed: `steps_taken` is the number of Euler updates the scheduler actually took.
|
| 897 |
schedule = " → ".join(f"{s:g}" for s in sigmas_used)
|
| 898 |
report = (
|
| 899 |
-
f"{width}x{height}
|
|
|
|
| 900 |
f"{steps_taken} steps (FlashGen σ {schedule}) · "
|
| 901 |
f"conditioner {condition_seconds:.0f}s{' (prompt enhanced)' if enhanced else ''} · "
|
| 902 |
f"denoise + decode {generate_seconds:.0f}s · seed {int(seed)}"
|
|
|
|
| 708 |
return image_path
|
| 709 |
|
| 710 |
def _fit_keyframe(image_path, current_canvas):
|
| 711 |
+
"""Crop a keyframe to the selected canvas ratio without changing the selected resolution."""
|
| 712 |
+
return _cover_crop(image_path, current_canvas), current_canvas
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 713 |
|
| 714 |
def _fit_keyframe_ui(image_path, current_canvas):
|
| 715 |
+
"""Crop the uploaded keyframe to the selected canvas ratio; preserve the Canvas dropdown."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 716 |
path = _as_path(image_path)
|
| 717 |
if not path:
|
| 718 |
return gr.update(), gr.update()
|
|
|
|
| 828 |
|
| 829 |
from diffusers.utils import encode_video
|
| 830 |
|
| 831 |
+
# The user's Canvas selection is authoritative. Crop keyframes to its aspect ratio,
|
| 832 |
+
# but never replace the selected resolution with a preset chosen from the source image.
|
|
|
|
| 833 |
first, last = _as_path(image_path), _as_path(last_image_path)
|
| 834 |
if first:
|
| 835 |
+
first = _cover_crop(first, canvas)
|
| 836 |
+
if last:
|
| 837 |
+
last = _cover_crop(last, canvas)
|
|
|
|
|
|
|
| 838 |
|
| 839 |
num_frames = snap_frames(duration)
|
| 840 |
|
|
|
|
| 856 |
rewrite_prompt=bool(enhance_prompt),
|
| 857 |
)
|
| 858 |
condition_seconds = time.time() - conditioned
|
| 859 |
+
# Keep the requested Canvas dimensions even if the remote conditioner reports a fallback size.
|
| 860 |
+
height, width = CANVASES[canvas]
|
| 861 |
+
num_frames = int(metadata["num_frames"])
|
| 862 |
# `None` whenever no rewrite happened — either none was asked for, or the conditioner declined one.
|
| 863 |
enhanced = (plan or {}).get("refined_prompt") or ""
|
| 864 |
|
|
|
|
| 882 |
path = os.path.join(OUTPUT_DIR, f"h3-flashgen-{int(time.time() * 1000)}.mp4")
|
| 883 |
encode_video(frames, fps=FPS, output_path=path, audio=audio, audio_sample_rate=sampling_rate)
|
| 884 |
|
| 885 |
+
# Verify the encoded MP4 dimensions. If the pipeline/encoder emitted another size, normalize
|
| 886 |
+
# the final video to the selected Canvas while preserving its audio track.
|
| 887 |
+
import json
|
| 888 |
+
import shutil
|
| 889 |
+
import subprocess
|
| 890 |
+
|
| 891 |
+
ffprobe = shutil.which("ffprobe")
|
| 892 |
+
ffmpeg = shutil.which("ffmpeg")
|
| 893 |
+
actual_width, actual_height = width, height
|
| 894 |
+
if ffprobe:
|
| 895 |
+
probe = subprocess.run(
|
| 896 |
+
[ffprobe, "-v", "error", "-select_streams", "v:0",
|
| 897 |
+
"-show_entries", "stream=width,height", "-of", "json", path],
|
| 898 |
+
capture_output=True, text=True, check=True,
|
| 899 |
+
)
|
| 900 |
+
streams = json.loads(probe.stdout).get("streams", [])
|
| 901 |
+
if streams:
|
| 902 |
+
actual_width = int(streams[0]["width"])
|
| 903 |
+
actual_height = int(streams[0]["height"])
|
| 904 |
+
|
| 905 |
+
resized = False
|
| 906 |
+
if (actual_width, actual_height) != (width, height):
|
| 907 |
+
if not ffmpeg:
|
| 908 |
+
raise gr.Error(
|
| 909 |
+
f"Выбрано {width}x{height}, но модель создала {actual_width}x{actual_height}; "
|
| 910 |
+
"FFmpeg недоступен для исправления размера."
|
| 911 |
+
)
|
| 912 |
+
corrected = path[:-4] + "-canvas.mp4"
|
| 913 |
+
subprocess.run(
|
| 914 |
+
[ffmpeg, "-y", "-i", path,
|
| 915 |
+
"-vf", f"scale={width}:{height}:force_original_aspect_ratio=increase:flags=lanczos,"
|
| 916 |
+
f"crop={width}:{height}",
|
| 917 |
+
"-c:v", "libx264", "-preset", "veryfast", "-crf", "18",
|
| 918 |
+
"-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", corrected],
|
| 919 |
+
capture_output=True, text=True, check=True,
|
| 920 |
+
)
|
| 921 |
+
os.replace(corrected, path)
|
| 922 |
+
resized = True
|
| 923 |
+
print(
|
| 924 |
+
f"[canvas] Model/encoder output {actual_width}x{actual_height}; "
|
| 925 |
+
f"final MP4 normalized to selected Canvas {width}x{height}.",
|
| 926 |
+
flush=True,
|
| 927 |
+
)
|
| 928 |
+
|
| 929 |
# Reported, not assumed: `steps_taken` is the number of Euler updates the scheduler actually took.
|
| 930 |
schedule = " → ".join(f"{s:g}" for s in sigmas_used)
|
| 931 |
report = (
|
| 932 |
+
f"{width}x{height}{' (final size corrected with FFmpeg)' if resized else ''} · "
|
| 933 |
+
f"{num_frames} frames ({num_frames / FPS:.3f}s) · "
|
| 934 |
f"{steps_taken} steps (FlashGen σ {schedule}) · "
|
| 935 |
f"conditioner {condition_seconds:.0f}s{' (prompt enhanced)' if enhanced else ''} · "
|
| 936 |
f"denoise + decode {generate_seconds:.0f}s · seed {int(seed)}"
|