---
title: "Architecture"
manual: "nr_repurpose"
version: "main"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-repurpose:architecture@main"
source: "Architecture/Index.rst"
rendered: "2026-09-30T16:39:51+00:00"
---

# Architecture {#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](https://docs.typo3.org/permalink/netresearch/nr-repurpose:pipeline-overview@main)
-   [Submission and the async boundary](https://docs.typo3.org/permalink/netresearch/nr-repurpose:submission-and-the-async-boundary@main)
-   [Stage 1 — Ingestion](https://docs.typo3.org/permalink/netresearch/nr-repurpose:stage-1-ingestion@main)
-   [Stage 2 — Understanding](https://docs.typo3.org/permalink/netresearch/nr-repurpose:stage-2-understanding@main)
-   [Stage 3 — Generation](https://docs.typo3.org/permalink/netresearch/nr-repurpose:stage-3-generation@main)
-   [Rendering toolchain](https://docs.typo3.org/permalink/netresearch/nr-repurpose:rendering-toolchain@main)
-   [Stage 4 — Storage and result](https://docs.typo3.org/permalink/netresearch/nr-repurpose:stage-4-storage-and-result@main)
-   [How nr-llm capabilities are composed](https://docs.typo3.org/permalink/netresearch/nr-repurpose:how-nr-llm-capabilities-are-composed@main)

## Pipeline overview {#architecture-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 (`GenerationOrchestrator::process()`) 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 {#architecture-submission}

The backend `create` action and the `JobSubmissionService` persist the
`Job` Extbase entity, flush persistence to obtain its uid, and dispatch an
immutable `GenerateArtifactsMessage` 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](https://docs.typo3.org/permalink/netresearch/nr-repurpose:configuration-messenger@main)). Because TYPO3 v14.3 Core ships no retry/failure
transport, the `GenerateArtifactsHandler` 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](https://docs.typo3.org/permalink/netresearch/nr-repurpose:adr-001@main).

## Stage 1 — Ingestion {#architecture-ingestion}

`SourceIngestionService` is the single entry point and dispatches on the
job's `source_type`:

-   **URL** — `WebPageFetcher` fetches the page over a PSR-18 client,
    strips boilerplate node types (`script`, `nav`, `footer`, …) and HTML
    comments, then keeps the densest of `<article>` / `<main>` (falling back
    to `<body>`) and collapses whitespace.
-   **PDF** (URL or FAL) — `PdfFileResolver` resolves a local absolute
    path, then each page is read through a per-page tier dispatcher honouring
    `pdf_mode`:

    | Mode | Per-page behaviour |
    | --- | --- |
    | `auto` | Tier 1 embedded text; a sparse page falls back to Tier 2 Vision OCR; a page that looks tabular uses Tier 3 layout extraction. |
    | `text` | Tier 1 (embedded text) for every page. |
    | `vision` | Tier 2 (Vision OCR via nr-llm) for every page. |
    | `tables` | Tier 3 (poppler layout extraction) for every page. |

Both branches produce a `SourceDocument` value object (title, text, source
label, page count, language hint, meta). An empty result raises an
`IngestionException`, which aborts the job before any artifact is created.

## Stage 2 — Understanding {#architecture-analysis}

`DocumentAnalyzer` produces exactly one `ContentBrief` from the
`SourceDocument` using nr-llm's `CompletionService` 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 {#architecture-generation}

The orchestrator collects the generators tagged `nr_repurpose.artifact_generator`
(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 `GenerationContext`
(the job row typed once as a `JobSnapshot`, 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_done`
(some), or `failed` (none). All generators extend `AbstractGenerator`,
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:

-   [Generators](https://docs.typo3.org/permalink/netresearch/nr-repurpose:generators@main)

## Rendering toolchain {#architecture-rendering}

Three render primitives sit behind interfaces (so they are swappable and
testable) and shell out through a `ProcessRunnerInterface` (Symfony
Process):

-   **HTML → PNG** — `PlaywrightHtmlToImageRenderer` drives the bundled
    `render.cjs` (`playwright-core` \+ the apt `chromium` binary). HTML is
    fed on **stdin** (avoiding argv limits and shell quoting); `render.cjs`
    reads the Chromium path from `CHROMIUM_PATH`, which the renderer passes
    to the child process from its `$chromiumPath` argument (see
    [Renderer environment](https://docs.typo3.org/permalink/netresearch/nr-repurpose:configuration-rendering@main)). `height=null` renders
    full-page/auto-height (the diagram); a fixed height clips to the viewport
    (the story). `transparent` uses `omitBackground` for the overlay layers.
-   **Compositing** — `GdImageCompositor` overlays 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 (`gpt-image-2` 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.
-   **Audio** — `FfmpegAudioStitcher` (concat demuxer + `ffprobe` for
    durations), described above.

See [ADR-002: Node + Playwright Renderer for Image Composition](https://docs.typo3.org/permalink/netresearch/nr-repurpose:adr-002@main) for why image composition runs through a Node + Playwright
renderer rather than a PHP imaging library.

## Stage 4 — Storage and result {#architecture-storage}

Generated bytes are written to the default FAL storage under a `repurpose/`
folder by `JobFileStorage`, which returns a `sys_file`; the artifact rows
reference files by `sys_file` uid (audio as `file_uid`, subtitles as
`subtitle_file_uid`). 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
(`ResourceStorage::addFile()` 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['TYPO3_CONF_VARS']['SYS']['textfile_ext']` 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 {#architecture-nr-llm}

nr_repurpose owns no provider code. It depends on three nr-llm surfaces:

-   `CompletionService` — 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).
-   `TextToSpeechService` — wrapped by `OpenAiSpeechSynthesizer` behind
    a local `SpeechSynthesizerInterface` (model resolved through the
    `nr_repurpose_tts` nr-llm Configuration, fallback `tts-1`).
-   `DallEImageService` — wrapped by `DallEImageGenerator` behind a
    local `ImageGeneratorInterface` (model resolved through the
    `nr_repurpose_image` nr-llm Configuration, fallback `gpt-image-2`).

The two specialized wrappers are thin adapters: they expose `isAvailable()`
and a single `…ToFile()` method, and translate nr-llm exceptions into the
extension's `RenderingException`. 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/Services.yaml`). The provider keys behind all of these are
resolved by nr-llm from an identifier (see [ADR-003: Provider Credentials Delegated to nr-llm](https://docs.typo3.org/permalink/netresearch/nr-repurpose:adr-003@main)).
