Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/api/src/routes/books.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,8 +529,8 @@ describe("POST /books/:label/stages/run", () => {
"X-Gemini-API-Key": "gm-test",
},
body: JSON.stringify({
fromStage: "text-and-speech",
toStage: "text-and-speech",
fromStage: "translation",
toStage: "speech",
}),
})

Expand Down
16 changes: 8 additions & 8 deletions apps/api/src/routes/pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,7 +667,7 @@ describe("Page routes", () => {
}
}

/** Assert that all caption + text-and-speech node data and step_runs were cleared. */
/** Assert that all caption + translation + speech node data and step_runs were cleared. */
function expectAllDownstreamCleared(dir: string, bookLabel: string) {
const s = createBookStorage(bookLabel, dir)
try {
Expand All @@ -687,7 +687,7 @@ describe("Page routes", () => {
}
}

/** Assert that text-and-speech (but NOT image-captioning) node data and step_runs were cleared. */
/** Assert that translation + speech (but NOT image-captioning) node data and step_runs were cleared. */
function expectTextAndSpeechCleared(dir: string, bookLabel: string) {
const s = createBookStorage(bookLabel, dir)
try {
Expand All @@ -709,7 +709,7 @@ describe("Page routes", () => {
}

describe("PUT /api/books/:label/pages/:pageId/sectioning clears downstream", () => {
it("clears caption + text-and-speech data on sectioning save", async () => {
it("clears caption + translation + speech data on sectioning save", async () => {
seedDownstreamData(tmpDir, label)

const data = {
Expand Down Expand Up @@ -746,7 +746,7 @@ describe("Page routes", () => {
})

describe("PUT /api/books/:label/pages/:pageId/rendering clears downstream", () => {
it("clears caption + text-and-speech data on rendering save", async () => {
it("clears caption + translation + speech data on rendering save", async () => {
seedDownstreamData(tmpDir, label)

const data = {
Expand All @@ -773,7 +773,7 @@ describe("Page routes", () => {
})

describe("POST clone clears downstream", () => {
it("clears caption + text-and-speech data on section clone", async () => {
it("clears caption + translation + speech data on section clone", async () => {
seedDownstreamData(tmpDir, label)

const res = await app.request(
Expand All @@ -787,7 +787,7 @@ describe("Page routes", () => {
})

describe("POST delete clears downstream", () => {
it("clears caption + text-and-speech data on section delete", async () => {
it("clears caption + translation + speech data on section delete", async () => {
// Need at least 2 sections so delete is valid
const s = createBookStorage(label, tmpDir)
try {
Expand Down Expand Up @@ -837,7 +837,7 @@ describe("Page routes", () => {
})

describe("POST crop (images) clears downstream", () => {
it("clears caption + text-and-speech data on image crop", async () => {
it("clears caption + translation + speech data on image crop", async () => {
seedDownstreamData(tmpDir, label)

// Minimal valid PNG (1x1 pixel)
Expand Down Expand Up @@ -865,7 +865,7 @@ describe("Page routes", () => {
})
})

describe("PUT image-captioning clears text-and-speech downstream", () => {
describe("PUT image-captioning clears translation + speech downstream", () => {
it("clears text-catalog/translations/TTS but keeps image-captioning", async () => {
seedDownstreamData(tmpDir, label)

Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/routes/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ async function executeAiImageGeneration(params: AiImageGenParams): Promise<{
}
}

/** Clear caption + downstream text-and-speech data when images change. */
/** Clear caption + downstream translation + speech data when images change. */
function clearCaptionData(storage: Storage): void {
storage.clearNodesByType(["image-captioning", "text-catalog", "text-catalog-translation", "tts"])
storage.clearStepRuns(["image-captioning", "text-catalog", "catalog-translation", "tts"])
Expand Down
37 changes: 37 additions & 0 deletions apps/api/src/routes/stages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,43 @@ export function createStageRoutes(
return c.json({ status: result.status, label, fromStage, toStage })
})

// DELETE /books/:label/stages/:stageName — Clear a stage's data and step runs
app.delete("/books/:label/stages/:stageName", (c) => {
const { label, stageName } = c.req.param()

let safeLabel: string
try {
safeLabel = parseBookLabel(label)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
throw new HTTPException(400, { message })
}

const parsed = StageName.safeParse(stageName)
if (!parsed.success) {
throw new HTTPException(400, { message: `Invalid stage name: ${stageName}` })
}

const stage = parsed.data
const storage = createBookStorage(safeLabel, booksDir)
try {
const nodes = getStageClearNodes(stage)
if (nodes.length > 0) {
storage.clearNodesByType(nodes)
}
const stagesToClear = getStageClearOrder(stage)
const stepsToClear = PIPELINE
.filter((s) => stagesToClear.includes(s.name))
.flatMap((s) => s.steps.map((step) => step.name))
storage.clearStepRuns(stepsToClear)
} finally {
storage.close()
}

console.log(`[stages] ${label}: cleared stage ${stage}`)
return c.json({ ok: true, stage })
})

// GET /books/:label/step-status — Unified stage + step status
// DB step_runs is the single source of truth for step/stage state.
// Only "queued" comes from the in-memory run queue.
Expand Down
16 changes: 11 additions & 5 deletions apps/api/src/services/stage-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,12 @@ function seedTextAndSpeechBook(booksDir: string, label: string): void {
},
],
})

// Pre-seed text catalog so the speech stage can read it
storage.putNodeData("text-catalog", "book", {
entries: [{ id: "pg001_t001", text: "Hello world" }],
generatedAt: new Date().toISOString(),
})
} finally {
storage.close()
}
Expand Down Expand Up @@ -351,7 +357,7 @@ describe("createStageRunner storyboard render-only", () => {
})
})

describe("createStageRunner text-and-speech Gemini partial failures", () => {
describe("createStageRunner speech Gemini partial failures", () => {
let tmpDir = ""

beforeEach(() => {
Expand Down Expand Up @@ -402,8 +408,8 @@ speech:
geminiApiKey: "gm-test",
promptsDir,
configPath,
fromStage: "text-and-speech",
toStage: "text-and-speech",
fromStage: "speech",
toStage: "speech",
},
{ emit: (event) => events.push(event) }
)
Expand Down Expand Up @@ -472,8 +478,8 @@ speech:
geminiApiKey: "gm-test",
promptsDir,
configPath,
fromStage: "text-and-speech",
toStage: "text-and-speech",
fromStage: "speech",
toStage: "speech",
},
{ emit: (event) => events.push(event) }
)
Expand Down
70 changes: 60 additions & 10 deletions apps/api/src/services/stage-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,8 @@ const STAGE_RUNNERS: Record<StageName, RunFn> = {
"captions": runCaptionsStep,
"glossary": runGlossaryStep,
"toc": runTocStep,
"text-and-speech": runTextAndSpeechStep,
"translation": runTranslationStep,
"speech": runSpeechStep,
"package": async () => { /* packaging handled separately */ },
}

Expand Down Expand Up @@ -1350,7 +1351,7 @@ async function runTocStep(
// Text & Speech stage (text catalog + catalog translation + TTS)
// ---------------------------------------------------------------------------

async function runTextAndSpeechStep(
async function runTranslationStep(
label: string,
options: StageRunOptions,
progress: StageRunProgress
Expand All @@ -1365,15 +1366,11 @@ async function runTextAndSpeechStep(
try {
const config = loadBookConfig(label, booksDir, configPath)
const cacheDir = path.join(path.resolve(booksDir), label, ".cache")
const bookDir = path.join(path.resolve(booksDir), label)
const bookPromptsDir = path.join(path.resolve(booksDir), label, "prompts")
const promptEngine = createPromptEngine([bookPromptsDir, promptsDir])
const rateLimiter = config.rate_limit
? createRateLimiter(config.rate_limit.requests_per_minute)
: undefined
const configDir = configPath
? path.join(path.dirname(configPath), "config")
: path.resolve(process.cwd(), "config")

// Get book language from metadata
const metadataRow = storage.getLatestNodeData("metadata", "book")
Expand Down Expand Up @@ -1514,8 +1511,61 @@ async function runTextAndSpeechStep(
progress.emit({ type: "step-complete", step: "catalog-translation" })
console.log(`[stage-run] ${label}: catalog translation complete`)
}
} finally {
storage.close()
if (previousKey !== undefined) {
process.env.OPENAI_API_KEY = previousKey
} else {
delete process.env.OPENAI_API_KEY
}
}
}

async function runSpeechStep(
label: string,
options: StageRunOptions,
progress: StageRunProgress
): Promise<void> {
const { booksDir, apiKey, configPath } = options

const previousKey = process.env.OPENAI_API_KEY
process.env.OPENAI_API_KEY = apiKey

const storage = createBookStorage(label, booksDir)

try {
const config = loadBookConfig(label, booksDir, configPath)
const cacheDir = path.join(path.resolve(booksDir), label, ".cache")
const bookDir = path.join(path.resolve(booksDir), label)
const configDir = configPath
? path.join(path.dirname(configPath), "config")
: path.resolve(process.cwd(), "config")

// Get book language from metadata
const metadataRow = storage.getLatestNodeData("metadata", "book")
const metadata = metadataRow?.data as { language_code?: string | null } | null
const language = normalizeLocale(config.editing_language ?? metadata?.language_code ?? "en")

const effectiveConcurrency = config.concurrency ?? 32

// Output languages default to editing language if not set
const outputLanguages = Array.from(
new Set(
(config.output_languages && config.output_languages.length > 0
? config.output_languages
: [language]).map((code) => normalizeLocale(code))
)
)

// Load text catalog from storage (produced by the translation stage)
const catalogRow = storage.getLatestNodeData("text-catalog", "book")
if (!catalogRow) {
progress.emit({ type: "step-skip", step: "tts" })
console.log(`[stage-run] ${label}: TTS skipped (no text catalog)`)
return
}
const catalog = catalogRow.data as TextCatalogOutput

// ── Step 3: Generate TTS ────────────────────────────────────────
if (catalog.entries.length === 0) {
progress.emit({ type: "step-skip", step: "tts" })
console.log(`[stage-run] ${label}: TTS skipped (empty catalog)`)
Expand Down Expand Up @@ -1805,18 +1855,18 @@ async function runTextAndSpeechStep(
}

if (geminiFailedItems.length > 0) {
const summary = `${geminiFailedItems.length} Gemini TTS item(s) failed. Missing Gemini audio can be generated one by one from the Text & Speech view.`
const summary = `${geminiFailedItems.length} Gemini TTS item(s) failed. Missing Gemini audio can be generated one by one from the Speech view.`
progress.emit({
type: "step-error",
step: "tts",
error: summary,
})
console.log(`[stage-run] ${label}: text & speech completed with Gemini TTS gaps`)
console.log(`[stage-run] ${label}: speech completed with Gemini TTS gaps`)
return
}

progress.emit({ type: "step-complete", step: "tts" })
console.log(`[stage-run] ${label}: text & speech complete`)
console.log(`[stage-run] ${label}: speech complete`)
} finally {
storage.close()
if (previousKey !== undefined) {
Expand Down
3 changes: 3 additions & 0 deletions apps/studio/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,9 @@ export const api = {
deleteBook: (label: string) =>
request<{ ok: boolean }>(`/books/${label}`, { method: "DELETE" }),

clearStage: (label: string, stageName: string) =>
request<{ ok: boolean; stage: string }>(`/books/${label}/stages/${stageName}`, { method: "DELETE" }),

runStages: (
label: string,
apiKey: string,
Expand Down
Loading