ひとつの API、ふたつの認証情報。コンソールも他のプログラムと同じく、この
API の一利用者です。人はセッション Cookie で、プログラムは Bearer トークンで、
どちらも同じルートを使います。すべてのパスは /api/v1 配下にあり、この
ページは連携プログラムが依存すべき厳選されたサブセットです。
トークンの取得
管理者として チーム → API トークン → 新しいトークン。シークレットの 表示は一度きりで、保存されるのはハッシュだけ。失効は即時です。
トークンが持つのはメンバーの権限です — 画面に映るものは変えられますが、
画面そのものには触れません。画面の登録・再起動・設定変更、メンバーの招待、
別トークンの発行はできず、管理者と記された操作はすべて 403 を返します。
すべてのリクエストに付けてください:
Authorization: Bearer anysign_<64 hex characters>エラーはどこでも同じ形式です。機械可読の reason、英語の message、
そして文が制限値に言及するときの params。
トークンにはアクセス範囲もあります。見て変更できるグループを、
発行時に指定し(POST /tokens { name, scope? }。scope は
{ groupIds? } または null)、後から変更できます
(PUT /tokens/:tokenId/scope { scope }、管理者限定——トークンは自分の
視野を広げることはできず、管理者も自分の視野の内側にあるトークンしか
変更できません)。何も付与されていないトークンは組織全体に対して働きます。
GET /tokens は各トークンの scope を返し、付与がなければ null です。
このボディは初回リリースのトップレベル groupIds / screenIds フィールドを
置き換えます。screenIds は廃止されました。画面 1 面だけが必要なトークンには、
その 1 面だけのグループを与えてください。範囲を絞られたトークンは、自分が保持
するグループとその下のグループにある画面だけを見ることができ、それ以外の画面へ
のルートはすべて 404 を返します。ツリーはその場で読まれるので、保持している
グループに画面が移されると、次のリクエストからトークンの視野に入ります。
モデル:一つの枠と、グループのツリー
画面が映すものはちょうど一つ — ループするプレイリスト、または単体のアセット
です。それがその画面の枠であり、そこへの書き込みがすべてです。下に隠れて
いるものはなく、外して現れる階層もありません。最後の書き込みが勝ちます。誰か
が 1 面を手で設定した一分後にグループへ配信すればその画面も変わり、その一分後
に画面へ配信すれば元に戻ります。すべての書き込みは、何を置き換えたかとともに
監査ログに残り、GET /screens/:screenId/history で直近の表示履歴を読めます。
画面はグループのツリーの中にあります。ルートは組織ごとに一つで、組織の名前 が付きます。画面はちょうど一つのグループに属し、「Lobby」にいることは 「Branch A」にいることです。グループに何かを表示すると、その下のすべての画面 に表示され、グループがそれを覚えているので、後から移ってきた画面もそれを 引き継ぎます。
アクセス範囲
人でもトークンでも、主体のフリート上の視野は一部のグループに絞ることができます。付与が一つもない主体はルート、つまり組織全体を保持します。グループを保持することは、その下のすべてを保持することです。範囲を絞られた主体は、自分が保持するグループとその下のグループにある画面だけを見て、それ以外の画面へのルートはすべて 404 を返します。
他のすべては一つの規則から導かれます。**グループは、保持しているか、保持しているグループの下にあるときに限り視野に入ります。**保持しているグループの下に作ったグループには付与が要りません。親がすでにそれを含んでいるからです。登録では何も付与されません。範囲を絞られた主体は保持しているグループを指定しなければならず(422 claim_group_required)、組織の管理者は指定を省略でき、その場合画面はルートに置かれます。編集者は自分が保持するものだけを渡すことができ、組織全体を渡すことはできません(403 scope_cannot_widen)。また、視野全体が自分の視野の内側にある主体だけを変更・限定・削除・失効できます(403 outside_scope)。
ロールはアクセス範囲の影響を受けません。ワイヤ上の scope は { groupIds } で、null は組織全体を意味します。リストが空ならアクセスなしです。唯一保持していたグループが削除されるとこの状態になります(付与はグループとともに消え、組織全体に広がることはありません)。groupIds にルートの id を入れると組織全体を意味し、null に正規化されます。対象がすべて削除された招待は受諾時に拒否され(409 invite_scope_stale)、保留のまま残ります。コンテンツライブラリは組織全体で共有のままです。削除ガードは組織全体を見て拒否しますが、名前を挙げるのはあなたに見える画面だけです。オフライン通知メールと監査ログは読み手の視野で絞られます。
制限
GET /limits
この API がリクエストを判定する上限値です。ハードコードせず読んでください — あなた側のリリースなしで引き上げられることがあります。
{
"upload": { "maxBytes": 33554432 },
"image": {
"maxBytes": 33554432,
"maxDimension": 4096,
"maxPixels": 8847360,
"unplayableMimes": ["image/webp"]
},
"video": { "maxBytes": 33554432 },
"playlist": { "maxItems": 500 }
}サーバー側のリサイズはありません。 保存されたバイトがそのまま重複排除の ハッシュ対象なので、変換はコンテンツアドレッシングを壊してしまいます。 リサイズはあなたの仕事です — ブラウザなら 6 行:
const bmp = await createImageBitmap(file, { imageOrientation: "from-image" });
const scale = Math.min(
1,
4096 / Math.max(bmp.width, bmp.height),
Math.sqrt(8847360 / (bmp.width * bmp.height)),
);
const canvas = Object.assign(document.createElement("canvas"), {
width: Math.floor(bmp.width * scale),
height: Math.floor(bmp.height * scale),
});
canvas.getContext("2d").drawImage(bmp, 0, 0, canvas.width, canvas.height);
const blob = await new Promise((r) => canvas.toBlob(r, "image/jpeg", 0.9));imageOrientation: "from-image" は重要です。これがないと縦位置の写真が
誤った軸で測られ、そのうえレンダラーにもう一度回転されます。
画面
GET /screens
{ "screens": [ {
"id": "…", "name": "Lobby", "site": "HQ",
"group": { "id": "…", "name": "Lobby", "path": "Branch A / Lobby" },
"lastSeenAt": 1756400000000,
"connectivity": "online", // online | offline | never-seen
"state": "converged", // converged | syncing | no-content | unknown
"converged": true,
"revoked": false,
"content": { "kind": "playlist", "playlistId": "…", "name": "Lobby loop" }
} ] }判断の根拠にすべきフィールドは converged です。 画面が通信しており、
映すものがあり、最後に指示したバージョンを報告しているときに限って true に
なります — サーバーが送信しただけでは決してなりません。content はその画面の
枠で、何も映していなければ null です。group は画面が属する唯一のグループを
示します。報告が途絶えた画面は、最後に何と言っていようと unknown になります。
GET /screens/:screenId は 1 台分を返し、稼働状態の詳細と、デバイスが
最後に報告した自身の状態が付きます。
PUT /screens/:screenId/content
{ "playlistId": "…" } // プレイリストをループ
{ "assetId": "…", "durationMs": 15000 } // 単体のアセットで画面を占有いずれか一方だけです。プレイリストは存在していなければならず、アセットは アップロードが完了していなければなりません。まだ届いていないバイト列を指す 画面は、それを欠落と数えて延々とステージングを続けます。
DELETE /screens/:screenId/content
画面は何も映さなくなります。もともと空の枠をクリアするのも成功です。 消したかったものが消えている、というだけのことです。
POST /screens/content
同じボディに screenIds: […](最大 1000)を加えたもの。複数の画面に対する
一つの操作で、{ ok, screens, cause } を返します。POST /audit/:cause/undo
は、その操作が届いたすべての画面を、各行に記録された以前の内容へ戻します。
GET /screens/:screenId/history
その画面が最近映したもの、重複を除いて最大 20 件、新しい順。プレイリストや
アセットがすでに削除されていれば available: false が付きます。
POST /screens/:screenId/commands
{ "type": "refetch" } // メンバー:いますぐ再同期
{ "type": "screenshot" } // メンバー:パネルの表示をキャプチャ
{ "type": "reboot" } // 管理者コマンドは画面の次回確認に相乗りして届き、実行結果も報告されます。
refetch はデバイスが誤ったバイトを抱えた場合の修復ツールであって、
近道ではありません — 通常の公開は画面自身の確認で届きます。
グループ
グループはツリーを作ります。ルートは組織ごとに一つで、組織の名前が付きます。画面はちょうど一つのグループに属し、「Lobby」にいることは「Branch A」にいることです。グループには二つのものが結び付きます。下に述べるコンテンツと、アクセスです。あるグループを保持する人やトークンは、その配下すべてを見ます。詳しくは上のアクセス範囲を参照してください。
GET /groups は、あなたの視野にあるグループを読みやすい順で返します。
{ "groups": [ {
"id": "…", "name": "Lobby", "parentId": "…", "path": "Branch A / Lobby", "depth": 2,
"createdAt": 1756400000000,
"screenIds": ["…"], // このグループに直接属する画面
"content": { "kind": "playlist", "playlistId": "…", "name": "Lobby loop" },
"grantees": 2, "soleHolders": 1
} ] }ルートは parentId: null を持ち、id は組織の id です。ツリーの形を変えるのは管理者の操作です。POST /groups { name, parentId }(同じ親の下では名前が一意。重複は 409 group_name_taken)、PATCH /groups/:groupId { name }、POST /groups/:groupId/move { parentId }(自分自身の中へは移せません。409 group_cycle)、DELETE /groups/:groupId — 中の画面と子グループは一つ上のグループへ移ります。このグループを名指ししているすべての付与は取り除かれ、その結果何も残らなくなった相手はアクセスなしのままで、組織全体に広がることはありません(ルートは 409 group_root、自分のアクセス権の元になっているグループは 409 group_is_grant_root)。POST /screens/move { screenIds, groupId } は画面をグループに入れます。移動先はあなたの視野の中でなければならないので、移動が視野の外へ出ることはありません。
グループのコンテンツは、画面のコンテンツと同じくメンバー権限です。PUT /groups/:groupId/content は画面と同じボディを受け取り、そのグループの下にあるすべての画面に表示します。グループとその下のすべてのグループがそれを覚えます。DELETE /groups/:groupId/content はそれらをすべてクリアします。どちらも { ok, screens, cause } を返します。GET /groups/:groupId/history はそのグループの最近の配信を返します。
グループのコンテンツは記憶であって権威ではありません。リゾルバが読むのは画面自身の枠だけです。中身のあるグループへ移された画面はそれを引き継ぎ、空のグループへ移された画面はそれまで映していたものをそのまま映し続けます。
POST /screens/claim は groupId を受け取ります。範囲を絞られた主体は保持しているグループを指定しなければならず、組織の管理者は省略でき、その場合画面はルートに置かれます。
プレイリストと素材の削除ガードは、いずれかの画面の枠、またはいずれかのグループの記憶がそれを保持している限り拒否します。
コンテンツのアップロード
POST /content?name=<filename>[&durationMs=<ms>]
ボディがそのままファイルです。型は Content-Type ヘッダーで指定します。
curl -X POST "$BASE/api/v1/content?name=menu.png" \
-H "authorization: Bearer $TOKEN" \
-H "content-type: image/png" \
--data-binary @menu.png{
"assetId": "…", "sha256": "…", "kind": "image", "bytes": 20480,
"width": 1920, "height": 1080, "deduped": false
}width と height はサーバーが測った値で、測れなかったときは null
です — ファイル自体は保存されますが、ここが null なら、その画面に何も
描画されないという合図です。画面に割り当てる前に必ず検証してください。
同一バイトの再アップロードは no-op で、既存のアセットを 200
(deduped: true)で返します。ふたつのプッシュが競合しても同じです。
これは意図的な設計です — コネクタの自然な形は「毎回ぜんぶ送る」だから
です。
応答の sha256 は保存されたバイトから当方が計算したもの。画像のガード レール(最長辺、総ピクセル数)はファイル自身の PNG/JPEG/GIF ヘッダー に対して実行され、テレビのパネルがデコードできない画像は画面を巻き込む 前に、入り口で拒否されます。
制限:1 リクエスト 32 MB、画像と動画のみ、WebP は不可 — アップロードも 同期も通りますが、パネルには決して描画されません。
32 MB 超はマルチパートで:POST /assets にファイルの sha256、mime、
バイト数、種類を渡すと assetId が返ります。PUT /assets/:assetId/parts/:n
は 10 MB 以下のパートを受け取り、それぞれ etag を返します。
POST /assets/:assetId/complete にパート一覧を渡せば完了、
POST /assets/:assetId/abort で破棄。コンソールが動画にこの経路を使うのは、
ブラウザが会場の Wi-Fi で再開する必要があるからで、同じ理由であなたにも
開かれています。ここでの sha256 はあなたの申告値です — 偽れば汚れるのは
自分のライブラリだけ。デバイスはダウンロード時に検証します。
GET /assets、GET /assets/:assetId/content
組織の準備済みアセット。後者はバイトを返し、Range に対応します。
PATCH /assets/:assetId
{ "name": "lobby-final.png" }表示名だけです。アイデンティティは sha256 なので、名前を変えてもバイトは 動かず、画面が保持しているものも無効になりません。
DELETE /assets/:assetId
決まったスケジュールでコンテンツを生成し、放っておけばライブラリ項目も
スケジュール通りに増やしてしまう連携のためのものです。アセットを保持して
いるものがあるときは削除せず 409 を返し、何が保持しているかを名指し
します:
reason |
意味 |
|---|---|
asset_still_uploading |
マルチパートアップロードがこの行を所有中。そちらを中止してください。 |
asset_in_playlists |
名指しされたプレイリストからまず外してください。 |
asset_on_screens |
画面が表示中。まずその画面の枠をクリアするか差し替えてください。 |
最後のひとつは礼儀の話ではありません。画面が指しているバイトを消すと、 そのデバイスは永遠にファイル欠落を数え続け、二度と同期が完了しません。
テンプレートとデータソース
テンプレートは静止画と同じようにアップロードする HTML ファイルです。
POST /content に content-type: text/html で送り、最大 4 MB、1 ファイルで
完結していること。画面はネットワークのないサーフェスで描画するので、フォント・
アイコン・画像は data URI で埋め込み、動画は入れません。応答は kind: "html"
です。ファイル内のマニフェストブロックで、読むデータと、コンソールが
プレビューに使うサンプルを宣言します:
<script type="application/anysign+json">
{ "name": "Queue board", "sources": ["queue"],
"sample": { "queue": { "serving": [{ "number": "A042", "room": "Room 1" }] } } }
</script>テンプレートは anysign.data.queue を読み、変更は anysign.onData(cb) で
受け取ります。anysign.data.screen は常にあり({ id, name, group: { id, name, path } })、どの画面に表示されているかを読めば 1 つのテンプレートで
すべての画面をまかなえます。anysign.data.__meta.queue は { version, updatedAt, kind, stale } を伝えます。
データソースはテンプレートが名前で受け取る JSON です。種類は 3 つ:
manual(コンソールで入力)、push(あなたのプログラムが書き込む)、
fetch(サービスが URL から定期的に取得)。値は UTF-8 で最大 64 KB
(日本語や中国語なら英語の約三分の一の文字数)、組織あたり
最大 50 個、名前は ^[a-z][a-z0-9_-]{0,63}$ で screen は使えません。
PUT /data/:name
プッシュです。本文が値そのものです:
curl -X PUT "$BASE/api/v1/data/queue" \
-H "authorization: Bearer $TOKEN" -H "content-type: application/json" \
-d '{"serving":[{"number":"A042","room":"Room 1"}],"waiting":["A043"]}'{ "ok": true, "version": 1042 }queue を参照するテンプレートを表示中の画面には約 1 秒で通知が届き、
ドキュメントが変わるので、GET /screens の converged は「この値を表示
している」という意味になります。PUT を受け付けるのは push 型だけで、
manual や fetch は 409 data_source_not_pushable を返します。プッシュには
下記の 120 回/分とは別に、トークンあたり 600 回/分の独自の枠があります。
呼び出しのたびに送信する順番待ちシステムがコンソールを飢えさせることも、
その逆もありません。
GET /data、POST /data、GET|PATCH|DELETE /data/:name
一覧・作成・取得・設定・削除。POST /data は { "name", "kind": "push", "config": { "staleAfterSec": 120 } }、{ "name", "kind": "manual", "value": {...} }、{ "name", "kind": "fetch", "config": { "url", "refreshSec": 300, "headers": { "x-api-key": "…" } } } を受け付けます。ヘッダーの値は返さず、
名前だけ返します。取得型の url は http: か https: に限ります。取得型は
最大でも 1 分に 1 回読み、64 KB を超える本文は抱え込まずに拒否し、失敗しても
lastError 以外は変わらず、更新 2 回分の間に成功がなければ stale が真に
なります。
プレイリスト
GET /playlists は一覧を返します。GET /playlists/:playlistId は項目
付きの 1 件を返すので、こちらの状態のコピーを持たずに、最後に書き込んだ
内容と突き合わせできます。
{
"playlist": { "id": "…", "name": "Photo wall" },
"items": [ {
"id": "…", "position": 0, "assetId": "…", "sha256": "…",
"kind": "image", "name": "guest-0042.jpg", "durationMs": 5000
} ]
}POST /playlists に { "name": "…" } で 201 と { "playlistId" }。
PATCH /playlists/:playlistId は名前変更。DELETE /playlists/:playlistId
は 1 件削除しますが、割り当てられた画面があれば 409 playlist_on_screens
でその画面を名指しします — 壁が映しているものの削除は、明示的な 2 段階で
なければなりません。
PUT /playlists/:playlistId/items
{ "items": [
{ "assetId": "…", "durationMs": 5000 },
{ "assetId": "…" }
] }これはリスト全体を置き換えます。そしてそれが特長です。望むリストを
送るだけで、差分の計算は一切不要。変更のないリストの再送は無害です。
順序は配列の順序。durationMs は省略可能 — 動画は自身の長さ、静止画は
デフォルト値が使われます。最大 playlist.maxItems 件。
存在しない、またはアップロード未完了のアセットを指す項目は
playlist_item_asset_not_found / playlist_item_asset_not_ready として
その assetId を名指しで拒否され、何も書き込まれません。
レート制限
ふたつの予算が、意図的に別のものを計っています。
| 予算 | キー | サイズ | 429 の reason |
|---|---|---|---|
| トークンで認証されたリクエスト | あなたのトークン | 120 / 分 | too_many_api_requests |
| 認証に失敗したリクエスト | あなたのアドレス | 20 / 分 | too_many_attempts |
ペース配分の対象は前者です。 トークン単位なので、どこかのプログラムの 暴走ループが他者の予算を使うことはなく、課金されるのはトークンだけ — プログラムがコンソールのオペレーターの枠を食いつぶすことはなく、その逆もありません。 後者は拒否だけに課金されます — 有効なトークンを持つリクエストは決して 触れません。実際に当たるのは、失効済みや打ち間違いのトークンをデプロイして 強くリトライしたときくらいです。
どちらも毎分の予算で、配信ロケーションごとに数えられます。使い切る枠では なく、バックオフすべき天井として扱ってください。429 は他の拒否と同じ形式で 返ります。バックオフしてリトライを — どちらの予算もアクセス禁止ではありません。
まだできないこと
忘れたと思われないよう、明記します:
- 保護された緊急表示はありません。 ルートへの配信はすべての画面に届き、
その後どれか一面へ配信すればそこでは後の書き込みが勝ちます — だからこそ、
ここでの書き込みはすべて監査され、配信の
causeに対する取り消しが 戻る道になります。 - トークンに有効期限はありません。 コントロールは失効で、
lastUsedAtが忘れられたトークンをコンソールで可視化します。 - 外向きの webhook なし、再生証明のエクスポートなし、SSO なし。
- 429 に
Retry-Afterはありません。 どちらの予算も毎分なので、 1 分待てば必ず足ります。 - サーバー側リサイズなし — 上の制限を参照。欠落ではなく決定です。
- プッシュなし:コンテンツは即時ではなく、画面の 1 回の確認以内 (約 20 秒)で届きます。
- SDK なし。 ルートの半分は
curl1 行で済み、残りは JSON ボディを 受け取るだけです。 - テンプレートの表示には対応ビルドのプレイヤーが必要です。 画面のドキュメントには今日から
htmlアイテムとそのデータが載ります。描画するサーフェスは実装済みでリリース待ちです。未対応のプレイヤーはそのアイテムをスキップします。
監査ログ
上記のすべての書き込みは — 人によるものもプログラムによるものも —
その時点での行為者の名前で記録されます。失効や名前変更で歴史は
書き換えられません。GET /audit で読むか、コンソールの
チーム → アクティビティ で確認できます。ふたりの書き手が画面を
共有し始めたとき、その共有を安全にしているのがこのログです。