Architecture
This section describes how nr_repurpose turns one source into its artifacts, and how it composes nr-llm's capabilities with a local rendering toolchain.
Pipeline overview
A job moves through four stages. Submission only enqueues work; everything below the dashed line runs in the worker process:
Backend "New job" form / CLI
│ persist Job, dispatch GenerateArtifactsMessage(jobUid)
▼
┌─────────────────────────── Symfony Messenger (doctrine) ───────────────────────────┐
│ worker: messenger:consume │
│ │
│ 1. INGEST SourceIngestionService URL fetch | tiered PDF read → SourceDocument│
│ 2. ANALYZE DocumentAnalyzer (nr-llm CompletionService, JSON) → ContentBrief │
│ 3. GENERATE PodcastGenerator | SchaubildGenerator | StoryGenerator │
│ ExecutiveSummary | Faq | SocialPost | Newsletter (text formats) │
│ └─ nr-llm completion / TTS / image + local render → PNG/MP3/VTT │
│ 4. STORE JobFileStorage → FAL (repurpose/ folder), Artifact rows updated │
│ │
│ status: queued → ingesting → analyzing → generating → done|partially_done|failed │
└─────────────────────────────────────────────────────────────────────────────────────┘
The orchestrator (Generation) drives the worker
side. It is idempotent — a job already in a terminal status is never reprocessed
— and it aborts the whole run if ingestion or analysis fails, but isolates
per-artifact failures in the generation stage.
Submission and the async boundary
The backend create action and the Job persist the
Job Extbase entity, flush persistence to obtain its uid, and dispatch an
immutable Generate carrying only the job uid — the
worker re-reads all inputs from the database, so the message stays minimal and
the job row is the single source of truth.
The message is routed to the doctrine transport (see
Messenger routing). Because TYPO3 v14.3 Core ships no retry/failure
transport, the Generate catches any throwable from the
orchestrator, marks the job failed, and does not rethrow — a crash is recorded
on the job rather than lost. See ADR-001: Asynchronous Generation via Symfony Messenger.
Stage 1 — Ingestion
Source is the single entry point and dispatches on the
job's source_:
- URL —
Webfetches the page over a PSR-18 client, strips boilerplate node types (Page Fetcher script,nav,footer, …) and HTML comments, then keeps the densest of<article>/<main>(falling back to<body>) and collapses whitespace. -
PDF (URL or FAL) —
Pdfresolves a local absolute path, then each page is read through a per-page tier dispatcher honouringFile Resolver pdf_:mode Mode Per-page behaviour autoTier 1 embedded text; a sparse page falls back to Tier 2 Vision OCR; a page that looks tabular uses Tier 3 layout extraction. textTier 1 (embedded text) for every page. visionTier 2 (Vision OCR via nr-llm) for every page. tablesTier 3 (poppler layout extraction) for every page.
Both branches produce a Source value object (title, text, source
label, page count, language hint, meta). An empty result raises an
Ingestion, which aborts the job before any artifact is created.
Stage 2 — Understanding
Document produces exactly one Content from the
Source using nr-llm's Completion in JSON mode. The
brief holds the title, summary, key points, sections, audience, and the detected
source language (an ISO-639-1 code returned by the model, with the document
language hint as fallback). The detected language is recorded on the job for the
result view, and every downstream generator writes its copy in that language.
For large documents (above the analyzer's chunk threshold, 24 000 characters by default) the analyzer uses map-reduce: it splits the text on paragraph boundaries into chunks, summarizes each chunk (map), and synthesizes one brief from the concatenated summaries (reduce), to stay within provider token limits. Each completion call carries the backend-user uid so nr-llm's budget middleware can enforce the user's budget.
Stage 3 — Generation
The orchestrator collects the generators tagged nr_
(podcast, Schaubild, story and the four text formats), filters them by
supports against the job's
want_* flags, and runs each one with a shared per-run Generation
(the job row typed once as a Job, document, brief, theme, backend
user). A generator records its own
artifact rows and returns a boolean; one failing generator never aborts the
others. The final job status is done (all succeeded), partially_
(some), or failed (none). All generators extend Abstract,
which provides Fluid theme rendering (v14 ViewFactory), per-run temp
directories, the failed-artifact helper, and the specialized-call budget guard.
The cost paths, the cost attribution and each generator are described on their own page:
Rendering toolchain
Three render primitives sit behind interfaces (so they are swappable and
testable) and shell out through a Process (Symfony
Process):
- HTML → PNG —
Playwrightdrives the bundledHtml To Image Renderer render.(cjs playwright-+ the aptcore chromiumbinary). HTML is fed on stdin (avoiding argv limits and shell quoting);render.reads the Chromium path fromcjs CHROMIUM_, which the renderer passes to the child process from itsPATH $chromiumargument (see Renderer environment).Path height=nullrenders full-page/auto-height (the diagram); a fixed height clips to the viewport (the story).transparentusesomitfor the overlay layers.Background - Compositing —
Gdoverlays a transparent foreground PNG (the exact text/label layer) onto a background PNG (the AI image) using GD (Imagick is not in the stack). The foreground defines the output canvas; the background is requested at a layout-matching size where the image model allows it (Image Compositor gpt-accepts arbitrary dimensions within its aspect limits), but whenever the aspect ratios still differ it is scaled to cover (centre-cropped, no distortion), then the foreground is alpha-composited on top so transparent areas reveal the background.image- 2 - Audio —
Ffmpeg(concat demuxer +Audio Stitcher ffprobefor durations), described above.
See ADR-002: Node + Playwright Renderer for Image Composition for why image composition runs through a Node + Playwright renderer rather than a PHP imaging library.
Stage 4 — Storage and result
Generated bytes are written to the default FAL storage under a repurpose/
folder by Job, which returns a sys_; the artifact rows
reference files by sys_ uid (audio as file_, subtitles as
subtitle_). The backend result view reads these references to play
the podcast and display every image. Because each artifact tracks its own status
and FAL references, a partially successful run is fully renderable.
The bytes are in the file before FAL indexes it
(Resource from a temporary file), so a listener of the
metadata record FAL creates at that moment, such as an alt-text generator, reads
the finished file. Stored files pass the same extension and MIME-type check as an
upload; the extension adds vtt to
$GLOBALS for the podcast
subtitles, because TYPO3 14 enforces that list on a new installation and does
not contain it.
How nr-llm capabilities are composed
nr_repurpose owns no provider code. It depends on three nr-llm surfaces:
Completion— the brief, the podcast script, the diagram body, the story copy and the text formats (JSON, schema-validated JSON or Markdown responses, budget-middleware guarded).Service Text— wrapped byTo Speech Service Openbehind a localAi Speech Synthesizer Speech(model resolved through theSynthesizer Interface nr_nr-llm Configuration, fallbackrepurpose_ tts tts-).1 Dall— wrapped byEImage Service Dallbehind a localEImage Generator Image(model resolved through theGenerator Interface nr_nr-llm Configuration, fallbackrepurpose_ image gpt-).image- 2
The two specialized wrappers are thin adapters: they expose is
and a single …To method, and translate nr-llm exceptions into the
extension's Rendering. This keeps the generators independent of
nr-llm's concrete service shapes, lets unit tests substitute fakes, and is the
seam for additional image/speech backends (the DI aliases live in
Configuration/). The provider keys behind all of these are
resolved by nr-llm from an identifier (see ADR-003: Provider Credentials Delegated to nr-llm).