diff --git a/CHANGELOG.md b/CHANGELOG.md index 76987a6..560ae88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ Everything below is opt-in. Notes for existing consumers: - `StepError` messages are richer (multi-line, with artifact paths). If you parsed them, prefer the new structured `error.artifacts` field. - The timing manifest gained optional fields (`lang`, `capture`). Old kept work dirs still post-process fine. +## Unreleased + +- **`render --phase record` runs without a prior `tts` phase (#50).** The TTS-free framing check — `--phase record --contact-sheet`, used to verify selectors and framing across a whole tutorial before paying for narration — no longer throws `run the tts phase first` on a fresh work dir. When `tts.json` is absent it falls back to silent placeholder timings (steps pace as silent), so you get a contact sheet at zero TTS cost. `--phase post` still requires real timings. New exports: `silentTTSResult`, `loadTTSResultIfPresent`. + ## 0.12.0 — chapters that work on YouTube & Vimeo - **Chapters now activate on YouTube & Vimeo (#52).** The emitted chapter artifacts respect each platform's activation rules so they don't silently fail on upload. The YouTube `.chapters.txt` list folds any sub-10s chapter into its neighbor — YouTube ignores the *entire* description chapter list if a single chapter is under 10s. Chapter titles are now capped at **50 characters** (Vimeo's limit, the strictest common target) instead of 60 — a behavior change to the default title cap (`ChapterOptions.maxTitleChars` / `deriveChapterTitle`), so titles of 51–60 chars that previously survived are now truncated. The MP4 chapter track and the `.chapters.vtt` (Vimeo/web) keep the full per-step list. New exports: `enforceMinChapterDuration`, `YOUTUBE_MIN_CHAPTER_MS`. Docs gained a "Getting chapters onto YouTube and Vimeo" section. No breaking signature changes. diff --git a/docs/writing-tutorials.md b/docs/writing-tutorials.md index 8a7b3f3..88ed9ba 100644 --- a/docs/writing-tutorials.md +++ b/docs/writing-tutorials.md @@ -190,6 +190,8 @@ tutorial-forge preview set-status --only my-tutorial **`tutorial-forge render --contact-sheet`** keeps a settled screenshot per step and emits a labeled grid PNG next to the video (`-contact-sheet.png`), one thumbnail per step tagged with its id and narration. Scan it to confirm every step framed the right thing at a glance, instead of scrubbing the video. Enable it persistently with `contactSheet: true` in `forge.config.ts`. If a step fails mid-render, you still get a **partial** sheet of the steps that completed plus the failure frame as the last cell — the at-a-glance view of how the run got to the failure. +Pair it with `--phase record` for a **TTS-free** whole-tutorial check: `tutorial-forge render --only --phase record --contact-sheet` drives the browser through every step and emits the sheet without synthesizing any narration (steps pace as silent when no TTS has run), so you can validate selectors and framing across the whole tutorial before spending on a real voice. `preview ` is the faster single-step loop; the contact sheet is the whole-tutorial version. + ## Timing manifest Every render writes `manifest.json` describing the full timeline (per-step start/end, action windows, audio durations, callout boxes). It is the contract between the record and post phases and a debugging gold mine — run with `--keep-work` to keep it on success. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 43e9563..ef8e31c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -19,7 +19,7 @@ export { localizeTutorial, availableLanguages, OBJECTIVES_KEY, SUMMARY_KEY } fro export { defineConfig, validateConfig, type ForgeConfig } from './config.js'; export { render, type RenderResult } from './pipeline/render.js'; -export { runTTSPhase, loadTTSResult } from './pipeline/tts.js'; +export { runTTSPhase, loadTTSResult, loadTTSResultIfPresent, silentTTSResult } from './pipeline/tts.js'; export { runRecordPhase, loadManifest, RAW_VIDEO_FILE, MANIFEST_FILE } from './pipeline/record.js'; export { createRecorder, diff --git a/packages/core/src/pipeline/render.ts b/packages/core/src/pipeline/render.ts index b44939e..3da4834 100644 --- a/packages/core/src/pipeline/render.ts +++ b/packages/core/src/pipeline/render.ts @@ -3,7 +3,7 @@ import type { RenderOptions, TimingManifest, Tutorial, TutorialAdapter } from '. import { StepError } from '../types.js'; import { validateTutorial } from '../spec.js'; import { localizeTutorial } from '../i18n.js'; -import { runTTSPhase, loadTTSResult } from './tts.js'; +import { runTTSPhase, loadTTSResult, loadTTSResultIfPresent, silentTTSResult } from './tts.js'; import { runRecordPhase, loadManifest } from './record.js'; import { runPostPhase, type PostPhaseResult } from './post.js'; import { renderContactSheet, contactSheetEntries, contactSheetPath } from './contact-sheet.js'; @@ -53,7 +53,13 @@ export async function render( cacheDir: options.ttsCacheDir ?? defaultCacheDir(), concurrency: options.ttsConcurrency ?? 4, }) - : await loadTTSResult(workDir); + : phase === 'record' + ? // A standalone `--phase record` (the TTS-free framing check) falls back + // to placeholder timings when no tts.json exists yet — paces steps as + // silent rather than throwing (#50). `--phase post` still needs real + // timings, so it stays strict below. + ((await loadTTSResultIfPresent(workDir)) ?? silentTTSResult(tutorial)) + : await loadTTSResult(workDir); if (phase === 'tts') { return partialResult(workDir, output, await safeLoadManifest(workDir, tutorial.id)); } diff --git a/packages/core/src/pipeline/tts.ts b/packages/core/src/pipeline/tts.ts index 9cd26d8..f4c7917 100644 --- a/packages/core/src/pipeline/tts.ts +++ b/packages/core/src/pipeline/tts.ts @@ -46,13 +46,43 @@ export async function runTTSPhase( return result; } -/** Load a previous run's tts.json (for `--phase record`/`--phase post`). */ -export async function loadTTSResult(workDir: string): Promise { +/** + * Load a previous run's tts.json, or `null` if it doesn't exist yet. A corrupt + * or unreadable file still throws — only a *missing* file is a clean null, which + * lets `--phase record` fall back to placeholder timings (see {@link silentTTSResult}). + */ +export async function loadTTSResultIfPresent(workDir: string): Promise { try { return JSON.parse(await readFile(join(workDir, TTS_RESULT_FILE), 'utf8')) as TTSPhaseResult; } catch (err) { - throw new Error( - `No ${TTS_RESULT_FILE} in ${workDir} — run the tts phase first (cause: ${err instanceof Error ? err.message : err})`, - ); + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw err; + } +} + +/** Load a previous run's tts.json (for `--phase post`); throws if it's absent. */ +export async function loadTTSResult(workDir: string): Promise { + const result = await loadTTSResultIfPresent(workDir); + if (!result) { + throw new Error(`No ${TTS_RESULT_FILE} in ${workDir} — run the tts phase first`); } + return result; +} + +/** + * Per-step placeholder timings with no audio (null file, 0 ms) — the same shape + * {@link runTTSPhase} emits for silent steps, applied to every step. Lets + * `--phase record` run a TTS-free framing check (e.g. `--contact-sheet`) without + * a prior tts phase: steps are paced as silent, so the record reaches each + * step's state without synthesizing any narration (#50). + */ +export function silentTTSResult(tutorial: Tutorial): TTSPhaseResult { + return { + steps: tutorial.steps.map((step, i) => ({ + id: stepId(step, i), + narration: step.narration, + audioFile: null, + audioDurationMs: 0, + })), + }; } diff --git a/packages/core/test/tts.test.ts b/packages/core/test/tts.test.ts new file mode 100644 index 0000000..fd526d2 --- /dev/null +++ b/packages/core/test/tts.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { tutorial, step } from '../src/spec.js'; +import { silentTTSResult, loadTTSResultIfPresent, loadTTSResult } from '../src/pipeline/tts.js'; + +const noop = async () => {}; +const freshDir = () => mkdtempSync(join(tmpdir(), 'tf-tts-')); + +describe('silentTTSResult (#50)', () => { + it('maps every step to null audio / 0 ms, preserving narration and step ids', () => { + const t = tutorial('Demo', [step('First line.', noop), step('Second line.', noop)]); + expect(silentTTSResult(t).steps).toEqual([ + { id: 'step-01', narration: 'First line.', audioFile: null, audioDurationMs: 0 }, + { id: 'step-02', narration: 'Second line.', audioFile: null, audioDurationMs: 0 }, + ]); + }); +}); + +describe('loadTTSResultIfPresent (#50)', () => { + it('returns null when tts.json is absent (lets --phase record fall back to silent timings)', async () => { + expect(await loadTTSResultIfPresent(freshDir())).toBeNull(); + }); + + it('returns the parsed result when tts.json is present', async () => { + const dir = freshDir(); + const data = { steps: [{ id: 'step-01', narration: 'x', audioFile: null, audioDurationMs: 0 }] }; + writeFileSync(join(dir, 'tts.json'), JSON.stringify(data)); + expect(await loadTTSResultIfPresent(dir)).toEqual(data); + }); + + it('throws on a corrupt tts.json — a present-but-unreadable file is not the same as absent', async () => { + const dir = freshDir(); + writeFileSync(join(dir, 'tts.json'), '{ not valid json'); + await expect(loadTTSResultIfPresent(dir)).rejects.toThrow(); + }); +}); + +describe('loadTTSResult', () => { + it('throws a clear "run the tts phase first" error when tts.json is absent (used by --phase post)', async () => { + await expect(loadTTSResult(freshDir())).rejects.toThrow(/run the tts phase first/); + }); +}); diff --git a/packages/example-app/test/e2e.ts b/packages/example-app/test/e2e.ts index c4e36f7..204cdd5 100644 --- a/packages/example-app/test/e2e.ts +++ b/packages/example-app/test/e2e.ts @@ -136,6 +136,30 @@ try { console.log(`\ne2e OK: ${output} (${(actualMs / 1000).toFixed(1)}s, offset ${result.videoClockOffsetMs}ms, ${narratedSteps} cues)`); + // #50 — `render --phase record` is the TTS-free framing check. On a *fresh* + // work dir (no prior tts.json) it must fall back to silent placeholder timings + // and still emit a contact sheet, instead of throwing "run the tts phase first". + { + const recordOnlyDir = join(outDir, 'work-record-only'); + const recordOnly = await render(gettingStarted, adapter, { + tts: SilentProvider(), + output: join(outDir, 'record-only.mp4'), + workDir: recordOnlyDir, + keepWorkDir: true, + phase: 'record', + contactSheet: true, + }); + assert.ok( + recordOnly.contactSheetPath && existsSync(recordOnly.contactSheetPath), + 'phase=record produced a contact sheet with no prior tts phase', + ); + assert.ok( + !existsSync(join(recordOnlyDir, 'tts.json')), + 'record-only phase synthesized no TTS (no tts.json written)', + ); + console.log('e2e OK [phase-record-no-tts]: TTS-free contact sheet on a fresh work dir (#50)'); + } + // Localized render: load the Spanish sidecar the way the CLI would and // verify the pipeline produces a Spanish video + subtitles. gettingStarted.translations = {