# caption display / encoding / audio QC v1 contract

- Date: 2026-08-03
- Status: implementation contract
- Compatibility: all new behavior is opt-in; captions without `display_policy`, renders without
  `output.encoding`, and edits without an `audio.master` object retain their legacy paths

## 1. One Node caption-display kernel

`packages/edit-store/src/caption-display.ts` is the only resolver for the opt-in
`display_policy.mode: "single_line_sequential"` contract. It projects source captions through a
linear cut/speed/multi-source timeline, then resolves one or two fragments, timing, source
provenance, merged style variables, and reference-pixel geometry. Browser code only selects
already-resolved timeline cues; it must not call `Intl.Segmenter` or implement the split algorithm.

Every consumer reaches that kernel through one shared entry point,
`packages/render-cut/src/caption-resolve.mjs` (`resolveCaptionPlan`), which also owns the steps in
front of it: style-preset resolution, excluded-cue filtering, word-book protected terms, and cut
normalization. The consumers are render-cut's internal render path, preview-server, gpu-export,
and osr-export. Naming the kernel alone was not enough: until 2026-09-20 each consumer assembled
that front half itself, so preview-server handed the kernel a v2 edit with no derived `cuts` and
resolved zero display cues, while gpu-export and osr-export never consulted `display_policy` at all
and re-split captions through the legacy overlay generator. A consumer that calls the kernel
directly, or rebuilds any of those front-half steps, is a deviation.

**Known remaining deviation — the shell backend.** `AkariPreviewService.resolveCaptionDisplay`
(`apps/shell/extensions/akari-preview/src/node/akari-preview-service.ts`) still calls the kernel
directly and rebuilds the front half on its own: its own preset resolution, its own cut
normalization (`captionCompatibleCuts`, computed in frames off `internal.tracks` rather than the
shared `captionDisplayEdit`), and its own word-book lookup that walks up from `__dirname` and
silently degrades to no protected terms on any failure. **It never applies excluded-cue
filtering**, so a cue excluded through `tracks[].items[].source.exclude` still appears in the
in-app preview while the other four paths drop it. This is a preview-parity hazard of the same
class as the two defects above. It is listed here rather than silently tolerated; the shell is
bundled by Theia and cannot assume `packages/` sits next to it, so routing it through
`resolveCaptionPlan` is a packaging question, not a one-line import.

The policy rejects unsupported `at`, `track`, transition, and timeline winner semantics; caption
style/emphasis conflicts; non-NFC or trimmed text; invalid manual fragments; unresolved long text;
overlap; and reference/output aspect mismatch. A resolved production render stores
`.akari/reports/caption-layout/<payload-sha256>.json`, including Node/ICU provenance and the boundary
projection digest, and its immutable render receipt references the file and summary.
Captions with `time_domain: "output"` (placed text) are exempt from overlap and order checks; any number may share the same time (2026-09-22 decision). Source-domain captions retain their per-source checks.

2026-10-06 amendment: `display_policy.word_style` is the global word display default (`none` when
omitted, or `karaoke`). An explicit `captions[].style: "karaoke"` or `"plain"` takes priority over
that default; `plain` restores ordinary display for one row under a global karaoke policy.
Under `display_policy`, `pop`, `reveal`, and `reveal-word` remain `STYLE_CONFLICT` errors, while any
unknown style is `INVALID_CAPTION`. Karaoke uses measured `words[]` projected into each resolved
fragment. Missing words or synthetic timing fall back to ordinary text. Without `display_policy`,
caption style handling remains on the unchanged legacy path.
If measured words omit only punctuation, symbols, or whitespace, those characters remain visible as
untimed unlit tokens while the measured words keep karaoke timing; other text mismatches fall back to ordinary text.

`reference-pixel` geometry is scaled only after exact aspect agreement. `webkit-outline` produces
real `-webkit-text-stroke` plus `paint-order:stroke fill` and disables shadow. Single-line consumers
use the resolved plate box, `white-space:nowrap`, zero padding/gap, transparent background, and no
caption animation. Noto Sans JP remains the portable font contract; Hiragino glyph parity is
`UNRESOLVED_FONT_ASSET` and is not implied by numeric geometry parity.

### 1.1 Caption text animation

Outside `display_policy.mode: "single_line_sequential"`, the shared `textStyle` contract accepts an
optional `animation` object. `default_text_style.animation` supplies project-wide defaults, while
`captions[].text_style.animation` overrides only the named slots for that caption. The legacy
`captions[].style` values (`karaoke`, `pop`, and `reveal`) remain a separate word-display axis and
are not text-animation preset ids.

The animation object has three optional, independently merged slots: `in`, `out`, and `loop`; at
least one slot is required when the object is present. Each slot requires an `id` and may carry a
positive `duration_sec`, a non-empty CSS `ease`, and a positive `amp`; `ease` and `amp` may also be
`null` to request renderer defaults. The closed 47-id vocabulary is defined by
`presets/textanim/index.jsonl`. Unknown ids and unknown object keys are errors. `out` reverses the
selected recipe, and `loop` repeats it for the caption lifetime. The single-line sequential policy
continues to disable caption animation as specified above.

### 1.2 Reference-height scaling for zone captions (2026-09-01, issue #40 §2)

The shared `textStyle` contract accepts an optional `reference_height_px` (integer >= 1) in both
`default_text_style` and `captions[].text_style`; the cue-level value overrides the default field by
field like every other text-style field. It declares that the pixel fields of that text style were
written for an output whose height is `reference_height_px`. The kernel resolves

`scale = output.height / reference_height_px` (omitted: `scale = 1`)

and multiplies every declared pixel field by it. The reference is the output *height* because type
size is a vertical quantity, so the same declaration stays natural for portrait outputs. Without a
declaration nothing changes: an existing project renders byte-identical CSS variables (the zone path
kept `scale = 1` before this contract, which is why a 720p `size_px: 36` came out at one third of the
frame height on a 4K export).

`reference_height_px` and `layout` (reference-pixel) are mutually exclusive. The exclusion is enforced
three times in the same shape as `zone` + `layout`: `captions.schema.json`
(`allOf: [{ not: { required: ["layout", "reference_height_px"] } }]`), the kernel
(`STYLE_LAYOUT_CONFLICT` from `validateCaptionTextStyle`, `mergeCaptionDisplayStyles`, and
`resolveCaptionStyleForOutput`), and edit-lint (`captions.text-style`). `layout` keeps its own
`output.width / reference_width_px` scale unchanged. When `reference_height_px` is present and
`output.height` is unknown, the kernel fails with `INVALID_OUTPUT_GEOMETRY`, the same code the
reference-pixel path uses.

Scaled fields are exactly the fields that produce CSS `px`. Fields declared in `em`, `%` / `pct`,
frame ratios, or unitless factors are untouched. Renderer defaults that stand in for an omitted field
(the 1.5px stroke, the 40px glow spread, the 38px default font size) are not declarations and are not
scaled, matching the reference-pixel path.

| Field | CSS variable | Scaled |
|---|---|---|
| `size_px` | `--caption-font-size` | yes |
| `stroke.width_px` | `--caption-webkit-text-stroke` / `--caption-text-shadow` (kernel), `--caption-stroke` (render-cut) | yes |
| `shadow.blur_px`, `shadow.distance_px` | `--caption-text-shadow` | yes |
| `glow.spread`, `glow.offset_x`, `glow.offset_y` | `--caption-text-shadow` | yes |
| `background.radius_px` | `--plate-radius` / `--plate-block-radius` / `--plate-ext-radius` | yes |
| `background.padding_px` | `--plate-pad-x` / `--plate-pad-y` / `--plate-ext-width` / `--plate-ext-height` | yes |
| `background.offset_x`, `background.offset_y` | `--plate-offset-x` / `--plate-offset-y` | yes |
| `letter_spacing_em` | `--caption-letter-spacing` (`em`) | no |
| `max_width_pct`, `background.width_pct`, `background.height_pct` | `--caption-line-max-width` / `--plate-ext-*` (`%`) | no |
| `background.fit` | `text` (default) keeps the existing text-sized plate; `frame` spans the caption frame inside its 4% side margins, takes precedence over `width_pct`, and preserves vertical padding in per-line and block modes | no |
| `line_height`, `shadow.opacity`, `shadow.angle_deg`, `glow.density`, `background.opacity` | unitless | no |
| `position.x`, `position.y` | frame ratio 0..1 | no |

The scale has one definition, `resolveCaptionReferenceScale` in
`packages/edit-store/src/caption-display.ts` (with `scaleCaptionPx`, which rounds a scaled value to
six decimals and returns the input untouched at `scale = 1`). `resolveCaptionStyleForOutput` applies
it to the fields the kernel emits (`size_px`, `stroke.width_px`, `background.radius_px`);
`packages/render-cut/src/captions.mjs` `captionTextStyleVars(style, output)` applies the same scale
to every field in the table for the zone rail that both the GPU page builder and the OSR page builder
consume through `generateCaptionOverlays`, so both export engines see the same effective pixels; the
GPU sprite manifest's `emPx` is read back from that `--caption-font-size`, not from the raw `size_px`.
Real-render evidence (task 2026-09-01-caption-style-reference-scale): one caption declared with
`size_px: 36` / `reference_height_px: 720` measures the same 4.72% of the frame height at 1280×720 and
at 3840×2160 (34 px and 102 px), while the same caption without the declaration measures 1.57% at
3840×2160 (the pre-contract behaviour).

## 2. Encoding resolution

`output.encoding` accepts `quality: master|high|standard|light` and
`encoder: auto|videotoolbox|nvenc|qsv|amf|mf|x264`. Resolution is once per render with field precedence
CLI flag > edit field > legacy default. The returned `requested` and `effective` values retain an
`origin`. With neither CLI nor edit opt-in, the legacy plan and command bytes remain unchanged.

`master` means x264, High profile, preset slow, CRF 15. Omitted encoder becomes x264 with origin
`master-required`; explicit `auto`, `videotoolbox`, `nvenc`, `qsv`, `amf`, or `mf` is rejected. The same effective argument array
is used by every video-encoding stage. The audio-only final mix/mux records its reason and uses
`-c:v copy`.

## 3. Audio QC verdict and evidence

Changed 2026-09-24 (field report B-7): an in-target decoded measurement now yields PASS.

`audio.master.true_peak_dbtp` is optional in `[-9, 0]` and defaults to the existing -1.5 dBTP.
Whenever `audio.master` is an object, the immutable render receipt requires `audio_qc`. It preserves
the real loudnorm filter report separately from a second-process decode measurement of the finished
artifact. Raw FFmpeg strings and normalized `finite number | "-inf"` values are both retained.

Successful measurement yields `PASS` when `decoded_measurement.normalized.input_i` is within
±1.0 LU of the configured integrated loudness, `input_tp` is no more than 0.1 dB above the
configured true peak, both measurements are finite numbers, and `tool_version` is present.
Otherwise the verdict is `INCONCLUSIVE`; existing valid `INCONCLUSIVE` receipts remain valid.
Full status and `akari accept` request human audio review only for `INCONCLUSIVE`.
`PASS` is an audio QC verdict; final human acceptance of the artifact remains a separate record.
Parse, field, process, or 1 MiB capture failures retain the closed `MEASUREMENT_ERROR` shape,
keep a content-addressed artifact and receipt when structurally possible, leave render state in
`phase:error`, return exit 1, and cannot produce an integrity candidate.

### 3.1 True peak AAC overshoot guard (2026-08-17, task 2026-08-17-render-cut-true-peak-guard)

The `filter_report.normalized.output_tp` loudnorm reports for the PCM stage is not the artifact's
real true peak: the AAC re-encode that follows can measurably overshoot it (a real render measured
`filter_report` at -1.00 dBTP against a decoded artifact at +0.23 dBFS — about +1.2 dB of
codec-introduced overshoot; `planning/notes-2026-08-17-mac-fresh-install-bug-reports.md` #05). Two
additive mitigations apply to explicit `true_peak_dbtp` in `audio.master` — the -1.5
dBTP default is unchanged and unmargined:

- **Applied margin.** Only when the output audio codec is AAC (h264 / hevc),
  `packages/render-cut/src/plan.mjs` hands loudnorm `configured -
  AAC_TRUE_PEAK_OVERSHOOT_MARGIN_DBTP` (1.5 dB, `packages/render-cut/src/audio-qc.mjs`) instead of
  the raw configured value, so the *decoded* artifact — not just the PCM stage — has a better chance
  of landing under what the caller asked for. The receipt records both under an additive
  `audio_qc.true_peak_margin: { overshoot_margin_dbtp, applied_true_peak_dbtp, reason: "aac_reencode_overshoot", audio_codec: "aac" }` field;
  `audio_qc.configured.true_peak_dbtp` is unchanged and still reports the caller's original value.
  PCM output (prores422 / png → `pcm_s16le`) passes the configured target directly to loudnorm
  and has no `audio_qc.true_peak_margin` field (#122, 2026-09-28).
  The margin is a fixed mitigation, not a guarantee — real-render testing found synthetic
  high-transient material where even the margined target still decodes above 0 dBFS (this is what
  the next mitigation exists to catch).
- **Overshoot detection.** When `decoded_measurement.normalized.input_tp` exceeds
  `configured.true_peak_dbtp` by more than 0.1 dB, `buildAudioQc` adds an
  `audio_qc.warnings: ["TRUE_PEAK_EXCEEDED: ..."]` entry to the receipt and sets the verdict to
  `INCONCLUSIVE`. `packages/akari-launcher/src/status-core/integrity.mjs` (mirrored at
  `plugin/runtime/status-core/integrity.mjs`) recalculates the PASS conditions from the receipt's
  configured target, decoded measurement, and tool version; a mismatch is a structural error.

These two fields are additive to the existing `configured` / `filter_report` /
`decoded_measurement` fields. No margin is recorded when `true_peak_dbtp` is omitted or the output
audio codec is PCM, and no
overshoot warning is recorded when the measured peak does not exceed the threshold.

## 4. Recipe boundary and evidence grade

Recipe `caption_style_ref` is descriptive only. It is not registry-backed and never injects caption
policy/style, encoding, or audio fields. The current request wins, freeze contains only explicitly
human-confirmed values, and `render_profile_ref` is not introduced before a versioned registry with
a content digest exists. Preview parity and generated-media checks without new production material
are `partial_run`, never `production_run`.

Executable specifications live in the edit-store, schemas, edit-lint, render-cut, preview-server,
shell preview, and launcher test suites. The external A4 verifier accepts an explicitly supplied
project root and independent manifest; absence of the frozen local fixture is a skip, not a pass.

## 置いた文字の折り返し幅（2026-09-25 追記）

`captions[].text_style.wrap_width_pct` は出力画面幅に対する文字の折り返し幅（0 より大きく 100 以下）を表す。省略時は従来の幅を使う。左右の辺の操作はこの値だけを変更し、`scale` と `size_px` を変更しない。`max_width_pct` は座布団の幅、`max_characters` は文字数による改行であり、この値とは独立する。現在のプレビューは置いた文字（`time_domain: output`）に適用する。書き出し側の対応が入るまではプレビューと書き出しの幅が異なる。
