nickdigger commited on
Commit
9335f9e
Β·
verified Β·
1 Parent(s): 48d03a0

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +298 -617
app.py CHANGED
@@ -1,723 +1,404 @@
1
- import spaces
2
- import gradio as gr
3
- import torch
4
- from transformers import AutoTokenizer, AutoModelForCausalLM
5
- import time
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  import re
7
  import gc
8
- import os
9
- import shutil
10
  import json
11
- from datetime import datetime, timezone
12
  import uuid
13
- import random
 
 
 
 
 
 
14
 
15
- # Storage optimization - redirect cache to temporary directories
16
- os.environ["HF_HOME"] = "/tmp/hf_cache"
17
  os.environ["TRANSFORMERS_CACHE"] = "/tmp/transformers_cache"
18
- os.environ["HF_DATASETS_CACHE"] = "/tmp/datasets_cache"
19
- os.environ["TORCH_HOME"] = "/tmp/torch_cache"
20
 
21
  MODEL_PATH = "dphn/dolphin-2.6-mistral-7b-dpo"
 
 
22
 
23
  TITLE = """
24
- <div style="text-align: center; margin: 20px 0;">
25
- <h1>🌊 Venice Edition NSFW Enhancer</h1>
26
- <p><strong>Transform JoyCaption output into SEO-optimized, uncensored descriptions</strong></p>
27
- <p><em>Powered by Dolphin 2.6 Mistral 7B DPO - Venice Edition</em></p>
28
  </div>
29
  <hr>
30
  """
31
 
32
- print("πŸš€ Loading Venice Edition NSFW Enhancer... v3.5")
33
- print(f"πŸ“¦ Model: {MODEL_PATH}")
34
 
35
- def cleanup_memory_aggressive():
36
  try:
37
- temp_dirs = ["/tmp/hf_cache", "/tmp/transformers_cache", "/tmp/datasets_cache", "/tmp/torch_cache"]
38
- for temp_dir in temp_dirs:
39
- if os.path.exists(temp_dir):
40
- try:
41
- shutil.rmtree(temp_dir, ignore_errors=True)
42
- except:
43
- pass
44
  gc.collect()
45
  if torch.cuda.is_available():
46
  torch.cuda.empty_cache()
47
  torch.cuda.synchronize()
48
  except Exception as e:
49
- print(f"⚠️ Cleanup warning: {e}")
50
 
51
- def cleanup_storage():
52
- cleanup_memory_aggressive()
53
- print("βœ… Storage and memory cleanup completed")
54
 
55
- def process_joycaption_import(json_file):
56
- """Process uploaded JoyCaption JSON (v6.0 sample schema) and return field data"""
57
  if not json_file:
58
- return "❌ No file uploaded", "", "", "", "", None
59
  try:
60
- if hasattr(json_file, 'name'):
61
- with open(json_file.name, 'r', encoding='utf-8') as f:
62
- data = json.load(f)
63
- else:
64
- with open(json_file, 'r', encoding='utf-8') as f:
65
- data = json.load(f)
66
-
67
- if not isinstance(data, dict) or 'data' not in data:
68
- return "❌ Invalid JSON format - missing 'data' field", "", "", "", "", None
69
-
70
- joy_data = data['data']
71
- success_msg = f"βœ… Imported JoyCaption v6.0 at {data.get('timestamp', 'unknown time')}"
72
-
73
- # Build captions (Friendly, Casual, Erotic all included)
74
- desc = joy_data.get('descriptions', {}) or {}
75
- captions_list = []
76
- if desc.get('friendly'):
77
- captions_list.append(f"═══ FRIENDLY ═══\n{desc['friendly']}")
78
- if desc.get('casual'):
79
- captions_list.append(f"═══ CASUAL ═══\n{desc['casual']}")
80
- if desc.get('erotic'):
81
- captions_list.append(f"═══ EROTIC ═══\n{desc['erotic']}")
82
- all_captions = "\n\n".join(captions_list)
83
- if captions_list:
84
- all_captions = f"πŸ“Š IMPORTED {len(captions_list)} CAPTION TONES:\n\n" + all_captions
85
-
86
- # Tags as keywords
87
- keywords = (joy_data.get('tags') or "").strip()
88
-
89
- # Corrections/context (user instructions)
90
  parts = []
91
- if joy_data.get('mention'):
92
- parts.append(f"MENTION: {joy_data['mention']}")
93
- if joy_data.get('avoid'):
94
- parts.append(f"AVOID: {joy_data['avoid']}")
95
- if joy_data.get('ask'):
96
- parts.append(f"ASK: {joy_data['ask']}")
97
- qa = joy_data.get('qa') or {}
98
- if qa.get('question') or qa.get('answer'):
99
- parts.append(f"Q&A: Q: {qa.get('question','')} A: {qa.get('answer','')}")
100
- corrections = " | ".join(parts)
101
-
102
- # Image URL
103
- image_url = joy_data.get('image_path') or ""
104
-
105
- import_details = []
106
- if all_captions: import_details.append("Captions")
107
- if keywords: import_details.append("Tags")
108
- if corrections: import_details.append("Instructions/Q&A")
109
- summary = f"{success_msg}\nπŸ“Š Imported: {', '.join(import_details) if import_details else 'No usable fields'}"
110
-
111
- return summary, all_captions, keywords, corrections, image_url, data
112
- except json.JSONDecodeError as e:
113
- return f"❌ Invalid JSON file: {str(e)}", "", "", "", "", None
114
  except Exception as e:
115
- return f"❌ Import failed: {str(e)}", "", "", "", "", None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
 
117
- print("πŸ“¦ Loading Venice Edition model and tokenizer...")
118
- try:
119
- tokenizer = AutoTokenizer.from_pretrained(
120
- MODEL_PATH,
121
- trust_remote_code=True
122
- )
123
- model = AutoModelForCausalLM.from_pretrained(
124
- MODEL_PATH,
125
- torch_dtype=torch.bfloat16,
126
- device_map="auto",
127
- trust_remote_code=True,
128
- low_cpu_mem_usage=True
129
- )
130
- model.eval()
131
- if tokenizer.pad_token is None:
132
- tokenizer.pad_token = tokenizer.eos_token
133
- print("βœ… Venice Edition model loaded and ready!")
134
- cleanup_storage()
135
- except Exception as e:
136
- print(f"❌ Error loading model: {e}")
137
- raise e
138
-
139
- def strip_markers(text: str) -> str:
140
- if not text:
141
- return ""
142
- for marker in ['═══ FRIENDLY ═══','═══ CASUAL ═══','═══ EROTIC ═══','πŸ“Š IMPORTED','CAPTION TONES:']:
143
- text = text.replace(marker, '')
144
- return ' '.join(text.split())
145
 
146
- def build_keywords_hint(seo_keywords: str) -> str:
147
- if not seo_keywords or not seo_keywords.strip():
148
- return ""
149
- ks = [k.strip() for k in re.split(r"[,\n]+", seo_keywords) if k.strip()]
150
- return ", ".join(ks)
151
-
152
- def _truncate_words(text: str, max_words: int) -> str:
153
- words = re.findall(r"\S+", text or "")
154
- if len(words) <= max_words:
155
- return text
156
- return " ".join(words[:max_words]).rstrip(" ,;.")
157
-
158
- # Default editable prompts for BLOG POST (HTML, longer, late-night confession style)
159
- DEFAULT_SYSTEM_PROMPT = (
160
- "You are an adult-themed blog writer. You write sensual, suggestive, late-night confession style posts "
161
- "focused on mood, atmosphere, inner thoughts, and fantasies. "
162
- "Avoid graphic descriptions of sexual acts or explicit anatomy. "
163
- "Write like a real person: natural flow, varied sentence lengths, sensory detail, contractions, vivid imagery. "
164
- "NEVER use the words 'woman' or 'women'β€”instead use slang like babe, chick, beauty, stunner, hottie, goddess, vixen, etc. "
165
- "No meta-commentary. Write as a blog post, not an image caption."
166
  )
167
 
168
- DEFAULT_USER_PROMPT = """You are given detailed JoyCaption descriptions and some priority rules.
 
 
 
 
169
 
170
- JOY CAPTION DESCRIPTIONS (SOURCE MATERIAL):
171
- [[DESCRIPTIONS]]
172
 
173
- PRIORITY RULES (follow these if present):
174
- [[RULES]]
175
 
176
- Write a first-person, late-night confession style blog post (about 220–350 words) as HTML.
 
177
 
178
- Tone and content:
179
- - Adult, sensual, suggestive
180
- - Not a dry description of the image; instead, inner thoughts, fantasies, micro-stories, atmosphere
181
- - Focus on feelings, sensations, impressions, and imagined scenarios inspired by the descriptions
182
- - Avoid graphic descriptions of sexual acts or explicit anatomy
183
- - NEVER use "woman" or "women"β€”use slang: babe, chick, beauty, stunner, hottie, goddess, vixen, etc.
184
 
185
- Structure and formatting:
186
- - Output VALID HTML only
187
- - Start with a single <h2> heading that naturally includes 1–2 of the SEO KEYWORDS from the rules
188
- - Then write 2–4 paragraphs of confession-style story text
189
- - Whenever you naturally use any of the SEO KEYWORDS from the rules (the line starting with "KEYWORDS"), wrap them in <strong>...</strong> tags
190
- - Do NOT include <html>, <head>, <body>, or any other boilerplate; only <h2> and paragraphs
191
 
192
- Return ONLY the HTML blog post (the <h2> heading + paragraphs), no explanation or extra text:
 
 
 
193
  """
194
 
195
- def build_priority_rules(instructions: str, keywords: str) -> str:
196
- """Build priority rules string from instructions (MENTION/AVOID/ASK/Q&A) and keywords."""
197
- rules = []
198
- if instructions and instructions.strip():
199
- # Parse MENTION, AVOID, ASK, Q&A
200
- mention_match = re.search(r'MENTION:\s*([^|]+)', instructions)
201
- if mention_match:
202
- rules.append(f"MENTION: {mention_match.group(1).strip()}")
203
-
204
- keywords_str = build_keywords_hint(keywords)
205
- if keywords_str:
206
- rules.append(f"KEYWORDS (integrate naturally): {keywords_str}")
207
-
208
- qa_match = re.search(r'Q&A:\s*Q:\s*([^A]+)A:\s*([^|]+)', instructions)
209
- if qa_match:
210
- rules.append(f"QUESTION/ANSWER: {qa_match.group(1).strip()} / {qa_match.group(2).strip()}")
211
-
212
- avoid_match = re.search(r'AVOID:\s*([^|]+)', instructions)
213
- if avoid_match:
214
- rules.append(f"AVOID: {avoid_match.group(1).strip()}")
215
- elif keywords and keywords.strip():
216
- # If no instructions but keywords present
217
- keywords_str = build_keywords_hint(keywords)
218
- rules.append(f"KEYWORDS (integrate naturally): {keywords_str}")
219
-
220
- return "\n".join(rules) if rules else "(none)"
221
-
222
- def fill_user_prompt_template(user_prompt_tpl: str, descriptions: str, rules: str) -> str:
223
- tpl = user_prompt_tpl or ""
224
- tpl = tpl.replace("[[DESCRIPTIONS]]", descriptions or "(none)")
225
- tpl = tpl.replace("[[RULES]]", rules or "(none)")
226
- return tpl
227
-
228
- @spaces.GPU(duration=65)
229
- @torch.no_grad()
230
- def enhance_text(all_captions, seo_keywords="", user_corrections="", sys_override="", user_override=""):
231
- """
232
- Generate an adult, sensual HTML blog post (β‰ˆ220–350 words) using
233
- full JoyCaption descriptions + priority rules, with <h2> heading and <strong>KEYWORDS</strong>.
234
- """
235
- src = strip_markers(all_captions or "")
236
- if not src:
237
- return "❌ Please provide JoyCaption descriptions first (via JSON import or paste)."
238
- try:
239
- system_prompt = (sys_override or DEFAULT_SYSTEM_PROMPT)
240
- priority_rules = build_priority_rules(user_corrections, seo_keywords)
241
- user_prompt = fill_user_prompt_template(
242
- user_override or DEFAULT_USER_PROMPT,
243
- src, priority_rules
244
- )
245
- full = f"<|im_start|>system\n{system_prompt}<|im_end|>\n<|im_start|>user\n{user_prompt}<|im_end|>\n<|im_start|>assistant\n"
246
- inputs = tokenizer(full, return_tensors="pt", truncation=True, max_length=3000, padding=True)
247
- device = next(model.parameters()).device
248
- inputs = {k: v.to(device) for k, v in inputs.items()}
249
- # Higher randomness and length for more varied, longer posts
250
- outputs = model.generate(
251
- **inputs,
252
- max_new_tokens=700,
253
- temperature=0.95,
254
- top_p=0.96,
255
- top_k=80,
256
- do_sample=True,
257
- pad_token_id=tokenizer.eos_token_id,
258
- eos_token_id=tokenizer.eos_token_id,
259
- repetition_penalty=1.05,
260
- use_cache=False
261
- )
262
- ilen = inputs["input_ids"].shape[1]
263
- gen = outputs[0][ilen:]
264
- txt = tokenizer.decode(gen, skip_special_tokens=True, clean_up_tokenization_spaces=True)
265
-
266
- # Clean special tokens and boilerplate
267
- txt = re.sub(r'<\|im_.*?\|>', '', txt, flags=re.DOTALL).strip()
268
- txt = re.sub(r'^(assistant|Assistant)\s*', '', txt).strip()
269
- txt = re.sub(r'^\s*:', '', txt).strip()
270
-
271
- # Remove any full-page boilerplate, keep from first <h2> onward
272
- lower = txt.lower()
273
- idx = lower.find("<h2")
274
- if idx != -1:
275
- txt = txt[idx:]
276
- # Strip trailing </body>, </html>, etc.
277
- txt = re.sub(r'(?is)</body>.*$', '', txt).strip()
278
- txt = re.sub(r'(?is)</html>.*$', '', txt).strip()
279
-
280
- # If still no <h2>, wrap the first line in <h2> as a fallback
281
- if "<h2" not in txt.lower():
282
- lines = txt.splitlines()
283
- if lines:
284
- first = lines[0].strip()
285
- rest = "\n".join(lines[1:]).strip()
286
- if first:
287
- txt = f"<h2>{first}</h2>\n\n{rest}"
288
-
289
- txt = re.sub(r"\s{2,}", " ", txt)
290
- cleanup_memory_aggressive()
291
- cleanup_storage()
292
- return txt if txt else "❌ No blog post generated"
293
- except Exception as e:
294
- cleanup_memory_aggressive()
295
- cleanup_storage()
296
- return f"❌ Error: {str(e)[:200]}..."
297
-
298
- def create_title_prompt(descriptions: str, instructions: str, seo_keywords: str):
299
- kw_hint = build_keywords_hint(seo_keywords)
300
- system_prompt = (
301
- "You write short, catchy, SEO-optimized, click-bait titles for adult-themed blog posts. "
302
- "Titles should be around 8–14 words, very enticing, and include 1–2 provided keywords naturally. "
303
- "Make them bold, suggestive, and optimized for adult search traffic. "
304
- "Avoid graphic descriptions of sexual acts or explicit anatomy. "
305
- "NEVER use 'woman' or 'women'β€”use slang like babe, chick, beauty, stunner, hottie, etc. "
306
- "Return ONLY the title text (no quotes/numbering)."
307
  )
308
- user_prompt = f"""Write ONE click-bait adult blog title (8–14 words) for the post.
309
-
310
- JOY CAPTION DESCRIPTIONS (SOURCE MATERIAL):
311
- {descriptions or '(none provided)'}
312
-
313
- INSTRUCTIONS (if present):
314
- {instructions or '(none)'}
315
-
316
- KEYWORDS (include 1–2 naturally for SEO):
317
- {kw_hint or '(none)'}
318
-
319
- Rules:
320
- - 8–14 words
321
- - Sentence-style phrase (not all caps, no numbering)
322
- - No quotes, no leading numbers
323
- - NEVER use "woman" or "women"β€”use slang: babe, chick, beauty, stunner, hottie, goddess, vixen, etc.
324
- - Suggestive and enticing, but not graphically explicit
325
- - Include 1–2 keywords naturally
326
- - SEO-optimized and highly click-bait
327
- Return only the title text:"""
328
- return system_prompt, user_prompt
329
-
330
- def _normalize_title_sentence(s: str, keywords: str) -> str:
331
- if not s:
332
- return s
333
- s = re.sub(r"[.,;:!?\"'(){}\[\]<>/\\\-–—‒…]+", " ", s)
334
- s = re.sub(r"\s{2,}", " ", s).strip()
335
- s = s.capitalize()
336
- words = s.split()
337
- # Allow longer titles before truncation
338
- if len(words) > 14:
339
- s = " ".join(words[:14])
340
- if keywords and keywords.strip():
341
- ks = [k.strip().lower() for k in re.split(r"[,\n]+", keywords) if k.strip()]
342
- if ks and not any(k in s.lower() for k in ks):
343
- if len(words) < 14:
344
- s = f"{s} {ks[0]}"
345
- return s
346
-
347
- @spaces.GPU(duration=30)
348
- @torch.no_grad()
349
- def generate_single_title(all_captions, user_corrections="", seo_keywords=""):
350
- """
351
- Generate one short, suggestive, click-bait blog title (8–14 words);
352
- SEO-optimized with keywords; natural sentence, no quotes/numbering.
353
- """
354
- src = strip_markers(all_captions or "")
355
- if not src.strip():
356
- return "❌ Please provide JoyCaption descriptions first (via JSON import or paste)."
357
- try:
358
- system_prompt, user_prompt = create_title_prompt(src, user_corrections, seo_keywords)
359
- full = f"<|im_start|>system\n{system_prompt}<|im_end|>\n<|im_start|>user\n{user_prompt}<|im_end|>\n<|im_start|>assistant\n"
360
- inputs = tokenizer(full, return_tensors="pt", truncation=True, max_length=2500, padding=True)
361
- device = next(model.parameters()).device
362
- inputs = {k: v.to(device) for k, v in inputs.items()}
363
- outputs = model.generate(
364
- **inputs,
365
- max_new_tokens=120,
366
- temperature=0.95,
367
- top_p=0.95,
368
- do_sample=True,
369
- pad_token_id=tokenizer.eos_token_id,
370
- eos_token_id=tokenizer.eos_token_id,
371
- use_cache=False
372
- )
373
- ilen = inputs["input_ids"].shape[1]
374
- gen = outputs[0][ilen:]
375
- txt = tokenizer.decode(gen, skip_special_tokens=True, clean_up_tokenization_spaces=True)
376
- txt = re.sub(r'<\|im_.*?\|>', '', txt).strip()
377
- txt = re.sub(r'^(assistant|Assistant)\s*', '', txt).strip()
378
- if "\n" in txt:
379
- for line in txt.splitlines():
380
- if line.strip():
381
- txt = line.strip()
382
- break
383
- txt = _normalize_title_sentence(txt, seo_keywords)
384
- cleanup_memory_aggressive()
385
- cleanup_storage()
386
- return txt if txt else "❌ No title generated"
387
- except Exception as e:
388
- cleanup_memory_aggressive()
389
- cleanup_storage()
390
- return f"❌ Error: {str(e)[:200]}..."
391
 
392
- BANNED_ACTIONS = {
393
- "kissing","hugging","holding","touching","walking","running","posing","smiling","standing","looking",
394
- "talking","laughing","dancing","drinking","eating","singing","sleeping","licking","sucking","riding"
395
- }
 
 
396
 
397
- @spaces.GPU(duration=25)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398
  @torch.no_grad()
399
- def generate_keywords_expanded(tags_text, all_captions="", user_corrections=""):
400
  """
401
- For each tag, ask the model for adult-blog synonyms (lexical, precise),
402
- then keep 2–3 short expressions per tag.
403
  """
404
- if not tags_text or not tags_text.strip():
405
- return "❌ No tags found in JSON."
406
- tags = [t.strip() for t in re.split(r"[,\n]+", tags_text) if t.strip()]
407
- if not tags:
408
- return "❌ No valid tags."
409
  try:
410
- system_prompt = (
411
- "You are an SEO assistant for an adult-themed blog. For EACH tag provided, produce 3–5 close lexical synonyms/phrases "
412
- "(prefer nouns/adjectives, 1–2 words each). Do NOT invent actions, relationships, or scenes unless the tag itself is an action. "
413
- "Stay precise, adult-context-appropriate, and avoid hashtags/quotes/digits/duplicates. Output one line per tag."
414
- )
415
- tag_lines = "\n".join([f"- {t}" for t in tags])
416
- instr = f"\nCONTEXT (PRIORITY): {user_corrections}" if user_corrections and user_corrections.strip() else ""
417
- cap_hint = f"\nTONES HINT: {strip_markers(all_captions)[:250]}" if all_captions and all_captions.strip() else ""
418
- user_prompt = f"""Tags:
419
- {tag_lines}
420
- For each tag, return a single line strictly in the format (3–5 options):
421
- tag: expr1, expr2, expr3[, expr4][, expr5]
422
- Rules:
423
- - Adult blog tone
424
- - Prefer nouns/adjectives; 1–2 words each
425
- - No hashtags, no digits, no quotes
426
- - Do NOT invent actions unless the tag itself is an action
427
- {instr}{cap_hint}
428
- Only return the list, no extra text."""
429
- full = f"<|im_start|>system\n{system_prompt}<|im_end|>\n<|im_start|>user\n{user_prompt}<|im_end|>\n<|im_start|>assistant\n"
430
- inputs = tokenizer(full, return_tensors="pt", truncation=True, max_length=1700, padding=True)
431
  device = next(model.parameters()).device
432
  inputs = {k: v.to(device) for k, v in inputs.items()}
433
- outputs = model.generate(
434
  **inputs,
435
- max_new_tokens=260,
436
- temperature=0.6,
437
- top_p=0.92,
438
  do_sample=True,
 
439
  pad_token_id=tokenizer.eos_token_id,
440
  eos_token_id=tokenizer.eos_token_id,
441
- use_cache=False
442
  )
443
- ilen = inputs["input_ids"].shape[1]
444
- gen = outputs[0][ilen:]
445
- raw = tokenizer.decode(gen, skip_special_tokens=True, clean_up_tokenization_spaces=True)
446
- raw = re.sub(r'<\|im_.*?\|>', '', raw).strip()
447
-
448
- flat = []
449
- found = {}
450
- for line in raw.splitlines():
451
- line = line.strip()
452
- if not line or ":" not in line:
453
- continue
454
- tag, syns = line.split(":", 1)
455
- tag = tag.strip().strip("-β€’").strip()
456
- if not tag:
457
- continue
458
- syns_list = [s.strip().strip(' "\'') for s in syns.split(",") if s.strip()]
459
- cleaned = []
460
- for s in syns_list:
461
- if not s or any(ch.isdigit() for ch in s) or "#" in s:
462
- continue
463
- words = s.split()
464
- if not (1 <= len(words) <= 2):
465
- continue
466
- if s.lower() == tag.lower():
467
- continue
468
- if any(w.lower() in BANNED_ACTIONS for w in words):
469
- continue
470
- if any(w.lower().endswith("ing") for w in words) and tag.lower() not in BANNED_ACTIONS:
471
- continue
472
- cleaned.append(s)
473
- seen = set()
474
- dedup = []
475
- for s in cleaned:
476
- key = s.lower()
477
- if key not in seen:
478
- seen.add(key)
479
- dedup.append(s)
480
- k = 3 if random.random() < 0.5 else 2
481
- if len(dedup) >= k:
482
- chosen = random.sample(dedup, k)
483
- elif len(dedup) >= 2:
484
- chosen = random.sample(dedup, 2)
485
- else:
486
- chosen = dedup
487
- found[tag.lower()] = [tag] + chosen
488
-
489
- for t in tags:
490
- key = t.lower()
491
- if key not in found:
492
- found[key] = [t]
493
-
494
- for t in tags:
495
- flat.extend(found.get(t.lower(), [t]))
496
-
497
- del inputs, outputs
498
- cleanup_memory_aggressive()
499
- cleanup_storage()
500
- out_list = []
501
- seen_all = set()
502
- for x in flat:
503
- key = x.lower()
504
- if key not in seen_all:
505
- seen_all.add(key)
506
- out_list.append(x)
507
- return ", ".join(out_list)
508
  except Exception as e:
509
- cleanup_memory_aggressive()
510
- cleanup_storage()
511
- return f"❌ Error: {str(e)[:200]}..."
512
 
513
- def _first_title_line(titles_text: str) -> str:
514
- if not titles_text:
515
- return ""
516
- s = titles_text.splitlines()[0].strip()
517
- s = re.sub(r'^\s*\d+\.\s*', '', s).strip(' "\'')
518
- return s
519
 
520
- def export_blog_json(raw_data, title_text, caption_text, keywords_text, image_url):
521
- """
522
- Export the blog data as a single JSON file for download.
523
- """
524
- if not raw_data:
525
  return None
526
- out = {
527
  "timestamp": datetime.now(timezone.utc).isoformat(),
528
- "source": "Venice Edition Blog Generator",
529
- "input": {
530
- "tags": (raw_data.get("data", {}) or {}).get("tags"),
531
- "mention": (raw_data.get("data", {}) or {}).get("mention"),
532
- "avoid": (raw_data.get("data", {}) or {}).get("avoid"),
533
- "ask": (raw_data.get("data", {}) or {}).get("ask"),
534
- "qa": (raw_data.get("data", {}) or {}).get("qa"),
535
- "image_path": (raw_data.get("data", {}) or {}).get("image_path"),
536
- },
537
  "output": {
538
- "image_url": (image_url or (raw_data.get("data", {}) or {}).get("image_path") or "").strip(),
539
- "title": _first_title_line(title_text or ""),
540
- "keywords": [k.strip() for k in (keywords_text or "").split(",") if k.strip()],
541
- "post": (caption_text or "").strip(),
542
- }
 
 
 
543
  }
544
  path = f"/tmp/blog_{uuid.uuid4().hex}.json"
545
  with open(path, "w", encoding="utf-8") as f:
546
- json.dump(out, f, ensure_ascii=False, indent=2)
547
  return path
548
 
549
- # UI
 
550
  with gr.Blocks(title="Venice Edition NSFW Enhancer", theme=gr.themes.Soft()) as demo:
551
  gr.HTML(TITLE)
552
-
553
- raw_import_state = gr.State(value=None)
554
 
555
  with gr.Row():
556
- # LEFT COLUMN
557
  with gr.Column(scale=1):
558
- gr.Markdown("### πŸ“₯ Import JoyCaption Data")
559
  with gr.Row():
560
- import_file = gr.File(label="Upload JoyCaption JSON", file_types=[".json"], scale=2)
561
- import_btn = gr.Button("πŸ“₯ Import", variant="primary", scale=1, size="lg")
 
562
 
563
- import_status = gr.Textbox(label="Import Status", lines=3, interactive=False, visible=False)
564
-
565
- gr.Markdown("### πŸ“ Caption Data (All Tones)")
566
  all_captions_input = gr.Textbox(
567
- placeholder="Import from JoyCaption or paste all caption tones here...",
568
  label="JoyCaption Descriptions",
569
- lines=12,
570
- max_lines=18,
571
- info="Shows caption tones from JoyCaption (Friendly, Casual, Erotic) β€” may be 1–3 tones"
572
- )
573
-
574
- user_corrections_input = gr.Textbox(
575
- placeholder="Your instructions from JSON (MENTION/AVOID/ASK/Q&A) or custom edits",
576
- label="✏️ Your Instructions (PRIORITY)",
577
- lines=4,
578
- info="Your instructions override AI descriptions"
579
  )
580
-
581
  seo_keywords_input = gr.Textbox(
582
- placeholder="Will auto-fill from JSON 'tags'",
583
- label="🏷️ SEO Keywords (from JSON tags)",
584
  lines=2,
585
- info="We will expand each tag with 2–3 precise adult blog synonyms"
 
 
 
 
 
 
 
586
  )
 
587
 
588
- # Keywords section
589
- keywords_output = gr.Textbox(
590
- label="🏷️ Blog Keywords (Original + 2–3 Synonyms Each)",
591
- lines=4,
592
- max_lines=10,
593
- show_copy_button=True,
594
- placeholder="Expanded keyword list will appear here...",
595
- info="Comma-separated list for an adult blog"
596
  )
597
- generate_keywords_btn = gr.Button("πŸ”‘ Generate Keywords + Synonyms", variant="primary", size="lg")
598
 
599
- # RIGHT COLUMN
600
  with gr.Column(scale=1):
601
- image_url_input = gr.Textbox(
602
- placeholder="Paste image URL here...",
603
- label="πŸ–ΌοΈ Image URL",
604
- lines=1
605
  )
606
- gr.Markdown("πŸ“Έ Blog Image Preview & Download")
607
- image_preview = gr.HTML()
608
-
609
- # Editable prompts (Blog Post)
610
- gr.Markdown("### πŸͺ„ Editable Prompts (Blog Post)")
611
- system_prompt_input = gr.Textbox(
612
- label="System Prompt (Blog Post)",
613
- value=DEFAULT_SYSTEM_PROMPT,
614
- lines=5
615
  )
616
- user_prompt_input = gr.Textbox(
617
- label="User Prompt (Blog Post) β€” use [[DESCRIPTIONS]] [[RULES]]",
618
- value=DEFAULT_USER_PROMPT,
619
- lines=12
620
  )
621
-
622
- title_output = gr.Textbox(
623
- label="πŸ“° Blog Post Title (8–14 words, SEO-optimized, click-bait, no 'woman/women')",
624
- lines=2,
625
- max_lines=4,
626
- show_copy_button=True,
627
- placeholder="Short suggestive blog title will appear here..."
628
  )
629
- generate_title_btn = gr.Button("🎯 Generate Title", variant="primary", size="lg")
630
-
631
- description_output = gr.Textbox(
632
- label="🌊 HTML Blog Post (220–350 words, <h2> + <strong>keywords</strong>, no 'woman/women')",
633
- lines=14,
634
- max_lines=22,
635
- show_copy_button=True,
636
- placeholder="Your HTML blog post will appear here...",
637
- info="Adult, sensual, confession-style HTML blog post; starts with <h2> heading using 1–2 SEO keywords, then paragraphs. Any used SEO keywords are wrapped in <strong>...</strong>."
638
  )
639
- enhance_btn = gr.Button("🌊 Generate Blog Post", variant="primary", size="lg")
640
 
641
- # Export blog JSON
642
- export_file = gr.File(
643
- label="Download Blog JSON",
644
- interactive=False
645
- )
646
- export_btn = gr.Button("πŸ’Ύ Export Blog JSON", variant="secondary", size="lg")
647
-
648
- # Handlers
649
- def handle_import(json_file):
650
- status, all_captions, keywords, corrections, image_url, raw = process_joycaption_import(json_file)
651
- if image_url:
652
- u = image_url.strip()
653
- img_html = f"""
654
- <div>
655
- <img src="{u}" style="max-width:100%; height:auto; border-radius:8px" />
656
- <div style="margin-top:8px;">
657
- <a href="{u}" download="image.jpg" target="_blank">⬇️ Download image (image.jpg)</a>
658
- </div>
659
- </div>
660
- """
661
  else:
662
- img_html = ""
663
  return (
664
  gr.update(value=status, visible=True),
665
- gr.update(value=all_captions),
666
- gr.update(value=corrections),
667
- gr.update(value=keywords),
668
- gr.update(value=image_url),
669
- gr.update(value=img_html),
670
- raw
671
  )
672
 
673
  import_btn.click(
674
- handle_import,
675
  inputs=[import_file],
676
- outputs=[import_status, all_captions_input, user_corrections_input, seo_keywords_input, image_url_input, image_preview, raw_import_state]
 
677
  )
678
 
679
- def update_image_preview(url):
680
- if url and url.strip() and (url.startswith("http://") or url.startswith("https://")):
681
- u = url.strip()
682
- return f"""
683
- <div>
684
- <img src="{u}" style="max-width:100%; height:auto; border-radius:8px" />
685
- <div style="margin-top:8px;">
686
- <a href="{u}" download="image.jpg" target="_blank">⬇️ Download image (image.jpg)</a>
687
- </div>
688
- </div>
689
- """
690
  return ""
691
 
692
- image_url_input.change(update_image_preview, inputs=image_url_input, outputs=image_preview)
693
-
694
- generate_title_btn.click(
695
- generate_single_title,
696
- inputs=[all_captions_input, user_corrections_input, seo_keywords_input],
697
- outputs=title_output,
698
- show_progress=True
699
- )
700
-
701
- enhance_btn.click(
702
- enhance_text,
703
- inputs=[all_captions_input, seo_keywords_input, user_corrections_input, system_prompt_input, user_prompt_input],
704
- outputs=description_output,
705
- show_progress=True
706
- )
707
-
708
- generate_keywords_btn.click(
709
- generate_keywords_expanded,
710
- inputs=[seo_keywords_input, all_captions_input, user_corrections_input],
711
- outputs=keywords_output,
712
- show_progress=True
713
  )
714
 
715
  export_btn.click(
716
  export_blog_json,
717
- inputs=[raw_import_state, title_output, description_output, keywords_output, image_url_input],
 
718
  outputs=export_file,
719
- show_progress=True
720
  )
721
 
722
  if __name__ == "__main__":
723
- demo.launch()
 
1
+ """
2
+ Venice Edition NSFW Enhancer β€” consolidated single-call build.
3
+
4
+ One @spaces.GPU invocation produces every output the blog needs:
5
+ 1. Title (click-bait, 8–14 words, SEO keywords woven in)
6
+ 2. Meta description (140–160 chars, teaser one-liner)
7
+ 3. H2 heading (6–10 words, suggestive)
8
+ 4. Rant (120–200 word roasty / funny confession)
9
+ 5. Tags (each seed tag followed by 2 slang synonyms)
10
+
11
+ Why one call? Each @spaces.GPU allocation has scheduling + warm-up overhead and
12
+ consumes its own slice of the daily ZeroGPU quota. Three sequential buttons
13
+ cost β‰ˆ60s of effective runtime per image; the consolidated function returns
14
+ everything in β‰ˆ30–45s and uses ~half the quota.
15
+
16
+ Prompts are intentionally short β€” the previous versions were multi-paragraph
17
+ specs that blew up the input token budget for little benefit.
18
+ """
19
+
20
+ import os
21
  import re
22
  import gc
 
 
23
  import json
 
24
  import uuid
25
+ import shutil
26
+ from datetime import datetime, timezone
27
+
28
+ import spaces
29
+ import gradio as gr
30
+ import torch
31
+ from transformers import AutoTokenizer, AutoModelForCausalLM
32
 
33
+ # ─── Cache redirection ──────────────────────────────────────────────────────
34
+ os.environ["HF_HOME"] = "/tmp/hf_cache"
35
  os.environ["TRANSFORMERS_CACHE"] = "/tmp/transformers_cache"
36
+ os.environ["HF_DATASETS_CACHE"] = "/tmp/datasets_cache"
37
+ os.environ["TORCH_HOME"] = "/tmp/torch_cache"
38
 
39
  MODEL_PATH = "dphn/dolphin-2.6-mistral-7b-dpo"
40
+ print(f"πŸš€ Loading Venice Edition NSFW Enhancer v4.0 (consolidated)")
41
+ print(f"πŸ“¦ Model: {MODEL_PATH}")
42
 
43
  TITLE = """
44
+ <div style="text-align:center;margin:20px 0;">
45
+ <h1>🌊 Venice Edition NSFW Enhancer</h1>
46
+ <p><strong>One-shot: Title Β· Meta Β· H2 Β· Rant Β· Tags</strong></p>
47
+ <p><em>Dolphin 2.6 Mistral 7B DPO</em></p>
48
  </div>
49
  <hr>
50
  """
51
 
52
+ # ─── Housekeeping ───────────────────────────────────────────────────────────
 
53
 
54
+ def _cleanup():
55
  try:
56
+ for d in ("/tmp/hf_cache", "/tmp/transformers_cache",
57
+ "/tmp/datasets_cache", "/tmp/torch_cache"):
58
+ if os.path.exists(d):
59
+ shutil.rmtree(d, ignore_errors=True)
 
 
 
60
  gc.collect()
61
  if torch.cuda.is_available():
62
  torch.cuda.empty_cache()
63
  torch.cuda.synchronize()
64
  except Exception as e:
65
+ print(f"⚠️ cleanup: {e}")
66
 
67
+ # ─── JoyCaption JSON import ────────────────────────────────────────────────
 
 
68
 
69
+ def import_joycaption_json(json_file):
70
+ """Parse a JoyCaption v6 export and pre-fill the captions / tags / URL fields."""
71
  if not json_file:
72
+ return "❌ No file uploaded", "", "", "", None
73
  try:
74
+ path = json_file.name if hasattr(json_file, "name") else json_file
75
+ with open(path, "r", encoding="utf-8") as f:
76
+ raw = json.load(f)
77
+ data = raw.get("data") or {}
78
+ desc = data.get("descriptions") or {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  parts = []
80
+ for tone in ("casual", "friendly", "erotic"):
81
+ if desc.get(tone):
82
+ parts.append(f"[{tone.upper()}]\n{desc[tone]}")
83
+ captions = "\n\n".join(parts)
84
+ tags = (data.get("tags") or "").strip()
85
+ img_url = (data.get("image_path") or "").strip()
86
+ return f"βœ… Imported {len(parts)} tone(s)", captions, tags, img_url, raw
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  except Exception as e:
88
+ return f"❌ Import failed: {e}", "", "", "", None
89
+
90
+ # ─── Model load ────────────────────────────────────────────────────────────
91
+
92
+ print("πŸ“¦ Loading model + tokenizer...")
93
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH, trust_remote_code=True)
94
+ if tokenizer.pad_token is None:
95
+ tokenizer.pad_token = tokenizer.eos_token
96
+ model = AutoModelForCausalLM.from_pretrained(
97
+ MODEL_PATH,
98
+ torch_dtype=torch.bfloat16,
99
+ device_map="auto",
100
+ trust_remote_code=True,
101
+ low_cpu_mem_usage=True,
102
+ )
103
+ model.eval()
104
+ print("βœ… Model ready")
105
+ _cleanup()
106
 
107
+ # ─── Prompt builders (short and opinionated) ───────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
+ SYSTEM_PROMPT = (
110
+ "You write adult-blog content. Voice: suggestive, cheeky, roasty, funny. "
111
+ "Never use 'woman' or 'women' β€” swap in slang (babe, chick, stunner, "
112
+ "hottie, vixen, goddess, bombshell). No graphic anatomy. "
113
+ "Return ONLY the 5 labelled sections below, nothing else, no preamble."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  )
115
 
116
+ USER_TEMPLATE = """IMAGE DESCRIPTIONS:
117
+ {captions}
118
+
119
+ TAG SEEDS:
120
+ {tags}
121
 
122
+ Write each section. Follow the labels exactly.
 
123
 
124
+ ===TITLE===
125
+ <click-bait blog title, 8–14 words, weave in 1–2 tag keywords>
126
 
127
+ ===META===
128
+ <SEO meta description, 1–2 sentences, 140–160 characters total, teasing>
129
 
130
+ ===H2===
131
+ <single H2 heading, 6–10 words, suggestive, no quotes>
 
 
 
 
132
 
133
+ ===RANT===
134
+ <roasty, funny, slightly savage 120–200 word rant riffing on the scene.
135
+ First-person narrator. Adult tone but not graphic. Wrap any tag keyword
136
+ you use in <strong>…</strong>.>
 
 
137
 
138
+ ===TAGS===
139
+ <for each seed tag, output the tag followed by 2 slang synonyms.
140
+ Comma-separated, no hashtags, no duplicates. Example:
141
+ beach, shoreline, coastal spot, sunset, golden hour, dusk glow>
142
  """
143
 
144
+ def _build_prompt(captions: str, tags: str) -> str:
145
+ cap = (captions or "").strip() or "(none provided)"
146
+ tag = (tags or "").strip() or "(none)"
147
+ user = USER_TEMPLATE.format(captions=cap, tags=tag)
148
+ return (
149
+ f"<|im_start|>system\n{SYSTEM_PROMPT}<|im_end|>\n"
150
+ f"<|im_start|>user\n{user}<|im_end|>\n"
151
+ f"<|im_start|>assistant\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
+ # ─── Output parser ─────────────────────────────────────────────────────────
155
+
156
+ _SECTION_RE = re.compile(
157
+ r"===\s*(TITLE|META|H2|RANT|TAGS)\s*===\s*(.*?)(?=(?:===\s*(?:TITLE|META|H2|RANT|TAGS)\s*===)|\Z)",
158
+ re.DOTALL | re.IGNORECASE,
159
+ )
160
 
161
+ def _parse_sections(raw: str) -> dict:
162
+ out = {"title": "", "meta": "", "h2": "", "rant": "", "tags": ""}
163
+ if not raw:
164
+ return out
165
+ for m in _SECTION_RE.finditer(raw):
166
+ key = m.group(1).lower()
167
+ body = m.group(2).strip()
168
+ # Strip the placeholder brackets the model sometimes echoes
169
+ body = re.sub(r"^<|>$", "", body).strip()
170
+ if key == "title" or key == "h2":
171
+ body = body.splitlines()[0].strip(' "\'') if body else ""
172
+ out[key if key != "h2" else "h2"] = body
173
+ return out
174
+
175
+ def _postprocess(parts: dict, tags_seed: str) -> dict:
176
+ # Title: keep to 14 words, no quotes, capitalize
177
+ t = parts.get("title", "")
178
+ t = re.sub(r'["\']', "", t).strip()
179
+ words = t.split()
180
+ if len(words) > 14:
181
+ t = " ".join(words[:14])
182
+ parts["title"] = t[:140]
183
+
184
+ # Meta: squash whitespace, clip to 180 chars
185
+ m = re.sub(r"\s+", " ", parts.get("meta", "")).strip()
186
+ parts["meta"] = m[:180]
187
+
188
+ # H2: one line, no tags
189
+ h2 = re.sub(r"<[^>]+>", "", parts.get("h2", "")).strip()
190
+ parts["h2"] = h2
191
+
192
+ # Rant: strip any leading/trailing code fences, collapse blank runs
193
+ r = parts.get("rant", "")
194
+ r = re.sub(r"^```.*?\n|```\s*$", "", r, flags=re.DOTALL)
195
+ r = re.sub(r"\n{3,}", "\n\n", r).strip()
196
+ parts["rant"] = r
197
+
198
+ # Tags: backfill seeds if the model skipped them
199
+ seeds = [s.strip() for s in re.split(r"[,\n]+", tags_seed or "") if s.strip()]
200
+ tagstr = parts.get("tags", "")
201
+ tagstr = re.sub(r"#", "", tagstr)
202
+ tagstr = re.sub(r"\s+", " ", tagstr)
203
+ tag_list = [x.strip() for x in tagstr.split(",") if x.strip()]
204
+ seen, ordered = set(), []
205
+ for seed in seeds:
206
+ if seed.lower() not in seen:
207
+ seen.add(seed.lower()); ordered.append(seed)
208
+ for t in tag_list:
209
+ if t.lower() not in seen:
210
+ seen.add(t.lower()); ordered.append(t)
211
+ parts["tags"] = ", ".join(ordered)
212
+ return parts
213
+
214
+ # ─── The one GPU call ──────────────────────────────────────────────────────
215
+
216
+ @spaces.GPU(duration=55)
217
  @torch.no_grad()
218
+ def generate_all(captions: str, tags: str):
219
  """
220
+ Single forward pass β†’ all five fields.
221
+ Returns (title, meta, h2, rant, tags) so Gradio can fan them out.
222
  """
223
+ if not (captions and captions.strip()):
224
+ empty = "❌ Provide JoyCaption descriptions first."
225
+ return empty, "", "", "", ""
 
 
226
  try:
227
+ prompt = _build_prompt(captions, tags)
228
+ inputs = tokenizer(prompt, return_tensors="pt", truncation=True,
229
+ max_length=2600, padding=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  device = next(model.parameters()).device
231
  inputs = {k: v.to(device) for k, v in inputs.items()}
232
+ output = model.generate(
233
  **inputs,
234
+ max_new_tokens=900,
235
+ temperature=0.9,
236
+ top_p=0.95,
237
  do_sample=True,
238
+ repetition_penalty=1.05,
239
  pad_token_id=tokenizer.eos_token_id,
240
  eos_token_id=tokenizer.eos_token_id,
241
+ use_cache=True,
242
  )
243
+ gen = output[0][inputs["input_ids"].shape[1]:]
244
+ raw = tokenizer.decode(gen, skip_special_tokens=True).strip()
245
+ raw = re.sub(r"<\|im_.*?\|>", "", raw).strip()
246
+ parts = _postprocess(_parse_sections(raw), tags)
247
+ _cleanup()
248
+ return parts["title"], parts["meta"], parts["h2"], parts["rant"], parts["tags"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  except Exception as e:
250
+ _cleanup()
251
+ err = f"❌ Error: {str(e)[:200]}"
252
+ return err, "", "", "", ""
253
 
254
+ # ─── Export ────────────────────────────────────────────────────────────────
 
 
 
 
 
255
 
256
+ def export_blog_json(raw_data, title, meta, h2, rant, tags, image_url):
257
+ if not (title or rant):
 
 
 
258
  return None
259
+ payload = {
260
  "timestamp": datetime.now(timezone.utc).isoformat(),
261
+ "source": "Venice Edition v4.0",
 
 
 
 
 
 
 
 
262
  "output": {
263
+ "image_url": (image_url or "").strip(),
264
+ "title": title or "",
265
+ "meta": meta or "",
266
+ "h2_heading": h2 or "",
267
+ "rant_html": rant or "",
268
+ "tags": [t.strip() for t in (tags or "").split(",") if t.strip()],
269
+ },
270
+ "source_import": raw_data,
271
  }
272
  path = f"/tmp/blog_{uuid.uuid4().hex}.json"
273
  with open(path, "w", encoding="utf-8") as f:
274
+ json.dump(payload, f, ensure_ascii=False, indent=2)
275
  return path
276
 
277
+ # ─── Gradio UI ─────────────────────────────────────────────────────────────
278
+
279
  with gr.Blocks(title="Venice Edition NSFW Enhancer", theme=gr.themes.Soft()) as demo:
280
  gr.HTML(TITLE)
281
+ raw_state = gr.State(value=None)
 
282
 
283
  with gr.Row():
284
+ # ── Left: inputs ────────────────────────────────────────────────
285
  with gr.Column(scale=1):
286
+ gr.Markdown("### πŸ“₯ Import JoyCaption JSON (optional)")
287
  with gr.Row():
288
+ import_file = gr.File(label="JoyCaption JSON", file_types=[".json"], scale=2)
289
+ import_btn = gr.Button("πŸ“₯ Import", variant="primary", scale=1, size="lg")
290
+ import_status = gr.Textbox(label="Status", lines=2, interactive=False, visible=False)
291
 
292
+ gr.Markdown("### πŸ“ Inputs")
 
 
293
  all_captions_input = gr.Textbox(
 
294
  label="JoyCaption Descriptions",
295
+ lines=12, max_lines=18,
296
+ placeholder="Paste JoyCaption output (one or more tones)...",
297
+ elem_id="venice_captions_input",
 
 
 
 
 
 
 
298
  )
 
299
  seo_keywords_input = gr.Textbox(
300
+ label="🏷️ Tag Seeds",
 
301
  lines=2,
302
+ placeholder="comma,separated,seed,tags",
303
+ elem_id="venice_tags_input",
304
+ )
305
+ image_url_input = gr.Textbox(
306
+ label="πŸ–ΌοΈ Image URL",
307
+ lines=1,
308
+ placeholder="https://...",
309
+ elem_id="venice_image_url",
310
  )
311
+ image_preview = gr.HTML(elem_id="venice_image_preview")
312
 
313
+ generate_btn = gr.Button(
314
+ "🌊 Generate All", variant="primary", size="lg",
315
+ elem_id="venice_generate_btn",
 
 
 
 
 
316
  )
 
317
 
318
+ # ── Right: outputs ──────────────────────────────────────────────
319
  with gr.Column(scale=1):
320
+ title_output = gr.Textbox(
321
+ label="πŸ“° Title",
322
+ lines=2, show_copy_button=True,
323
+ elem_id="venice_title_output",
324
  )
325
+ meta_output = gr.Textbox(
326
+ label="πŸ”Ž SEO Meta Description (≀160 chars)",
327
+ lines=2, show_copy_button=True,
328
+ elem_id="venice_meta_output",
 
 
 
 
 
329
  )
330
+ h2_output = gr.Textbox(
331
+ label="🏷️ H2 Heading",
332
+ lines=2, show_copy_button=True,
333
+ elem_id="venice_h2_output",
334
  )
335
+ rant_output = gr.Textbox(
336
+ label="πŸ”₯ Roasty Rant (120–200 words)",
337
+ lines=12, max_lines=20, show_copy_button=True,
338
+ elem_id="venice_rant_output",
 
 
 
339
  )
340
+ tags_output = gr.Textbox(
341
+ label="🏷️ SEO Tags (seeds + slang synonyms)",
342
+ lines=4, show_copy_button=True,
343
+ elem_id="venice_tags_output",
 
 
 
 
 
344
  )
 
345
 
346
+ export_btn = gr.Button("πŸ’Ύ Export Blog JSON", variant="secondary")
347
+ export_file = gr.File(label="Download", interactive=False)
348
+
349
+ # ── Handlers ─────────────────────────��──────────────────────────────
350
+
351
+ def _handle_import(file):
352
+ status, captions, tags, url, raw = import_joycaption_json(file)
353
+ if url:
354
+ img = f'<img src="{url}" style="max-width:100%;border-radius:8px" />'
 
 
 
 
 
 
 
 
 
 
 
355
  else:
356
+ img = ""
357
  return (
358
  gr.update(value=status, visible=True),
359
+ gr.update(value=captions),
360
+ gr.update(value=tags),
361
+ gr.update(value=url),
362
+ gr.update(value=img),
363
+ raw,
 
364
  )
365
 
366
  import_btn.click(
367
+ _handle_import,
368
  inputs=[import_file],
369
+ outputs=[import_status, all_captions_input, seo_keywords_input,
370
+ image_url_input, image_preview, raw_state],
371
  )
372
 
373
+ def _update_preview(url):
374
+ u = (url or "").strip()
375
+ if u.startswith(("http://", "https://")):
376
+ return f'<img src="{u}" style="max-width:100%;border-radius:8px" />'
 
 
 
 
 
 
 
377
  return ""
378
 
379
+ image_url_input.change(_update_preview, inputs=image_url_input, outputs=image_preview)
380
+
381
+ # Clear old outputs on every fresh run β†’ kills stale "Error" badges.
382
+ def _clear_outputs():
383
+ return "", "", "", "", ""
384
+
385
+ generate_btn.click(
386
+ _clear_outputs, inputs=None,
387
+ outputs=[title_output, meta_output, h2_output, rant_output, tags_output],
388
+ queue=False,
389
+ ).then(
390
+ generate_all,
391
+ inputs=[all_captions_input, seo_keywords_input],
392
+ outputs=[title_output, meta_output, h2_output, rant_output, tags_output],
393
+ show_progress=True,
 
 
 
 
 
 
394
  )
395
 
396
  export_btn.click(
397
  export_blog_json,
398
+ inputs=[raw_state, title_output, meta_output, h2_output, rant_output,
399
+ tags_output, image_url_input],
400
  outputs=export_file,
 
401
  )
402
 
403
  if __name__ == "__main__":
404
+ demo.launch()