Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Everything below is opt-in. Notes for existing consumers:

- **`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`.
- **Broader recap-framing lint (#49).** The strict-mode lint that nudges the last step to close with a recap (#36) no longer false-warns on valid past-tense / accomplishment recaps (e.g. "you created an event, set up ticketing… from here you can…"). `RECAP_CUE_RE` now also matches accomplishment phrasing and the "from here you…" hand-off, bringing it to parity with the looser objective cue. Advisory-only — it never fails a render.
- **Parallel batch rendering (#62).** Render multiple tutorials × languages concurrently with `--render-concurrency <n>` (or `renderConcurrency` in config); default **1** (serial, unchanged). The record phase is mostly real-time waiting, so a batch (e.g. regenerating a whole tutorial set) runs much faster in parallel — near-linear up to CPU/RAM. **Opt-in because it requires a parallel-safe adapter** (concurrent renders each run their own `setup`/`teardown`, so a shared seed DB must isolate per render — see [adapters.md → Parallel rendering](docs/adapters.md#parallel-rendering)). New export: `mapLimit`.
- **Parallel batch rendering (#62).** Render multiple tutorials × languages concurrently with `--render-concurrency <n>` (or `renderConcurrency` in config); default **1** (serial, unchanged). The record phase is mostly real-time waiting, so a batch (e.g. regenerating a whole tutorial set) runs much faster in parallel — near-linear up to CPU/RAM. **Opt-in because it requires a parallel-safe adapter** (concurrent renders each run their own `setup`/`teardown`, so a shared seed DB must isolate per render — see [adapters.md → Parallel rendering](docs/adapters.md#parallel-rendering)). New export: `mapLimit`. As a backstop, `buildRenderJobs` rejects any two jobs that would resolve to the same work dir / output with a clear error (#65) — though tutorial ids are already slug-validated (no dots), which prevents the cross-tutorial case through the normal CLI path.

## 0.12.0 — chapters that work on YouTube & Vimeo

Expand Down
35 changes: 33 additions & 2 deletions packages/cli/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,19 +65,50 @@ export function resolveRenderConcurrency(
return Number.isFinite(raw) && (raw as number) >= 1 ? Math.floor(raw as number) : 1;
}

/** Work dir / output key for a job: `<id>` or `<id>.<lang>`. Two jobs that share it write the same paths. */
function jobPathKey(id: string, lang: string | null): string {
return lang ? `${id}.${lang}` : id;
}

/**
* Flatten discovered tutorials × languages into a flat render-job list, in
* tutorial-major order. Languages are de-duplicated first: two jobs with the
* same `(id, lang)` would target the same `.forge/<id><suffix>` work dir and
* `<id><suffix>.mp4` output, which under `--render-concurrency > 1` means two
* renders writing the same paths at once (e.g. `--lang "es,es"`).
*
* Also throws on the rarer cross-tutorial collision (#65): distinct ids whose
* id+language suffixes coincide (e.g. a tutorial named `setup.es` and a tutorial
* `setup` rendered in `es` both resolve to `setup.es`). This is a **backstop** —
* `validateTutorial` already forbids dots in ids (`SLUG_RE`) and `discoverTutorials`
* rejects duplicate ids, so it can't occur through the normal CLI path today. It
* guards against a future relaxed slug rule or a caller that skips validation,
* where the collision would mean simultaneous corruption under concurrency rather
* than a benign sequential overwrite.
*/
export function buildRenderJobs<T>(
export function buildRenderJobs<T extends { id: string }>(
discovered: Array<{ tutorial: T }>,
langs: Array<string | null>,
): Array<{ tutorial: T; lang: string | null }> {
const uniqueLangs = [...new Set(langs)];
return discovered.flatMap(({ tutorial }) => uniqueLangs.map((lang) => ({ tutorial, lang })));
const jobs = discovered.flatMap(({ tutorial }) => uniqueLangs.map((lang) => ({ tutorial, lang })));

const byKey = new Map<string, { tutorial: T; lang: string | null }>();
for (const job of jobs) {
const key = jobPathKey(job.tutorial.id, job.lang);
const prior = byKey.get(key);
if (prior) {
const desc = (j: { tutorial: T; lang: string | null }) =>
`"${j.tutorial.id}" (${j.lang ? `lang ${j.lang}` : 'source language'})`;
throw new Error(
`Render job collision: ${desc(prior)} and ${desc(job)} both resolve to "${key}" — ` +
`same work dir (.forge/${key}) and output (${key}.mp4). Rename one tutorial so its ` +
`id and language suffix don't coincide with another's.`,
);
}
byKey.set(key, job);
}
return jobs;
}

export async function renderCommand(globs: string[], opts: RenderCmdOptions): Promise<void> {
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/test/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,20 @@ describe('buildRenderJobs (#62)', () => {
it('produces exactly one job for a single tutorial rendered in the source language', () => {
expect(buildRenderJobs([{ tutorial: { id: 'a' } }], [null])).toHaveLength(1);
});

it('backstop: throws if two jobs would resolve to the same path (#65)', () => {
// `setup` rendered in `es` → "setup.es"; `setup.es` rendered source → also "setup.es".
// Note: a dotted id can't reach here via the CLI (validateTutorial/SLUG_RE
// forbids it — see spec.test.ts); these literals bypass that to exercise the
// backstop directly.
expect(() =>
buildRenderJobs([{ tutorial: { id: 'setup' } }, { tutorial: { id: 'setup.es' } }], [null, 'es']),
).toThrow(/collision.*setup\.es/i);
});

it('does not flag legitimately distinct id+suffix combinations', () => {
expect(() =>
buildRenderJobs([{ tutorial: { id: 'setup' } }, { tutorial: { id: 'teardown' } }], ['es', 'fr']),
).not.toThrow();
});
});
6 changes: 6 additions & 0 deletions packages/core/test/spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ describe('tutorial()', () => {
expect(() => tutorial('x', [step('a', noop)], { id: 'Has Spaces' })).toThrow(/lowercase slug/);
});

it('rejects ids with dots — the first line of defense against render path collisions (#65)', () => {
// A dotted id like `setup.es` would otherwise collide with `setup` rendered
// in `es` (both → .forge/setup.es); the slug rule forbids it up front.
expect(() => tutorial('x', [step('a', noop)], { id: 'setup.es' })).toThrow(/lowercase slug/);
});

it('rejects a non-function focus with the index in the message', () => {
expect(() =>
tutorial('x', [{ narration: 'a', run: noop, focus: 'nope' as unknown as () => never }]),
Expand Down
Loading