nsfwalex Claude Opus 4.8 (1M context) commited on
Commit
bae8329
Β·
1 Parent(s): b3b69d0

feat: add UI-less prompt_to_video_assets endpoint (LLM->image->LLM)

Browse files

Chains Qwen (first-frame image prompt) -> image model -> R2 upload ->
Qwen (video prompt grounded on the frame) in one streaming call. Exposed
via gr.api so it adds no Gradio UI. Returns video_prompt + first_frame_url
(presigned GET) plus r2 filekey/bucket and the intermediate frame prompt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

__pycache__/app.cpython-312.pyc ADDED
Binary file (41.7 kB). View file
 
__pycache__/r2_uploader.cpython-312.pyc ADDED
Binary file (9.71 kB). View file
 
app.py CHANGED
@@ -676,6 +676,174 @@ def assistant_chat(
676
  yield text, _progress("done", 1.0, label="Done")
677
 
678
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
679
  # Recommended defaults per model: (steps, guidance, height, width)
680
  MODEL_DEFAULTS = {
681
  MODEL_ZIMAGE: dict(steps=9, guidance=0.0, height=1024, width=1024),
@@ -951,6 +1119,11 @@ with gr.Blocks(fill_height=True) as demo:
951
  fn=assistant_chat, inputs=vlm_inputs, outputs=[vlm_output, vlm_progress],
952
  )
953
 
 
 
 
 
 
954
  if __name__ == "__main__":
955
  demo.launch(
956
  theme=custom_theme,
 
676
  yield text, _progress("done", 1.0, label="Done")
677
 
678
 
679
+ # =============================================================================
680
+ # Combined endpoint: text -> first-frame image + video prompt (no UI)
681
+ # =============================================================================
682
+ # One call chains LLM + image model + LLM to turn a raw idea into the two assets
683
+ # an image-to-video pipeline needs:
684
+ # 1. LLM (Qwen) writes a first-frame *image* prompt from
685
+ # `image_instruction` + `original_input`.
686
+ # 2. The image model renders that first frame.
687
+ # 3. The frame is uploaded to R2 (same path as the Generate tab).
688
+ # 4. LLM (Qwen) writes a *video* prompt from `video_instruction` +
689
+ # `original_input`, grounded on the rendered first frame image.
690
+ # It returns the video prompt and the first-frame URL (+ R2 filekey/bucket and
691
+ # the intermediate image prompt). Exposed via `gr.api` so it has no Gradio UI.
692
+ #
693
+ # Like the other streaming endpoints it is a generator yielding one structured
694
+ # dict per frame (so progress crosses the /call SSE stream, see _progress); the
695
+ # same dict carries the results, filled in as each stage completes, and the final
696
+ # yield has `done=True`. Stage weights below sum to 1.0.
697
+ _P2V_W_FRAME_PROMPT = 0.30 # LLM writes the first-frame image prompt
698
+ _P2V_W_IMAGE = 0.30 # image model renders the first frame
699
+ _P2V_W_UPLOAD = 0.05 # R2 upload
700
+ _P2V_W_VIDEO_PROMPT = 0.35 # LLM writes the video prompt
701
+
702
+
703
+ def _compose_instruction(instruction, original_input):
704
+ """Join an instruction with the raw idea into a single LLM message."""
705
+ instruction = (instruction or "").strip()
706
+ original_input = (original_input or "").strip()
707
+ if instruction and original_input:
708
+ return f"{instruction}\n\nInput:\n{original_input}"
709
+ return instruction or original_input
710
+
711
+
712
+ def prompt_to_video_assets(
713
+ original_input: str,
714
+ image_instruction: str,
715
+ video_instruction: str,
716
+ model_name: str = MODEL_ZIMAGE,
717
+ height: int = 1024,
718
+ width: int = 1024,
719
+ num_inference_steps: int = 9,
720
+ guidance_scale: float = 0.0,
721
+ seed: int = 42,
722
+ randomize_seed: bool = True,
723
+ reasoning: str = "Off",
724
+ max_new_tokens: int = 512,
725
+ request: gr.Request = None,
726
+ ) -> dict:
727
+ """Text -> first-frame image (uploaded to R2) + video generation prompt.
728
+
729
+ Streams progress and returns a dict with ``video_prompt``, ``first_frame_url``
730
+ (a presigned GET, usable directly), ``r2_filekey``/``r2_bucket`` (for callers
731
+ that resolve their own public URL), ``first_frame_prompt`` (the intermediate
732
+ image prompt) and ``seed``. Has no UI (registered via ``gr.api``).
733
+ """
734
+ state = {
735
+ "stage": "frame_prompt", "p": 0.0, "step": 0, "total": 0, "label": "",
736
+ "first_frame_prompt": None,
737
+ "first_frame_url": None,
738
+ "r2_filekey": None,
739
+ "r2_bucket": None,
740
+ "video_prompt": None,
741
+ "seed": None,
742
+ "done": False,
743
+ "error": None,
744
+ }
745
+
746
+ def frame(stage, base, span, frac=1.0, step=0, total=0, label=""):
747
+ state.update(
748
+ stage=stage,
749
+ p=max(0.0, min(1.0, base + span * max(0.0, min(1.0, frac)))),
750
+ step=int(step), total=int(total), label=label,
751
+ )
752
+ return dict(state)
753
+
754
+ if not (original_input or "").strip():
755
+ state["error"] = "original_input is required"
756
+ state["done"] = True
757
+ yield dict(state)
758
+ return
759
+
760
+ # --- Stage 1: LLM writes the first-frame image prompt ---------------------
761
+ base = 0.0
762
+ frame_prompt = ""
763
+ for ev in vlm_chat(
764
+ _compose_instruction(image_instruction, original_input),
765
+ None, reasoning, max_new_tokens,
766
+ ):
767
+ if ev[0] == "progress":
768
+ _, produced, budget, partial = ev
769
+ frame_prompt = partial
770
+ yield frame("frame_prompt", base, _P2V_W_FRAME_PROMPT,
771
+ frac=produced / max(budget, 1), step=produced, total=budget,
772
+ label="Writing first-frame prompt")
773
+ else:
774
+ frame_prompt = ev[1]
775
+ frame_prompt = (frame_prompt or "").strip()
776
+ state["first_frame_prompt"] = frame_prompt
777
+ yield frame("frame_prompt", base, _P2V_W_FRAME_PROMPT, label="First-frame prompt ready")
778
+
779
+ # --- Stage 2: image model renders the first frame -------------------------
780
+ base += _P2V_W_FRAME_PROMPT
781
+ image, used_seed = None, seed
782
+ use_negative_prompt = (model_name == MODEL_NOOBXL)
783
+ for ev in generate_image(
784
+ model_name, frame_prompt, NOOBXL_NEGATIVE, use_negative_prompt,
785
+ height, width, num_inference_steps, guidance_scale, seed, randomize_seed,
786
+ ):
787
+ if ev[0] == "progress":
788
+ _, step, total = ev
789
+ yield frame("image", base, _P2V_W_IMAGE,
790
+ frac=step / max(total, 1), step=step, total=total,
791
+ label=f"Rendering first frame {step}/{total}")
792
+ else:
793
+ _, image, used_seed = ev
794
+ state["seed"] = int(used_seed)
795
+
796
+ # --- Stage 3: upload the first frame to R2 --------------------------------
797
+ base += _P2V_W_IMAGE
798
+ yield frame("upload", base, _P2V_W_UPLOAD, frac=0.1, label="Uploading first frame")
799
+ uid = r2_uploader.uid_from_request(request)
800
+ buf = io.BytesIO()
801
+ image.save(buf, format="PNG")
802
+ params = {
803
+ "model": model_name,
804
+ "prompt": frame_prompt,
805
+ "negative_prompt": (NOOBXL_NEGATIVE if use_negative_prompt else ""),
806
+ "height": int(height),
807
+ "width": int(width),
808
+ "num_inference_steps": int(num_inference_steps),
809
+ "guidance_scale": float(guidance_scale),
810
+ "seed": int(used_seed),
811
+ "uid": uid,
812
+ "source": "prompt_to_video_assets",
813
+ "original_input": original_input,
814
+ }
815
+ up = r2_uploader.upload_asset(
816
+ namespace=R2_NAMESPACE, prompt=frame_prompt, params=params,
817
+ data=buf.getvalue(), ext=".png", content_type="image/png", uid=uid,
818
+ )
819
+ if up.get("ok"):
820
+ state["r2_filekey"] = up["filekey"]
821
+ state["r2_bucket"] = up["bucket"]
822
+ state["first_frame_url"] = r2_uploader.presign_get_url(up["filekey"], up["bucket"])
823
+ else:
824
+ state["error"] = up.get("error", "R2 upload failed")
825
+
826
+ # --- Stage 4: LLM writes the video prompt (grounded on the frame) ---------
827
+ base += _P2V_W_UPLOAD
828
+ video_prompt = ""
829
+ for ev in vlm_chat(
830
+ _compose_instruction(video_instruction, original_input),
831
+ image, reasoning, max_new_tokens,
832
+ ):
833
+ if ev[0] == "progress":
834
+ _, produced, budget, partial = ev
835
+ video_prompt = partial
836
+ yield frame("video_prompt", base, _P2V_W_VIDEO_PROMPT,
837
+ frac=produced / max(budget, 1), step=produced, total=budget,
838
+ label="Writing video prompt")
839
+ else:
840
+ video_prompt = ev[1]
841
+ state["video_prompt"] = (video_prompt or "").strip()
842
+
843
+ state["done"] = True
844
+ yield frame("done", 1.0, 0.0, label="Done")
845
+
846
+
847
  # Recommended defaults per model: (steps, guidance, height, width)
848
  MODEL_DEFAULTS = {
849
  MODEL_ZIMAGE: dict(steps=9, guidance=0.0, height=1024, width=1024),
 
1119
  fn=assistant_chat, inputs=vlm_inputs, outputs=[vlm_output, vlm_progress],
1120
  )
1121
 
1122
+ # UI-less combined endpoint: text -> first-frame image (R2) + video prompt.
1123
+ # `gr.api` derives its schema from the function's type hints and registers no
1124
+ # components, so it adds an API route without touching the visible UI.
1125
+ gr.api(prompt_to_video_assets, api_name="prompt_to_video_assets")
1126
+
1127
  if __name__ == "__main__":
1128
  demo.launch(
1129
  theme=custom_theme,
r2_uploader.py CHANGED
@@ -126,6 +126,27 @@ def _ensure_bucket(s3, bucket: str) -> None:
126
  _ensured_buckets.add(bucket)
127
 
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  def upload_asset(
130
  *,
131
  namespace: str,
 
126
  _ensured_buckets.add(bucket)
127
 
128
 
129
+ def presign_get_url(filekey: str, bucket: str | None = None, expires: int = 604800) -> str | None:
130
+ """Return a presigned GET URL for an uploaded object, or None on failure.
131
+
132
+ ``expires`` defaults to 7 days (the SigV4 maximum). This lets callers hand
133
+ back a directly-usable URL even when no public R2 domain is bound; downstream
134
+ consumers that have a public base can still rebuild a clean URL from the
135
+ ``filekey``/``bucket`` reported alongside it. Never raises.
136
+ """
137
+ try:
138
+ cfg = _load_cfg()
139
+ bucket = bucket or cfg.get("bucket") or DEFAULT_BUCKET
140
+ s3 = _client(cfg)
141
+ return s3.generate_presigned_url(
142
+ "get_object",
143
+ Params={"Bucket": bucket, "Key": filekey},
144
+ ExpiresIn=int(expires),
145
+ )
146
+ except Exception: # noqa: BLE001 - URL is best-effort, never fatal
147
+ return None
148
+
149
+
150
  def upload_asset(
151
  *,
152
  namespace: str,