File size: 14,763 Bytes
5de606a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
from fractions import Fraction

from typing_extensions import override

from comfy_api.latest import IO, ComfyExtension, Input, InputImpl, Types
from comfy_api_nodes.apis.beeble import (
    CreateSwitchXRequest,
    SwitchXStatusResponse,
)
from comfy_api_nodes.util import (
    ApiEndpoint,
    bytesio_to_image_tensor,
    convert_mask_to_image,
    download_url_as_bytesio,
    download_url_to_image_tensor,
    download_url_to_video_output,
    downscale_image_tensor,
    downscale_video_to_max_pixels,
    poll_op,
    sync_op,
    upload_image_to_comfyapi,
    upload_video_to_comfyapi,
    validate_string,
    validate_video_frame_count,
)

_MAX_PIXELS = 2_770_000
_MAX_FRAMES = 240
_MAX_PROMPT_LEN = 2000


def _validate_inputs(prompt: str | None, reference_image: Input.Image | None) -> str | None:
    """Beeble requires at least one of prompt or reference_image. Returns the cleaned prompt."""
    cleaned = prompt.strip() if prompt else ""
    if not cleaned and reference_image is None:
        raise ValueError("At least one of 'prompt' or 'reference_image' must be provided.")
    if cleaned:
        validate_string(cleaned, strip_whitespace=False, max_length=_MAX_PROMPT_LEN)
    return cleaned or None


async def _upload_mask_as_image(
    cls: type[IO.ComfyNode],
    mask: Input.Image,
    *,
    wait_label: str,
) -> str:
    """Encode a single-frame MASK (H, W) or (1, H, W) as a PNG and upload."""
    if mask.dim() == 2:
        mask = mask.unsqueeze(0)
    image = convert_mask_to_image(mask[:1])
    return await upload_image_to_comfyapi(
        cls,
        image,
        mime_type="image/png",
        wait_label=wait_label,
        total_pixels=_MAX_PIXELS,
    )


async def _upload_mask_batch_as_video(
    cls: type[IO.ComfyNode],
    mask: Input.Image,
    *,
    frame_rate: Fraction,
    source_frame_count: int,
    wait_label: str,
) -> str:
    """Encode a MASK batch (N, H, W) as a grayscale H.264 MP4 at frame_rate and upload.

    The matte is always downscaled to the pixel budget so it stays within Beeble's limit and
    keeps the same dimensions as the (similarly downscaled) source — both use the same algorithm
    from the same starting dimensions, and downscaling is a no-op when already within budget.
    """
    if mask.dim() == 2:
        mask = mask.unsqueeze(0)
    if mask.shape[0] != source_frame_count:
        raise ValueError(
            f"Custom alpha video frame count ({mask.shape[0]}) does not match the "
            f"source video frame count ({source_frame_count}). The Beeble API requires "
            "one mask per source frame."
        )
    images = downscale_image_tensor(convert_mask_to_image(mask), _MAX_PIXELS)
    alpha_video = InputImpl.VideoFromComponents(Types.VideoComponents(images=images, audio=None, frame_rate=frame_rate))
    return await upload_video_to_comfyapi(cls, alpha_video, wait_label=wait_label)


def _alpha_mode_input(*, video: bool) -> IO.DynamicCombo.Input:
    """Build the alpha_mode DynamicCombo with mode-specific extra inputs."""
    select_keyframe_tooltip = (
        "First-frame keyframe mask. Beeble propagates this across the video." if video else "Grayscale keyframe mask."
    )
    custom_tooltip = (
        "Per-frame grayscale mask covering the entire video. "
        "Must have the same frame count as the source. "
        "Connect a MASK output from SAM3_TrackToMask or similar."
        if video
        else "Grayscale mask to apply."
    )
    return IO.DynamicCombo.Input(
        "alpha_mode",
        tooltip=(
            "Controls how SwitchX decides what to keep vs. regenerate. "
            "'auto' isolates the main subject automatically. "
            "'fill' regenerates the entire frame while preserving geometry. "
            "'select' propagates a first-frame keyframe across the clip. "
            "'custom' uses a per-frame alpha matte you provide."
        ),
        options=[
            IO.DynamicCombo.Option("auto", []),
            IO.DynamicCombo.Option("fill", []),
            IO.DynamicCombo.Option(
                "select",
                [IO.Mask.Input("alpha_keyframe", tooltip=select_keyframe_tooltip)],
            ),
            IO.DynamicCombo.Option(
                "custom",
                [IO.Mask.Input("alpha_mask", tooltip=custom_tooltip)],
            ),
        ],
    )


def _common_inputs(*, source: IO.Input, video: bool) -> list[IO.Input]:
    return [
        source,
        IO.String.Input(
            "prompt",
            multiline=True,
            default="",
            tooltip=(
                "Text description of the desired output (max 2000 chars). "
                "At least one of 'prompt' or 'reference_image' is required."
            ),
        ),
        IO.Image.Input(
            "reference_image",
            optional=True,
            tooltip=(
                "Reference image whose look (background, lighting, costume) the result "
                "should adopt. At least one of 'reference_image' or 'prompt' is required."
            ),
        ),
        _alpha_mode_input(video=video),
        IO.Combo.Input(
            "max_resolution",
            options=["1080p", "720p"],
            default="1080p",
            tooltip="Maximum output resolution.",
        ),
        IO.Int.Input(
            "seed",
            default=0,
            min=0,
            max=2147483647,
            control_after_generate=True,
            tooltip=(
                "Seed controls whether the node should re-run; " "results are non-deterministic regardless of seed."
            ),
        ),
    ]


async def _submit_and_poll(
    cls: type[IO.ComfyNode],
    request: CreateSwitchXRequest,
) -> SwitchXStatusResponse:
    initial = await sync_op(
        cls,
        ApiEndpoint(path="/proxy/beeble/v1/switchx/generations", method="POST"),
        response_model=SwitchXStatusResponse,
        data=request,
    )
    return await poll_op(
        cls,
        ApiEndpoint(path=f"/proxy/beeble/v1/switchx/generations/{initial.id}"),
        response_model=SwitchXStatusResponse,
        status_extractor=lambda r: r.status,
        progress_extractor=lambda r: r.progress,
    )


def _require_output_url(response: SwitchXStatusResponse, name: str) -> str:
    if response.output is None or getattr(response.output, name) is None:
        raise RuntimeError(f"Beeble job {response.id} completed without a {name!r} output URL.")
    return getattr(response.output, name)


def _alpha_url(response: SwitchXStatusResponse, mode: str) -> str | None:
    """URL of the alpha matte, or None when the mode produces no separate matte.

    'fill' selects the whole frame, so Beeble writes no alpha asset even though the status
    response still returns a (dangling) signed URL for it — fetching it 403s with S3
    AccessDenied. The other three modes ('auto', 'custom', 'select') all produce a real,
    downloadable matte.
    """
    if mode == "fill" or response.output is None:
        return None
    return response.output.alpha


class BeebleSwitchXVideoEdit(IO.ComfyNode):

    @classmethod
    def define_schema(cls) -> IO.Schema:
        return IO.Schema(
            node_id="BeebleSwitchXVideoEdit",
            display_name="Beeble SwitchX Video Edit",
            category="partner/video/Beeble",
            description=(
                "Edit a video with Beeble SwitchX. Switches anything in the scene (background, "
                "lighting, costume) while preserving the original subject's pixels and motion. "
                "Provide a reference image and/or text prompt to describe the new look. "
                "Max 240 frames, max ~2.77MP per frame."
            ),
            inputs=_common_inputs(source=IO.Video.Input("video"), video=True),
            outputs=[
                IO.Video.Output(display_name="video"),
                IO.Video.Output(
                    display_name="alpha",
                    tooltip="The alpha matte Beeble used. Empty for 'fill' mode, which has no separate matte.",
                ),
            ],
            hidden=[
                IO.Hidden.auth_token_comfy_org,
                IO.Hidden.api_key_comfy_org,
                IO.Hidden.unique_id,
            ],
            is_api_node=True,
            price_badge=IO.PriceBadge(
                depends_on=IO.PriceBadgeDepends(widgets=["max_resolution"]),
                expr="""
                (
                  $rate := widgets.max_resolution = "1080p" ? 0.429 : 0.143;
                  {"type":"usd","usd": $rate, "format":{"suffix":"/30 frames"}}
                )
                """,
            ),
        )

    @classmethod
    async def execute(
        cls,
        video: Input.Video,
        prompt: str,
        alpha_mode: dict,
        max_resolution: str,
        seed: int,
        reference_image: Input.Image | None = None,
    ) -> IO.NodeOutput:
        cleaned_prompt = _validate_inputs(prompt, reference_image)

        validate_video_frame_count(video, max_frame_count=_MAX_FRAMES)
        video = downscale_video_to_max_pixels(video, _MAX_PIXELS)

        mode = alpha_mode["alpha_mode"]
        alpha_uri: str | None = None
        if mode == "select":
            alpha_uri = await _upload_mask_as_image(cls, alpha_mode["alpha_keyframe"], wait_label="Uploading keyframe")
        elif mode == "custom":
            alpha_uri = await _upload_mask_batch_as_video(
                cls,
                alpha_mode["alpha_mask"],
                frame_rate=video.get_frame_rate(),
                source_frame_count=video.get_frame_count(),
                wait_label="Uploading alpha video",
            )

        source_uri = await upload_video_to_comfyapi(cls, video, wait_label="Uploading source")
        reference_uri: str | None = None
        if reference_image is not None:
            reference_uri = await upload_image_to_comfyapi(
                cls,
                reference_image,
                mime_type="image/png",
                wait_label="Uploading reference",
                total_pixels=_MAX_PIXELS,
            )

        request = CreateSwitchXRequest(
            generation_type="video",
            source_uri=source_uri,
            alpha_mode=mode,
            prompt=cleaned_prompt,
            reference_image_uri=reference_uri,
            alpha_uri=alpha_uri,
            max_resolution=1080 if max_resolution == "1080p" else 720,
        )
        response = await _submit_and_poll(cls, request)

        render = await download_url_to_video_output(_require_output_url(response, "render"))
        alpha = None
        if (alpha_url := _alpha_url(response, mode)) is not None:
            alpha = await download_url_to_video_output(alpha_url)
        return IO.NodeOutput(render, alpha)


class BeebleSwitchXImageEdit(IO.ComfyNode):

    @classmethod
    def define_schema(cls) -> IO.Schema:
        return IO.Schema(
            node_id="BeebleSwitchXImageEdit",
            display_name="Beeble SwitchX Image Edit",
            category="partner/image/Beeble",
            description=(
                "Edit a single image with Beeble SwitchX. Switches anything in the scene "
                "(background, lighting, costume) while preserving the original subject's pixels. "
                "Provide a reference image and/or text prompt to describe the new look. "
                "Max ~2.77MP."
            ),
            inputs=_common_inputs(source=IO.Image.Input("image"), video=False),
            outputs=[
                IO.Image.Output(display_name="image"),
                IO.Mask.Output(
                    display_name="alpha",
                    tooltip="The alpha matte Beeble used. Empty for 'fill' mode, which has no separate matte.",
                ),
            ],
            hidden=[
                IO.Hidden.auth_token_comfy_org,
                IO.Hidden.api_key_comfy_org,
                IO.Hidden.unique_id,
            ],
            is_api_node=True,
            price_badge=IO.PriceBadge(
                depends_on=IO.PriceBadgeDepends(widgets=["max_resolution"]),
                expr="""
                (
                  $rate := widgets.max_resolution = "1080p" ? 0.429 : 0.143;
                  {"type":"usd","usd": $rate}
                )
                """,
            ),
        )

    @classmethod
    async def execute(
        cls,
        image: Input.Image,
        prompt: str,
        alpha_mode: dict,
        max_resolution: str,
        seed: int,
        reference_image: Input.Image | None = None,
    ) -> IO.NodeOutput:
        cleaned_prompt = _validate_inputs(prompt, reference_image)

        image = downscale_image_tensor(image, _MAX_PIXELS)

        mode = alpha_mode["alpha_mode"]
        alpha_uri: str | None = None
        if mode == "select":
            alpha_uri = await _upload_mask_as_image(cls, alpha_mode["alpha_keyframe"], wait_label="Uploading keyframe")
        elif mode == "custom":
            alpha_uri = await _upload_mask_as_image(cls, alpha_mode["alpha_mask"], wait_label="Uploading alpha")

        source_uri = await upload_image_to_comfyapi(
            cls,
            image,
            mime_type="image/png",
            wait_label="Uploading source",
            total_pixels=None,
        )
        reference_uri: str | None = None
        if reference_image is not None:
            reference_uri = await upload_image_to_comfyapi(
                cls,
                reference_image,
                mime_type="image/png",
                wait_label="Uploading reference",
                total_pixels=_MAX_PIXELS,
            )

        request = CreateSwitchXRequest(
            generation_type="image",
            source_uri=source_uri,
            alpha_mode=mode,
            prompt=cleaned_prompt,
            reference_image_uri=reference_uri,
            alpha_uri=alpha_uri,
            max_resolution=1080 if max_resolution == "1080p" else 720,
        )
        response = await _submit_and_poll(cls, request)

        render = await download_url_to_image_tensor(_require_output_url(response, "render"))
        alpha_mask = None
        if (alpha_url := _alpha_url(response, mode)) is not None:
            alpha_image = bytesio_to_image_tensor(await download_url_as_bytesio(alpha_url), mode="L")
            alpha_mask = alpha_image.squeeze(-1) if alpha_image.dim() == 4 else alpha_image
        return IO.NodeOutput(render, alpha_mask)


class BeebleExtension(ComfyExtension):
    @override
    async def get_node_list(self) -> list[type[IO.ComfyNode]]:
        return [
            BeebleSwitchXVideoEdit,
            BeebleSwitchXImageEdit,
        ]


async def comfy_entrypoint() -> BeebleExtension:
    return BeebleExtension()