展示屏

循环播放的全屏页面,用在活动大屏、店铺橱窗或展厅。它的设计目标是无人值守连续运行多天,限制见文末的“已知限制”。和演示一样,它是一个离线 HTML 文件。

规则为什么是这样,以及出处:代码仓库的 plans/research-screen.md。

做一个展示屏

npx slidehtml screen new my-hall         # 生成 src/screens/my-hall/index.tsx
npx slidehtml screen dev my-hall         # 预览
npx slidehtml screen check my-hall       # 字体、类型检查、构建、QA,最后一行是 PASS 或 FAIL
npx slidehtml screen build my-hall       # 输出 dist/screen/my-hall.html

写完要跑到 PASS,并看过 qa/screen/my-hall/ 里的每张图。

check 需要 Playwright 的 Chromium(npx playwright install chromium)。其中的字体步骤需要 Python 3、fonttools、brotli 和 Noto CJK 的 .ttc 文件(apt install fonts-noto-cjk fonts-noto-cjk-extra,或者设置 NOTO_CJK_DIR)。缺了这些,check 会以 FAIL 结束。build 不跑字体步骤,也不跑 QA,所以要在 check 之后用,不能代替它。

import { BigNumber, Hero, defineScreen } from '../../screen'

export const screen = defineScreen({
  title: '我的展厅',
  theme: 'cobalt',
  face: 'editorial',                  // editorial | grotesk | heavy
  atmosphere: 'stars',                // stars | grid | none
  orientation: 'landscape',           // landscape | portrait | both
  venue: { diagonalIn: 98, distanceM: 5, baselineM: 1.4, panel: 'lcd' },
  clock: { date: true },
  reloadAt: '04:30',
  scenes: [
    { id: 'open', label: '开场', seconds: 12, render: () => <Hero title={['一个信息']} source="来源:…" /> },
  ],
})

字号由场地决定

venue 必填。写明屏幕大小(diagonalIn,LED 墙用 longEdgeMm)、希望从多远读到(distanceM)、文字离地多高(baselineM)。引擎据此算出所有字号,所以没有哪个模板能设置字号。字段放不下,就删字或拆场景。

QA 测量字形的真实高度:

文字规则
拉丁字母、数字2010 ADA Standards Table 703.5.5,按大写 I 测量
中文同一数值 × 1.54(Zhang et al. 2020)
小字(来源、署名)在 nearM(默认 1.5 米)处 16 弧分,且至少 20 设计 px

举例:98 英寸屏幕从 5 米读,中文字号约 80 设计 px。5 米宽的 LED 墙从 10 米读、安装位置较高时,约 77 设计 px。

模板

每个模板都有自己的横屏和竖屏版式。下表的上限针对示例场地,真正的上限以你的场地下 QA 的结果为准。

模板用途字段
Hero一个信息kicker?、title[](≤ 3 行)、portraitTitle[]?、sub?、art?、source、credit?
BigNumber一个数字kicker?、value(≤ 7 个字符)、unit?、label、note?、art?、source、credit?
Moments3–5 个事件,依次点亮kicker?、title[]、items[{ when, at?, label }]、source
Quote某人说过的一句话kicker?、lines[]、original?、by、source
Pair两半,后一半是落点kicker?、items[2]{ big, small }、source
Board固定的小表格kicker?、title、rows[{ left, right }](≤ 5)、source
NowNext正在进行和下一场,由时钟决定source
Agenda今日议程,由时钟决定title?、source

art 写成 { src, mode: 'disc' | 'ground' },src 是用 import 引入的 decor- 或 photo- 图片,并在 credit 里写明。

中文可以在任意两个字之间换行,所以较长的 sub 或 note 可能把词组拆开。要保持完整,用 JS 字符串写换行:sub={'第一句,\n第二句。'}。普通的 sub="..." 属性里,JSX 会把 \n 当成字面文字。议程标题来自数据,不能写换行,要写得足够短,或者检查竖屏的 NowNext,那里会以最大字号换行。

不要嵌入 kit 幻灯片。它们的正文字号是给坐在屋里的人设计的,在任何真实的展示屏场地都过不了距离规则。

内容规则

  • 一个场景一个信息。三个数字就是三个场景。
  • source 必填,以小字显示。查不到出处的事实不要写。
  • 虚构数据:在屏幕上设置 sample。它以正文字号显示在每个场景上,任何场景缺了它 QA 都会 FAIL。
  • seconds 要够读完:2 秒 + 字符数 ÷ 每分钟 260 + 单词数 ÷ 每分钟 238,最少 6 秒。

时间

timezone 是 IANA 名称,不写就用播放设备自己的时区。下面的内容都跟随它。

{ id: 'lunch', label: '午餐', seconds: 9, when: { from: '12:00', to: '13:30' }, render: … }  // 只在这段时间播放
{ when: { from: '22:00', to: '02:00' } }                                      // 跨午夜
{ when: [{ from: '09:00', to: '18:00', days: [1, 2, 3, 4, 5] }] }             // 工作日;0 是周日
{ when: { from: '08:00', to: '20:00', dates: ['2026-11-14'] } }               // 某一天

要显示正在进行和下一场,写 agenda: (data) => sessions。每场有 start、end、title、who?、room?、date?、isBreak?。NowNext、Agenda 和 items 为函数的 ticker 都跟随时钟,不需要重新加载。states 列出 QA 要拍照的时刻。

实时数据

data: { initial: SAMPLE, parse: parseEvent, everySec: 30, staleAfterSec: 120, source: 'event-data.js' }

设置 source,或者在 URL 里写 ?data=。

  • 本地文件必须是放在页面旁边的 .js 文件,用相对路径。内容是一次调用:window.__screenData({ ... })。从 file:// 打开的页面读不了 JSON。
  • URL 必须返回 JSON,如果页面在 file:// 上,还要允许 CORS。远程 .js 会被拒绝,因为它会在页面里作为代码运行。

parse 遇到格式错误的内容应该抛出异常:上一份正确的数据会保留,刷新后也还在。超过 staleAfterSec 没有拿到正确数据时,屏幕上显示 数据已过期 · 最后更新 14:32,数据恢复后消失。

安装

把这一个 HTML 文件复制到播放设备,然后:

# Linux 或树莓派。参数来自树莓派的 kiosk 教程。
chromium --kiosk --noerrdialogs --disable-infobars --no-first-run --start-maximized \
  "file:///home/signage/my-hall.html"

浏览器之外必须有一个守护进程。 页面能接住自己的错误,但重启不了崩溃的渲染进程,也发现不了自己卡死的主线程。下面的 unit 只是起点,没有在真实的播放设备上跑过;用户服务还需要你的图形会话环境,所以活动之前要测试,包括拔电源。

# /etc/systemd/user/signage.service
[Service]
ExecStart=/usr/bin/chromium --kiosk --noerrdialogs --disable-infobars --no-first-run --start-maximized file:///home/signage/my-hall.html
Restart=always
RestartSec=5
[Install]
WantedBy=default.target

设备上还要做这些:

  • 在操作系统里关闭屏幕熄灭和 DPMS,关闭屏幕自带的节能定时器。页面会请求 wake lock,但浏览器只把它当成建议。
  • 把屏幕的画面尺寸设为 1:1 / Just Scan / 不过扫描。
  • 让系统时钟通过 NTP 同步。排期准不准,取决于设备时钟准不准。
  • 树莓派要打开 overlay 文件系统,这样断电不会损坏存储卡。
  • 竖装屏幕:尽量在操作系统里旋转。做不到的话,在 URL 加 ?rotate=90 或 ?rotate=270。屏幕可以往哪边转,查说明书,各厂商不一样。

操作按键

不按键时什么都不显示。鼠标指针是隐藏的。

按键作用
F全屏
Space暂停,10 分钟后自动继续
N / →,P / ←下一个,上一个
I状态:时钟、运行时长、内存、wake lock、数据、错误日志
R重新加载

URL 参数

参数含义
?now=14:30,?now=2026-11-14T14:30让屏幕时钟假定现在是这个时间
?speed=60让时间跑快,用来预览
?pin=<scene id>停在一个场景上
?rotate=90在没有旋转的输出上旋转画面
?data=<路径或 url>数据源

出问题时会怎样

故障行为
场景抛出异常或什么都没渲染记录并跳过,不会黑屏。休息 5 分钟,翻倍直到 1 小时,然后再试
所有场景都失败,或者没有场景在排期内显示带标题的等待卡,每 30 秒重试
找不到责任场景的错误记录,循环继续
数据轮询失败或格式错误上一份正确的数据保留。退避到 10 分钟一次。到设定时间后显示过期提示
堆内存超过 memoryLimitMb(默认 400)在下一次场景切换时重新加载,30 分钟内最多一次。仅限 Chromium
reloadAt每天在下一次场景切换时重新加载一次。通过 http 提供时,只有 HEAD 检查成功才会加载
渲染进程崩溃或卡死,或者机器重启页面无能为力:这是守护进程的工作

烧屏

底色接近纯黑,不用纯白。时钟、名称和来源每次切换场景时移动几个像素,每 10 分钟左右互换(burnIn: { flipMinutes, shift })。panel: 'oled' 把移动幅度加倍,'led' 关闭移动。如果完整循环一遍后,屏幕上有超过 1% 的区域始终高于 25% 亮度,QA 会 FAIL。这只能降低风险,不能消除风险。比如 LG 的 B2B 保修不包括残影。请读你自己的保修条款。

QA 检查什么

scripts/screen-qa.mjs 针对构建好的文件运行,按 orientation 在 1920×1080 和 3840×2160,和/或 1080×1920 和 2160×3840 上检查:

溢出、裁切、重叠和 90% 安全区。按字形检查文字大小是否符合场地。相对真实像素的对比度至少 4.5:1。回退到系统字体。来源是否存在,示例标注。每个场景的时间。每个时间窗口的边界和实时时钟翻转。场景抛出异常、在 effect 里失败、什么都没渲染。所有场景失败,然后恢复。异步错误。操作按键。数据源、错误数据、文件缺失、过期状态、恢复、断线期间重新加载、远程脚本。夜间重新加载和内存重新加载。烧屏像素。800 次场景切换的 soak 测试,堆内存、DOM 或监听器无限增长时 FAIL。没有控制台错误和网络请求。

已知限制

  • soak 测试是把时间加速跑 800 次场景切换,不等于在设备上跑几天,也测不到 GPU 内存、驱动和屏幕。活动之前,在真实的播放设备上跑几天。
  • 页面重启不了崩溃的渲染进程,也发现不了自己卡死的主线程,所以浏览器之外必须有守护进程。上面的 systemd unit 只是起点,没有在真实的播放设备上跑过。
  • wake lock 只是建议。设备上也要关闭熄屏和节能。
  • 排期准不准,取决于设备时钟准不准。
  • 内存重新加载只在 Chromium 里有效。
  • 防烧屏措施只能降低风险,不能消除风险。
  • 数据过期和暂停的提示只有中文,写了 lang: 'en' 也一样。