@danything/arib 2.0.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -3
- package/dist/b24caption.js +4 -1
- package/dist/bits.d.ts +25 -0
- package/dist/bits.js +66 -0
- package/dist/eit.d.ts +14 -2
- package/dist/eit.js +56 -33
- package/dist/mh-eit.d.ts +43 -0
- package/dist/mh-eit.js +275 -0
- package/dist/mmt-media.d.ts +168 -0
- package/dist/mmt-media.js +536 -0
- package/dist/mmt-service-filter.d.ts +44 -0
- package/dist/mmt-service-filter.js +165 -0
- package/dist/mmt-si.d.ts +183 -0
- package/dist/mmt-si.js +456 -0
- package/dist/mmtp.d.ts +84 -0
- package/dist/mmtp.js +232 -0
- package/dist/psi.d.ts +20 -2
- package/dist/psi.js +41 -12
- package/dist/synth-mmt-av.d.ts +83 -0
- package/dist/synth-mmt-av.js +336 -0
- package/dist/synth-mmt.d.ts +176 -0
- package/dist/synth-mmt.js +476 -0
- package/dist/synth.d.ts +4 -0
- package/dist/synth.js +2 -2
- package/dist/tlv-ip.d.ts +44 -0
- package/dist/tlv-ip.js +118 -0
- package/dist/tlv.d.ts +43 -0
- package/dist/tlv.js +119 -0
- package/dist/ttml.d.ts +25 -0
- package/dist/ttml.js +455 -0
- package/package.json +5 -3
package/dist/tlv.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TLV パケット (ARIB STD-B32 第 3 部) を切り出す。4K の放送 (MMT/TLV) の土台で、TS の `PacketStream` と同じ役。
|
|
3
|
+
*
|
|
4
|
+
* TLV パケットは `0x7F`・種類 (1 バイト)・長さ (2 バイト) のヘッダーと、長さぶんの中身でできている。
|
|
5
|
+
* 長さが毎回違うので、TS のように決まった間隔で同期のバイトを探すことはできない。
|
|
6
|
+
* エージェントはケーブルの分割 TLV を組み立て直した TLV のバイト列を流す
|
|
7
|
+
* ([agent.md](https://github.com/danything/denpa/blob/main/docs/agent.md))。
|
|
8
|
+
*/
|
|
9
|
+
export declare const TLV_IPV4 = 1;
|
|
10
|
+
export declare const TLV_IPV6 = 2;
|
|
11
|
+
/** ヘッダー圧縮した IP (`tlv-ip.ts`) */
|
|
12
|
+
export declare const TLV_COMPRESSED_IP = 3;
|
|
13
|
+
/** 伝送制御信号 (TLV-SI。TLV-NIT・AMT のセクションが並ぶ) */
|
|
14
|
+
export declare const TLV_SIGNALING = 254;
|
|
15
|
+
export declare const TLV_NULL = 255;
|
|
16
|
+
export interface TlvPacket {
|
|
17
|
+
type: number;
|
|
18
|
+
/** ヘッダーを除いた中身 */
|
|
19
|
+
payload: Uint8Array;
|
|
20
|
+
/** ヘッダーを含めたパケット全体。1 局に絞るとき (`mmt-service-filter.ts`) はこれをそのまま出す */
|
|
21
|
+
bytes: Uint8Array;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 任意の長さで届く TLV のバイト列を TLV パケットに切り分ける。
|
|
25
|
+
*
|
|
26
|
+
* **先頭がパケットの先頭とは限らない。** 共有に途中から入った利用者は、TLV パケットの途中から受け取る
|
|
27
|
+
* ([agent.md](https://github.com/danything/denpa/blob/main/docs/agent.md))。そこで、同期が取れていないときは
|
|
28
|
+
* 先頭のバイトと種類が合い、**長さの先にある次のパケットの先頭も合う**位置を探して合わせる。
|
|
29
|
+
* 1 か所だけで決めると、中身にたまたまある `7F 03` を先頭と取り違える。そのため、同期を取る最初のパケットは次のパケットの先頭が届くまで出さない。
|
|
30
|
+
* 同期が取れたあとは、先頭が合わなくなるまでヘッダーの長さのとおりに進む。
|
|
31
|
+
*
|
|
32
|
+
* 出すパケットの中身は、渡されたチャンクか前の残りの一部を指す (コピーしない)。
|
|
33
|
+
* **次の `feed` より後まで持つならコピーすること** (`PacketStream` と同じ)
|
|
34
|
+
*/
|
|
35
|
+
export declare class TlvStream {
|
|
36
|
+
private rest;
|
|
37
|
+
private synced;
|
|
38
|
+
/** 合わせるために読み飛ばしたバイトの合計 */
|
|
39
|
+
skipped: number;
|
|
40
|
+
/** 同期を失って合わせ直した回数 */
|
|
41
|
+
resyncs: number;
|
|
42
|
+
feed(chunk: Uint8Array): Generator<TlvPacket>;
|
|
43
|
+
}
|
package/dist/tlv.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TLV パケット (ARIB STD-B32 第 3 部) を切り出す。4K の放送 (MMT/TLV) の土台で、TS の `PacketStream` と同じ役。
|
|
3
|
+
*
|
|
4
|
+
* TLV パケットは `0x7F`・種類 (1 バイト)・長さ (2 バイト) のヘッダーと、長さぶんの中身でできている。
|
|
5
|
+
* 長さが毎回違うので、TS のように決まった間隔で同期のバイトを探すことはできない。
|
|
6
|
+
* エージェントはケーブルの分割 TLV を組み立て直した TLV のバイト列を流す
|
|
7
|
+
* ([agent.md](https://github.com/danything/denpa/blob/main/docs/agent.md))。
|
|
8
|
+
*/
|
|
9
|
+
import { concatBytes, u16 } from "./bytes.js";
|
|
10
|
+
/** TLV パケットの先頭のバイト (上位 2 ビットが 01、残りは予約で 1) */
|
|
11
|
+
const TLV_SYNC = 0x7f;
|
|
12
|
+
const HEADER = 4;
|
|
13
|
+
export const TLV_IPV4 = 0x01;
|
|
14
|
+
export const TLV_IPV6 = 0x02;
|
|
15
|
+
/** ヘッダー圧縮した IP (`tlv-ip.ts`) */
|
|
16
|
+
export const TLV_COMPRESSED_IP = 0x03;
|
|
17
|
+
/** 伝送制御信号 (TLV-SI。TLV-NIT・AMT のセクションが並ぶ) */
|
|
18
|
+
export const TLV_SIGNALING = 0xfe;
|
|
19
|
+
export const TLV_NULL = 0xff;
|
|
20
|
+
const TYPES = new Set([
|
|
21
|
+
TLV_IPV4,
|
|
22
|
+
TLV_IPV6,
|
|
23
|
+
TLV_COMPRESSED_IP,
|
|
24
|
+
TLV_SIGNALING,
|
|
25
|
+
TLV_NULL,
|
|
26
|
+
]);
|
|
27
|
+
/** `at` が TLV パケットの先頭に見えるか (先頭のバイトと種類) */
|
|
28
|
+
function looksLikeHead(data, at) {
|
|
29
|
+
return data[at] === TLV_SYNC && TYPES.has(data[at + 1]);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 任意の長さで届く TLV のバイト列を TLV パケットに切り分ける。
|
|
33
|
+
*
|
|
34
|
+
* **先頭がパケットの先頭とは限らない。** 共有に途中から入った利用者は、TLV パケットの途中から受け取る
|
|
35
|
+
* ([agent.md](https://github.com/danything/denpa/blob/main/docs/agent.md))。そこで、同期が取れていないときは
|
|
36
|
+
* 先頭のバイトと種類が合い、**長さの先にある次のパケットの先頭も合う**位置を探して合わせる。
|
|
37
|
+
* 1 か所だけで決めると、中身にたまたまある `7F 03` を先頭と取り違える。そのため、同期を取る最初のパケットは次のパケットの先頭が届くまで出さない。
|
|
38
|
+
* 同期が取れたあとは、先頭が合わなくなるまでヘッダーの長さのとおりに進む。
|
|
39
|
+
*
|
|
40
|
+
* 出すパケットの中身は、渡されたチャンクか前の残りの一部を指す (コピーしない)。
|
|
41
|
+
* **次の `feed` より後まで持つならコピーすること** (`PacketStream` と同じ)
|
|
42
|
+
*/
|
|
43
|
+
export class TlvStream {
|
|
44
|
+
rest = new Uint8Array(0);
|
|
45
|
+
synced = false;
|
|
46
|
+
/** 合わせるために読み飛ばしたバイトの合計 */
|
|
47
|
+
skipped = 0;
|
|
48
|
+
/** 同期を失って合わせ直した回数 */
|
|
49
|
+
resyncs = 0;
|
|
50
|
+
*feed(chunk) {
|
|
51
|
+
let data = chunk;
|
|
52
|
+
if (this.rest.length > 0) {
|
|
53
|
+
/*
|
|
54
|
+
* 前の残りが同期の取れたパケットの途中なら、そのパケットだけをチャンクの先頭と繋ぐ。
|
|
55
|
+
* **チャンクの全体は写さない** (38 Mbps を毎回写すことになる)
|
|
56
|
+
*/
|
|
57
|
+
const head = concatBytes([this.rest, chunk.subarray(0, HEADER)]);
|
|
58
|
+
if (this.synced && head.length >= HEADER && looksLikeHead(head, 0)) {
|
|
59
|
+
const used = HEADER + u16(head, 2) - this.rest.length;
|
|
60
|
+
if (used > chunk.length) {
|
|
61
|
+
this.rest = concatBytes([this.rest, chunk]);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const packet = concatBytes([this.rest, chunk.subarray(0, used)]);
|
|
65
|
+
yield {
|
|
66
|
+
type: packet[1],
|
|
67
|
+
payload: packet.subarray(HEADER),
|
|
68
|
+
bytes: packet,
|
|
69
|
+
};
|
|
70
|
+
data = chunk.subarray(used);
|
|
71
|
+
}
|
|
72
|
+
else
|
|
73
|
+
data = concatBytes([this.rest, chunk]);
|
|
74
|
+
}
|
|
75
|
+
let at = 0;
|
|
76
|
+
for (;;) {
|
|
77
|
+
if (!this.synced) {
|
|
78
|
+
const found = findHead(data, at);
|
|
79
|
+
// 見つからなければ、次のチャンクと繋げて探せるよう末尾の 3 バイトだけを残す
|
|
80
|
+
const next = found?.at ?? Math.max(at, data.length - (HEADER - 1));
|
|
81
|
+
this.skipped += next - at;
|
|
82
|
+
at = next;
|
|
83
|
+
if (found === null || found.wait)
|
|
84
|
+
break;
|
|
85
|
+
this.synced = true;
|
|
86
|
+
}
|
|
87
|
+
if (at + HEADER > data.length)
|
|
88
|
+
break;
|
|
89
|
+
if (!looksLikeHead(data, at)) {
|
|
90
|
+
this.synced = false;
|
|
91
|
+
this.resyncs++;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const end = at + HEADER + u16(data, at + 2);
|
|
95
|
+
if (end > data.length)
|
|
96
|
+
break;
|
|
97
|
+
yield {
|
|
98
|
+
type: data[at + 1],
|
|
99
|
+
payload: data.subarray(at + HEADER, end),
|
|
100
|
+
bytes: data.subarray(at, end),
|
|
101
|
+
};
|
|
102
|
+
at = end;
|
|
103
|
+
}
|
|
104
|
+
this.rest = data.slice(at);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** `from` から、先頭と次のパケットの先頭がどちらも合う位置を探す */
|
|
108
|
+
function findHead(data, from) {
|
|
109
|
+
for (let at = from; at + HEADER <= data.length; at++) {
|
|
110
|
+
if (!looksLikeHead(data, at))
|
|
111
|
+
continue;
|
|
112
|
+
const next = at + HEADER + u16(data, at + 2);
|
|
113
|
+
if (next + 2 > data.length)
|
|
114
|
+
return { at, wait: true };
|
|
115
|
+
if (looksLikeHead(data, next))
|
|
116
|
+
return { at, wait: false };
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
package/dist/ttml.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 4K の放送 (MMT/TLV) の字幕 (ARIB STD-B62 の TTML) を、字幕の文字の配置 (`caption-text.ts`) にする。
|
|
3
|
+
* TS の字幕の `b24caption.ts` に当たり、受け側は TS の字幕と同じように描くだけで済む。
|
|
4
|
+
*
|
|
5
|
+
* 字幕の文書は MPU 1 つに 1 つ来る (`mmt-media.ts` の `CaptionUnit`)。文書の中の時刻 (begin・end・dur) は、
|
|
6
|
+
* MPU の最初の提示時刻からのずれとして読む (推定。字幕のパケットは実機の録りにまだ来ていない)。
|
|
7
|
+
*
|
|
8
|
+
* 読むのは文字・位置 (region の origin・extent、textAlign・displayAlign)・大きさ (fontSize・lineHeight)・
|
|
9
|
+
* 色 (color・backgroundColor・textOutline)・太字・斜体・下線・ルビ。画像を参照する字幕 (PNG) と
|
|
10
|
+
* 縦書き・アニメーション (set) は読まない
|
|
11
|
+
*/
|
|
12
|
+
import { type CaptionPage } from "./caption-text.js";
|
|
13
|
+
/** 字幕 1 枚と、出す時刻 (90 kHz) */
|
|
14
|
+
export interface TimedCaptionPage {
|
|
15
|
+
at: number;
|
|
16
|
+
page: CaptionPage;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* TTML の文書を、出す時刻ごとの字幕の文字の配置にする。`start` は文書の時刻の始まり (MPU の最初の提示時刻、90 kHz)。
|
|
20
|
+
*
|
|
21
|
+
* 段落 (p) の出る・消える時刻ごとに 1 枚を作る (`CaptionPage` は前の 1 枚を丸ごと置き換えるので、
|
|
22
|
+
* その時刻に出ている段落を全部並べる)。最初の 1 枚は `start` で、何も出ていなければ空 (前の文書の字幕を消す)。
|
|
23
|
+
* 読めない文書は空の並びを返す
|
|
24
|
+
*/
|
|
25
|
+
export declare function ttmlPages(document: string, start: number): TimedCaptionPage[];
|
package/dist/ttml.js
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 4K の放送 (MMT/TLV) の字幕 (ARIB STD-B62 の TTML) を、字幕の文字の配置 (`caption-text.ts`) にする。
|
|
3
|
+
* TS の字幕の `b24caption.ts` に当たり、受け側は TS の字幕と同じように描くだけで済む。
|
|
4
|
+
*
|
|
5
|
+
* 字幕の文書は MPU 1 つに 1 つ来る (`mmt-media.ts` の `CaptionUnit`)。文書の中の時刻 (begin・end・dur) は、
|
|
6
|
+
* MPU の最初の提示時刻からのずれとして読む (推定。字幕のパケットは実機の録りにまだ来ていない)。
|
|
7
|
+
*
|
|
8
|
+
* 読むのは文字・位置 (region の origin・extent、textAlign・displayAlign)・大きさ (fontSize・lineHeight)・
|
|
9
|
+
* 色 (color・backgroundColor・textOutline)・太字・斜体・下線・ルビ。画像を参照する字幕 (PNG) と
|
|
10
|
+
* 縦書き・アニメーション (set) は読まない
|
|
11
|
+
*/
|
|
12
|
+
import { CAPTION_TEXT_VERSION, } from "./caption-text.js";
|
|
13
|
+
import { CLOCK } from "./mmt-media.js";
|
|
14
|
+
const ENTITIES = {
|
|
15
|
+
lt: "<",
|
|
16
|
+
gt: ">",
|
|
17
|
+
amp: "&",
|
|
18
|
+
quot: '"',
|
|
19
|
+
apos: "'",
|
|
20
|
+
};
|
|
21
|
+
function unescape(text) {
|
|
22
|
+
return text.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (whole, name) => {
|
|
23
|
+
const code = name;
|
|
24
|
+
if (code.startsWith("#x") || code.startsWith("#X"))
|
|
25
|
+
return String.fromCodePoint(parseInt(code.slice(2), 16));
|
|
26
|
+
if (code.startsWith("#"))
|
|
27
|
+
return String.fromCodePoint(Number(code.slice(1)));
|
|
28
|
+
return ENTITIES[code] ?? whole;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const localName = (name) => name.slice(name.indexOf(":") + 1);
|
|
32
|
+
/** 字幕の文書を読むだけの小さな XML の読み取り。壊れていれば null */
|
|
33
|
+
function parseXml(text) {
|
|
34
|
+
const root = { name: "", attributes: {}, children: [] };
|
|
35
|
+
const stack = [root];
|
|
36
|
+
const token = /<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<!\[CDATA\[([\s\S]*?)\]\]>|<![^>]*>|<\/([^\s>]+)\s*>|<([^\s/>]+)((?:\s+[^\s=]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>|([^<]+)/g;
|
|
37
|
+
for (const match of text.matchAll(token)) {
|
|
38
|
+
const top = stack.at(-1);
|
|
39
|
+
const [, cdata, close, open, attributes, selfClosing, chars] = match;
|
|
40
|
+
if (cdata !== undefined)
|
|
41
|
+
top.children.push(cdata);
|
|
42
|
+
else if (chars !== undefined)
|
|
43
|
+
top.children.push(unescape(chars));
|
|
44
|
+
else if (close !== undefined) {
|
|
45
|
+
if (stack.length < 2 || top.name !== localName(close))
|
|
46
|
+
return null;
|
|
47
|
+
stack.pop();
|
|
48
|
+
}
|
|
49
|
+
else if (open !== undefined) {
|
|
50
|
+
const element = {
|
|
51
|
+
name: localName(open),
|
|
52
|
+
attributes: {},
|
|
53
|
+
children: [],
|
|
54
|
+
};
|
|
55
|
+
for (const [, key = "", , a, b] of (attributes ?? "").matchAll(/([^\s=]+)\s*=\s*("([^"]*)"|'([^']*)')/g))
|
|
56
|
+
element.attributes[localName(key)] = unescape(a ?? b ?? "");
|
|
57
|
+
top.children.push(element);
|
|
58
|
+
if (selfClosing !== "/")
|
|
59
|
+
stack.push(element);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const tt = root.children.find((c) => typeof c !== "string");
|
|
63
|
+
return stack.length === 1 && tt?.name === "tt" ? tt : null;
|
|
64
|
+
}
|
|
65
|
+
function* elements(node, name) {
|
|
66
|
+
for (const child of node.children) {
|
|
67
|
+
if (typeof child === "string")
|
|
68
|
+
continue;
|
|
69
|
+
if (child.name === name)
|
|
70
|
+
yield child;
|
|
71
|
+
yield* elements(child, name);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** 時刻の書き方 (`00:00:01.500`・`00:00:01:15`・`1.5s`・`1500ms`・`90f`) を秒に。読めなければ null */
|
|
75
|
+
function seconds(value, frameRate) {
|
|
76
|
+
if (value === undefined)
|
|
77
|
+
return null;
|
|
78
|
+
const clock = /^(\d+):(\d{2}):(\d{2}(?:\.\d+)?)(?::(\d+(?:\.\d+)?))?$/.exec(value.trim());
|
|
79
|
+
if (clock !== null) {
|
|
80
|
+
const [, h = "0", m = "0", s = "0", f] = clock;
|
|
81
|
+
return (Number(h) * 3600 +
|
|
82
|
+
Number(m) * 60 +
|
|
83
|
+
Number(s) +
|
|
84
|
+
(f === undefined ? 0 : Number(f) / frameRate));
|
|
85
|
+
}
|
|
86
|
+
const offset = /^(\d+(?:\.\d+)?)(h|m|s|ms|f)$/.exec(value.trim());
|
|
87
|
+
if (offset === null)
|
|
88
|
+
return null;
|
|
89
|
+
const [, count = "0", unit] = offset;
|
|
90
|
+
const scale = {
|
|
91
|
+
h: 3600,
|
|
92
|
+
m: 60,
|
|
93
|
+
s: 1,
|
|
94
|
+
ms: 0.001,
|
|
95
|
+
f: 1 / frameRate,
|
|
96
|
+
}[unit];
|
|
97
|
+
return Number(count) * scale;
|
|
98
|
+
}
|
|
99
|
+
/** 名前で書く色 (TTML の名前の色) */
|
|
100
|
+
const NAMED = {
|
|
101
|
+
transparent: "#00000000",
|
|
102
|
+
black: "#000000ff",
|
|
103
|
+
silver: "#c0c0c0ff",
|
|
104
|
+
gray: "#808080ff",
|
|
105
|
+
white: "#ffffffff",
|
|
106
|
+
maroon: "#800000ff",
|
|
107
|
+
red: "#ff0000ff",
|
|
108
|
+
purple: "#800080ff",
|
|
109
|
+
fuchsia: "#ff00ffff",
|
|
110
|
+
magenta: "#ff00ffff",
|
|
111
|
+
green: "#008000ff",
|
|
112
|
+
lime: "#00ff00ff",
|
|
113
|
+
olive: "#808000ff",
|
|
114
|
+
yellow: "#ffff00ff",
|
|
115
|
+
navy: "#000080ff",
|
|
116
|
+
blue: "#0000ffff",
|
|
117
|
+
teal: "#008080ff",
|
|
118
|
+
aqua: "#00ffffff",
|
|
119
|
+
cyan: "#00ffffff",
|
|
120
|
+
};
|
|
121
|
+
const hex2 = (value) => Math.max(0, Math.min(255, Math.round(value)))
|
|
122
|
+
.toString(16)
|
|
123
|
+
.padStart(2, "0");
|
|
124
|
+
/** 色を `#rrggbbaa` に。読めなければ null */
|
|
125
|
+
function color(value) {
|
|
126
|
+
if (value === undefined)
|
|
127
|
+
return null;
|
|
128
|
+
const text = value.trim().toLowerCase();
|
|
129
|
+
if (/^#[0-9a-f]{6}$/.test(text))
|
|
130
|
+
return `${text}ff`;
|
|
131
|
+
if (/^#[0-9a-f]{8}$/.test(text))
|
|
132
|
+
return text;
|
|
133
|
+
const rgb = /^rgba?\(([^)]*)\)$/.exec(text);
|
|
134
|
+
if (rgb !== null) {
|
|
135
|
+
const [r = 0, g = 0, b = 0, a = 255] = (rgb[1] ?? "")
|
|
136
|
+
.split(",")
|
|
137
|
+
.map(Number);
|
|
138
|
+
return `#${hex2(r)}${hex2(g)}${hex2(b)}${hex2(a)}`;
|
|
139
|
+
}
|
|
140
|
+
return NAMED[text] ?? null;
|
|
141
|
+
}
|
|
142
|
+
/** 長さ 1 つ (`12px`・`50%`・`1.5c`)。% は `whole` に対して。読めなければ null */
|
|
143
|
+
function length(value, whole, cell) {
|
|
144
|
+
const match = /^(-?\d+(?:\.\d+)?)(px|%|c|em)$/.exec(value.trim());
|
|
145
|
+
if (match === null)
|
|
146
|
+
return null;
|
|
147
|
+
const number = Number(match[1]);
|
|
148
|
+
switch (match[2]) {
|
|
149
|
+
case "px":
|
|
150
|
+
return number;
|
|
151
|
+
case "%":
|
|
152
|
+
return (number / 100) * whole;
|
|
153
|
+
default:
|
|
154
|
+
return number * cell;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** 2 つの長さ (`x y`・`幅 高さ`)。1 つしか無ければ両方に使う */
|
|
158
|
+
function pair(value, frame, wholeX = frame.width, wholeY = frame.height) {
|
|
159
|
+
if (value === undefined)
|
|
160
|
+
return null;
|
|
161
|
+
const parts = value.trim().split(/\s+/);
|
|
162
|
+
const x = length(parts[0] ?? "", wholeX, frame.width / frame.columns);
|
|
163
|
+
const y = length(parts[1] ?? parts[0] ?? "", wholeY, frame.height / frame.rows);
|
|
164
|
+
return x === null || y === null ? null : [x, y];
|
|
165
|
+
}
|
|
166
|
+
/** `style` で参照した名前の付いた style を、参照の順に重ねる (style がさらに参照するものも先に) */
|
|
167
|
+
function referenced(ids, context, seen = new Set()) {
|
|
168
|
+
let out = {};
|
|
169
|
+
for (const id of (ids ?? "").split(/\s+/)) {
|
|
170
|
+
const style = context.styles.get(id);
|
|
171
|
+
if (style === undefined || seen.has(id))
|
|
172
|
+
continue;
|
|
173
|
+
seen.add(id);
|
|
174
|
+
out = { ...out, ...referenced(style["style"], context, seen), ...style };
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
/** 要素の見た目。親から受け継いだものに、参照した style と要素の属性を重ねる */
|
|
179
|
+
function styleOf(element, inherited, context) {
|
|
180
|
+
return {
|
|
181
|
+
...inherited,
|
|
182
|
+
...referenced(element.attributes["style"], context),
|
|
183
|
+
...element.attributes,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/** p の中身を行に分ける。xml:space が default なら、空白の並びを 1 つにして行の端を落とす */
|
|
187
|
+
function lines(p, style, context) {
|
|
188
|
+
const out = [[]];
|
|
189
|
+
const walk = (node, inherited) => {
|
|
190
|
+
if (typeof node === "string") {
|
|
191
|
+
const preserve = inherited["space"] === "preserve";
|
|
192
|
+
const text = preserve ? node : node.replace(/\s+/g, " ");
|
|
193
|
+
for (const char of text)
|
|
194
|
+
out.at(-1).push({ char, style: inherited });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (node.name === "br") {
|
|
198
|
+
out.push([]);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (node.name !== "span")
|
|
202
|
+
return;
|
|
203
|
+
const own = styleOf(node, inherited, context);
|
|
204
|
+
// ルビの入れ物: 親字 (base) の並びと、ルビ (text) を組にする
|
|
205
|
+
if (own["ruby"] === "container") {
|
|
206
|
+
const parts = node.children.filter((c) => typeof c !== "string");
|
|
207
|
+
const find = (kind) => parts.find((c) => styleOf(c, own, context)["ruby"] === kind) ??
|
|
208
|
+
parts
|
|
209
|
+
.filter((c) => styleOf(c, own, context)["ruby"] === `${kind}Container`)
|
|
210
|
+
.flatMap((c) => c.children.filter((x) => typeof x !== "string"))[0];
|
|
211
|
+
const base = find("base");
|
|
212
|
+
const ruby = find("text");
|
|
213
|
+
const before = out.at(-1).length;
|
|
214
|
+
if (base !== undefined)
|
|
215
|
+
walk(base, own);
|
|
216
|
+
const added = out.at(-1).length - before;
|
|
217
|
+
const first = out.at(-1)[before];
|
|
218
|
+
if (ruby !== undefined && first !== undefined && added > 0)
|
|
219
|
+
first.ruby = { text: textOf(ruby).trim(), count: added };
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
for (const child of node.children)
|
|
223
|
+
walk(child, own);
|
|
224
|
+
};
|
|
225
|
+
for (const child of p.children)
|
|
226
|
+
walk(child, style);
|
|
227
|
+
return out.map((line) => style["space"] === "preserve" ? line : trimLine(line));
|
|
228
|
+
}
|
|
229
|
+
function textOf(node) {
|
|
230
|
+
return typeof node === "string" ? node : node.children.map(textOf).join("");
|
|
231
|
+
}
|
|
232
|
+
function trimLine(line) {
|
|
233
|
+
let start = 0;
|
|
234
|
+
let end = line.length;
|
|
235
|
+
while (start < end && line[start].char === " ")
|
|
236
|
+
start++;
|
|
237
|
+
while (end > start && line[end - 1].char === " ")
|
|
238
|
+
end--;
|
|
239
|
+
return line.slice(start, end);
|
|
240
|
+
}
|
|
241
|
+
/** 半角の字 (ASCII・半角カナ)。区画の幅を半分にする */
|
|
242
|
+
function narrow(char) {
|
|
243
|
+
const code = char.codePointAt(0);
|
|
244
|
+
return code < 0x7f || (code >= 0xff61 && code <= 0xff9f);
|
|
245
|
+
}
|
|
246
|
+
/** 見た目から、字の大きさ (縦) と横の縮め方 */
|
|
247
|
+
function fontSize(style, frame) {
|
|
248
|
+
const cell = frame.height / frame.rows;
|
|
249
|
+
const parts = (style["fontSize"] ?? "1c").trim().split(/\s+/);
|
|
250
|
+
const height = length(parts[1] ?? parts[0] ?? "", cell, cell) ?? cell;
|
|
251
|
+
const width = parts[1] === undefined
|
|
252
|
+
? height
|
|
253
|
+
: (length(parts[0] ?? "", cell, cell) ?? height);
|
|
254
|
+
return [height, height > 0 ? width / height : 1];
|
|
255
|
+
}
|
|
256
|
+
function lineHeight(style, size, frame) {
|
|
257
|
+
const value = style["lineHeight"];
|
|
258
|
+
if (value === undefined || value === "normal")
|
|
259
|
+
return size * 1.2;
|
|
260
|
+
return length(value, size, frame.height / frame.rows) ?? size * 1.2;
|
|
261
|
+
}
|
|
262
|
+
/** 1 行を区画に並べた run にする。`x`・`y` は行の左上 */
|
|
263
|
+
function lineRuns(line, x, y, height, frame) {
|
|
264
|
+
const runs = [];
|
|
265
|
+
const rubies = [];
|
|
266
|
+
let at = x;
|
|
267
|
+
line.forEach((glyph, i) => {
|
|
268
|
+
const [size, scaleX] = fontSize(glyph.style, frame);
|
|
269
|
+
const w = glyphWidth(glyph.char, glyph.style, frame);
|
|
270
|
+
const run = runOf(glyph.style, glyph.char, at, y, w, height, size, scaleX);
|
|
271
|
+
const last = runs.at(-1);
|
|
272
|
+
if (last !== undefined &&
|
|
273
|
+
sameLook(last, run) &&
|
|
274
|
+
Math.abs(last.x + last.w * [...last.text].length - at) < 1e-6)
|
|
275
|
+
last.text += glyph.char;
|
|
276
|
+
else
|
|
277
|
+
runs.push(run);
|
|
278
|
+
if (glyph.ruby !== undefined) {
|
|
279
|
+
// 親字の並びの幅の真ん中に、半分の大きさで、行の上へはみ出して置く
|
|
280
|
+
const baseWidth = line
|
|
281
|
+
.slice(i, i + glyph.ruby.count)
|
|
282
|
+
.reduce((sum, g) => sum + glyphWidth(g.char, g.style, frame), 0);
|
|
283
|
+
const rubySize = size / 2;
|
|
284
|
+
const chars = [...glyph.ruby.text];
|
|
285
|
+
const widths = chars.map((c) => rubySize * scaleX * (narrow(c) ? 0.5 : 1));
|
|
286
|
+
let rx = at + (baseWidth - widths.reduce((a, b) => a + b, 0)) / 2;
|
|
287
|
+
chars.forEach((char, k) => {
|
|
288
|
+
const rw = widths[k];
|
|
289
|
+
const ruby = runOf(glyph.style, char, rx, y - rubySize, rw, rubySize, rubySize, scaleX);
|
|
290
|
+
ruby.ruby = true;
|
|
291
|
+
ruby.bg = "#00000000";
|
|
292
|
+
rubies.push(ruby);
|
|
293
|
+
rx += rw;
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
at += w;
|
|
297
|
+
});
|
|
298
|
+
return [...runs, ...rubies];
|
|
299
|
+
}
|
|
300
|
+
/** 1 字の区画の幅 */
|
|
301
|
+
function glyphWidth(char, style, frame) {
|
|
302
|
+
const [size, scaleX] = fontSize(style, frame);
|
|
303
|
+
return size * scaleX * (narrow(char) ? 0.5 : 1);
|
|
304
|
+
}
|
|
305
|
+
function runOf(style, text, x, y, w, h, size, scaleX) {
|
|
306
|
+
const run = {
|
|
307
|
+
x,
|
|
308
|
+
y,
|
|
309
|
+
w,
|
|
310
|
+
h,
|
|
311
|
+
fx: 0,
|
|
312
|
+
fy: (h - size) / 2,
|
|
313
|
+
size,
|
|
314
|
+
scaleX,
|
|
315
|
+
text,
|
|
316
|
+
fg: color(style["color"]) ?? "#ffffffff",
|
|
317
|
+
bg: color(style["backgroundColor"]) ?? "#00000000",
|
|
318
|
+
};
|
|
319
|
+
const outline = style["textOutline"]?.trim().split(/\s+/) ?? ["none"];
|
|
320
|
+
if (outline[0] !== "none")
|
|
321
|
+
run.stroke = color(outline[0]) ?? run.fg;
|
|
322
|
+
if (style["fontWeight"] === "bold")
|
|
323
|
+
run.bold = true;
|
|
324
|
+
if (style["fontStyle"] === "italic" || style["fontStyle"] === "oblique")
|
|
325
|
+
run.italic = true;
|
|
326
|
+
if (style["textDecoration"]?.includes("underline") === true)
|
|
327
|
+
run.underline = true;
|
|
328
|
+
return run;
|
|
329
|
+
}
|
|
330
|
+
function sameLook(a, b) {
|
|
331
|
+
return (a.y === b.y &&
|
|
332
|
+
a.w === b.w &&
|
|
333
|
+
a.h === b.h &&
|
|
334
|
+
a.size === b.size &&
|
|
335
|
+
a.scaleX === b.scaleX &&
|
|
336
|
+
a.fg === b.fg &&
|
|
337
|
+
a.bg === b.bg &&
|
|
338
|
+
a.stroke === b.stroke &&
|
|
339
|
+
a.bold === b.bold &&
|
|
340
|
+
a.italic === b.italic &&
|
|
341
|
+
a.underline === b.underline &&
|
|
342
|
+
a.ruby === undefined &&
|
|
343
|
+
b.ruby === undefined);
|
|
344
|
+
}
|
|
345
|
+
/** p を、region の中に置いた run にする */
|
|
346
|
+
function layout(p, style, context) {
|
|
347
|
+
const { frame } = context;
|
|
348
|
+
const region = context.regions.get(style["region"] ?? "");
|
|
349
|
+
const regionStyle = region === undefined ? {} : styleOf(region, {}, context);
|
|
350
|
+
const merged = { ...regionStyle, ...style };
|
|
351
|
+
const [x, y] = pair(regionStyle["origin"], frame) ?? [0, 0];
|
|
352
|
+
const [width, height] = pair(regionStyle["extent"], frame) ?? [
|
|
353
|
+
frame.width - x,
|
|
354
|
+
frame.height - y,
|
|
355
|
+
];
|
|
356
|
+
const rows = lines(p, merged, context).filter((line) => line.length > 0);
|
|
357
|
+
const heights = rows.map((line) => Math.max(...line.map((g) => lineHeight(g.style, fontSize(g.style, frame)[0], frame))));
|
|
358
|
+
const total = heights.reduce((sum, h) => sum + h, 0);
|
|
359
|
+
const align = merged["displayAlign"];
|
|
360
|
+
let top = y +
|
|
361
|
+
(align === "after"
|
|
362
|
+
? height - total
|
|
363
|
+
: align === "center"
|
|
364
|
+
? (height - total) / 2
|
|
365
|
+
: 0);
|
|
366
|
+
const runs = [];
|
|
367
|
+
rows.forEach((line, i) => {
|
|
368
|
+
const lineWidth = line.reduce((sum, g) => sum + glyphWidth(g.char, g.style, frame), 0);
|
|
369
|
+
const textAlign = merged["textAlign"];
|
|
370
|
+
const left = x +
|
|
371
|
+
(textAlign === "center"
|
|
372
|
+
? (width - lineWidth) / 2
|
|
373
|
+
: textAlign === "right" || textAlign === "end"
|
|
374
|
+
? width - lineWidth
|
|
375
|
+
: 0);
|
|
376
|
+
runs.push(...lineRuns(line, left, top, heights[i], frame));
|
|
377
|
+
top += heights[i];
|
|
378
|
+
});
|
|
379
|
+
return runs;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* TTML の文書を、出す時刻ごとの字幕の文字の配置にする。`start` は文書の時刻の始まり (MPU の最初の提示時刻、90 kHz)。
|
|
383
|
+
*
|
|
384
|
+
* 段落 (p) の出る・消える時刻ごとに 1 枚を作る (`CaptionPage` は前の 1 枚を丸ごと置き換えるので、
|
|
385
|
+
* その時刻に出ている段落を全部並べる)。最初の 1 枚は `start` で、何も出ていなければ空 (前の文書の字幕を消す)。
|
|
386
|
+
* 読めない文書は空の並びを返す
|
|
387
|
+
*/
|
|
388
|
+
export function ttmlPages(document, start) {
|
|
389
|
+
const tt = parseXml(document);
|
|
390
|
+
if (tt === null)
|
|
391
|
+
return [];
|
|
392
|
+
const [columns = 32, rows = 15] = (tt.attributes["cellResolution"] ?? "32 15")
|
|
393
|
+
.split(/\s+/)
|
|
394
|
+
.map(Number);
|
|
395
|
+
const [width = 1920, height = 1080] = /^\s*(\d+(?:\.\d+)?)px\s+(\d+(?:\.\d+)?)px\s*$/
|
|
396
|
+
.exec(tt.attributes["extent"] ?? "")
|
|
397
|
+
?.slice(1)
|
|
398
|
+
.map(Number) ?? [1920, 1080];
|
|
399
|
+
const context = {
|
|
400
|
+
frame: { width, height, columns, rows },
|
|
401
|
+
frameRate: Number(tt.attributes["frameRate"] ?? 30) || 30,
|
|
402
|
+
styles: new Map(),
|
|
403
|
+
regions: new Map(),
|
|
404
|
+
};
|
|
405
|
+
for (const style of elements(tt, "style")) {
|
|
406
|
+
const id = style.attributes["id"];
|
|
407
|
+
if (id !== undefined)
|
|
408
|
+
context.styles.set(id, style.attributes);
|
|
409
|
+
}
|
|
410
|
+
for (const region of elements(tt, "region")) {
|
|
411
|
+
const id = region.attributes["id"];
|
|
412
|
+
if (id !== undefined)
|
|
413
|
+
context.regions.set(id, region);
|
|
414
|
+
}
|
|
415
|
+
const paragraphs = [];
|
|
416
|
+
/** 時刻は親の始まりからのずれ (par)。end も dur も無ければ親の終わりまで */
|
|
417
|
+
const walk = (node, inherited, begin, end) => {
|
|
418
|
+
const style = styleOf(node, inherited, context);
|
|
419
|
+
const own = seconds(node.attributes["begin"], context.frameRate);
|
|
420
|
+
const from = begin + (own ?? 0);
|
|
421
|
+
const until = seconds(node.attributes["end"], context.frameRate);
|
|
422
|
+
const dur = seconds(node.attributes["dur"], context.frameRate);
|
|
423
|
+
const to = Math.min(end, until !== null ? begin + until : Infinity, dur !== null ? from + dur : Infinity);
|
|
424
|
+
if (node.name === "p") {
|
|
425
|
+
paragraphs.push({
|
|
426
|
+
begin: from,
|
|
427
|
+
end: to,
|
|
428
|
+
runs: layout(node, style, context),
|
|
429
|
+
});
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
for (const child of node.children)
|
|
433
|
+
if (typeof child !== "string")
|
|
434
|
+
walk(child, style, from, to);
|
|
435
|
+
};
|
|
436
|
+
const body = [...elements(tt, "body")][0];
|
|
437
|
+
if (body !== undefined)
|
|
438
|
+
walk(body, {}, 0, Infinity);
|
|
439
|
+
const times = [
|
|
440
|
+
...new Set([0, ...paragraphs.flatMap((p) => [p.begin, p.end])]),
|
|
441
|
+
]
|
|
442
|
+
.filter((t) => Number.isFinite(t) && t >= 0)
|
|
443
|
+
.sort((a, b) => a - b);
|
|
444
|
+
return times.map((t) => ({
|
|
445
|
+
at: start + Math.round(t * CLOCK),
|
|
446
|
+
page: {
|
|
447
|
+
v: CAPTION_TEXT_VERSION,
|
|
448
|
+
plane: [width, height],
|
|
449
|
+
duration: null,
|
|
450
|
+
runs: paragraphs
|
|
451
|
+
.filter((p) => p.begin <= t && t < p.end)
|
|
452
|
+
.flatMap((p) => p.runs),
|
|
453
|
+
},
|
|
454
|
+
}));
|
|
455
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danything/arib",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "日本のデジタル放送 (ARIB) の TS を読む: PSI・PES・EIT・ARIB
|
|
3
|
+
"version": "2.2.0",
|
|
4
|
+
"description": "日本のデジタル放送 (ARIB) の TS と MMT/TLV を読む: PSI・PES・EIT・ARIB の文字・字幕・データ放送、TLV・MMTP・MMT-SI・MH-EIT",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
"epg",
|
|
17
17
|
"b24",
|
|
18
18
|
"caption",
|
|
19
|
-
"bml"
|
|
19
|
+
"bml",
|
|
20
|
+
"mmt",
|
|
21
|
+
"tlv"
|
|
20
22
|
],
|
|
21
23
|
"type": "module",
|
|
22
24
|
"sideEffects": false,
|