VirusDumb commited on
Commit
313f141
·
1 Parent(s): c6815eb

Big Leagues Calling

Browse files
Files changed (4) hide show
  1. README.md +10 -3
  2. app.py +83 -34
  3. images.py +18 -0
  4. modal_app.py +6 -0
README.md CHANGED
@@ -7,6 +7,7 @@ sdk: gradio
7
  sdk_version: 6.17.3
8
  python_version: '3.12'
9
  app_file: app.py
 
10
  pinned: true
11
  license: apache-2.0
12
  short_description: A text-image based RPG built to help fight procrastination
@@ -27,9 +28,15 @@ boss quest you face first.
27
  reference, then edits that same image into success / "retreat to fight another day" states.
28
 
29
  Your photo and quest state live in your browser (localStorage) **and** in a private per-user JSON
30
- record on the Space's storage, keyed by a random **Hero Code** shown in the left panel write it
31
- down and paste it on any device (or after a cleared browser) to get everything back. No login.
32
- The photo is sent to the GPU only transiently during generation.
 
 
 
 
 
 
33
 
34
  ### Durable storage (Storage Bucket)
35
 
 
7
  sdk_version: 6.17.3
8
  python_version: '3.12'
9
  app_file: app.py
10
+ hf_oauth: true
11
  pinned: true
12
  license: apache-2.0
13
  short_description: A text-image based RPG built to help fight procrastination
 
28
  reference, then edits that same image into success / "retreat to fight another day" states.
29
 
30
  Your photo and quest state live in your browser (localStorage) **and** in a private per-user JSON
31
+ record on the Space's storage. **Sign in with your Hugging Face account** (top right) and the
32
+ record is keyed to your account same state on any device, nothing to remember. Logged out, it's
33
+ keyed by a random **Hero Code** shown in the left panel instead paste it on a new device (or
34
+ after a cleared browser) to get everything back. The photo is sent to the GPU only transiently
35
+ during generation.
36
+
37
+ NOTE: after enabling/changing `hf_oauth`, fully restart (factory rebuild) the Space so the OAuth
38
+ login route gets registered — clicking Sign in before that yields a "Could not parse server
39
+ response" error.
40
 
41
  ### Durable storage (Storage Bucket)
42
 
app.py CHANGED
@@ -48,7 +48,7 @@ def _warmup():
48
  MODEL_IMPORT_ERROR = None
49
  try:
50
  from llm import generate_campaign_raw, generate_quests_raw, route_intent
51
- from images import edit_image, initial_image, pil_to_data_url
52
  except Exception:
53
  MODEL_IMPORT_ERROR = traceback.format_exc()
54
 
@@ -208,7 +208,8 @@ def select_quest(qid, photo, adventure, images, quests, campaigns, browser):
208
  images = dict(images or {})
209
  quest = _find(quests, qid)
210
  if quest is None:
211
- return qid, None, _desc_html(None, adventure), images, _merge(browser, selected_id=qid)
 
212
 
213
  world = _world_for(quest, adventure, campaigns)
214
  cache = dict(images.get(qid) or {})
@@ -232,7 +233,8 @@ def select_quest(qid, photo, adventure, images, quests, campaigns, browser):
232
  images[qid] = cache
233
  images = _trim_images(images, qid)
234
  scene = pil
235
- return qid, scene, _desc_html(quest, world or adventure, hint), images, _merge(browser, selected_id=qid, images=images)
 
236
 
237
 
238
  def _apply_result(qid, quests, adventure, photo, images, campaigns, browser, kind, reason):
@@ -309,7 +311,7 @@ def remove_quest(qid, quests, adventure, images, selected_id, browser):
309
  if data:
310
  scene = _dataurl_to_pil(data)
311
  return (quests, images, selected_id, scene, _desc_html(quest, adventure), _stats_html(quests),
312
- _merge(browser, quests=quests, images=images, selected_id=selected_id))
313
 
314
 
315
  # ----------------------------- chat (Frog Master, full intent routing) -----------------------------
@@ -338,9 +340,22 @@ def _resolve_target(quests: list, target: str, selected_id):
338
  return selected_id
339
 
340
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  def _do_forge(message, theme, photo, quests_old, images_old, campaigns, browser):
342
  """Forge a fresh DAY log. Campaign quests (and their cached scenes) survive the re-forge —
343
- only standalone day quests are replaced."""
 
344
  raw = generate_quests_raw(message, theme)
345
  adv = validate_and_clamp(raw, theme)
346
  day_quests, adventure = adv["quests"], adv["adventure"]
@@ -352,6 +367,9 @@ def _do_forge(message, theme, photo, quests_old, images_old, campaigns, browser)
352
  quests = day_quests + keep
353
  images = {k: v for k, v in (images_old or {}).items() if k in keep_ids}
354
  frog_id = day_quests[0]["id"]
 
 
 
355
  _sel, scene, desc, images2, _b = select_quest(frog_id, photo, adventure, images, quests,
356
  campaigns, browser)
357
  browser2 = _merge(browser, theme=theme, adventure=adventure, quests=quests,
@@ -414,12 +432,15 @@ def upload_photo(pil, browser):
414
  return b64, _merge(browser, photo=b64)
415
 
416
 
417
- # forge_campaign outputs (order): campaign_goal_box, quests_state, campaigns_state, stats_html, browser
418
- def forge_campaign(goal, do_research, theme, quests, campaigns, browser):
 
419
  """One long-term goal -> a campaign + its ordered quest chain, appended to the log. With
420
- do_research, ddgs+BS4 snippets ground the plan (research.py; degrades gracefully without it)."""
 
 
421
  goal = (goal or "").strip()
422
- nochange = (gr.update(),) * 4
423
  if not goal:
424
  gr.Info("Name your long-term goal first.")
425
  return (gr.update(),) + nochange
@@ -433,7 +454,7 @@ def forge_campaign(goal, do_research, theme, quests, campaigns, browser):
433
  snippets, sources = "", []
434
  if do_research:
435
  try:
436
- from research import research_goal # lazy: optional until Phase 3 ships
437
  gr.Info("Scouting the web for your goal…")
438
  r = research_goal(goal)
439
  snippets = "\n".join(f"- {s}" for s in r.get("snippets") or [])
@@ -450,10 +471,20 @@ def forge_campaign(goal, do_research, theme, quests, campaigns, browser):
450
  camp = result["campaign"]
451
  camp["sources"] = sources
452
  campaigns2 = campaigns + [camp]
453
- quests2 = list(quests or []) + result["quests"]
454
- gr.Info(f"Campaign forged: {camp['title']} {len(result['quests'])} quests.")
455
- return ("", quests2, campaigns2, _stats_html(quests2),
456
- _merge(browser, quests=quests2, campaigns=campaigns2))
 
 
 
 
 
 
 
 
 
 
457
 
458
 
459
  def change_theme(theme, adventure, images, browser):
@@ -478,29 +509,44 @@ def reset_all(uid):
478
 
479
 
480
  def persist(uid, browser):
481
- """Write-through the full state to the user's durable SQLite record. No-op when logged out
482
- (anonymous users live in BrowserState only). Bound via .then(...) after every write event."""
 
483
  store.save(uid, browser)
484
 
485
 
486
- def _hero_code_html(uid) -> str:
 
 
 
 
 
 
 
 
 
 
487
  return (f'<p class="hint">Your Hero Code: <b>{_esc(uid)}</b><br>Write it down — paste it on '
488
- 'any device (or after a cleared browser) to summon this hero back.</p>')
 
489
 
490
 
491
- def boot(browser):
492
- """Restore on page load. The uid ("Hero Code") lives in the browser dict; the server-side JSON
493
- record for that uid is the source of truth and is mirrored back into BrowserState. A fresh
494
- browser gets a new uid. Shows cached scenes only (never generates) so reloads are instant."""
 
 
495
  b = dict(browser or _default_state())
496
- uid = b.get("uid") or uuid.uuid4().hex[:12]
 
497
  saved = store.load(uid)
498
  if saved:
499
  b = saved
500
  b["uid"] = uid
501
  if not saved:
502
- store.save(uid, b) # first visit under this uid: adopt the browser's current state
503
- return _rehydrate(uid, b)
504
 
505
 
506
  def restore_hero(code, browser):
@@ -514,7 +560,7 @@ def restore_hero(code, browser):
514
  return _rehydrate(code, saved)
515
 
516
 
517
- def _rehydrate(uid, b):
518
  """Build the full page state tuple (shared by boot and restore_hero)."""
519
  photo = b.get("photo")
520
  quests = b.get("quests") or []
@@ -533,8 +579,8 @@ def _rehydrate(uid, b):
533
  scene = _dataurl_to_pil(data)
534
  photo_pil = _dataurl_to_pil(photo) if photo else None
535
  world = _world_for(quest, adventure, campaigns) or adventure
536
- return (uid, _hero_code_html(uid), b, photo_pil, photo, quests, campaigns, adventure, selected,
537
- images, scene, _desc_html(quest, world), _stats_html(quests), theme)
538
 
539
 
540
  # ----------------------------- UI -----------------------------
@@ -554,7 +600,9 @@ with gr.Blocks(title="FrogQuest") as demo:
554
  campaign_filter_state = gr.State(None) # campaign id to filter the quest log by (None = all)
555
  uid_state = gr.State(None) # the "Hero Code" uid; generated by boot, lives in BrowserState
556
 
557
- gr.HTML('<div class="fq-topbar"><h1 class="fq-logo">FROG<b>QUEST</b></h1></div>')
 
 
558
 
559
  with gr.Row(elem_classes=["fq-app-grid"]):
560
  # ---------- LEFT: hero ----------
@@ -677,7 +725,7 @@ with gr.Blocks(title="FrogQuest") as demo:
677
  inputs=[photo_state, adventure_state, images_state, quests_state,
678
  campaigns_state, browser],
679
  outputs=[selected_id_state, scene_image, desc_html, images_state, browser],
680
- ).then(persist, [uid_state, browser])
681
  # Clear button appears only once a quest is done/failed, to remove it.
682
  if finished:
683
  clr = gr.Button("✕", elem_classes=["pix-btn", "small", "fq-task-clear"])
@@ -687,7 +735,7 @@ with gr.Blocks(title="FrogQuest") as demo:
687
  inputs=[quests_state, adventure_state, images_state, selected_id_state, browser],
688
  outputs=[quests_state, images_state, selected_id_state, scene_image,
689
  desc_html, stats_html, browser],
690
- ).then(persist, [uid_state, browser])
691
 
692
  # ---------- BOTTOM: Frog Master chat ----------
693
  with gr.Row(elem_classes=["fq-chatbar"]):
@@ -723,9 +771,10 @@ with gr.Blocks(title="FrogQuest") as demo:
723
  ).then(persist, [uid_state, browser])
724
 
725
  campaign_btn.click(
726
- forge_campaign, [campaign_goal_box, research_check, theme_radio, quests_state,
727
- campaigns_state, browser],
728
- [campaign_goal_box, quests_state, campaigns_state, stats_html, browser],
 
729
  ).then(persist, [uid_state, browser])
730
 
731
  _chat_inputs = [chat_input, quests_state, adventure_state, theme_radio, photo_state,
 
48
  MODEL_IMPORT_ERROR = None
49
  try:
50
  from llm import generate_campaign_raw, generate_quests_raw, route_intent
51
+ from images import edit_image, initial_image, initial_images, pil_to_data_url
52
  except Exception:
53
  MODEL_IMPORT_ERROR = traceback.format_exc()
54
 
 
208
  images = dict(images or {})
209
  quest = _find(quests, qid)
210
  if quest is None:
211
+ return (qid, None, _desc_html(None, adventure), images,
212
+ _persist_browser(_merge(browser, selected_id=qid)))
213
 
214
  world = _world_for(quest, adventure, campaigns)
215
  cache = dict(images.get(qid) or {})
 
233
  images[qid] = cache
234
  images = _trim_images(images, qid)
235
  scene = pil
236
+ return (qid, scene, _desc_html(quest, world or adventure, hint), images,
237
+ _persist_browser(_merge(browser, selected_id=qid, images=images)))
238
 
239
 
240
  def _apply_result(qid, quests, adventure, photo, images, campaigns, browser, kind, reason):
 
311
  if data:
312
  scene = _dataurl_to_pil(data)
313
  return (quests, images, selected_id, scene, _desc_html(quest, adventure), _stats_html(quests),
314
+ _persist_browser(_merge(browser, quests=quests, images=images, selected_id=selected_id)))
315
 
316
 
317
  # ----------------------------- chat (Frog Master, full intent routing) -----------------------------
 
340
  return selected_id
341
 
342
 
343
+ def _batch_initials(photo, art_style, seed, quests, images, keep_id):
344
+ """ONE batched GPU call generating every quest's initial scene into the images cache.
345
+ Mutates and returns `images`. No-op without a photo (lazy/hint path takes over)."""
346
+ if not photo or not quests:
347
+ return images
348
+ pils = initial_images(_dataurl_to_pil(photo), art_style,
349
+ [q.get("initial_image_prompt", "") for q in quests], int(seed))
350
+ for q, pil in zip(quests, pils):
351
+ images.setdefault(q["id"], {})["initial"] = pil_to_data_url(pil)
352
+ return _trim_images(images, keep_id)
353
+
354
+
355
  def _do_forge(message, theme, photo, quests_old, images_old, campaigns, browser):
356
  """Forge a fresh DAY log. Campaign quests (and their cached scenes) survive the re-forge —
357
+ only standalone day quests are replaced. With a photo present, ALL day scenes are generated
358
+ eagerly in one batched GPU call (clicking quests afterwards is instant)."""
359
  raw = generate_quests_raw(message, theme)
360
  adv = validate_and_clamp(raw, theme)
361
  day_quests, adventure = adv["quests"], adv["adventure"]
 
367
  quests = day_quests + keep
368
  images = {k: v for k, v in (images_old or {}).items() if k in keep_ids}
369
  frog_id = day_quests[0]["id"]
370
+ images = _batch_initials(photo, adventure["art_style"], adventure["seed"],
371
+ day_quests, images, frog_id)
372
+ # Cache is warm now (or photo absent) -> this just selects the frog; no extra GPU call.
373
  _sel, scene, desc, images2, _b = select_quest(frog_id, photo, adventure, images, quests,
374
  campaigns, browser)
375
  browser2 = _merge(browser, theme=theme, adventure=adventure, quests=quests,
 
432
  return b64, _merge(browser, photo=b64)
433
 
434
 
435
+ # forge_campaign outputs (order): campaign_goal_box, quests_state, campaigns_state, stats_html,
436
+ # images_state, selected_id_state, scene_image, desc_html, browser
437
+ def forge_campaign(goal, do_research, theme, photo, quests, campaigns, images, browser):
438
  """One long-term goal -> a campaign + its ordered quest chain, appended to the log. With
439
+ do_research, ddgs+BS4 snippets ground the plan (research.py; degrades gracefully without it).
440
+ With a photo present, ALL the campaign's scenes are generated in one batched GPU call and the
441
+ first quest is shown."""
442
  goal = (goal or "").strip()
443
+ nochange = (gr.update(),) * 8
444
  if not goal:
445
  gr.Info("Name your long-term goal first.")
446
  return (gr.update(),) + nochange
 
454
  snippets, sources = "", []
455
  if do_research:
456
  try:
457
+ from research import research_goal
458
  gr.Info("Scouting the web for your goal…")
459
  r = research_goal(goal)
460
  snippets = "\n".join(f"- {s}" for s in r.get("snippets") or [])
 
471
  camp = result["campaign"]
472
  camp["sources"] = sources
473
  campaigns2 = campaigns + [camp]
474
+ new_quests = result["quests"]
475
+ quests2 = list(quests or []) + new_quests
476
+ first = new_quests[0]
477
+
478
+ images2 = _batch_initials(photo, camp["art_style"], camp["seed"],
479
+ new_quests, dict(images or {}), first["id"])
480
+ cache = images2.get(first["id"]) or {}
481
+ scene = _dataurl_to_pil(cache["initial"]) if cache.get("initial") else None
482
+ desc = _desc_html(first, _world_for(first, None, campaigns2),
483
+ None if scene else "Upload your photo (left) to draw this scene.")
484
+ gr.Info(f"Campaign forged: {camp['title']} — {len(new_quests)} quests.")
485
+ return ("", quests2, campaigns2, _stats_html(quests2), images2, first["id"], scene, desc,
486
+ _merge(browser, quests=quests2, campaigns=campaigns2, images=images2,
487
+ selected_id=first["id"]))
488
 
489
 
490
  def change_theme(theme, adventure, images, browser):
 
509
 
510
 
511
  def persist(uid, browser):
512
+ """Write-through the full state to the user's durable server record. Bound via .then(...) on
513
+ TOP-LEVEL events only never on events created inside @gr.render: a render re-run re-registers
514
+ those functions under new indices, so a queued .then step 500s with KeyError."""
515
  store.save(uid, browser)
516
 
517
 
518
+ def _persist_browser(b):
519
+ """In-handler persistence for @gr.render-created events (quest select/remove), where a chained
520
+ .then(persist) would break (see persist's docstring). The uid travels inside the browser dict."""
521
+ store.save((b or {}).get("uid"), b)
522
+ return b
523
+
524
+
525
+ def _hero_code_html(uid, hf_user=None) -> str:
526
+ if hf_user:
527
+ return (f'<p class="hint">Synced to your Hugging Face account (<b>{_esc(hf_user)}</b>) — '
528
+ 'no code needed. Your quests follow your login on any device.</p>')
529
  return (f'<p class="hint">Your Hero Code: <b>{_esc(uid)}</b><br>Write it down — paste it on '
530
+ 'any device (or after a cleared browser) to summon this hero back. Or just sign in '
531
+ 'with Hugging Face (top right) and skip the code.</p>')
532
 
533
 
534
+ def boot(browser, request: gr.Request):
535
+ """Restore on page load. Identity precedence: a logged-in HF account ("hf-<username>", durable
536
+ and cross-device) > the browser dict's random uid ("Hero Code") > a freshly generated one.
537
+ The server-side JSON record for that uid is the source of truth and is mirrored back into
538
+ BrowserState; on first sight of an identity the browser's current state migrates into it.
539
+ Shows cached scenes only (never generates) so reloads are instant."""
540
  b = dict(browser or _default_state())
541
+ hf_user = getattr(request, "username", None) if request is not None else None
542
+ uid = f"hf-{hf_user}" if hf_user else (b.get("uid") or uuid.uuid4().hex[:12])
543
  saved = store.load(uid)
544
  if saved:
545
  b = saved
546
  b["uid"] = uid
547
  if not saved:
548
+ store.save(uid, b) # first visit under this identity: adopt the browser's current state
549
+ return _rehydrate(uid, b, hf_user)
550
 
551
 
552
  def restore_hero(code, browser):
 
560
  return _rehydrate(code, saved)
561
 
562
 
563
+ def _rehydrate(uid, b, hf_user=None):
564
  """Build the full page state tuple (shared by boot and restore_hero)."""
565
  photo = b.get("photo")
566
  quests = b.get("quests") or []
 
579
  scene = _dataurl_to_pil(data)
580
  photo_pil = _dataurl_to_pil(photo) if photo else None
581
  world = _world_for(quest, adventure, campaigns) or adventure
582
+ return (uid, _hero_code_html(uid, hf_user), b, photo_pil, photo, quests, campaigns, adventure,
583
+ selected, images, scene, _desc_html(quest, world), _stats_html(quests), theme)
584
 
585
 
586
  # ----------------------------- UI -----------------------------
 
600
  campaign_filter_state = gr.State(None) # campaign id to filter the quest log by (None = all)
601
  uid_state = gr.State(None) # the "Hero Code" uid; generated by boot, lives in BrowserState
602
 
603
+ with gr.Row(elem_classes=["fq-topbar"]):
604
+ gr.HTML('<h1 class="fq-logo">FROG<b>QUEST</b></h1>')
605
+ gr.LoginButton(elem_classes=["fq-login"])
606
 
607
  with gr.Row(elem_classes=["fq-app-grid"]):
608
  # ---------- LEFT: hero ----------
 
725
  inputs=[photo_state, adventure_state, images_state, quests_state,
726
  campaigns_state, browser],
727
  outputs=[selected_id_state, scene_image, desc_html, images_state, browser],
728
+ ) # NO .then here: render-internal chains 500 (see persist docstring)
729
  # Clear button appears only once a quest is done/failed, to remove it.
730
  if finished:
731
  clr = gr.Button("✕", elem_classes=["pix-btn", "small", "fq-task-clear"])
 
735
  inputs=[quests_state, adventure_state, images_state, selected_id_state, browser],
736
  outputs=[quests_state, images_state, selected_id_state, scene_image,
737
  desc_html, stats_html, browser],
738
+ ) # NO .then here: render-internal chains 500 (see persist docstring)
739
 
740
  # ---------- BOTTOM: Frog Master chat ----------
741
  with gr.Row(elem_classes=["fq-chatbar"]):
 
771
  ).then(persist, [uid_state, browser])
772
 
773
  campaign_btn.click(
774
+ forge_campaign, [campaign_goal_box, research_check, theme_radio, photo_state, quests_state,
775
+ campaigns_state, images_state, browser],
776
+ [campaign_goal_box, quests_state, campaigns_state, stats_html, images_state,
777
+ selected_id_state, scene_image, desc_html, browser],
778
  ).then(persist, [uid_state, browser])
779
 
780
  _chat_inputs = [chat_input, quests_state, adventure_state, theme_radio, photo_state,
images.py CHANGED
@@ -98,6 +98,15 @@ def _initial_image_local(user_photo: Image.Image, art_style: str, scene_prompt:
98
  return _gen(build_initial_prompt(art_style, scene_prompt), image=[user_photo], seed=seed)
99
 
100
 
 
 
 
 
 
 
 
 
 
101
  def _edit_image_local(base_image: Image.Image, edit_instruction: str, art_style: str, seed: int) -> Image.Image:
102
  """Edit the existing image into a success/failure state."""
103
  return _gen(build_edit_prompt(art_style, edit_instruction), image=base_image, seed=seed)
@@ -111,6 +120,13 @@ def _initial_image_modal(user_photo: Image.Image, art_style: str, scene_prompt:
111
  return flux.initial.remote(user_photo, art_style, scene_prompt, seed)
112
 
113
 
 
 
 
 
 
 
 
114
  def _edit_image_modal(base_image: Image.Image, edit_instruction: str, art_style: str, seed: int) -> Image.Image:
115
  import modal
116
  flux = modal.Cls.from_name("frogquest", "Flux")()
@@ -120,9 +136,11 @@ def _edit_image_modal(base_image: Image.Image, edit_instruction: str, art_style:
120
  # ----------------------------- bind public names from the backend -----------------------------
121
  if BACKEND == "modal":
122
  initial_image = _initial_image_modal
 
123
  edit_image = _edit_image_modal
124
  else:
125
  initial_image = spaces.GPU(duration=30)(_initial_image_local)
 
126
  edit_image = spaces.GPU(duration=30)(_edit_image_local)
127
 
128
 
 
98
  return _gen(build_initial_prompt(art_style, scene_prompt), image=[user_photo], seed=seed)
99
 
100
 
101
+ def _initial_images_local(user_photo: Image.Image, art_style: str, scene_prompts: list[str],
102
+ seed: int) -> list[Image.Image]:
103
+ """Batch: ALL of a forge's initial scenes in ONE GPU call. On ZeroGPU each @spaces.GPU call is
104
+ a separate metered reservation (+~30s admission overhead), so looping initial_image would cost
105
+ N reservations; here the pipeline loads once and the gens run back-to-back."""
106
+ return [_gen(build_initial_prompt(art_style, p), image=[user_photo], seed=seed)
107
+ for p in scene_prompts]
108
+
109
+
110
  def _edit_image_local(base_image: Image.Image, edit_instruction: str, art_style: str, seed: int) -> Image.Image:
111
  """Edit the existing image into a success/failure state."""
112
  return _gen(build_edit_prompt(art_style, edit_instruction), image=base_image, seed=seed)
 
120
  return flux.initial.remote(user_photo, art_style, scene_prompt, seed)
121
 
122
 
123
+ def _initial_images_modal(user_photo: Image.Image, art_style: str, scene_prompts: list[str],
124
+ seed: int) -> list[Image.Image]:
125
+ import modal
126
+ flux = modal.Cls.from_name("frogquest", "Flux")()
127
+ return flux.initials.remote(user_photo, art_style, scene_prompts, seed)
128
+
129
+
130
  def _edit_image_modal(base_image: Image.Image, edit_instruction: str, art_style: str, seed: int) -> Image.Image:
131
  import modal
132
  flux = modal.Cls.from_name("frogquest", "Flux")()
 
136
  # ----------------------------- bind public names from the backend -----------------------------
137
  if BACKEND == "modal":
138
  initial_image = _initial_image_modal
139
+ initial_images = _initial_images_modal
140
  edit_image = _edit_image_modal
141
  else:
142
  initial_image = spaces.GPU(duration=30)(_initial_image_local)
143
+ initial_images = spaces.GPU(duration=60)(_initial_images_local) # one reservation, many gens
144
  edit_image = spaces.GPU(duration=30)(_edit_image_local)
145
 
146
 
modal_app.py CHANGED
@@ -191,6 +191,12 @@ class Flux:
191
  def initial(self, user_photo, art_style: str, scene_prompt: str, seed: int):
192
  return self._gen(build_initial_prompt(art_style, scene_prompt), [user_photo], seed)
193
 
 
 
 
 
 
 
194
  @modal.method()
195
  def edit(self, base_image, edit_instruction: str, art_style: str, seed: int):
196
  return self._gen(build_edit_prompt(art_style, edit_instruction), base_image, seed)
 
191
  def initial(self, user_photo, art_style: str, scene_prompt: str, seed: int):
192
  return self._gen(build_initial_prompt(art_style, scene_prompt), [user_photo], seed)
193
 
194
+ @modal.method()
195
+ def initials(self, user_photo, art_style: str, scene_prompts: list, seed: int):
196
+ """Batch counterpart of initial() — all of a forge's scenes in one container call."""
197
+ return [self._gen(build_initial_prompt(art_style, p), [user_photo], seed)
198
+ for p in scene_prompts]
199
+
200
  @modal.method()
201
  def edit(self, base_image, edit_instruction: str, art_style: str, seed: int):
202
  return self._gen(build_edit_prompt(art_style, edit_instruction), base_image, seed)