Merge pull request #1501 from CREDO23/feat/podcast-brief-duration-seconds

feat(podcasts): short default brief length with seconds and unit picker
This commit is contained in:
Thierry CH. 2026-06-16 15:04:07 -07:00 committed by GitHub
commit 284df841ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 253 additions and 86 deletions

View file

@ -157,8 +157,8 @@ async def create_podcast(
session, session,
search_space_id=body.search_space_id, search_space_id=body.search_space_id,
speaker_count=body.speaker_count, speaker_count=body.speaker_count,
min_minutes=body.min_minutes, min_seconds=body.min_seconds,
max_minutes=body.max_minutes, max_seconds=body.max_seconds,
focus=body.focus, focus=body.focus,
) )
await service.attach_brief(podcast, spec) await service.attach_brief(podcast, spec)

View file

@ -11,6 +11,12 @@ from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from app.podcasts.duration_limits import (
DEFAULT_MAX_SECONDS,
DEFAULT_MIN_SECONDS,
MAX_DURATION_SECONDS,
MIN_DURATION_SECONDS,
)
from app.podcasts.persistence import Podcast, PodcastStatus from app.podcasts.persistence import Podcast, PodcastStatus
from app.podcasts.schemas import PodcastSpec, Transcript from app.podcasts.schemas import PodcastSpec, Transcript
from app.podcasts.service import has_stored_episode, read_spec, read_transcript from app.podcasts.service import has_stored_episode, read_spec, read_transcript
@ -18,8 +24,6 @@ from app.podcasts.service import has_stored_episode, read_spec, read_transcript
# Defaults applied when a create request omits brief sizing; the brief gate lets # Defaults applied when a create request omits brief sizing; the brief gate lets
# the user adjust before any cost is incurred. # the user adjust before any cost is incurred.
DEFAULT_SPEAKER_COUNT = 2 DEFAULT_SPEAKER_COUNT = 2
DEFAULT_MIN_MINUTES = 10
DEFAULT_MAX_MINUTES = 20
class CreatePodcastRequest(BaseModel): class CreatePodcastRequest(BaseModel):
@ -30,8 +34,16 @@ class CreatePodcastRequest(BaseModel):
source_content: str = Field(..., min_length=1) source_content: str = Field(..., min_length=1)
thread_id: int | None = None thread_id: int | None = None
speaker_count: int = Field(default=DEFAULT_SPEAKER_COUNT, ge=1, le=6) speaker_count: int = Field(default=DEFAULT_SPEAKER_COUNT, ge=1, le=6)
min_minutes: int = Field(default=DEFAULT_MIN_MINUTES, ge=1) min_seconds: int = Field(
max_minutes: int = Field(default=DEFAULT_MAX_MINUTES, ge=1) default=DEFAULT_MIN_SECONDS,
ge=MIN_DURATION_SECONDS,
le=MAX_DURATION_SECONDS,
)
max_seconds: int = Field(
default=DEFAULT_MAX_SECONDS,
ge=MIN_DURATION_SECONDS,
le=MAX_DURATION_SECONDS,
)
focus: str | None = Field(default=None, max_length=2000) focus: str | None = Field(default=None, max_length=2000)

View file

@ -0,0 +1,6 @@
"""Shared bounds and defaults for podcast target duration."""
MAX_DURATION_SECONDS = 24 * 60 * 60
MIN_DURATION_SECONDS = 15
DEFAULT_MIN_SECONDS = 20
DEFAULT_MAX_SECONDS = 30

View file

@ -6,10 +6,13 @@ from dataclasses import dataclass, field, fields
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from app.podcasts.duration_limits import (
DEFAULT_MAX_SECONDS,
DEFAULT_MIN_SECONDS,
)
# Sensible defaults for a fresh brief; the user adjusts the range at the gate. # Sensible defaults for a fresh brief; the user adjusts the range at the gate.
DEFAULT_SPEAKER_COUNT = 2 DEFAULT_SPEAKER_COUNT = 2
DEFAULT_MIN_MINUTES = 10
DEFAULT_MAX_MINUTES = 20
@dataclass(kw_only=True) @dataclass(kw_only=True)
@ -17,8 +20,8 @@ class BriefConfig:
"""Signals used to propose a brief; everything here is non-LLM context.""" """Signals used to propose a brief; everything here is non-LLM context."""
speaker_count: int = DEFAULT_SPEAKER_COUNT speaker_count: int = DEFAULT_SPEAKER_COUNT
min_minutes: int = DEFAULT_MIN_MINUTES min_seconds: int = DEFAULT_MIN_SECONDS
max_minutes: int = DEFAULT_MAX_MINUTES max_seconds: int = DEFAULT_MAX_SECONDS
focus: str | None = None focus: str | None = None
last_used_language: str | None = None last_used_language: str | None = None
last_used_voices: list[str] = field(default_factory=list) last_used_voices: list[str] = field(default_factory=list)

View file

@ -79,7 +79,7 @@ def propose_spec(state: BriefState, config: RunnableConfig) -> dict[str, Any]:
style=PodcastStyle.CONVERSATIONAL, style=PodcastStyle.CONVERSATIONAL,
speakers=speakers, speakers=speakers,
duration=DurationTarget( duration=DurationTarget(
min_minutes=brief.min_minutes, max_minutes=brief.max_minutes min_seconds=brief.min_seconds, max_seconds=brief.max_seconds
), ),
focus=brief.focus, focus=brief.focus,
) )

View file

@ -4,11 +4,12 @@ from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.podcasts.duration_limits import DEFAULT_MAX_SECONDS, DEFAULT_MIN_SECONDS
from app.podcasts.persistence import PodcastRepository from app.podcasts.persistence import PodcastRepository
from app.podcasts.schemas import PodcastSpec from app.podcasts.schemas import PodcastSpec
from app.podcasts.service import preferences_from from app.podcasts.service import preferences_from
from .config import DEFAULT_MAX_MINUTES, DEFAULT_MIN_MINUTES, DEFAULT_SPEAKER_COUNT from .config import DEFAULT_SPEAKER_COUNT
from .graph import graph as brief_graph from .graph import graph as brief_graph
from .state import BriefState from .state import BriefState
@ -18,8 +19,8 @@ async def propose_brief(
*, *,
search_space_id: int, search_space_id: int,
speaker_count: int = DEFAULT_SPEAKER_COUNT, speaker_count: int = DEFAULT_SPEAKER_COUNT,
min_minutes: int = DEFAULT_MIN_MINUTES, min_seconds: int = DEFAULT_MIN_SECONDS,
max_minutes: int = DEFAULT_MAX_MINUTES, max_seconds: int = DEFAULT_MAX_SECONDS,
focus: str | None = None, focus: str | None = None,
) -> PodcastSpec: ) -> PodcastSpec:
"""Reuse the last-used language and voices, else English; return the spec.""" """Reuse the last-used language and voices, else English; return the spec."""
@ -29,8 +30,8 @@ async def propose_brief(
config = { config = {
"configurable": { "configurable": {
"speaker_count": speaker_count, "speaker_count": speaker_count,
"min_minutes": min_minutes, "min_seconds": min_seconds,
"max_minutes": max_minutes, "max_seconds": max_seconds,
"focus": focus, "focus": focus,
"last_used_language": last_language, "last_used_language": last_language,
"last_used_voices": last_voices, "last_used_voices": last_voices,

View file

@ -38,7 +38,7 @@ async def plan_outline(
tc = TranscriptConfig.from_runnable_config(config) tc = TranscriptConfig.from_runnable_config(config)
llm = await _require_llm(state, tc) llm = await _require_llm(state, tc)
target_words = round(tc.spec.duration.midpoint_minutes * _WORDS_PER_MINUTE) target_words = round(tc.spec.duration.midpoint_seconds * _WORDS_PER_MINUTE / 60)
suggested_segments = max(1, round(target_words / _WORDS_PER_SEGMENT)) suggested_segments = max(1, round(target_words / _WORDS_PER_SEGMENT))
messages = [ messages = [

View file

@ -10,17 +10,19 @@ from __future__ import annotations
import re import re
from enum import StrEnum from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.podcasts.duration_limits import (
MAX_DURATION_SECONDS,
MIN_DURATION_SECONDS,
)
# A speaker count beyond this is almost never a real podcast and explodes the # A speaker count beyond this is almost never a real podcast and explodes the
# voice/turn-attribution space, so we reject it at the brief gate. # voice/turn-attribution space, so we reject it at the brief gate.
MAX_SPEAKERS = 6 MAX_SPEAKERS = 6
# Long-form is a goal, but an open-ended upper bound invites runaway TTS bills.
# One day of audio is a generous ceiling that still blocks obvious mistakes.
MAX_DURATION_MINUTES = 24 * 60
# BCP-47 primary subtag plus optional region (e.g. ``en``, ``en-US``, ``pt-BR``). # BCP-47 primary subtag plus optional region (e.g. ``en``, ``en-US``, ``pt-BR``).
# Kept deliberately permissive: the voice catalog, not the brief, decides which # Kept deliberately permissive: the voice catalog, not the brief, decides which
# languages can actually be synthesised. Casing is normalised after matching. # languages can actually be synthesised. Casing is normalised after matching.
@ -91,7 +93,7 @@ class SpeakerSpec(BaseModel):
class DurationTarget(BaseModel): class DurationTarget(BaseModel):
"""The desired finished length as an inclusive minute range. """The desired finished length as an inclusive second range.
Drafting aims for the midpoint and treats the bounds as soft guardrails; Drafting aims for the midpoint and treats the bounds as soft guardrails;
storing a range (rather than a point) keeps long-form expectations honest storing a range (rather than a point) keeps long-form expectations honest
@ -100,19 +102,38 @@ class DurationTarget(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
min_minutes: int = Field(..., ge=1, le=MAX_DURATION_MINUTES) min_seconds: int = Field(..., ge=MIN_DURATION_SECONDS, le=MAX_DURATION_SECONDS)
max_minutes: int = Field(..., ge=1, le=MAX_DURATION_MINUTES) max_seconds: int = Field(..., ge=MIN_DURATION_SECONDS, le=MAX_DURATION_SECONDS)
@model_validator(mode="before")
@classmethod
def _coerce_legacy_minutes(cls, data: Any) -> Any:
"""Rows stored before seconds-based briefs still load from JSONB."""
if (
isinstance(data, dict)
and "min_seconds" not in data
and "min_minutes" in data
):
migrated = dict(data)
migrated["min_seconds"] = int(migrated.pop("min_minutes")) * 60
migrated["max_seconds"] = int(migrated.pop("max_minutes")) * 60
return migrated
return data
@model_validator(mode="after") @model_validator(mode="after")
def _check_order(self) -> DurationTarget: def _check_order(self) -> DurationTarget:
if self.max_minutes < self.min_minutes: if self.max_seconds < self.min_seconds:
raise ValueError("max_minutes must be >= min_minutes") raise ValueError("max_seconds must be >= min_seconds")
return self return self
@property @property
def midpoint_minutes(self) -> float: def midpoint_seconds(self) -> float:
"""The runtime drafting should aim for within the range.""" """The runtime drafting should aim for within the range."""
return (self.min_minutes + self.max_minutes) / 2 return (self.min_seconds + self.max_seconds) / 2
@property
def midpoint_minutes(self) -> float:
return self.midpoint_seconds / 60
class PodcastSpec(BaseModel): class PodcastSpec(BaseModel):

View file

@ -217,7 +217,7 @@ def build_spec(
slot=1, name="Guest", role=SpeakerRole.GUEST, voice_id=voice_ids[1] slot=1, name="Guest", role=SpeakerRole.GUEST, voice_id=voice_ids[1]
), ),
], ],
duration=DurationTarget(min_minutes=10, max_minutes=20), duration=DurationTarget(min_seconds=600, max_seconds=1200),
) )

View file

@ -76,8 +76,7 @@ async def test_quota_denial_fails_the_podcast_without_a_transcript(
async def _deny(**_kwargs): async def _deny(**_kwargs):
raise QuotaInsufficientError( raise QuotaInsufficientError(
usage_type="podcast_generation", usage_type="podcast_generation",
used_micros=5_000_000, balance_micros=5_000_000,
limit_micros=5_000_000,
remaining_micros=0, remaining_micros=0,
) )
yield # pragma: no cover - unreachable, satisfies the CM protocol yield # pragma: no cover - unreachable, satisfies the CM protocol

View file

@ -31,8 +31,8 @@ def make_spec():
language: str = "en", language: str = "en",
style: PodcastStyle = PodcastStyle.CONVERSATIONAL, style: PodcastStyle = PodcastStyle.CONVERSATIONAL,
speakers: list[SpeakerSpec] | None = None, speakers: list[SpeakerSpec] | None = None,
min_minutes: int = 10, min_seconds: int = 600,
max_minutes: int = 20, max_seconds: int = 1200,
focus: str | None = None, focus: str | None = None,
) -> PodcastSpec: ) -> PodcastSpec:
if speakers is None: if speakers is None:
@ -54,7 +54,7 @@ def make_spec():
language=language, language=language,
style=style, style=style,
speakers=speakers, speakers=speakers,
duration=DurationTarget(min_minutes=min_minutes, max_minutes=max_minutes), duration=DurationTarget(min_seconds=min_seconds, max_seconds=max_seconds),
focus=focus, focus=focus,
) )

View file

@ -66,7 +66,7 @@ def _spec(voice_id: str) -> PodcastSpec:
speakers=[ speakers=[
SpeakerSpec(slot=0, name="Host", role=SpeakerRole.HOST, voice_id=voice_id) SpeakerSpec(slot=0, name="Host", role=SpeakerRole.HOST, voice_id=voice_id)
], ],
duration=DurationTarget(min_minutes=5, max_minutes=10), duration=DurationTarget(min_seconds=300, max_seconds=600),
) )

View file

@ -57,7 +57,7 @@ def test_spec_normalizes_its_language_on_construction():
spec = PodcastSpec( spec = PodcastSpec(
language="EN-us", language="EN-us",
speakers=[_speaker(0)], speakers=[_speaker(0)],
duration=DurationTarget(min_minutes=5, max_minutes=10), duration=DurationTarget(min_seconds=300, max_seconds=600),
) )
assert spec.language == "en-us" assert spec.language == "en-us"
@ -68,7 +68,7 @@ def test_speakers_must_have_unique_slots():
PodcastSpec( PodcastSpec(
language="en", language="en",
speakers=[_speaker(0), _speaker(0, voice_id="kokoro:af_bella")], speakers=[_speaker(0), _speaker(0, voice_id="kokoro:af_bella")],
duration=DurationTarget(min_minutes=5, max_minutes=10), duration=DurationTarget(min_seconds=300, max_seconds=600),
) )
@ -77,7 +77,7 @@ def test_a_brief_needs_at_least_one_speaker():
PodcastSpec( PodcastSpec(
language="en", language="en",
speakers=[], speakers=[],
duration=DurationTarget(min_minutes=5, max_minutes=10), duration=DurationTarget(min_seconds=300, max_seconds=600),
) )
@ -86,7 +86,7 @@ def test_a_monologue_brief_carries_exactly_one_speaker():
language="en", language="en",
style=PodcastStyle.MONOLOGUE, style=PodcastStyle.MONOLOGUE,
speakers=[_speaker(0)], speakers=[_speaker(0)],
duration=DurationTarget(min_minutes=5, max_minutes=10), duration=DurationTarget(min_seconds=300, max_seconds=600),
) )
assert spec.style is PodcastStyle.MONOLOGUE assert spec.style is PodcastStyle.MONOLOGUE
@ -98,18 +98,25 @@ def test_a_monologue_brief_rejects_multiple_speakers():
language="en", language="en",
style=PodcastStyle.MONOLOGUE, style=PodcastStyle.MONOLOGUE,
speakers=[_speaker(0), _speaker(1, voice_id="kokoro:af_bella")], speakers=[_speaker(0), _speaker(1, voice_id="kokoro:af_bella")],
duration=DurationTarget(min_minutes=5, max_minutes=10), duration=DurationTarget(min_seconds=300, max_seconds=600),
) )
def test_duration_rejects_an_inverted_range(): def test_duration_rejects_an_inverted_range():
"""A max below the min is a user error caught at the brief gate.""" """A max below the min is a user error caught at the brief gate."""
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
DurationTarget(min_minutes=20, max_minutes=10) DurationTarget(min_seconds=1200, max_seconds=600)
def test_duration_midpoint_is_where_drafting_aims(): def test_duration_midpoint_is_where_drafting_aims():
assert DurationTarget(min_minutes=10, max_minutes=20).midpoint_minutes == 15 assert DurationTarget(min_seconds=600, max_seconds=1200).midpoint_seconds == 900
assert DurationTarget(min_seconds=600, max_seconds=1200).midpoint_minutes == 15
def test_duration_loads_legacy_minute_fields_from_json():
duration = DurationTarget.model_validate({"min_minutes": 10, "max_minutes": 20})
assert duration.min_seconds == 600
assert duration.max_seconds == 1200
def test_blank_focus_becomes_absent(): def test_blank_focus_becomes_absent():
@ -117,7 +124,7 @@ def test_blank_focus_becomes_absent():
spec = PodcastSpec( spec = PodcastSpec(
language="en", language="en",
speakers=[_speaker(0)], speakers=[_speaker(0)],
duration=DurationTarget(min_minutes=5, max_minutes=10), duration=DurationTarget(min_seconds=300, max_seconds=600),
focus=" ", focus=" ",
) )
assert spec.focus is None assert spec.focus is None
@ -127,7 +134,7 @@ def test_speaker_for_returns_the_speaker_bound_to_a_slot():
spec = PodcastSpec( spec = PodcastSpec(
language="en", language="en",
speakers=[_speaker(0), _speaker(1, voice_id="kokoro:af_bella")], speakers=[_speaker(0), _speaker(1, voice_id="kokoro:af_bella")],
duration=DurationTarget(min_minutes=5, max_minutes=10), duration=DurationTarget(min_seconds=300, max_seconds=600),
) )
assert spec.speaker_for(1).voice_id == "kokoro:af_bella" assert spec.speaker_for(1).voice_id == "kokoro:af_bella"
@ -136,7 +143,7 @@ def test_speaker_for_raises_when_no_speaker_matches():
spec = PodcastSpec( spec = PodcastSpec(
language="en", language="en",
speakers=[_speaker(0)], speakers=[_speaker(0)],
duration=DurationTarget(min_minutes=5, max_minutes=10), duration=DurationTarget(min_seconds=300, max_seconds=600),
) )
with pytest.raises(KeyError): with pytest.raises(KeyError):
spec.speaker_for(99) spec.speaker_for(99)

View file

@ -105,8 +105,7 @@ async def test_ainvoke_propagates_quota_insufficient_error(monkeypatch):
async def _denying_billable_call(**_kwargs): async def _denying_billable_call(**_kwargs):
raise QuotaInsufficientError( raise QuotaInsufficientError(
usage_type="vision_extraction", usage_type="vision_extraction",
used_micros=5_000_000, balance_micros=5_000_000,
limit_micros=5_000_000,
remaining_micros=0, remaining_micros=0,
) )
yield # unreachable but required for asynccontextmanager type yield # unreachable but required for asynccontextmanager type

View file

@ -98,8 +98,7 @@ async def _denying_billable_call(**kwargs):
_CALL_LOG.append(kwargs) _CALL_LOG.append(kwargs)
raise QuotaInsufficientError( raise QuotaInsufficientError(
usage_type=kwargs.get("usage_type", "?"), usage_type=kwargs.get("usage_type", "?"),
used_micros=5_000_000, balance_micros=5_000_000,
limit_micros=5_000_000,
remaining_micros=0, remaining_micros=0,
) )
yield SimpleNamespace() # pragma: no cover yield SimpleNamespace() # pragma: no cover

View file

@ -15,7 +15,9 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import {
MAX_DURATION_SECONDS,
MAX_SPEAKERS, MAX_SPEAKERS,
MIN_DURATION_SECONDS,
type PodcastSpec, type PodcastSpec,
type PodcastStyle, type PodcastStyle,
podcastStyle, podcastStyle,
@ -55,6 +57,9 @@ interface BriefReviewProps {
*/ */
export function BriefReview({ podcast, spec }: BriefReviewProps) { export function BriefReview({ podcast, spec }: BriefReviewProps) {
const [draft, setDraft] = useState<PodcastSpec>(spec); const [draft, setDraft] = useState<PodcastSpec>(spec);
const [durationUnit, setDurationUnit] = useState<DurationUnit>(() =>
defaultDurationUnit(spec.duration.max_seconds),
);
const [voices, setVoices] = useState<VoiceOption[] | null>(null); const [voices, setVoices] = useState<VoiceOption[] | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@ -63,6 +68,7 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
// biome-ignore lint/correctness/useExhaustiveDependencies: reset only when the server version moves // biome-ignore lint/correctness/useExhaustiveDependencies: reset only when the server version moves
useEffect(() => { useEffect(() => {
setDraft(spec); setDraft(spec);
setDurationUnit(defaultDurationUnit(spec.duration.max_seconds));
}, [podcast.specVersion]); }, [podcast.specVersion]);
useEffect(() => { useEffect(() => {
@ -304,39 +310,72 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
))} ))}
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="flex flex-col gap-2">
<div className="flex flex-col gap-2"> <div className="flex items-center justify-between gap-3">
<Label htmlFor="podcast-min-minutes">Min length (minutes)</Label> <Label>Target length</Label>
<Input <Select
id="podcast-min-minutes" value={durationUnit}
type="number" onValueChange={(value) => setDurationUnit(value as DurationUnit)}
min={1} >
value={draft.duration.min_minutes} <SelectTrigger className="w-[7.5rem]" aria-label="Length unit">
onChange={(e) => <SelectValue />
setDraft((current) => ({ </SelectTrigger>
...current, <SelectContent>
duration: { ...current.duration, min_minutes: Number(e.target.value) || 1 }, <SelectItem value="seconds">Seconds</SelectItem>
})) <SelectItem value="minutes">Minutes</SelectItem>
} <SelectItem value="hours">Hours</SelectItem>
/> </SelectContent>
</Select>
</div> </div>
<div className="flex flex-col gap-2"> <div className="grid grid-cols-2 gap-4">
<Label htmlFor="podcast-max-minutes">Max length (minutes)</Label> <div className="flex flex-col gap-2">
<Input <Label htmlFor="podcast-min-length">Min</Label>
id="podcast-max-minutes" <Input
type="number" id="podcast-min-length"
min={draft.duration.min_minutes} type="number"
value={draft.duration.max_minutes} min={durationUnitBounds(durationUnit).min}
onChange={(e) => max={durationUnitBounds(durationUnit).max}
setDraft((current) => ({ step={durationInputStep(durationUnit)}
...current, value={formatDurationForUnit(draft.duration.min_seconds, durationUnit)}
duration: { onChange={(e) => {
...current.duration, const seconds = clampDurationSeconds(
max_minutes: Number(e.target.value) || current.duration.min_minutes, fromUnitValue(Number(e.target.value), durationUnit),
}, );
})) setDraft((current) => ({
} ...current,
/> duration: { ...current.duration, min_seconds: seconds },
}));
}}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="podcast-max-length">Max</Label>
<Input
id="podcast-max-length"
type="number"
min={secondsToUnitValue(draft.duration.min_seconds, durationUnit)}
max={durationUnitBounds(durationUnit).max}
step={durationInputStep(durationUnit)}
value={formatDurationForUnit(draft.duration.max_seconds, durationUnit)}
onChange={(e) => {
const parsed = Number(e.target.value);
const fallback = secondsToUnitValue(
draft.duration.min_seconds,
durationUnit,
);
const seconds = clampDurationSeconds(
fromUnitValue(
Number.isFinite(parsed) ? parsed : fallback,
durationUnit,
),
);
setDraft((current) => ({
...current,
duration: { ...current.duration, max_seconds: seconds },
}));
}}
/>
</div>
</div> </div>
</div> </div>
@ -365,7 +404,9 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
<Button <Button
type="button" type="button"
onClick={handleApprove} onClick={handleApprove}
disabled={isSubmitting || draft.duration.max_minutes < draft.duration.min_minutes} disabled={
isSubmitting || draft.duration.max_seconds < draft.duration.min_seconds
}
> >
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null} {isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
{isDirty ? "Approve changes & draft transcript" : "Approve & draft transcript"} {isDirty ? "Approve changes & draft transcript" : "Approve & draft transcript"}
@ -377,6 +418,50 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
/** The current selection stays listed even when it no longer matches the /** The current selection stays listed even when it no longer matches the
* language filter, so the Select never renders an orphaned value. */ * language filter, so the Select never renders an orphaned value. */
type DurationUnit = "seconds" | "minutes" | "hours";
function defaultDurationUnit(maxSeconds: number): DurationUnit {
if (maxSeconds >= 3600) return "hours";
if (maxSeconds >= 60) return "minutes";
return "seconds";
}
function secondsToUnitValue(seconds: number, unit: DurationUnit): number {
if (unit === "minutes") return seconds / 60;
if (unit === "hours") return seconds / 3600;
return seconds;
}
function fromUnitValue(value: number, unit: DurationUnit): number {
if (!Number.isFinite(value)) return MIN_DURATION_SECONDS;
if (unit === "minutes") return value * 60;
if (unit === "hours") return value * 3600;
return value;
}
function formatDurationForUnit(seconds: number, unit: DurationUnit): number {
const raw = secondsToUnitValue(seconds, unit);
if (unit === "seconds") return Math.round(raw);
return Math.round(raw * 100) / 100;
}
function durationInputStep(unit: DurationUnit): number {
if (unit === "hours") return 0.1;
return 1;
}
function durationUnitBounds(unit: DurationUnit): { min: number; max: number } {
return {
min: formatDurationForUnit(MIN_DURATION_SECONDS, unit),
max: formatDurationForUnit(MAX_DURATION_SECONDS, unit),
};
}
function clampDurationSeconds(value: number): number {
if (!Number.isFinite(value)) return MIN_DURATION_SECONDS;
return Math.min(MAX_DURATION_SECONDS, Math.max(MIN_DURATION_SECONDS, Math.round(value)));
}
function voiceItems(candidates: VoiceOption[], selectedId: string): VoiceOption[] { function voiceItems(candidates: VoiceOption[], selectedId: string): VoiceOption[] {
if (candidates.some((voice) => voice.voice_id === selectedId)) return candidates; if (candidates.some((voice) => voice.voice_id === selectedId)) return candidates;
return [ return [

View file

@ -47,6 +47,11 @@ export type PodcastStyle = z.infer<typeof podcastStyle>;
export const MAX_SPEAKERS = 6; export const MAX_SPEAKERS = 6;
export const MAX_DURATION_SECONDS = 24 * 60 * 60;
export const MIN_DURATION_SECONDS = 15;
export const DEFAULT_MIN_SECONDS = 20;
export const DEFAULT_MAX_SECONDS = 30;
export const speakerSpec = z.object({ export const speakerSpec = z.object({
slot: z.number().int().min(0), slot: z.number().int().min(0),
name: z.string().min(1).max(120), name: z.string().min(1).max(120),
@ -55,10 +60,40 @@ export const speakerSpec = z.object({
}); });
export type SpeakerSpec = z.infer<typeof speakerSpec>; export type SpeakerSpec = z.infer<typeof speakerSpec>;
export const durationTarget = z.object({ export const durationTarget = z.preprocess(
min_minutes: z.number().int().min(1), (raw) => {
max_minutes: z.number().int().min(1), if (
}); raw &&
typeof raw === "object" &&
"min_minutes" in raw &&
!("min_seconds" in raw)
) {
const legacy = raw as { min_minutes: number; max_minutes: number };
return {
min_seconds: legacy.min_minutes * 60,
max_seconds: legacy.max_minutes * 60,
};
}
return raw;
},
z
.object({
min_seconds: z
.number()
.int()
.min(MIN_DURATION_SECONDS)
.max(MAX_DURATION_SECONDS),
max_seconds: z
.number()
.int()
.min(MIN_DURATION_SECONDS)
.max(MAX_DURATION_SECONDS),
})
.refine((duration) => duration.max_seconds >= duration.min_seconds, {
message: "Max length must be at least min length",
path: ["max_seconds"],
}),
);
export type DurationTarget = z.infer<typeof durationTarget>; export type DurationTarget = z.infer<typeof durationTarget>;
export const podcastSpec = z export const podcastSpec = z