ACloudCenter Claude Sonnet 5 commited on
Commit
d802243
·
1 Parent(s): 65ca893

Add a target-length control for AI script generation

Browse files

Users had no way to influence how long the AI-written script should
be — the system prompt's word cap was fixed at 1000 words regardless
of scenario, and the LLM's actual output length was whatever it felt
like producing within that ceiling.

Adds a Length select (1/2/5/10/15/20/30/45/60 min) next to the prompt
box. Selected minutes convert to a word target at the same 150 wpm
pace already used for the duration estimate elsewhere in the app, which
drives: the system prompt's length instruction (now phrased as a target
to reach, not just a ceiling not to exceed), the completion's max_tokens
(scaled to the target, capped at 8192), and the turn-count budget/trim
ceiling (previously a flat 50-turn cap that would have silently
truncated anything past ~20 minutes).

Long targets (30-60 min) are a best effort, not a guarantee — a single
LLM completion may not reliably produce that much coherent dialogue in
one pass, and the underlying provider's real max_tokens ceiling isn't
documented, so the longest options may come back shorter than asked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Files changed (4) hide show
  1. app.py +37 -9
  2. static/app.js +8 -3
  3. static/index.html +1 -0
  4. static/styles.css +13 -1
app.py CHANGED
@@ -35,14 +35,23 @@ AVAILABLE_VOICES = list(VOICE_GENDERS.keys())
35
  DEFAULT_SPEAKERS = ["Cherry", "Chicago", "Janus", "Mantis"]
36
 
37
  SCRIPT_GEN_MODEL = "Qwen/Qwen2.5-Coder-32B-Instruct"
38
- SCRIPT_MAX_WORDS = 1000 # AI generation cap
 
 
 
 
39
  MAX_SCRIPT_WORDS = 100000 # Effectively uncapped (2026-08-14, Josh) — backend chunking has no
40
  # real ceiling; the old 20,000 cap was a leftover UI guess that
41
  # blocked genuine long-form renders below the backend's actual limit
42
- MAX_TURNS = 50 # Max conversation turns
43
  AUDIO_TTL_SECONDS = 900
44
 
45
 
 
 
 
 
 
46
  # --- Load example scripts ---
47
  def load_example_scripts():
48
  examples_dir = ROOT / "text_examples"
@@ -232,7 +241,10 @@ FORMAT RULES:
232
  - Use EXACTLY this format for dialogue: "Speaker N: dialogue text" where N starts at 1
233
  - Each turn is separated by a blank line
234
  - Choose the right number of speakers for the scenario (1 to 4 max)
235
- - Keep the total script under {max_words} words
 
 
 
236
  - Output ONLY the title and script — no stage directions, no commentary, no preamble
237
 
238
  CRITICAL — ONE SPEAKER PER TURN:
@@ -337,15 +349,22 @@ def assign_voices_by_gender(genders: dict[int, str], num_speakers: int) -> list[
337
  return chosen
338
 
339
 
340
- def generate_script_from_prompt(prompt: str) -> tuple[list[dict], int, str, list[str | None]]:
 
 
341
  """Returns (turns, num_speakers, title, voice_selections)."""
342
- system = SCRIPT_SYSTEM_PROMPT.format(max_words=SCRIPT_MAX_WORDS)
 
 
 
 
 
343
  response = llm_client.chat_completion(
344
  messages=[
345
  {"role": "system", "content": system},
346
  {"role": "user", "content": prompt},
347
  ],
348
- max_tokens=4096,
349
  temperature=0.7,
350
  )
351
  raw = response.choices[0].message.content
@@ -367,9 +386,11 @@ def generate_script_from_prompt(prompt: str) -> tuple[list[dict], int, str, list
367
  for t in turns
368
  ]
369
  turns = [t for t in turns if t["text"].strip()]
370
- turns = turns[:MAX_TURNS]
 
 
371
  total_words = sum(len(t["text"].split()) for t in turns)
372
- while total_words > MAX_SCRIPT_WORDS and turns:
373
  turns.pop()
374
  total_words = sum(len(t["text"].split()) for t in turns)
375
  speaker_ids = {t["speaker"] for t in turns}
@@ -534,6 +555,12 @@ async def api_parse_script(
534
 
535
  class ScriptPromptRequest(BaseModel):
536
  prompt: str
 
 
 
 
 
 
537
 
538
 
539
  @app.post("/api/generate-script")
@@ -541,10 +568,11 @@ async def api_generate_script(payload: ScriptPromptRequest) -> dict:
541
  prompt = (payload.prompt or "").strip()
542
  if not prompt:
543
  raise HTTPException(status_code=400, detail="Please enter a prompt.")
 
544
 
545
  try:
546
  script_result, parody_lines = await asyncio.gather(
547
- asyncio.to_thread(generate_script_from_prompt, prompt),
548
  asyncio.to_thread(generate_parody_story, prompt),
549
  )
550
  except Exception as e:
 
35
  DEFAULT_SPEAKERS = ["Cherry", "Chicago", "Janus", "Mantis"]
36
 
37
  SCRIPT_GEN_MODEL = "Qwen/Qwen2.5-Coder-32B-Instruct"
38
+ WORDS_PER_MINUTE = 150 # Matches the pace assumed by the client's duration estimate
39
+ DURATION_OPTIONS_MINUTES = [1, 2, 5, 10, 15, 20, 30, 45, 60]
40
+ MAX_COMPLETION_TOKENS = 8192 # Good-faith ceiling for a single chat_completion call; the
41
+ # underlying provider may cap lower, in which case the longest
42
+ # duration options may come back shorter than requested
43
  MAX_SCRIPT_WORDS = 100000 # Effectively uncapped (2026-08-14, Josh) — backend chunking has no
44
  # real ceiling; the old 20,000 cap was a leftover UI guess that
45
  # blocked genuine long-form renders below the backend's actual limit
46
+ MAX_TURNS = 250 # Hard ceiling regardless of target length (safety valve)
47
  AUDIO_TTL_SECONDS = 900
48
 
49
 
50
+ def _turns_budget_for_words(target_words: int) -> int:
51
+ """How many turns a script of this length plausibly needs, given full-paragraph turns."""
52
+ return max(6, min(MAX_TURNS, round(target_words / 55)))
53
+
54
+
55
  # --- Load example scripts ---
56
  def load_example_scripts():
57
  examples_dir = ROOT / "text_examples"
 
241
  - Use EXACTLY this format for dialogue: "Speaker N: dialogue text" where N starts at 1
242
  - Each turn is separated by a blank line
243
  - Choose the right number of speakers for the scenario (1 to 4 max)
244
+ - LENGTH TARGET: write approximately {target_words} words total — enough dialogue to fill
245
+ roughly {target_minutes} minute(s) of natural spoken audio. This is a target, not just a
246
+ ceiling: keep the conversation developing — new angles, follow-up questions, examples,
247
+ pushback — rather than wrapping up early. Do not stop far short of the target.
248
  - Output ONLY the title and script — no stage directions, no commentary, no preamble
249
 
250
  CRITICAL — ONE SPEAKER PER TURN:
 
349
  return chosen
350
 
351
 
352
+ def generate_script_from_prompt(
353
+ prompt: str, target_minutes: int = 2
354
+ ) -> tuple[list[dict], int, str, list[str | None]]:
355
  """Returns (turns, num_speakers, title, voice_selections)."""
356
+ target_minutes = target_minutes if target_minutes in DURATION_OPTIONS_MINUTES else 2
357
+ target_words = target_minutes * WORDS_PER_MINUTE
358
+ turns_budget = _turns_budget_for_words(target_words)
359
+ completion_tokens = min(MAX_COMPLETION_TOKENS, int(target_words * 1.6) + 400)
360
+
361
+ system = SCRIPT_SYSTEM_PROMPT.format(target_words=target_words, target_minutes=target_minutes)
362
  response = llm_client.chat_completion(
363
  messages=[
364
  {"role": "system", "content": system},
365
  {"role": "user", "content": prompt},
366
  ],
367
+ max_tokens=completion_tokens,
368
  temperature=0.7,
369
  )
370
  raw = response.choices[0].message.content
 
386
  for t in turns
387
  ]
388
  turns = [t for t in turns if t["text"].strip()]
389
+ turns = turns[:turns_budget]
390
+ # Allow some overshoot past the target before trimming — the model runs long sometimes.
391
+ overshoot_ceiling = int(target_words * 1.3) + 100
392
  total_words = sum(len(t["text"].split()) for t in turns)
393
+ while total_words > overshoot_ceiling and turns:
394
  turns.pop()
395
  total_words = sum(len(t["text"].split()) for t in turns)
396
  speaker_ids = {t["speaker"] for t in turns}
 
555
 
556
  class ScriptPromptRequest(BaseModel):
557
  prompt: str
558
+ target_minutes: int = 2
559
+
560
+
561
+ @app.get("/api/duration-options")
562
+ async def api_duration_options() -> list[int]:
563
+ return DURATION_OPTIONS_MINUTES
564
 
565
 
566
  @app.post("/api/generate-script")
 
568
  prompt = (payload.prompt or "").strip()
569
  if not prompt:
570
  raise HTTPException(status_code=400, detail="Please enter a prompt.")
571
+ target_minutes = payload.target_minutes if payload.target_minutes in DURATION_OPTIONS_MINUTES else 2
572
 
573
  try:
574
  script_result, parody_lines = await asyncio.gather(
575
+ asyncio.to_thread(generate_script_from_prompt, prompt, target_minutes),
576
  asyncio.to_thread(generate_parody_story, prompt),
577
  )
578
  except Exception as e:
static/app.js CHANGED
@@ -41,7 +41,7 @@ const el = {};
41
  [
42
  "runtimeStatus", "runtimeLabel", "aboutBtn", "aboutDialog", "closeAboutBtn",
43
  "modelSelect", "speakerStepper", "voiceRows", "cfgScale", "cfgScaleValue",
44
- "scriptPrompt", "generateScriptBtn", "examplePills", "openImportBtn", "scriptGenStatus",
45
  "scriptTitle", "scriptDuration", "turnsList", "addTurnBtn",
46
  "generateBarMeta", "generateBtn",
47
  "statusCard", "statusTitle", "statusDesc",
@@ -331,7 +331,7 @@ el.generateScriptBtn.addEventListener("click", async () => {
331
  const res = await fetch("/api/generate-script", {
332
  method: "POST",
333
  headers: { "Content-Type": "application/json" },
334
- body: JSON.stringify({ prompt }),
335
  });
336
  const payload = await res.json();
337
  if (!res.ok) throw new Error(payload.detail || "Script generation failed.");
@@ -495,10 +495,11 @@ el.generateBtn.addEventListener("click", async () => {
495
 
496
  /* ---------------- Init ---------------- */
497
  async function init() {
498
- const [models, voices, examples] = await Promise.all([
499
  fetch("/api/models").then((r) => r.json()),
500
  fetch("/api/voices").then((r) => r.json()),
501
  fetch("/api/examples").then((r) => r.json()),
 
502
  ]);
503
  state.models = models;
504
  state.voices = voices;
@@ -506,6 +507,10 @@ async function init() {
506
  state.voiceSelections = voices.slice(0, 4).map((v) => v.name);
507
  while (state.voiceSelections.length < 4) state.voiceSelections.push(null);
508
 
 
 
 
 
509
  renderSidebar();
510
  renderTurns();
511
  renderExamplePills();
 
41
  [
42
  "runtimeStatus", "runtimeLabel", "aboutBtn", "aboutDialog", "closeAboutBtn",
43
  "modelSelect", "speakerStepper", "voiceRows", "cfgScale", "cfgScaleValue",
44
+ "scriptPrompt", "durationSelect", "generateScriptBtn", "examplePills", "openImportBtn", "scriptGenStatus",
45
  "scriptTitle", "scriptDuration", "turnsList", "addTurnBtn",
46
  "generateBarMeta", "generateBtn",
47
  "statusCard", "statusTitle", "statusDesc",
 
331
  const res = await fetch("/api/generate-script", {
332
  method: "POST",
333
  headers: { "Content-Type": "application/json" },
334
+ body: JSON.stringify({ prompt, target_minutes: Number(el.durationSelect.value) }),
335
  });
336
  const payload = await res.json();
337
  if (!res.ok) throw new Error(payload.detail || "Script generation failed.");
 
495
 
496
  /* ---------------- Init ---------------- */
497
  async function init() {
498
+ const [models, voices, examples, durationOptions] = await Promise.all([
499
  fetch("/api/models").then((r) => r.json()),
500
  fetch("/api/voices").then((r) => r.json()),
501
  fetch("/api/examples").then((r) => r.json()),
502
+ fetch("/api/duration-options").then((r) => r.json()),
503
  ]);
504
  state.models = models;
505
  state.voices = voices;
 
507
  state.voiceSelections = voices.slice(0, 4).map((v) => v.name);
508
  while (state.voiceSelections.length < 4) state.voiceSelections.push(null);
509
 
510
+ el.durationSelect.innerHTML = durationOptions.map((m) => `<option value="${m}">${m} min</option>`).join("");
511
+ const defaultDuration = durationOptions.includes(2) ? 2 : durationOptions[0];
512
+ el.durationSelect.value = String(defaultDuration);
513
+
514
  renderSidebar();
515
  renderTurns();
516
  renderExamplePills();
static/index.html CHANGED
@@ -70,6 +70,7 @@
70
  rows="1"
71
  placeholder="Describe a scenario — a wizard and an orc debating battle strategy..."
72
  ></textarea>
 
73
  <button id="generateScriptBtn" class="btn btn-ink" type="button">Write with AI</button>
74
  </div>
75
  <div class="example-chips" id="examplePills"></div>
 
70
  rows="1"
71
  placeholder="Describe a scenario — a wizard and an orc debating battle strategy..."
72
  ></textarea>
73
+ <select id="durationSelect" class="duration-select" aria-label="Target length"></select>
74
  <button id="generateScriptBtn" class="btn btn-ink" type="button">Write with AI</button>
75
  </div>
76
  <div class="example-chips" id="examplePills"></div>
static/styles.css CHANGED
@@ -214,7 +214,19 @@ input[type="range"] { flex: 1; accent-color: var(--accent); }
214
  box-shadow: var(--shadow);
215
  margin-bottom: 14px;
216
  }
217
- .composer-row { display: flex; gap: 10px; align-items: flex-end; }
 
 
 
 
 
 
 
 
 
 
 
 
218
  .composer textarea {
219
  flex: 1;
220
  border: 1px solid var(--border-strong);
 
214
  box-shadow: var(--shadow);
215
  margin-bottom: 14px;
216
  }
217
+ .composer-row { display: flex; gap: 10px; align-items: center; }
218
+ .duration-select {
219
+ width: auto;
220
+ flex-shrink: 0;
221
+ background: var(--field);
222
+ border: 1px solid var(--border-strong);
223
+ border-radius: var(--radius-sm);
224
+ padding: 10px 8px;
225
+ font-size: 0.82rem;
226
+ color: var(--ink-dim);
227
+ align-self: stretch;
228
+ }
229
+ .duration-select:focus { outline: none; border-color: var(--accent); }
230
  .composer textarea {
231
  flex: 1;
232
  border: 1px solid var(--border-strong);