|
7 | 7 | import torch |
8 | 8 | from typing_extensions import override |
9 | 9 |
|
| 10 | +from comfy.utils import common_upscale |
10 | 11 | from comfy_api.latest import IO, ComfyExtension, Input, Types |
11 | 12 | from comfy_api_nodes.apis.bytedance import ( |
12 | 13 | RECOMMENDED_PRESETS, |
@@ -131,6 +132,44 @@ def _prepare_seedance_image(image: Input.Image) -> Input.Image: |
131 | 132 | return image |
132 | 133 |
|
133 | 134 |
|
| 135 | +# Supported output aspect ratios, used to pre-size FLF frames to matching pixel pair to avoid the 1080p stretch jump. |
| 136 | +SEEDANCE2_RATIO_WH = { |
| 137 | + "16:9": (16, 9), |
| 138 | + "4:3": (4, 3), |
| 139 | + "1:1": (1, 1), |
| 140 | + "3:4": (3, 4), |
| 141 | + "9:16": (9, 16), |
| 142 | + "21:9": (21, 9), |
| 143 | +} |
| 144 | +SEEDANCE2_RES_SHORT_SIDE = {"480p": 480, "720p": 720, "1080p": 1080} |
| 145 | + |
| 146 | + |
| 147 | +def _seedance2_target_dims(resolution: str, ratio: str, image: torch.Tensor) -> tuple[int, int]: |
| 148 | + """Exact supported output (width, height) for (resolution, ratio). |
| 149 | +
|
| 150 | + The shorter side equals the resolution number (e.g. 1080p 16:9 -> 1920x1080). For ratio |
| 151 | + "adaptive" (or any unexpected value) the ratio is derived from the image's own aspect, snapped |
| 152 | + to the nearest supported ratio, so the output keeps the frame's orientation. |
| 153 | + """ |
| 154 | + short = SEEDANCE2_RES_SHORT_SIDE[resolution] |
| 155 | + if ratio not in SEEDANCE2_RATIO_WH: |
| 156 | + aspect = image.shape[-2] / image.shape[-3] # W / H; tensor is (B, H, W, C) |
| 157 | + ratio = min(SEEDANCE2_RATIO_WH, key=lambda k: abs(SEEDANCE2_RATIO_WH[k][0] / SEEDANCE2_RATIO_WH[k][1] - aspect)) |
| 158 | + rw, rh = SEEDANCE2_RATIO_WH[ratio] |
| 159 | + if rw >= rh: # landscape or square: shorter side is the height |
| 160 | + out_w, out_h = round(short * rw / rh), short |
| 161 | + else: # portrait: shorter side is the width |
| 162 | + out_w, out_h = short, round(short * rh / rw) |
| 163 | + return out_w - out_w % 2, out_h - out_h % 2 |
| 164 | + |
| 165 | + |
| 166 | +def _resize_to_exact(image: torch.Tensor, width: int, height: int) -> torch.Tensor: |
| 167 | + """Center-crop to the target aspect and resize to exactly width x height (lanczos).""" |
| 168 | + samples = image.movedim(-1, 1) # (B, H, W, C) -> (B, C, H, W) |
| 169 | + resized = common_upscale(samples, width, height, "lanczos", "center") |
| 170 | + return resized.movedim(1, -1) |
| 171 | + |
| 172 | + |
134 | 173 | async def _resolve_reference_assets( |
135 | 174 | cls: type[IO.ComfyNode], |
136 | 175 | asset_ids: list[str], |
@@ -1790,10 +1829,28 @@ async def execute( |
1790 | 1829 | if last_frame is not None and last_frame_asset_id: |
1791 | 1830 | raise ValueError("Provide only one of last_frame or last_frame_asset_id, not both.") |
1792 | 1831 |
|
1793 | | - if first_frame is not None: |
1794 | | - first_frame = _prepare_seedance_image(first_frame) |
1795 | | - if last_frame is not None: |
1796 | | - last_frame = _prepare_seedance_image(last_frame) |
| 1832 | + request_ratio = model["ratio"] |
| 1833 | + if first_frame_asset_id or last_frame_asset_id: |
| 1834 | + if first_frame is not None: |
| 1835 | + first_frame = _prepare_seedance_image(first_frame) |
| 1836 | + if last_frame is not None: |
| 1837 | + last_frame = _prepare_seedance_image(last_frame) |
| 1838 | + else: |
| 1839 | + # The 1080p FLF stretch fix (pre-size frames to a supported pixel pair + submit ratio="adaptive") |
| 1840 | + # only applies to local image inputs we can resize. |
| 1841 | + request_ratio = "adaptive" |
| 1842 | + target_dims: tuple[int, int] | None = None |
| 1843 | + if first_frame is not None: |
| 1844 | + validate_image_aspect_ratio(first_frame, (2, 5), (5, 2), strict=False) # 0.4 to 2.5 |
| 1845 | + validate_image_dimensions(first_frame, min_width=300, min_height=300) |
| 1846 | + target_dims = _seedance2_target_dims(model["resolution"], model["ratio"], first_frame) |
| 1847 | + first_frame = _resize_to_exact(first_frame, *target_dims) |
| 1848 | + if last_frame is not None: |
| 1849 | + validate_image_aspect_ratio(last_frame, (2, 5), (5, 2), strict=False) # 0.4 to 2.5 |
| 1850 | + validate_image_dimensions(last_frame, min_width=300, min_height=300) |
| 1851 | + if target_dims is None: |
| 1852 | + target_dims = _seedance2_target_dims(model["resolution"], model["ratio"], last_frame) |
| 1853 | + last_frame = _resize_to_exact(last_frame, *target_dims) |
1797 | 1854 |
|
1798 | 1855 | asset_ids_to_resolve = [a for a in (first_frame_asset_id, last_frame_asset_id) if a] |
1799 | 1856 | image_assets: dict[str, str] = {} |
@@ -1844,7 +1901,7 @@ async def execute( |
1844 | 1901 | content=content, |
1845 | 1902 | generate_audio=model["generate_audio"], |
1846 | 1903 | resolution=model["resolution"], |
1847 | | - ratio=model["ratio"], |
| 1904 | + ratio=request_ratio, |
1848 | 1905 | duration=model["duration"], |
1849 | 1906 | seed=seed, |
1850 | 1907 | watermark=watermark, |
|
0 commit comments