Add a target-length control for AI script generation
Browse filesUsers 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>
- app.py +37 -9
- static/app.js +8 -3
- static/index.html +1 -0
- static/styles.css +13 -1
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 |
-
-
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 341 |
"""Returns (turns, num_speakers, title, voice_selections)."""
|
| 342 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
response = llm_client.chat_completion(
|
| 344 |
messages=[
|
| 345 |
{"role": "system", "content": system},
|
| 346 |
{"role": "user", "content": prompt},
|
| 347 |
],
|
| 348 |
-
max_tokens=
|
| 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[:
|
|
|
|
|
|
|
| 371 |
total_words = sum(len(t["text"].split()) for t in turns)
|
| 372 |
-
while total_words >
|
| 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:
|
|
@@ -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();
|
|
@@ -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>
|
|
@@ -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:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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);
|