1
0
mirror of git://f0xx.org/ac/ac-docs synced 2026-07-29 05:59:06 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-23 12:20:43 +02:00
commit 69a448f156
151 changed files with 33372 additions and 0 deletions

View File

@@ -0,0 +1,408 @@
# Media service (SFU / MCU) — AI deployment prerequisites
<!-- doc-meta:start -->
| Field | Value |
|---|---|
| Author | Anton Afanasyeu |
| Revision | R1 |
| Creation date | 2026-06-12 |
| Last modification date | 2026-06-12 |
| Co-authored | |
| Severity | medium |
<!-- doc-meta:end -->
**Document type:** Requirements / handoff (pre-SPEC)
**Date:** 2026-06-12
**Audience:** PO / sysop → AI agent for full deploy + tune on fresh Alpine HVM(s)
**Related:** [docs/specs/20100612_1_scaling.md](../docs/specs/20100612_1_scaling.md), [docs/DRs/20100612_1_scaling.md](../docs/DRs/20100612_1_scaling.md), [examples/sfu/README.md](../examples/sfu/README.md), [docs/20260608_BE_SERVICES_and_infra.md](../20260608_BE_SERVICES_and_infra.md) §SFU
**PDF:** `tmp/20260612-MEDIASERVICE-AI-requirements.pdf` — regenerate with `.venv-pdf/bin/python docs/_pdf_build/md_to_pdf.py` on the copy under `tmp/`
**Code names (product):** **SPIDERWEB** = SFU-alike, **SPIDER** = MCU-alike ([draft glossary](../docs/drafts/20100611_1_net_topology_glossary.txt))
---
---
---
\newpage
## Table of contents
<!-- toc -->
- [1. Executive answer — HVM vs Docker vs monolith BE](#1-executive-answer-hvm-vs-docker-vs-monolith-be)
- [2. What you give the AI on day zero](#2-what-you-give-the-ai-on-day-zero)
- [3. Recommended instance layout (up to 3 HVMs)](#3-recommended-instance-layout-up-to-3-hvms)
- [4. Network, DNS, and firewall prerequisites](#4-network-dns-and-firewall-prerequisites)
- [5. Integration with existing BE (isolation model)](#5-integration-with-existing-be-isolation-model)
- [6. SFU vs MCU stack choice](#6-sfu-vs-mcu-stack-choice)
- [7. Packages and software (installed later by AI)](#7-packages-and-software-installed-later-by-ai)
- [8. Sizing and bandwidth (200/200 Mbit context)](#8-sizing-and-bandwidth-200200-mbit-context)
- [9. AI workflow after prerequisites are met](#9-ai-workflow-after-prerequisites-are-met)
- [10. Out of scope for first media PoP](#10-out-of-scope-for-first-media-pop)
- [11. PO checklist (copy before handoff)](#11-po-checklist-copy-before-handoff)
- [12. Changelog](#12-changelog)
<!-- /toc -->
\newpage
---
## 1. Executive answer — HVM vs Docker vs monolith BE
### Recommendation (DR-aligned)
| Approach | Verdict | Why |
|----------|---------|-----|
| **SFU/MCU on current androidcast BE monolith** | **No** | PHP/MariaDB/RSSH/Gitea share CPU/RAM/NIC; `pm.max_children=5` today; blast radius; [INFRA.md](../docs/INFRA.md) keeps `:8089` as **other** Janus stack |
| **Fresh dedicated Alpine HVM(s)** | **Yes (primary)** | Isolated **media plane**; snapshots; UDP/WebRTC without touching hub/crashes |
| **Docker on dedicated HVM** | **Yes (preferred delivery)** | Reproducible Janus/mediasoup/coturn/ffmpeg; easy rollback; same host still “empty” at handoff |
| **Docker on existing BE** | **No** | Does not isolate bandwidth or failure domain |
**Practical model:** PO provides **13 empty Alpine HVMs** with SSH + DNS. AI installs **Docker (+ compose)** on them and runs **SPIDERWEB (SFU)** or **SPIDER (MCU)** stacks in containers. **Control plane** (signaling auth, room lifecycle, RBAC) talks to existing crashes BE via **HTTPS/OpenAPI-style APIs** — no co-location of MariaDB or PHP-FPM with media.
```mermaid
flowchart TB
subgraph Today["Today — monolith BE (unchanged)"]
FE["Gentoo FE apps.f0xx.org"]
BE["Alpine BE :80<br/>PHP MariaDB Gitea RSSH"]
end
subgraph New["New — media PoP (13 HVMs)"]
MED["media-1 Alpine HVM"]
DOCK["Docker: SFU + TURN + signaling sidecar"]
end
MOB["Android app"] --> FE
FE --> BE
MOB -.->|"WebRTC UDP/TCP<br/>post-alpha"| MED
DOCK --> MED
BE -.->|"REST: token room RBAC<br/>OpenAPI"| DOCK
class New fill:#e8f5e9,stroke:#2e7d32
```
**Not too heavy:** one HVM + Docker Compose is enough for **lab/stress**; three HVMs match **prod-shaped** split (SFU / TURN / transcode). Start with **1**; split when metrics or PO asks.
---
## 2. What you give the AI on day zero
Minimum handoff — **no media packages required yet** on the empty machine.
### 2.1 Access
| Item | Required | Example |
|------|----------|---------|
| **SSH target** | Yes | `ssh media@media-lab.example.org` or intra `10.7.x.x` |
| **Sudo** | Yes | passwordless `sudo` for `apk`, `rc-service`, docker |
| **SSH key** | Yes | laptop → HVM; optional: HVM → git read for deploy scripts |
| **Jump host** | If no public SSH | `ProxyJump` via `f0xx-monstro` / `alpine-be` (document in `~/.ssh/config`) |
### 2.2 Machine identity
| Item | Required | Notes |
|------|----------|-------|
| **Hostname** | Yes | e.g. `media-1.intra.raptor.org` |
| **OS** | Yes | Alpine 3.x x86_64 (match prod BE) |
| **vCPU / RAM** | Yes | See [§8](#8-sizing-and-bandwidth-200200-mbit-context) |
| **Root disk** | ≥ 40 GiB SSD | containers + logs |
| **Intra IP** | Yes | `10.7.0.0/8` address stable across reboots |
| **Public reach** | For external test | DNS name OR FE proxy only (see [§4](#4-network-dns-and-firewall-prerequisites)) |
### 2.3 PO-owned (snapshots, DNS, firewall)
| Item | Owner | AI needs |
|------|-------|----------|
| **VM snapshots** | PO | “Snapshot before AI session” + rollback name |
| **External DNS** | PO | A/AAAA or CNAME for WebRTC/signaling test |
| **Firewall / DNAT** | PO | UDP/TCP port list from [§4](#4-network-dns-and-firewall-prerequisites) opened on router/FE |
| **TLS certs** | PO or AI via DNS-01 | Lets Encrypt on FE or on media host |
### 2.4 Integration secrets (existing BE — read-only API)
| Item | Required | Purpose |
|------|----------|---------|
| **Crashes base URL** | Yes | `https://apps.f0xx.org/app/androidcast_project/crashes` |
| **Service account or API token** | Yes | Mint rooms / validate session (new `sfu` API or extend OpenAPI) |
| **RBAC model** | Reference | Same pattern as remote access / short links |
| **Gitea / deploy** | Optional | Pull `android_cast` branch `feature/sfu-relay` or deploy tarball |
AI does **not** need MariaDB root on monolith BE if all integration is **HTTP APIs**.
### 2.5 Decisions PO should state (or defaults)
| Decision | Default if silent |
|----------|-------------------|
| **Topology code name** | **SPIDERWEB** (SFU) first; MCU later |
| **Engine** | **mediasoup** (greenfield) — Janus on `:8089` is **other** stack, avoid unless PO picks Janus on **new** host |
| **Instance count** | **1** for lab; **3** for prod-shaped |
| **Public path** | Subdomain `media.apps.f0xx.org` → FE → media HVM (keeps TLS at FE) |
| **TURN** | **coturn** on same HVM (lab) or instance 2 (prod) |
---
## 3. Recommended instance layout (up to 3 HVMs)
### 3.1 Lab — single HVM (minimum handoff)
```mermaid
flowchart LR
subgraph HVM1["media-1 (single Alpine HVM)"]
NG["nginx :443 reverse proxy"]
SIG["signaling sidecar<br/>Node or Go"]
SFU["mediasoup / Janus SFU"]
TURN["coturn STUN/TURN"]
FF["ffmpeg worker optional"]
end
Client["App / browser"] --> NG
NG --> SIG
Client --> TURN
Client --> SFU
SIG --> SFU
BE["Existing BE PHP"] -.->|HTTPS| SIG
```
All services in **one docker-compose** stack; fine for stress and first WAN cast tests.
### 3.2 Prod-shaped — three HVMs
| VM | Role | Containers / daemons |
|----|------|----------------------|
| **media-1** | **SPIDERWEB SFU** | mediasoup (or Janus), nginx edge, signaling sidecar |
| **media-2** | **TURN + signaling** | coturn, optional dedicated signaling if scaled |
| **media-3** | **Transcode / MCU path** | ffmpeg workers, optional MCU (SPIDER) if not SFU-only |
```mermaid
flowchart TB
FE["FE TLS"] --> M1["media-1 SFU"]
FE --> M2["media-2 TURN"]
APP["Android"] -->|UDP WebRTC| M1
APP -->|UDP TURN| M2
BE["Monolith BE APIs"] -->|REST| M1
M1 --> M3["media-3 ffmpeg optional"]
```
PO can hand **one** machine first; AI documents split boundaries so instances 23 are **additive**, not rewrite.
---
## 4. Network, DNS, and firewall prerequisites
### 4.1 Traffic paths
```mermaid
flowchart TB
Internet["Internet clients"]
Router["Router 200/200 Mbit"]
FE["Gentoo FE"]
M["Media HVM(s)"]
BE["Androidcast BE :80"]
Internet --> Router --> FE
FE -->|"HTTPS signaling<br/>wss / REST"| M
FE --> BE
Internet -->|"UDP WebRTC<br/>after DNAT or 1:1"| M
```
**Do not** attach media to androidcast **`:8089`** vhost ([INFRA.md](../docs/INFRA.md) §2).
### 4.2 Ports to plan (PO opens on FE/router → media HVM)
| Port / proto | Service | Notes |
|--------------|---------|-------|
| **443/tcp** | HTTPS / WSS signaling | Via FE reverse proxy recommended |
| **3478/udp,tcp** | STUN/TURN (coturn) | Required for many NATs |
| **4915265535/udp** | WebRTC media (ICE) | Range size depends on engine; document in SFU SPEC |
| **8080/tcp** | SFU HTTP API (internal) | **Not** public; FE or intra only |
| **22/tcp** | SSH | Intra or VPN only in prod |
Exact ICE port range: set in mediasoup/Janus config during deploy; PO must allow **UDP range** on path to media HVM.
### 4.3 DNS names (examples)
| Name | Points to | Use |
|------|-----------|-----|
| `media.apps.f0xx.org` | FE → proxy → media-1 | Public signaling + health |
| `turn.apps.f0xx.org` | FE or media-2 | TURN URI in app |
| `media-lab.intra.raptor.org` | 10.7.x.x | AI + sysop intra testing |
---
## 5. Integration with existing BE (isolation model)
Media HVM **must not** share MariaDB or PHP-FPM with crashes console. Integration is **API-only**:
```mermaid
sequenceDiagram
participant App as Android app
participant FE as FE nginx
participant BE as Crashes BE PHP
participant SIG as Media signaling
participant SFU as SFU engine
App->>FE: HTTPS session / cast token
FE->>BE: existing auth APIs
App->>SIG: WSS create room (bearer)
SIG->>BE: POST validate token / RBAC
BE-->>SIG: room allowed + ttl
SIG->>SFU: create room
App->>SFU: WebRTC publish/subscribe UDP
```
| Integration | Method | Existing artifact |
|-------------|--------|-------------------|
| Health gate | GET | `api/sfu_health.php` (stub today) |
| Feature flag | config | `config.php``sfu.enabled`, URLs |
| Room mint / revoke | **New REST** | OpenAPI under crashes or `media-api` submodule |
| Auth | Bearer + session RBAC | Same as remote access / short links pattern |
| Metrics | POST ingest | Optional → `graph_upload.php` or new series |
| No direct DB | — | Media DB (if any) is **local** to HVM (Redis/SQLite) |
**OpenAPI:** extend [examples/crash_reporter/backend](../examples/crash_reporter/backend) or new `backend/media-signaling` repo later — AI needs **written** API contract before prod wire-up.
---
## 6. SFU vs MCU stack choice
| | **SPIDERWEB (SFU)** | **SPIDER (MCU)** |
|---|---------------------|------------------|
| **Use** | Multiparty cast; subscribers receive per-stream | Mix down; single output; higher CPU per room |
| **Engine candidates** | **mediasoup** (preferred greenfield), Janus VideoRoom | Janus MCU plugin, ffmpeg bridge, custom |
| **CPU** | Moderate per stream; scales with subscribers | High per room (encode) |
| **Latency** | Lower | Higher |
| **First milestone** | **Yes** | Phase 2 / media-3 |
**Janus on existing `:8089`:** treat as **foreign** — do not piggyback androidcast nginx. New Janus instance on **media HVM** only if PO rejects mediasoup.
**Docker vs bare metal on HVM:** use **Docker** for SFU/TURN/signaling; host only needs Alpine + docker + nginx + firewall tools.
---
## 7. Packages and software (installed later by AI)
PO delivers **almost empty** Alpine; AI installs when handoff complete.
### 7.1 Host (Alpine packages)
```text
# Baseline (all media HVMs)
openssh sudo bash curl wget git ca-certificates
nginx openssl iptables ip6tables
docker docker-cli-compose
# Optional host tools
tcpdump bind-tools jq python3
# If bare-metal fallback (no Docker) — not recommended first
# janus-gateway mediasoup — prefer official containers
```
### 7.2 Container images (typical compose)
| Service | Image / build | Role |
|---------|---------------|------|
| **mediasoup** or **janus** | Official or pinned Dockerfile in repo | SFU |
| **coturn** | `coturn/coturn` | STUN/TURN |
| **signaling** | Custom Node/Go from repo | Rooms, BE API client |
| **nginx** | `nginx:alpine` or host nginx | TLS termination at edge or intra |
| **ffmpeg** | `jrottenberg/ffmpeg` or alpine | Transcode sidecar (media-3) |
| **redis** | `redis:alpine` | Room state optional |
### 7.3 Repo deliverables (AI produces after deploy)
| Artifact | Location (proposed) |
|----------|---------------------|
| `docker-compose.yml` | `examples/sfu/deploy/` or `backend/media-pop/` |
| nginx FE fragment | `examples/sfu/deploy/nginx.fe-media.snippet` |
| BE config keys | `config.example.php``sfu.*` (already stubbed) |
| Smoke script | `examples/sfu/scripts/smoke_sfu.sh` |
| OpenAPI | `examples/sfu/openapi.yaml` |
---
## 8. Sizing and bandwidth (200/200 Mbit context)
WAN **200/200 Mbit** sits behind FE; media UDP may be **DNAT** to media HVM.
| Profile | vCPU | RAM | Notes |
|---------|------|-----|-------|
| **Lab / stress (1 HVM)** | **48** | **816 GiB** | SFU + TURN + signaling; ~1030 parallel WebRTC streams |
| **Prod SFU node** | **8+** | **16 GiB** | Dedicated NIC budget; see [SPEC §12.3](../docs/specs/20100612_1_scaling.md#123-network-bandwidth-and-egress) |
| **TURN only** | **2** | **4 GiB** | Mostly relay bandwidth |
| **ffmpeg node** | **8+** | **1632 GiB** | CPU-bound transcodes |
**Bandwidth rule of thumb (SFU):**
| Streams | Approx WAN need |
|---------|-----------------|
| 10 × 1 Mbps video | **~1015 Mbps** + overhead |
| 50 × 1 Mbps | **~5080 Mbps** — approaches 200 Mbit shared with rest of estate |
| 100+ viewers | **CDN/SFU PoP or second site** — not single 200 Mbit |
Stress test: measure on **intra** first, then **public DNS** through FE.
---
## 9. AI workflow after prerequisites are met
```mermaid
flowchart TD
A["PO: empty HVM + SSH + DNS + firewall list"] --> B["AI: verify ssh sudo intra ping"]
B --> C["AI: apk + docker + compose"]
C --> D["AI: deploy SFU stack lab compose"]
D --> E["AI: nginx FE fragment + BE sfu.enabled URLs"]
E --> F["Smoke: sfu_health + WSS + ICE"]
F --> G{"PO: load test OK?"}
G -->|yes| H["Document + optional 2nd/3rd HVM split"]
G -->|no| I["Tune UDP range CPU docker limits"]
I --> F
```
| Step | AI action | PO action |
|------|-----------|-----------|
| 0 | — | Snapshot empty VM |
| 1 | Probe CPU/RAM/NIC, `apk update` | Confirm DNS/firewall |
| 2 | Install docker, compose, pin versions | — |
| 3 | Bring up mediasoup + coturn + signaling | — |
| 4 | FE nginx `location /sfu/` or subdomain proxy | Reload FE nginx |
| 5 | Enable `sfu.enabled` + URLs on monolith BE **only** | Sync config.php |
| 6 | Run smoke + optional load/WebRTC client | Review metrics |
| 7 | Snapshot “golden” media PoP | — |
---
## 10. Out of scope for first media PoP
- Replacing LAN UDP cast (alpha path unchanged)
- Moving crashes/tickets/Gitea to media HVM
- Full GEO policy ([SPEC §14](../docs/specs/20100612_1_scaling.md#14-global-availability-and-geo-policy)) — later with F3/F10
- Production payments / recording storage
- Merging with Janus stack on BE `:8089`
---
## 11. PO checklist (copy before handoff)
```text
[ ] Alpine 3.x HVM provisioned (13); specs noted
[ ] SSH + sudo for AI; key installed
[ ] Intra IP 10.7.x.x stable
[ ] External DNS name for test (or FE-only proxy agreed)
[ ] Firewall: UDP 3478 + WebRTC range → media HVM
[ ] Snapshot taken (empty baseline)
[ ] Decision: SPIDERWEB (SFU) first — mediasoup default
[ ] Crashes BE base URL + API token for room validation
[ ] Confirm: do NOT use androidcast :8089 / monolith BE for SFU
[ ] Optional: 2nd/3rd HVM reserved for TURN + ffmpeg
```
---
## 12. Changelog
| Date | Change |
|------|--------|
| 2026-06-12 | Initial prerequisites — HVM vs Docker, 3-instance layout, BE API isolation, PO checklist |
---
**Documentation index:** [docs/README.md](../docs/README.md)

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Build **/*.pdf for every **/*.md (recursive; skips _pdf_build/, drafts/)."""
from __future__ import annotations
import sys
from pathlib import Path
_BUILD_DIR = Path(__file__).resolve().parent
if (_BUILD_DIR.parent / "docs").is_dir():
ROOT = _BUILD_DIR.parent
DOCS = ROOT / "docs"
else:
ROOT = _BUILD_DIR.parent
DOCS = ROOT
sys.path.insert(0, str(_BUILD_DIR))
from md_to_pdf import build_pdf_from_markdown # noqa: E402
SUBTITLE_BY_REL = {
"specs/20100611_3_url_shortener.md": "URL shortener service — SPEC (post-alpha, s.f0xx.org)",
"DRs/20100611_3_url_shortener.md": "URL shortener service — design review",
"specs/20100612_1_scaling.md": "Platform scaling — SPEC (clusters, bandwidth, GEO, cloud path)",
"DRs/20100612_1_scaling.md": "Platform scaling — DR (architecture, bandwidth, GEO policy, task graph)",
"DRs/20260618_repos_reorganizing.md": "Repository & microservice split — frozen DR R1.1",
"specs/20260618_repos_reorganizing.md": "Repository & microservice split — SPEC R1 (normative)",
"20260620_postgres_timescale_db_platform.md": "PostgreSQL, TimescaleDB, platform DB adapters — advisory",
}
def iter_markdown_files() -> list[Path]:
out: list[Path] = []
for md in sorted(DOCS.rglob("*.md")):
if "_pdf_build" in md.parts or "drafts" in md.parts:
continue
out.append(md)
return out
def rel_label(md: Path) -> str:
try:
return str(md.relative_to(DOCS))
except ValueError:
return md.name
def main() -> int:
md_files = iter_markdown_files()
if not md_files:
print("No markdown files found", file=sys.stderr)
return 1
ok = 0
for md in md_files:
out = md.with_suffix(".pdf")
rel = rel_label(md)
subtitle = SUBTITLE_BY_REL.get(rel, f"From {rel}")
try:
build_pdf_from_markdown(md, out, doc_subtitle=subtitle)
ok += 1
except Exception as e:
print(f"FAIL {rel}: {e}", file=sys.stderr)
print(f"Built {ok}/{len(md_files)} PDFs under {DOCS}/")
return 0 if ok == len(md_files) else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,384 @@
#!/usr/bin/env python3
"""Build 20260521_next_gen_steps_egress_review.pdf from summary text and diagram PNGs."""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from reportlab.lib import colors
from reportlab.lib.enums import TA_LEFT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.platypus import (
Image,
PageBreak,
Paragraph,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
from page_footer import NumberedCanvas
ROOT = Path(__file__).resolve().parents[2]
BUILD = Path(__file__).resolve().parent
OUT_PDF = ROOT / "20260521_next_gen_steps_egress_review.pdf"
MM = [
("diagram_current_arch.mmd", "diagram_current_arch.png", "Current sender path (1 encode, N unicast sends)"),
("diagram_multicast_proposal.mmd", "diagram_multicast_proposal.png", "Proposed multicast setup (handshake then one media flow)"),
]
def render_mermaid() -> None:
for mmd, png, _ in MM:
src = BUILD / mmd
dst = BUILD / png
subprocess.run(
[
"npx",
"--yes",
"@mermaid-js/mermaid-cli@11.4.0",
"-i",
str(src),
"-o",
str(dst),
"-b",
"white",
"-w",
"1400",
"-H",
"900",
],
cwd=str(ROOT),
check=True,
timeout=180,
)
def p(text: str, style) -> Paragraph:
return Paragraph(text.replace("\n", "<br/>"), style)
def build_pdf() -> None:
styles = getSampleStyleSheet()
title = ParagraphStyle(
"DocTitle",
parent=styles["Title"],
fontSize=18,
spaceAfter=12,
textColor=colors.HexColor("#1565C0"),
)
h1 = ParagraphStyle("H1", parent=styles["Heading1"], fontSize=14, spaceBefore=14, spaceAfter=8)
h2 = ParagraphStyle("H2", parent=styles["Heading2"], fontSize=12, spaceBefore=10, spaceAfter=6)
body = ParagraphStyle("Body", parent=styles["Normal"], fontSize=10, leading=14, alignment=TA_LEFT)
small = ParagraphStyle("Small", parent=body, fontSize=9, textColor=colors.grey)
story = []
story.append(p("Android Cast — Next-gen egress review", title))
story.append(
p(
"Summary of sender 1:N behavior, bandwidth model, limitations, and feasibility "
"of single-stream multicast plus multi-tier (full / 720p / 480p) renditions. "
"Generated 2026-05-21.",
small,
)
)
story.append(Spacer(1, 0.4 * cm))
# --- Current state ---
story.append(p("1. Current sender behavior", h1))
story.append(
p(
"<b>How many receivers?</b> Up to 5 when MULTI_RECEIVER_ENABLED "
"(CastConfig.MAX_RECEIVERS). Sender opens one CastSession per selected device; "
"failed handshakes are skipped if at least one peer connects.",
body,
)
)
story.append(Spacer(1, 0.2 * cm))
story.append(
p(
"<b>CPU / GPU / memory:</b> Capture and encode run once per cast. "
"CastFanoutPump duplicates each encoded frame to every session. "
"Network send and per-peer UDP/FEC state scale with N; encode cost does not.",
body,
)
)
story.append(Spacer(1, 0.2 * cm))
story.append(
p(
"<b>Bandwidth:</b> Per-receiver unicast duplicate of the same bitstream — not IP multicast. "
"Rough LAN egress ≈ (video + ~96 kbps audio) × N, plus per-peer protocol overhead.",
body,
)
)
story.append(Spacer(1, 0.5 * cm))
img1 = BUILD / MM[0][1]
if img1.exists():
story.append(p(MM[0][2], h2))
iw = 16 * cm
story.append(Image(str(img1), width=iw, height=iw * 0.45))
story.append(Spacer(1, 0.4 * cm))
# Table current limits
story.append(p("1.1 Current limitations (sender-focused)", h2))
limits = [
["Area", "Limitation"],
["Primary peer", "First connected peer sets negotiated MIME, adaptive tuning, and network feedback."],
["Codec", "Single encoder; all peers must tolerate the same bitstream."],
["Send path", "One CastFanoutPump thread; serial fan-out to N sessions."],
["WiFi", "Uplink often saturates before CPU on 35 receivers."],
["Topology", "Sender dials out; no shared LAN broadcast stream today."],
]
t = Table(limits, colWidths=[3.2 * cm, 13.3 * cm])
t.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1565C0")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 9),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F5F5F5")]),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
]
)
)
story.append(t)
story.append(PageBreak())
# --- Proposal 1 ---
story.append(p("2. Proposal: single outgoing stream (multicast)", h1))
story.append(
p(
"<b>Goal:</b> One encoded stream on the air; only handshake-authorized receivers consume it; "
"save ~(N1) × bitrate vs todays unicast fan-out.",
body,
)
)
story.append(Spacer(1, 0.2 * cm))
story.append(
p(
"<b>Feasible</b> with UDP IP multicast + unicast handshake (group address, port, sessionId, PIN). "
"Handshake-only targeting without multicast does <b>not</b> reduce bandwidth. "
"TCP/QUIC cannot deliver true 1× WiFi airtime.",
body,
)
)
story.append(Spacer(1, 0.3 * cm))
img2 = BUILD / MM[1][1]
if img2.exists():
story.append(p(MM[1][2], h2))
iw = 16 * cm
story.append(Image(str(img2), width=iw, height=iw * 0.55))
story.append(Spacer(1, 0.4 * cm))
story.append(p("2.1 Approach comparison", h2))
approaches = [
["Approach", "1× sender airtime?", "Notes"],
["Unicast fan-out (today)", "No", "Same encode; N × send()."],
["UDP multicast", "Yes on good LAN", "MulticastLock; AP client isolation risk."],
["Sender server, N TCP clients", "No", "Flip connect direction; still N wire copies."],
["LAN relay receiver", "Sender 1×", "Relay pays duplicate bandwidth."],
]
t2 = Table(approaches, colWidths=[4.5 * cm, 3.2 * cm, 8.8 * cm])
t2.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1565C0")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 9),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F5F5F5")]),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
]
)
)
story.append(t2)
story.append(Spacer(1, 0.3 * cm))
story.append(
p(
"<b>Protocol notes:</b> CastFramingContext.sessionId and CastSessionGate already support "
"session binding. FEC/NACK must become group-scoped (one retransmit cache, resend to multicast). "
"Existing code paths: MultiCastCoordinator, CastFanoutPump, UdpCastTransport.",
body,
)
)
story.append(PageBreak())
# --- Proposal 2 ---
story.append(p("3. Proposal: multi-tier streams (e.g. full + 720p + 480p)", h1))
story.append(
p(
"<b>Goal:</b> Several encoded renditions from one capture; each receiver picks a tier at handshake "
"(e.g. full quality vs 480p for bandwidth-limited devices).",
body,
)
)
story.append(Spacer(1, 0.2 * cm))
story.append(
p(
"<b>Feasible</b> via multiple encoders fed from one capture (EGL / scaled Surfaces into separate "
"MediaCodec instances). Cost scales with <b>active</b> tiers (~23 HW encoders typical phone limit; "
"3× software VP9 is heavy). Audio usually stays one AAC stream.",
body,
)
)
story.append(Spacer(1, 0.2 * cm))
story.append(
p(
"<b>Bandwidth:</b> Unicast per tier → egress ≈ sum of each peers tier bitrate. "
"Multicast per tier → one group per tier; airtime ≈ sum of tiers with ≥1 subscriber.",
body,
)
)
story.append(Spacer(1, 0.3 * cm))
story.append(p("3.1 Tier architecture options", h2))
tiers = [
["Option", "Verdict"],
["Multiple HW encoders from one capture", "Recommended v1."],
["One encode + CPU re-encode", "Too expensive for live cast."],
["SVC / simulcast in one bitstream", "Poor Android HW support; defer."],
["Receiver-only downscale", "Saves sender encode, not downlink bandwidth."],
]
t3 = Table(tiers, colWidths=[6 * cm, 10.5 * cm])
t3.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1565C0")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 9),
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F5F5F5")]),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
]
)
)
story.append(t3)
story.append(PageBreak())
# --- Rollout ---
story.append(p("4. Recommended rollout", h1))
rollout = [
["Phase", "Deliverable", "Benefit"],
[
"A — Tier selection (unicast)",
"Handshake tier; 720p + 480p encoders; send chosen tier per peer",
"Per-device quality without multicast; no AP surprises",
],
[
"B — Multicast one tier",
"UDP multicast publish after unicast handshake",
"~(N1) egress for same-quality 1:N",
],
[
"C — Multicast per tier",
"Separate multicast group per tier; lazy encoder start",
"Combines (1) and (2) on good LANs",
],
]
t4 = Table(rollout, colWidths=[3.5 * cm, 7.5 * cm, 5.5 * cm])
t4.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1565C0")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 9),
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F5F5F5")]),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
]
)
)
story.append(t4)
story.append(Spacer(1, 0.4 * cm))
story.append(p("4.1 Summary judgment", h2))
summary = [
["Idea", "Feasible?", "Main win", "Main cost"],
[
"(1) Multicast + handshake gate",
"Yes (UDP)",
"~(N1) uplink on good LAN",
"Transport refactor; FEC/NACK; AP testing",
],
["(1) Unicast only", "Trivial", "Auth only", "No bandwidth win"],
["(2) Multi-tier encode", "Yes", "Per-receiver quality", "23× encode resources"],
["(1) + (2) combined", "Yes", "Multicast per tier", "Highest complexity"],
]
t5 = Table(summary, colWidths=[4.2 * cm, 2.2 * cm, 4.5 * cm, 4.6 * cm])
t5.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1565C0")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 8),
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F5F5F5")]),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
]
)
)
story.append(t5)
story.append(Spacer(1, 0.5 * cm))
story.append(
p(
"<b>Key code paths today:</b> CastConfig.MAX_RECEIVERS, MultiCastCoordinator, "
"CastFanoutPump, ScreenCastService (primary peer), UdpCastTransport, CastFramingContext, "
"CastSessionGate.",
small,
)
)
doc = SimpleDocTemplate(
str(OUT_PDF),
pagesize=A4,
leftMargin=1.8 * cm,
rightMargin=1.8 * cm,
topMargin=1.5 * cm,
bottomMargin=1.5 * cm,
title="Android Cast — Next-gen egress review",
author="Android Cast project",
)
doc.build(story, canvasmaker=NumberedCanvas)
print(f"Wrote {OUT_PDF}")
def main() -> int:
render_mermaid()
build_pdf()
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""Build docs/GIT_FLOW.pdf from structured content and Mermaid diagrams.
Diagrams: Mermaid CLI renders PNG via headless Chromium (text + layout correct).
Do NOT pipe Mermaid SVG through rsvg-convert — Mermaid uses foreignObject HTML labels
that librsvg drops (boxes only, no text).
Requires: reportlab, npx (@mermaid-js/mermaid-cli), Chromium (puppeteer bundled).
Regenerate: python3 docs/_pdf_build/build_git_flow_pdf.py
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from reportlab.lib import colors
from reportlab.lib.enums import TA_LEFT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.lib.utils import ImageReader
from reportlab.platypus import (
Image,
PageBreak,
Paragraph,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
from page_footer import NumberedCanvas
ROOT = Path(__file__).resolve().parents[2]
BUILD = Path(__file__).resolve().parent
CONFIG = BUILD / "mermaid-config.json"
OUT_PDF = ROOT / "docs" / "GIT_FLOW.pdf"
# (mmd, png, caption, mermaid width px, height px, scale, max width cm in PDF)
DIAGRAMS = [
("git_flow_diagram_branches.mmd", "git_flow_diagram_branches.png",
"Branch model (green master)", 1800, 900, 2, 15.5),
("git_flow_diagram_feature.mmd", "git_flow_diagram_feature.png",
"Daily development", 2400, 2000, 2, 14.0),
("git_flow_diagram_release.mmd", "git_flow_diagram_release.png",
"Release: next → master", 2400, 2000, 2, 14.0),
("git_flow_diagram_hotfix.mmd", "git_flow_diagram_hotfix.png",
"Hotfix decision and flow", 1300, 1100, 2, 14.5),
]
MAX_DIAGRAM_HEIGHT_CM = 16.5
DOC_VERSION = "1.3"
DOC_DATE = "2026-05-23"
DOC_AUTHOR = "Anton Afanaasyeu"
DOC_SOURCE = "docs/GIT_FLOW.md"
def render_mermaid() -> None:
if not CONFIG.exists():
raise FileNotFoundError(CONFIG)
for mmd, png_name, _, w_px, h_px, scale, _ in DIAGRAMS:
src = BUILD / mmd
dst = BUILD / png_name
if not src.exists():
raise FileNotFoundError(src)
subprocess.run(
[
"npx", "--yes", "@mermaid-js/mermaid-cli@11.4.0",
"-i", str(src),
"-o", str(dst),
"-c", str(CONFIG),
"-b", "white",
"-w", str(w_px),
"-H", str(h_px),
"-s", str(scale),
],
cwd=str(ROOT),
check=True,
timeout=180,
)
def diagram_size_cm(png_path: Path, max_width_cm: float) -> tuple[float, float]:
w_px, h_px = ImageReader(str(png_path)).getSize()
max_w = max_width_cm * cm
max_h = MAX_DIAGRAM_HEIGHT_CM * cm
scale = min(max_w / w_px, max_h / h_px)
return w_px * scale, h_px * scale
def p(text: str, style) -> Paragraph:
return Paragraph(text.replace("\n", "<br/>"), style)
def add_table(story, headers: list[str], rows: list[list[str]], col_widths) -> None:
data = [headers] + rows
t = Table(data, colWidths=col_widths)
t.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1565C0")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 9),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F5F5F5")]),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
]
)
)
story.append(t)
def add_diagram(story, png_name: str, caption: str, h2, max_width_cm: float) -> None:
path = BUILD / png_name
if not path.exists():
return
iw, ih = diagram_size_cm(path, max_width_cm)
story.append(p(caption, h2))
story.append(Image(str(path), width=iw, height=ih))
story.append(Spacer(1, 0.35 * cm))
def build_pdf() -> None:
styles = getSampleStyleSheet()
title = ParagraphStyle(
"DocTitle", parent=styles["Title"], fontSize=18, spaceAfter=10,
textColor=colors.HexColor("#1565C0"),
)
h1 = ParagraphStyle("H1", parent=styles["Heading1"], fontSize=14, spaceBefore=12, spaceAfter=6)
h2 = ParagraphStyle("H2", parent=styles["Heading2"], fontSize=11, spaceBefore=8, spaceAfter=4)
body = ParagraphStyle("Body", parent=styles["Normal"], fontSize=10, leading=14, alignment=TA_LEFT)
small = ParagraphStyle("Small", parent=body, fontSize=9, textColor=colors.grey)
bullet = ParagraphStyle("Bullet", parent=body, leftIndent=14, bulletIndent=6)
story: list = []
story.append(p("Android Cast — Git flow (green master)", title))
story.append(Spacer(1, 0.15 * cm))
add_table(
story,
["Field", "Value"],
[
["Version", DOC_VERSION],
["Published", DOC_DATE],
["Author(s)", DOC_AUTHOR],
["Markdown source", DOC_SOURCE],
["Diagrams", "Mermaid CLI → PNG (Chromium); proportional embed in PDF"],
],
[4.0 * cm, 12.5 * cm],
)
story.append(Spacer(1, 0.25 * cm))
story.append(
p(
"Diagrams are rendered by headless Chromium (not rsvg/SVG rasterize) so labels stay visible. "
"Regenerate after editing docs/_pdf_build/git_flow_diagram_*.mmd.",
small,
),
)
story.append(Spacer(1, 0.3 * cm))
story.append(p("1. Goals", h1))
add_table(
story,
["Goal", "Mechanism"],
[
["Always shippable master", "Merge to master only when CI/tests pass"],
["Safe integration", "Features land on next first"],
["Clear production line", "Release tags only on master"],
["Fast production patches", "hotfix/* from master, then sync to next"],
],
[4.5 * cm, 12 * cm],
)
story.append(Spacer(1, 0.4 * cm))
story.append(p("2. Branches", h1))
add_table(
story,
["Branch", "Role", "Direct commits?"],
[
["master", "Production; green; annotated tags vX.Y.Z", "No — merge only"],
["next", "Integration / release candidate", "No — merge only"],
["feature/*, fix/*", "Short-lived work", "Yes; merge → next"],
["hotfix/*", "Urgent fix for released code", "Yes; merge → master first"],
],
[3 * cm, 8.5 * cm, 4 * cm],
)
story.append(Spacer(1, 0.3 * cm))
add_diagram(story, DIAGRAMS[0][1], DIAGRAMS[0][2], h2, DIAGRAMS[0][6])
story.append(PageBreak())
story.append(p("3. Daily development", h1))
for line in [
"git fetch && git checkout next && git pull",
"git checkout -b feature/short-description",
"Commit on topic branch; run tests before merge",
"Merge topic → next (after CI green)",
]:
story.append(p(f"{line}", bullet))
story.append(Spacer(1, 0.15 * cm))
story.append(
p(
"<b>Topic branch cleanup:</b> optional locally. On the server, enable "
"<i>delete branch on merge</i> (Gitea/GitLab/GitHub) so merged feature/* "
"branches are removed automatically on origin.",
body,
),
)
story.append(Spacer(1, 0.15 * cm))
story.append(
p(
"<b>Agents:</b> never push directly to master or next unless the user explicitly "
"requests a release or hotfix merge.",
body,
),
)
add_diagram(story, DIAGRAMS[1][1], DIAGRAMS[1][2], h2, DIAGRAMS[1][6])
story.append(PageBreak())
story.append(p("4. Release (next → master)", h1))
for line in [
"Confirm next is QA-ready and CI green",
"Merge next → master",
"Smoke / CI on master",
"Tag on master: git tag -a v0.1.2 -m \"Release v0.1.2\"",
"Sync: git checkout next && git merge master && git push",
"Continue feature branches from updated next",
]:
story.append(p(f"{line}", bullet))
add_diagram(story, DIAGRAMS[2][1], DIAGRAMS[2][2], h2, DIAGRAMS[2][6])
story.append(PageBreak())
story.append(p("5. Hotfixes — recommended strategy", h1))
story.append(
p(
"<b>Default (option 1):</b> branch hotfix/* from <b>master</b> when the bug affects "
"released code. After fix and tests: merge → master, tag patch (e.g. v0.1.3), then "
"<b>merge master → next</b> so integration does not miss the fix.",
body,
),
)
story.append(Spacer(1, 0.15 * cm))
story.append(
p(
"<b>Exception (option 2):</b> bug exists only on next — branch fix/* from next, "
"merge to next only; ships with the next normal release.",
body,
),
)
story.append(Spacer(1, 0.15 * cm))
story.append(
p(
"<b>Why not hotfix only on next?</b> next often contains unreleased features; "
"merging next to master for a patch would ship extra code or block the fix.",
body,
),
)
add_diagram(story, DIAGRAMS[3][1], DIAGRAMS[3][2], h2, DIAGRAMS[3][6])
story.append(p("5.1 Sync cheat sheet", h2))
add_table(
story,
["Event", "Action"],
[
["After release + tag", "merge master → next"],
["After hotfix + tag", "merge master → next"],
["Feature done", "merge topic → next only"],
["Wrong base branch", "rebase topic onto latest next"],
],
[4.5 * cm, 12 * cm],
)
story.append(PageBreak())
story.append(p("6. Branch protection and CI", h1))
add_table(
story,
["Rule", "master", "next"],
[
["CI required before merge", "yes", "yes"],
["No WIP direct push", "yes", "yes"],
["Annotated release tags", "yes", "no"],
["Force-push", "never", "maintainer only, rare"],
["Delete branch on merge", "n/a", "yes (topic branches)"],
],
[5.5 * cm, 5 * cm, 5 * cm],
)
story.append(Spacer(1, 0.2 * cm))
story.append(
p(
"<b>Force-push on next:</b> keep disabled by default. Allow only when you intentionally "
"rewrite integration history — never on master. Prefer fixing on topic branches before "
"merging to next.",
body,
),
)
story.append(Spacer(1, 0.35 * cm))
story.append(p("7. Cursor / agent checklist", h1))
for line in [
"Branch from next for features and non-production fixes",
"Run tests before recommending merge to next",
"Releases and tags only with explicit user approval",
"Hotfix released code from master; sync master into next after tag",
"Never force-push master; avoid force-push on next unless user requests",
]:
story.append(p(f"{line}", bullet))
story.append(Spacer(1, 0.4 * cm))
story.append(
p("Regenerate: python3 docs/_pdf_build/build_git_flow_pdf.py", small),
)
doc = SimpleDocTemplate(
str(OUT_PDF),
pagesize=A4,
leftMargin=2 * cm,
rightMargin=2 * cm,
topMargin=2 * cm,
bottomMargin=2 * cm,
)
doc.build(story, canvasmaker=NumberedCanvas)
print(f"Wrote {OUT_PDF}")
def main() -> int:
try:
render_mermaid()
except subprocess.CalledProcessError as e:
print("Mermaid render failed:", e, file=sys.stderr)
return 1
except FileNotFoundError as e:
print(e, file=sys.stderr)
return 1
build_pdf()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,216 @@
#!/usr/bin/env python3
"""Build docs/GRAFANA_vs_others_graphvis_pivot.pdf decision memo."""
from __future__ import annotations
from pathlib import Path
from reportlab.lib import colors
from reportlab.lib.enums import TA_LEFT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
from page_footer import NumberedCanvas
ROOT = Path(__file__).resolve().parents[2]
OUT_PDF = ROOT / "docs" / "GRAFANA_vs_others_graphvis_pivot.pdf"
SOURCE_MD = ROOT / "docs" / "GRAFANA_vs_others_graphvis_pivot.md"
def p(text: str, style) -> Paragraph:
return Paragraph(text.replace("\n", "<br/>"), style)
def _cell(text: str, style: ParagraphStyle) -> Paragraph:
safe = (text or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
return Paragraph(safe.replace("\n", "<br/>"), style)
def add_table(story, headers: list[str], rows: list[list[str]], col_widths) -> None:
styles = getSampleStyleSheet()
head = ParagraphStyle("TblHead", parent=styles["Normal"], fontName="Helvetica-Bold", fontSize=9, leading=11)
body = ParagraphStyle("TblBody", parent=styles["Normal"], fontSize=9, leading=11)
data = [[_cell(h, head) for h in headers]]
for row in rows:
data.append([_cell(c, body) for c in row])
t = Table(data, colWidths=col_widths)
t.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1565C0")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 9),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
("WORDWRAP", (0, 0), (-1, -1), "CJK"),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F5F5F5")]),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
]
)
)
story.append(t)
def build_pdf() -> None:
styles = getSampleStyleSheet()
title = ParagraphStyle(
"DocTitle",
parent=styles["Title"],
fontSize=18,
spaceAfter=10,
textColor=colors.HexColor("#1565C0"),
)
h1 = ParagraphStyle("H1", parent=styles["Heading1"], fontSize=14, spaceBefore=12, spaceAfter=6)
body = ParagraphStyle("Body", parent=styles["Normal"], fontSize=10, leading=14, alignment=TA_LEFT)
small = ParagraphStyle("Small", parent=body, fontSize=9, textColor=colors.grey)
bullet = ParagraphStyle("Bullet", parent=body, leftIndent=14, bulletIndent=6)
story: list = []
story.append(p("Android Cast — Graph Visualization Pivot", title))
story.append(p("Scope: LAMP + nginx backend, no data duplication pipeline, role-based analytics.", small))
story.append(Spacer(1, 0.25 * cm))
add_table(
story,
["Field", "Value"],
[
["Date", "2026-06-02"],
["Context", "apps.f0xx.org + BE Alpine (nginx + php-fpm + MariaDB)"],
["Constraint", "No cron ETL/data copy/pipeline complexity"],
["Output", "Decision memo: Grafana vs custom PHP dashboards"],
["Markdown source", "docs/GRAFANA_vs_others_graphvis_pivot.md"],
],
[4.2 * cm, 12.2 * cm],
)
story.append(Spacer(1, 0.35 * cm))
story.append(p("1. Executive summary", h1))
story.append(
p(
"• Grafana is viable only if it can query MariaDB directly (or via native SQL datasource) with strict RBAC mapping and SSO/session bridging.<br/>"
"• If direct DB + auth integration becomes complex, custom PHP dashboards are lower risk and fit your stack with zero infra additions.<br/>"
"• Recommended path: <b>hybrid</b> — build canonical SQL views + metrics contract once; start with custom PHP pages, optionally add Grafana later reusing same SQL.",
bullet,
)
)
story.append(Spacer(1, 0.35 * cm))
story.append(p("2. Option comparison", h1))
add_table(
story,
["Option", "Pros", "Cons / Risks", "Stack impact"],
[
[
"Grafana (direct DB)",
"Fast charting, polished visuals, alerting, flexible panels",
"Session/RBAC integration effort; slug scoping must be enforced in queries; additional service to operate",
"Adds Grafana service only (no ETL required)",
],
[
"Custom PHP + nginx",
"Native app auth/session reuse; direct BE logic access; deterministic tenant filtering",
"More frontend/dev work for visual polish; chart UX must be implemented",
"No new runtime dependencies",
],
[
"Hybrid (recommended)",
"Ship fast with PHP now, preserve Grafana option later; one metrics SQL contract",
"Needs discipline in shared metrics layer",
"Minimal immediate changes",
],
],
[3.1 * cm, 4.4 * cm, 5.0 * cm, 3.9 * cm],
)
story.append(Spacer(1, 0.35 * cm))
story.append(p("3. Role-based graph pack (suggested)", h1))
add_table(
story,
["Role", "Core dashboards", "Example charts"],
[
[
"User",
"Own slug, own devices/sessions",
"DAU devices, avg cast duration, avg recv sessions/day, success rate, app version split",
],
[
"Slug admin",
"All slug analytics + health",
"Active/passive users, bandwidth send/recv 1d/7d, install source pie (Play/OTA/custom), NTP source usage and correction savings",
],
[
"Platform admin",
"Cross-slug global + reliability",
"Crashes per slug/device/user, trend by app version/SDK, links to ticket IDs/issues, top regressions by fingerprint",
],
],
[2.7 * cm, 5.1 * cm, 8.6 * cm],
)
story.append(Spacer(1, 0.35 * cm))
story.append(p("4. Effort and cost estimate (engineering)", h1))
add_table(
story,
["Track", "Initial delivery", "Hardening", "Total estimate"],
[
["Grafana direct DB", "4-7 dev days", "5-8 dev days", "9-15 dev days"],
["Custom PHP dashboards", "5-9 dev days", "3-6 dev days", "8-15 dev days"],
["Hybrid phase-1 PHP then Grafana", "6-10 dev days", "optional +4-7 days", "10-17 dev days"],
],
[4.3 * cm, 3.8 * cm, 3.8 * cm, 4.5 * cm],
)
story.append(
p(
"Assumptions: existing MariaDB schema with crash/ticket/session tables, role data available; excludes major schema migration.",
small,
)
)
story.append(Spacer(1, 0.35 * cm))
story.append(p("5. Recommendation under your constraints", h1))
story.append(
p(
"• Do not introduce ETL/duplication. Keep one source of truth: MariaDB + live SQL views.<br/>"
"• Implement a metrics SQL layer now (views/materialized semantics via SQL only, no copied store).<br/>"
"• Build custom PHP dashboard pages first for guaranteed auth/session + slug scoping correctness.<br/>"
"• Re-evaluate Grafana after metrics contract stabilizes; adopt only if direct DB + RBAC mapping is clean.",
bullet,
)
)
story.append(Spacer(1, 0.35 * cm))
story.append(p("6. Proposed next implementation slice", h1))
add_table(
story,
["Step", "Deliverable", "ETA"],
[
["1", "Define metrics dictionary + SQL views for user/slug/admin scopes", "1-2 days"],
["2", "Add heartbeat metric: time source in use + ntp correction savings counters", "0.5-1 day"],
["3", "Create PHP dashboards: user and slug-admin pages with 1d/7d selectors", "3-5 days"],
["4", "Add admin reliability board + crash/ticket links", "2-3 days"],
["5", "Optional Grafana POC over same SQL views", "2-3 days"],
],
[1.2 * cm, 11.9 * cm, 3.3 * cm],
)
doc = SimpleDocTemplate(
str(OUT_PDF),
pagesize=A4,
leftMargin=1.8 * cm,
rightMargin=1.8 * cm,
topMargin=1.6 * cm,
bottomMargin=1.6 * cm,
title="Grafana vs Others GraphVis Pivot",
author="Android Cast project",
)
doc.build(story, canvasmaker=NumberedCanvas)
print(f"Wrote {OUT_PDF}")
if __name__ == "__main__":
build_pdf()

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env python3
"""Build docs/20260602_REVERSE_SSH_proposals_summary.pdf from markdown source + heading TOC."""
from __future__ import annotations
from pathlib import Path
from md_to_pdf import build_pdf_from_markdown
ROOT = Path(__file__).resolve().parents[2]
SOURCE_MD = ROOT / "docs" / "20260602_REVERSE_SSH_proposals_summary.md"
OUT_PDF = ROOT / "docs" / "20260602_REVERSE_SSH_proposals_summary.pdf"
def build_pdf() -> None:
build_pdf_from_markdown(
SOURCE_MD,
OUT_PDF,
doc_subtitle="Generated from markdown — rev. 13 + Appendix 1 (WG test) + Appendix 2 (WG production)",
)
if __name__ == "__main__":
build_pdf()

View File

@@ -0,0 +1,15 @@
flowchart LR
subgraph once [Once per cast]
Cap[MediaProjection capture]
Enc[Video and audio encoders]
Q[CastFanoutPump queues]
end
subgraph perPeer [Per receiver today]
S1[CastSession 1 UDP or TCP]
S2[CastSession 2]
SN[CastSession N]
end
Cap --> Enc --> Q
Q -->|same encoded payload| S1
Q --> S2
Q --> SN

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -0,0 +1,9 @@
sequenceDiagram
participant S as Sender
participant R1 as Receiver 1
participant R2 as Receiver N
S->>R1: Unicast handshake PIN codec tier
S->>R2: Unicast handshake
Note over S: One encoder one sessionId
S-->>R1: Multicast media packets
S-->>R2: Same packets

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,16 @@
%% Branch model — render PNG via mermaid-cli (Chromium). Do not rasterize SVG with rsvg.
flowchart TB
M["master\n(always green, tagged releases)"]
N["next\n(integration / release candidate)"]
F["feature/* fix/*\n(fork next)"]
H["hotfix/*\n(fork master)"]
F -->|"merge + CI green"| N
N -->|"release: merge + CI green"| M
H -->|"urgent patch + CI green"| M
M -->|"sync after release or hotfix"| N
style M fill:#c8e6c9,stroke:#2e7d32
style N fill:#bbdefb,stroke:#1565c0
style F fill:#fff9c4,stroke:#f9a825
style H fill:#ffccbc,stroke:#e64a19

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View File

@@ -0,0 +1,14 @@
sequenceDiagram
autonumber
participant Dev as Developer or agent
participant Topic as feature/* or fix/*
participant Next as next
participant CI as Tests / CI
Dev->>Next: fetch, checkout, pull
Dev->>Topic: create branch from next
Dev->>Topic: commits
Dev->>CI: run tests
CI-->>Dev: pass
Dev->>Next: merge topic branch
Note over Next: delete branch on merge<br/>(origin server setting)

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@@ -0,0 +1,21 @@
flowchart TD
Start([Defect found])
Q{Affects released code\non master / latest tag?}
Start --> Q
Q -->|Yes| A[Branch hotfix/* from master]
A --> B[Fix + tests]
B --> C[Merge to master]
C --> D[Tag patch on master e.g. v0.1.3]
D --> E[Merge master into next]
E --> End([Done])
Q -->|No, only on next| F[Branch fix/* from next]
F --> G[Fix + tests]
G --> H[Merge to next only]
H --> End2([Ships with next release])
style C fill:#c8e6c9
style D fill:#c8e6c9
style E fill:#bbdefb
style H fill:#bbdefb

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View File

@@ -0,0 +1,16 @@
sequenceDiagram
autonumber
participant Next as next
participant Master as master
participant CI as Tests / CI
participant Tag as tag vX.Y.Z
Note over Next: feature complete,<br/>QA ok
Next->>CI: full test suite
CI-->>Next: green
Next->>Master: merge next to master
Master->>CI: smoke on master
CI-->>Master: green
Master->>Tag: annotated tag on master
Master->>Next: merge master into next (sync)
Note over Next: continue features<br/>from synced next

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

48
_pdf_build/md_common.py Normal file
View File

@@ -0,0 +1,48 @@
"""Shared markdown heading / slug helpers for docs PDF and TOC scripts."""
from __future__ import annotations
import re
TOC_START = "<!-- toc -->"
TOC_END = "<!-- /toc -->"
def github_slug(text: str) -> str:
"""GitHub/Cursor-compatible heading anchor."""
text = text.strip().lower()
text = re.sub(r"`([^`]+)`", r"\1", text)
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
text = re.sub(r"[*_]+", "", text)
text = re.sub(r"[^\w\s-]", "", text)
text = re.sub(r"\s+", "-", text)
text = re.sub(r"-+", "-", text)
return text.strip("-")
def collect_headings(lines: list[str]) -> list[tuple[int, str, str]]:
"""Return (level, title, slug) for ## and ### headings."""
out: list[tuple[int, str, str]] = []
used: dict[str, int] = {}
for line in lines:
m = re.match(r"^(#{2,3})\s+(.+?)\s*$", line)
if not m:
continue
level = len(m.group(1))
title = m.group(2).strip()
if title == "Table of contents":
continue
base = github_slug(title)
if not base:
continue
n = used.get(base, 0)
slug = base if n == 0 else f"{base}-{n}"
used[base] = n + 1
out.append((level, title, slug))
return out
def strip_md_link_text(title: str) -> str:
t = re.sub(r"\*\*([^*]+)\*\*", r"\1", title)
t = re.sub(r"`([^`]+)`", r"\1", t)
return t

393
_pdf_build/md_to_pdf.py Normal file
View File

@@ -0,0 +1,393 @@
"""Render a markdown file to PDF with TOC from ## / ### headings (internal links + outline)."""
from __future__ import annotations
import re
from pathlib import Path
from reportlab.lib import colors
from reportlab.lib.enums import TA_LEFT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.platypus import (
Flowable,
PageBreak,
Paragraph,
Preformatted,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
from md_common import TOC_END, TOC_START, collect_headings, github_slug, strip_md_link_text
from mermaid_render import (
cached_mermaid_png,
diagram_flowables,
ensure_diagram_caption_style,
)
from page_footer import NumberedCanvas
def _xml(text: str) -> str:
return (
text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
def _inline_md(text: str) -> str:
text = _xml(text)
text = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", text)
text = re.sub(r"`([^`]+)`", r'<font face="Courier" size="7">\1</font>', text)
text = re.sub(
r"\[([^\]]+)\]\(#([^)]+)\)",
r'<a href="#\2" color="#1565C0">\1</a>',
text,
)
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
return text
class AnchorParagraph(Paragraph):
"""Paragraph with PDF bookmark anchor."""
def __init__(self, text, style, *, anchor: str, outline_level: int):
super().__init__(f'<a name="{anchor}"/>{text}', style)
self._anchor = anchor
self._outline_level = outline_level
class OutlineDocTemplate(SimpleDocTemplate):
def afterFlowable(self, flowable: Flowable) -> None:
if isinstance(flowable, AnchorParagraph):
self.canv.bookmarkPage(flowable._anchor)
self.canv.addOutlineEntry(
flowable.getPlainText(),
flowable._anchor,
level=max(0, flowable._outline_level - 2),
)
def _cell(text: str, style: ParagraphStyle) -> Paragraph:
return Paragraph(_inline_md(text), style)
def _parse_table_row(line: str) -> list[str]:
line = line.strip()
if line.startswith("|"):
line = line[1:]
if line.endswith("|"):
line = line[:-1]
return [c.strip() for c in line.split("|")]
def _is_table_sep(line: str) -> bool:
return bool(re.match(r"^\|?[\s\-:|]+\|?$", line.strip()))
def build_slug_by_line(lines: list[str]) -> dict[int, str]:
"""Map source line index → anchor slug (same rules as collect_headings)."""
out: dict[int, str] = {}
used: dict[str, int] = {}
for i, line in enumerate(lines):
m = re.match(r"^(#{2,3})\s+(.+?)\s*$", line)
if not m:
continue
title = m.group(2).strip()
if title == "Table of contents":
continue
base = github_slug(title)
if not base:
continue
n = used.get(base, 0)
slug = base if n == 0 else f"{base}-{n}"
used[base] = n + 1
out[i] = slug
return out
def build_pdf_from_markdown(
source_md: Path,
out_pdf: Path,
*,
doc_title: str | None = None,
doc_subtitle: str = "",
) -> None:
lines = source_md.read_text(encoding="utf-8").splitlines()
headings = collect_headings(lines)
slug_by_line = build_slug_by_line(lines)
styles = getSampleStyleSheet()
title_style = ParagraphStyle(
"DocTitle",
parent=styles["Title"],
fontSize=15,
spaceAfter=5,
textColor=colors.HexColor("#1565C0"),
)
toc_title = ParagraphStyle(
"TocTitle",
parent=styles["Heading1"],
fontSize=12,
spaceBefore=4,
spaceAfter=6,
textColor=colors.HexColor("#1565C0"),
)
toc_item = ParagraphStyle(
"TocItem",
parent=styles["Normal"],
fontSize=8,
leading=11,
leftIndent=0,
)
toc_sub = ParagraphStyle(
"TocSub",
parent=toc_item,
leftIndent=14,
fontSize=7.5,
leading=10,
)
h1 = ParagraphStyle("H1", parent=styles["Heading1"], fontSize=11, spaceBefore=10, spaceAfter=4)
h2 = ParagraphStyle("H2", parent=styles["Heading2"], fontSize=9.5, spaceBefore=6, spaceAfter=3)
body = ParagraphStyle("Body", parent=styles["Normal"], fontSize=8, leading=10.5, alignment=TA_LEFT)
bullet = ParagraphStyle("Bullet", parent=body, leftIndent=12, bulletIndent=4, fontSize=8, leading=10.5)
small = ParagraphStyle("Small", parent=body, fontSize=7, textColor=colors.grey)
code_style = ParagraphStyle("Code", parent=body, fontName="Courier", fontSize=6.5, leading=8)
tbl_head = ParagraphStyle("TblHead", parent=body, fontName="Helvetica-Bold", fontSize=7, leading=9)
tbl_body = ParagraphStyle("TblBody", parent=body, fontSize=7, leading=9)
story: list = []
# Title from first # line if not overridden
if doc_title is None:
for line in lines:
if line.startswith("# "):
doc_title = line[2:].strip()
break
doc_title = doc_title or source_md.stem
story.append(Paragraph(_inline_md(doc_title), title_style))
if doc_subtitle:
story.append(Paragraph(_inline_md(doc_subtitle), small))
story.append(Spacer(1, 0.15 * cm))
# Optional first-page metadata table (doc template block)
meta_start = next((i for i, ln in enumerate(lines) if ln.strip() == "<!-- doc-meta:start -->"), None)
meta_end = next((i for i, ln in enumerate(lines) if ln.strip() == "<!-- doc-meta:end -->"), None)
if meta_start is not None and meta_end is not None and meta_end > meta_start:
meta_rows = [ln for ln in lines[meta_start + 1 : meta_end] if ln.strip().startswith("|")]
if len(meta_rows) >= 2:
headers = _parse_table_row(meta_rows[0])
data = [[_cell(h, tbl_head) for h in headers]]
for row_line in meta_rows[2:]: # skip separator row
row = _parse_table_row(row_line)
padded = row + [""] * (len(headers) - len(row))
data.append([_cell(c, tbl_body) for c in padded[: len(headers)]])
if data and len(headers) > 0:
col_widths = [5.2 * cm, 11.0 * cm] if len(headers) == 2 else [(16.2 * cm) / len(headers)] * len(headers)
meta_tbl = Table(data, colWidths=col_widths)
meta_tbl.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1565C0")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 8),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F5F5F5")]),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
]
)
)
story.append(meta_tbl)
story.append(Spacer(1, 0.2 * cm))
# Page 1: title + metadata, page 2: TOC
story.append(PageBreak())
# Table of contents (clickable internal links)
story.append(Paragraph("Table of contents", toc_title))
for level, title, slug in headings:
text = strip_md_link_text(title)
link = f'<a href="#{slug}" color="#1565C0">{_xml(text)}</a>'
style = toc_sub if level == 3 else toc_item
story.append(Paragraph(link, style))
story.append(Spacer(1, 0.2 * cm))
story.append(
Paragraph(
f"<i>Source: {source_md.name} — section links work in PDF viewers that support internal anchors.</i>",
small,
)
)
story.append(PageBreak())
# Body — skip title, TOC block, index link
skip_toc = False
in_code = False
code_lang = ""
code_lines: list[str] = []
ensure_diagram_caption_style(styles)
i = 0
while i < len(lines):
line = lines[i]
if line.strip() == TOC_START:
skip_toc = True
i += 1
continue
if line.strip() == TOC_END:
skip_toc = False
i += 1
continue
if skip_toc:
i += 1
continue
if line.startswith("# ") and not line.startswith("## "):
i += 1
continue
if line.strip() == "## Table of contents":
i += 1
continue
if line.strip().startswith("**Documentation index:**"):
i += 1
continue
if line.strip() == "<!-- doc-meta:start -->":
while i < len(lines) and lines[i].strip() != "<!-- doc-meta:end -->":
i += 1
i += 1
continue
if line.strip() == "<!-- doc-meta:end -->":
i += 1
continue
if line.strip() == "\\newpage":
story.append(PageBreak())
i += 1
continue
if line.strip().startswith("```"):
if in_code:
block = "\n".join(code_lines)
if code_lang == "mermaid" and block.strip():
try:
png = cached_mermaid_png(block)
story.extend(diagram_flowables(png, styles))
except Exception as exc:
story.append(
Paragraph(
f"<i>Diagram render failed ({_xml(str(exc))}) — source:</i>",
small,
)
)
story.append(Preformatted(block, code_style))
story.append(Spacer(1, 0.08 * cm))
else:
story.append(Preformatted(block, code_style))
story.append(Spacer(1, 0.08 * cm))
code_lines = []
code_lang = ""
in_code = False
else:
in_code = True
code_lang = line.strip()[3:].strip().lower()
i += 1
continue
if in_code:
code_lines.append(line)
i += 1
continue
if line.startswith("### "):
title = line[4:].strip()
slug = slug_by_line.get(i, github_slug(title))
story.append(
AnchorParagraph(_inline_md(title), h2, anchor=slug, outline_level=3)
)
i += 1
continue
if line.startswith("## "):
title = line[3:].strip()
slug = slug_by_line.get(i, github_slug(title))
story.append(
AnchorParagraph(_inline_md(title), h1, anchor=slug, outline_level=2)
)
i += 1
continue
if line.strip() == "---":
story.append(Spacer(1, 0.1 * cm))
i += 1
continue
if line.strip().startswith("|") and i + 1 < len(lines) and _is_table_sep(lines[i + 1]):
headers = _parse_table_row(line)
i += 2
rows: list[list[str]] = []
while i < len(lines) and lines[i].strip().startswith("|"):
rows.append(_parse_table_row(lines[i]))
i += 1
ncol = len(headers)
if ncol:
width = (16.2 * cm) / ncol
data = [[_cell(h, tbl_head) for h in headers]]
for row in rows:
padded = row + [""] * (ncol - len(row))
data.append([_cell(c, tbl_body) for c in padded[:ncol]])
t = Table(data, colWidths=[width] * ncol)
t.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1565C0")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 7),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F5F5F5")]),
("LEFTPADDING", (0, 0), (-1, -1), 3),
("RIGHTPADDING", (0, 0), (-1, -1), 3),
("TOPPADDING", (0, 0), (-1, -1), 2),
("BOTTOMPADDING", (0, 0), (-1, -1), 2),
]
)
)
story.append(t)
story.append(Spacer(1, 0.08 * cm))
continue
if line.strip().startswith("- "):
story.append(Paragraph(f"{_inline_md(line.strip()[2:])}", bullet))
i += 1
continue
if line.strip().startswith("_") and line.strip().endswith("_") and len(line.strip()) > 2:
story.append(Paragraph(f"<i>{_inline_md(line.strip()[1:-1])}</i>", small))
i += 1
continue
if not line.strip():
i += 1
continue
story.append(Paragraph(_inline_md(line.strip()), body))
i += 1
doc = OutlineDocTemplate(
str(out_pdf),
pagesize=A4,
leftMargin=1.4 * cm,
rightMargin=1.4 * cm,
topMargin=1.3 * cm,
bottomMargin=1.3 * cm,
title=doc_title,
author="Android Cast project",
)
doc.build(story, canvasmaker=NumberedCanvas)
print(f"Wrote {out_pdf} ({len(headings)} TOC entries from markdown headings)")

View File

@@ -0,0 +1,17 @@
{
"theme": "neutral",
"flowchart": {
"htmlLabels": true,
"curve": "basis"
},
"sequence": {
"diagramMarginX": 120,
"diagramMarginY": 28,
"actorMargin": 90,
"noteMargin": 24,
"messageMargin": 48,
"boxMargin": 14,
"mirrorActors": false,
"useMaxWidth": false
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -0,0 +1,145 @@
"""Render Mermaid diagram sources to PNG for PDF embedding (mermaid-cli + Chromium)."""
from __future__ import annotations
import hashlib
import subprocess
import tempfile
from pathlib import Path
from reportlab.lib.units import cm
from reportlab.lib.utils import ImageReader
from reportlab.platypus import Image, Paragraph, Spacer
BUILD_DIR = Path(__file__).resolve().parent
CONFIG = BUILD_DIR / "mermaid-config.json"
CACHE_DIR = BUILD_DIR / "mermaid_cache"
MERMAID_CLI = ("npx", "--yes", "@mermaid-js/mermaid-cli@11.4.0")
MAX_DIAGRAM_WIDTH_CM = 15.8
MAX_DIAGRAM_HEIGHT_CM = 17.0
CAPTION_STYLE_NAME = "DiagramCaption"
def _content_hash(content: str) -> str:
return hashlib.sha256(content.strip().encode("utf-8")).hexdigest()[:16]
def estimate_mermaid_pixels(content: str) -> tuple[int, int, int]:
"""Return (width_px, height_px, scale) heuristics for flowchart layout."""
lines = [ln for ln in content.splitlines() if ln.strip() and not ln.strip().startswith("%%")]
n_edges = sum(1 for ln in lines if "-->" in ln or "---" in ln or "-.->" in ln)
n_nodes = sum(
1
for ln in lines
if "[" in ln or "(" in ln or "{" in ln or (ln.strip() and "-->" not in ln and "subgraph" not in ln.lower())
)
n_sub = sum(1 for ln in lines if "subgraph" in ln.lower())
lr = any("flowchart LR" in ln or "graph LR" in ln for ln in lines)
scale = 2
if lr:
width = 2200 + min(n_nodes, 12) * 120
height = 650 + n_sub * 180 + min(n_edges, 10) * 30
else:
width = 1700 + min(n_sub, 4) * 200
height = 650 + n_edges * 70 + n_sub * 220 + min(n_nodes, 14) * 25
width = int(min(max(width, 1200), 3600))
height = int(min(max(height, 550), 3400))
return width, height, scale
def render_mermaid_to_png(content: str, dest: Path, *, width: int, height: int, scale: int) -> None:
if not CONFIG.exists():
raise FileNotFoundError(f"mermaid config missing: {CONFIG}")
dest.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", suffix=".mmd", delete=False, encoding="utf-8") as tmp:
tmp.write(content.strip() + "\n")
mmd_path = Path(tmp.name)
try:
subprocess.run(
[
*MERMAID_CLI,
"-i",
str(mmd_path),
"-o",
str(dest),
"-c",
str(CONFIG),
"-b",
"white",
"-w",
str(width),
"-H",
str(height),
"-s",
str(scale),
],
cwd=str(BUILD_DIR.parents[1]),
check=True,
timeout=180,
capture_output=True,
text=True,
)
except subprocess.CalledProcessError as exc:
msg = (exc.stderr or exc.stdout or str(exc)).strip()
raise RuntimeError(f"mermaid-cli failed: {msg}") from exc
finally:
mmd_path.unlink(missing_ok=True)
if not dest.is_file() or dest.stat().st_size == 0:
raise RuntimeError(f"mermaid-cli produced no PNG: {dest}")
def cached_mermaid_png(content: str) -> Path:
"""Render or reuse cached PNG for Mermaid source."""
CACHE_DIR.mkdir(parents=True, exist_ok=True)
key = _content_hash(content)
png = CACHE_DIR / f"{key}.png"
if png.is_file() and png.stat().st_size > 0:
return png
w, h, scale = estimate_mermaid_pixels(content)
render_mermaid_to_png(content, png, width=w, height=h, scale=scale)
return png
def diagram_flowables(
png_path: Path,
styles,
*,
caption: str = "",
max_width_cm: float = MAX_DIAGRAM_WIDTH_CM,
max_height_cm: float = MAX_DIAGRAM_HEIGHT_CM,
) -> list:
"""ReportLab flowables: image (+ optional caption) sized to fit A4 body."""
reader = ImageReader(str(png_path))
w_px, h_px = reader.getSize()
max_w = max_width_cm * cm
max_h = max_height_cm * cm
scale = min(max_w / w_px, max_h / h_px)
img = Image(str(png_path), width=w_px * scale, height=h_px * scale)
img.hAlign = "CENTER"
out: list = [Spacer(1, 0.12 * cm), img, Spacer(1, 0.06 * cm)]
if caption:
cap = styles[CAPTION_STYLE_NAME] if CAPTION_STYLE_NAME in styles else styles["Normal"]
out.insert(0, Spacer(1, 0.06 * cm))
out.append(Paragraph(f"<i>{caption}</i>", cap))
out.append(Spacer(1, 0.1 * cm))
return out
def ensure_diagram_caption_style(styles) -> None:
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib import colors
if CAPTION_STYLE_NAME not in styles:
styles.add(
ParagraphStyle(
CAPTION_STYLE_NAME,
parent=styles["Normal"],
fontSize=7,
textColor=colors.grey,
alignment=1,
spaceAfter=4,
)
)

36
_pdf_build/page_footer.py Normal file
View File

@@ -0,0 +1,36 @@
"""Shared PDF footer canvas: bottom-right `current / total` page counter."""
from __future__ import annotations
from reportlab.lib import colors
from reportlab.pdfgen import canvas
class NumberedCanvas(canvas.Canvas):
"""Canvas that renders `N / total` footer on every page."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._saved_page_states: list[dict] = []
def showPage(self) -> None:
self._saved_page_states.append(dict(self.__dict__))
self._startPage()
def save(self) -> None:
total = len(self._saved_page_states)
for state in self._saved_page_states:
self.__dict__.update(state)
self._draw_page_footer(total)
super().showPage()
super().save()
def _draw_page_footer(self, total_pages: int) -> None:
self.saveState()
self.setFillColor(colors.HexColor("#BDBDBD"))
self.setFont("Helvetica", 8)
label = f"{self._pageNumber} / {total_pages}"
x = self._pagesize[0] - 1.5 * 28.3465 # ~1.5 cm from right
y = 0.9 * 28.3465 # ~0.9 cm from bottom
self.drawRightString(x, y, label)
self.restoreState()

View File

@@ -0,0 +1,5 @@
reportlab>=4.0
# Diagrams in markdown (```mermaid blocks): npx @mermaid-js/mermaid-cli (uses bundled Chromium).
# Do NOT use rsvg-convert on Mermaid SVG — HTML labels are dropped.
# Example: npx --yes @mermaid-js/mermaid-cli@11.4.0 --version