Crayon art style, parallel/early narration, reveal PDF download buttons, coloring = img2img from color pages
Browse files
app.py
CHANGED
|
@@ -106,8 +106,9 @@ if ON_ZEROGPU:
|
|
| 106 |
logger.exception(f"Module-level load failed for {_name}")
|
| 107 |
|
| 108 |
COLOR_ART_STYLE = (
|
| 109 |
-
"children's
|
| 110 |
-
"
|
|
|
|
| 111 |
)
|
| 112 |
COLOR_PAGE_SUFFIX = "full colorful background scene, the character clearly visible."
|
| 113 |
LINE_ART_STYLE = (
|
|
@@ -381,52 +382,28 @@ def generate_images_gpu(
|
|
| 381 |
|
| 382 |
|
| 383 |
@spaces.GPU(duration=150)
|
| 384 |
-
def generate_coloring_images_gpu(
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
seed: int = 42,
|
| 389 |
-
) -> list:
|
| 390 |
-
"""Generate coloring pages directly with FLUX as line art (no tracing)."""
|
| 391 |
import io
|
| 392 |
from PIL import Image
|
| 393 |
|
| 394 |
pipe = load_flux()
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
).images[0]
|
| 410 |
-
logger.info("Line-art canonical character built from doodle")
|
| 411 |
-
except Exception as e:
|
| 412 |
-
logger.warning(f"Line-art canonical build failed ({e}); text2img fallback")
|
| 413 |
-
canonical = None
|
| 414 |
-
|
| 415 |
-
images = []
|
| 416 |
-
for i, scene in enumerate(scenes):
|
| 417 |
-
if canonical is not None:
|
| 418 |
-
prompt = f"The same character. {scene}. {LINE_ART_STYLE}, {LINE_ART_SUFFIX}"
|
| 419 |
-
kw = dict(image=canonical, prompt=prompt)
|
| 420 |
-
else:
|
| 421 |
-
prompt = (f"{character_desc}. Scene: {scene}. {LINE_ART_STYLE}, "
|
| 422 |
-
f"white background, centered, full character visible")
|
| 423 |
-
kw = dict(prompt=prompt)
|
| 424 |
-
kw.update(height=768, width=768, guidance_scale=guidance,
|
| 425 |
-
num_inference_steps=num_steps,
|
| 426 |
-
generator=torch.Generator("cuda").manual_seed(seed + i + 101))
|
| 427 |
-
images.append(pipe(**kw).images[0])
|
| 428 |
-
logger.info(f"Generated coloring page {i+1}/{len(scenes)}")
|
| 429 |
-
return images
|
| 430 |
|
| 431 |
|
| 432 |
@spaces.GPU(duration=120)
|
|
@@ -546,16 +523,44 @@ def create_book(doodle_image, character_name, theme, hero_name, voice=DEFAULT_VO
|
|
| 546 |
|
| 547 |
full_text = f"{title}. {' '.join(page_texts)}"
|
| 548 |
|
| 549 |
-
# ----
|
| 550 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 551 |
t_images = time.perf_counter()
|
| 552 |
try:
|
| 553 |
for kind, payload in _with_heartbeat(
|
| 554 |
lambda: generate_images_gpu(char_desc, scenes, doodle_bytes, BASE_SEED),
|
| 555 |
lambda s: (
|
| 556 |
magic_loader_html("images", hero_name),
|
| 557 |
-
f"{title} — illustrating
|
| 558 |
-
|
|
|
|
| 559 |
),
|
| 560 |
):
|
| 561 |
if kind == "hb":
|
|
@@ -580,28 +585,17 @@ def create_book(doodle_image, character_name, theme, hero_name, voice=DEFAULT_VO
|
|
| 580 |
|
| 581 |
book_html = build_book_html(img_bytes, page_texts, title, engine)
|
| 582 |
|
| 583 |
-
# ----
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
):
|
| 595 |
-
if kind == "hb":
|
| 596 |
-
yield payload
|
| 597 |
-
else:
|
| 598 |
-
voice_bytes = payload
|
| 599 |
-
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
| 600 |
-
tmp.write(voice_bytes)
|
| 601 |
-
audio_path = tmp.name
|
| 602 |
-
except Exception as e:
|
| 603 |
-
logger.exception("TTS failed")
|
| 604 |
-
trace_data["tts_error"] = repr(e)
|
| 605 |
trace_data["tts_sec"] = round(time.perf_counter() - t_tts, 2)
|
| 606 |
|
| 607 |
pdf_path = None
|
|
@@ -620,7 +614,7 @@ def create_book(doodle_image, character_name, theme, hero_name, voice=DEFAULT_VO
|
|
| 620 |
try:
|
| 621 |
from services.coloring import _crispen
|
| 622 |
for kind, payload in _with_heartbeat(
|
| 623 |
-
lambda: generate_coloring_images_gpu(
|
| 624 |
lambda s: (
|
| 625 |
book_html,
|
| 626 |
f"{title} — building coloring book… {s}s",
|
|
@@ -649,25 +643,30 @@ def create_book(doodle_image, character_name, theme, hero_name, voice=DEFAULT_VO
|
|
| 649 |
trace_data["coloring_book"] = True
|
| 650 |
trace_data["coloring_engine"] = "flux-direct-lineart"
|
| 651 |
except Exception as e:
|
| 652 |
-
logger.
|
|
|
|
| 653 |
try:
|
| 654 |
-
from services.coloring import
|
| 655 |
-
outlines =
|
| 656 |
coloring_html = build_coloring_html(outlines, page_texts, title)
|
| 657 |
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
|
| 658 |
coloring_pdf_path = export_coloring_pdf(outlines, page_texts, title, tmp.name)
|
| 659 |
trace_data["coloring_book"] = True
|
| 660 |
-
trace_data["coloring_engine"] = "trace-fallback"
|
| 661 |
except Exception as e2:
|
| 662 |
-
logger.
|
|
|
|
| 663 |
trace_data["coloring_sec"] = round(time.perf_counter() - t_coloring, 2)
|
| 664 |
|
| 665 |
trace_data["completed"] = True
|
| 666 |
trace_data["pages_generated"] = len(img_bytes)
|
| 667 |
trace_data["total_sec"] = round(time.perf_counter() - t_total, 2)
|
| 668 |
|
| 669 |
-
|
| 670 |
-
|
|
|
|
|
|
|
|
|
|
| 671 |
coloring_display_update = (gr.update(visible=True, value=coloring_html) if coloring_html
|
| 672 |
else _no)
|
| 673 |
|
|
|
|
| 106 |
logger.exception(f"Module-level load failed for {_name}")
|
| 107 |
|
| 108 |
COLOR_ART_STYLE = (
|
| 109 |
+
"hand-drawn crayon children's storybook illustration, soft waxy crayon "
|
| 110 |
+
"texture and visible crayon strokes, warm colorful crayon shading, "
|
| 111 |
+
"simple friendly shapes, looks drawn by hand with crayons"
|
| 112 |
)
|
| 113 |
COLOR_PAGE_SUFFIX = "full colorful background scene, the character clearly visible."
|
| 114 |
LINE_ART_STYLE = (
|
|
|
|
| 382 |
|
| 383 |
|
| 384 |
@spaces.GPU(duration=150)
|
| 385 |
+
def generate_coloring_images_gpu(color_pngs: list, seed: int = 7) -> list:
|
| 386 |
+
"""Coloring pages = FLUX redraws each finished COLOR page as clean line art
|
| 387 |
+
(img2img). This MATCHES the storybook composition and avoids the speckly
|
| 388 |
+
look of tracing crayon texture. Caller crispens the result to black-on-white."""
|
|
|
|
|
|
|
|
|
|
| 389 |
import io
|
| 390 |
from PIL import Image
|
| 391 |
|
| 392 |
pipe = load_flux()
|
| 393 |
+
prompt = f"{LINE_ART_STYLE}, {LINE_ART_SUFFIX}"
|
| 394 |
+
outs = []
|
| 395 |
+
for i, png in enumerate(color_pngs):
|
| 396 |
+
ref = Image.open(io.BytesIO(png)).convert("RGB")
|
| 397 |
+
base = dict(prompt=prompt, image=ref, height=768, width=768,
|
| 398 |
+
guidance_scale=1.0, num_inference_steps=6,
|
| 399 |
+
generator=torch.Generator("cuda").manual_seed(seed + i))
|
| 400 |
+
try: # strength may not be accepted
|
| 401 |
+
img = pipe(**base, strength=0.85).images[0]
|
| 402 |
+
except TypeError:
|
| 403 |
+
img = pipe(**base).images[0]
|
| 404 |
+
outs.append(img)
|
| 405 |
+
logger.info(f"Generated coloring page {i+1}/{len(color_pngs)}")
|
| 406 |
+
return outs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
|
| 408 |
|
| 409 |
@spaces.GPU(duration=120)
|
|
|
|
| 523 |
|
| 524 |
full_text = f"{title}. {' '.join(page_texts)}"
|
| 525 |
|
| 526 |
+
# ---- NARRATION starts NOW, in PARALLEL with the images (it only needs the
|
| 527 |
+
# story text). The audio is surfaced the moment it's ready — usually
|
| 528 |
+
# before the illustrations finish — so narration appears first. ----
|
| 529 |
+
import threading
|
| 530 |
+
voice_box = {}
|
| 531 |
+
t_tts = time.perf_counter()
|
| 532 |
+
|
| 533 |
+
def _do_voice():
|
| 534 |
+
try:
|
| 535 |
+
voice_box["bytes"] = generate_tts_gpu(full_text, voice)
|
| 536 |
+
except Exception as e:
|
| 537 |
+
voice_box["err"] = e
|
| 538 |
+
|
| 539 |
+
voice_thread = threading.Thread(target=_do_voice, daemon=True)
|
| 540 |
+
voice_thread.start()
|
| 541 |
+
|
| 542 |
+
def _audio_now():
|
| 543 |
+
"""Write the narration to a temp wav once it's ready; return its path."""
|
| 544 |
+
if voice_box.get("bytes") and not voice_box.get("path"):
|
| 545 |
+
try:
|
| 546 |
+
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
| 547 |
+
tmp.write(voice_box["bytes"])
|
| 548 |
+
voice_box["path"] = tmp.name
|
| 549 |
+
except Exception as e:
|
| 550 |
+
logger.warning(f"writing audio failed: {e}")
|
| 551 |
+
return voice_box.get("path")
|
| 552 |
+
|
| 553 |
+
# ---- IMAGES (FLUX on ZeroGPU), surfacing the narration as soon as it lands ----
|
| 554 |
+
img_bytes, engine, images = None, "sketch", None
|
| 555 |
t_images = time.perf_counter()
|
| 556 |
try:
|
| 557 |
for kind, payload in _with_heartbeat(
|
| 558 |
lambda: generate_images_gpu(char_desc, scenes, doodle_bytes, BASE_SEED),
|
| 559 |
lambda s: (
|
| 560 |
magic_loader_html("images", hero_name),
|
| 561 |
+
f"{title} — illustrating… {s}s"
|
| 562 |
+
+ (" · narration ready ▶" if _audio_now() else " · recording narration…"),
|
| 563 |
+
_audio_now(), _keep, story, "", json.dumps(trace_data, indent=2), _no, _keep,
|
| 564 |
),
|
| 565 |
):
|
| 566 |
if kind == "hb":
|
|
|
|
| 585 |
|
| 586 |
book_html = build_book_html(img_bytes, page_texts, title, engine)
|
| 587 |
|
| 588 |
+
# ---- collect the parallel narration (usually already finished) ----
|
| 589 |
+
while voice_thread.is_alive():
|
| 590 |
+
voice_thread.join(timeout=4)
|
| 591 |
+
if voice_thread.is_alive():
|
| 592 |
+
yield (book_html, f"{title} — finishing narration…",
|
| 593 |
+
_audio_now(), _keep, story, "", json.dumps(trace_data, indent=2),
|
| 594 |
+
_no, _keep)
|
| 595 |
+
audio_path = _audio_now()
|
| 596 |
+
if voice_box.get("err"):
|
| 597 |
+
logger.warning(f"TTS failed: {voice_box['err']}")
|
| 598 |
+
trace_data["tts_error"] = repr(voice_box["err"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 599 |
trace_data["tts_sec"] = round(time.perf_counter() - t_tts, 2)
|
| 600 |
|
| 601 |
pdf_path = None
|
|
|
|
| 614 |
try:
|
| 615 |
from services.coloring import _crispen
|
| 616 |
for kind, payload in _with_heartbeat(
|
| 617 |
+
lambda: generate_coloring_images_gpu(img_bytes, 7),
|
| 618 |
lambda s: (
|
| 619 |
book_html,
|
| 620 |
f"{title} — building coloring book… {s}s",
|
|
|
|
| 643 |
trace_data["coloring_book"] = True
|
| 644 |
trace_data["coloring_engine"] = "flux-direct-lineart"
|
| 645 |
except Exception as e:
|
| 646 |
+
logger.exception("FLUX line-art coloring failed; using local trace fallback")
|
| 647 |
+
trace_data["coloring_error"] = repr(e)
|
| 648 |
try:
|
| 649 |
+
from services.coloring import _to_line_art_opencv
|
| 650 |
+
outlines = [_to_line_art_opencv(b) for b in img_bytes]
|
| 651 |
coloring_html = build_coloring_html(outlines, page_texts, title)
|
| 652 |
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
|
| 653 |
coloring_pdf_path = export_coloring_pdf(outlines, page_texts, title, tmp.name)
|
| 654 |
trace_data["coloring_book"] = True
|
| 655 |
+
trace_data["coloring_engine"] = "opencv-trace-fallback"
|
| 656 |
except Exception as e2:
|
| 657 |
+
logger.exception("Coloring fallback also failed")
|
| 658 |
+
trace_data["coloring_error2"] = repr(e2)
|
| 659 |
trace_data["coloring_sec"] = round(time.perf_counter() - t_coloring, 2)
|
| 660 |
|
| 661 |
trace_data["completed"] = True
|
| 662 |
trace_data["pages_generated"] = len(img_bytes)
|
| 663 |
trace_data["total_sec"] = round(time.perf_counter() - t_total, 2)
|
| 664 |
|
| 665 |
+
# Reveal the download buttons (they start visible=False) — value alone left
|
| 666 |
+
# them hidden, which is why there was "no download option" (incl. on mobile).
|
| 667 |
+
pdf_update = gr.update(value=pdf_path, visible=True) if pdf_path else _keep
|
| 668 |
+
coloring_pdf_update = (gr.update(value=coloring_pdf_path, visible=True)
|
| 669 |
+
if coloring_pdf_path else _keep)
|
| 670 |
coloring_display_update = (gr.update(visible=True, value=coloring_html) if coloring_html
|
| 671 |
else _no)
|
| 672 |
|