├── idl ├── README.md ├── dom.idl ├── html1.idl └── bml.idl ├── .gitignore ├── client ├── number.ts ├── remote_controller.ts ├── util │ └── trace.ts ├── player │ ├── mp4.ts │ ├── null.ts │ ├── video_player.ts │ ├── hls.ts │ ├── webm.ts │ ├── mpegts.ts │ └── caption_player.ts ├── interpreter │ ├── interpreter.ts │ └── es2_interpreter.ts ├── text.ts ├── date.ts ├── broadcaster_6.ts ├── arib_jpeg.ts ├── string.ts ├── clut.ts ├── arib_png.ts ├── euc_jp.ts ├── overlay_input.ts ├── broadcaster_7.ts ├── video_list.tsx ├── default_clut.ts ├── shift_jis.ts ├── arib_aiff.ts ├── remote_controller_client.ts ├── broadcaster_4.ts ├── romsound.ts ├── zip_code.ts ├── bml_to_xhtml.ts ├── arib_mng.ts ├── play_local.ts ├── broadcaster_database.ts └── index.ts ├── fonts ├── Kosugi-Regular.woff2 ├── KosugiMaru-Bold.woff2 ├── KosugiMaru-Regular.woff2 ├── AUTHORS.txt ├── README.md ├── bold.py ├── CONTRIBUTORS.txt └── Dockerfile ├── .gitmodules ├── tsconfig.server.json ├── public ├── video_list.html ├── default_c.css ├── index.html ├── default.css └── play_local.html ├── es2 └── README.md ├── JS-Interpreter └── README.md ├── server ├── stream │ ├── hls_stream.ts │ └── live_stream.ts ├── ws_api.ts └── generate_jis_map.ts ├── LICENSE ├── Dockerfile ├── package.json ├── webpack.config.js ├── README.md ├── documents └── nvram.md └── tsconfig.json /idl/README.md: -------------------------------------------------------------------------------- 1 | ## DOM IDL 2 | 3 | 使い方は`../es2/README.md`を参照 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.env 3 | /dist 4 | /hls 5 | /build 6 | -------------------------------------------------------------------------------- /client/number.ts: -------------------------------------------------------------------------------- 1 | export const MIN_VALUE = 1; 2 | export const MAX_VALUE = 2147483647; 3 | -------------------------------------------------------------------------------- /fonts/Kosugi-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/otya128/web-bml/HEAD/fonts/Kosugi-Regular.woff2 -------------------------------------------------------------------------------- /fonts/KosugiMaru-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/otya128/web-bml/HEAD/fonts/KosugiMaru-Bold.woff2 -------------------------------------------------------------------------------- /fonts/KosugiMaru-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/otya128/web-bml/HEAD/fonts/KosugiMaru-Regular.woff2 -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "node-aribts"] 2 | path = node-aribts 3 | url = https://github.com/otya128/node-aribts.git 4 | -------------------------------------------------------------------------------- /tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "./server/**/*" 5 | ], 6 | } 7 | -------------------------------------------------------------------------------- /fonts/AUTHORS.txt: -------------------------------------------------------------------------------- 1 | # This is the official list of project authors for copyright purposes. 2 | # This file is distinct from the CONTRIBUTORS.txt file. 3 | # See the latter for an explanation. 4 | # 5 | # Names should be added to this file as: 6 | # Name or Organization 7 | 8 | Motoya Font -------------------------------------------------------------------------------- /fonts/README.md: -------------------------------------------------------------------------------- 1 | ## KosugiMaru-Bold 2 | 3 | `font-weight: bold;`,`font-family: 太丸ゴシック`用のフォント 4 | 5 | KosugiMaru-Regularの元となったフォントはモトヤマルベリ等幅3であり、モトヤマルベリ等幅6といったウェイト違いのフォントも存在しますが、KosugiMaruにはRegularしかないため機械的に太くしたフォントを生成しています. 6 | 7 | 生成方法: 8 | 9 | ``` 10 | docker build --output fonts/ fonts/ 11 | ``` 12 | -------------------------------------------------------------------------------- /client/remote_controller.ts: -------------------------------------------------------------------------------- 1 | export type RemoteControllerMessage = { 2 | type: "keydown" | "keyup", 3 | key: string, 4 | } | { 5 | type: "button", 6 | keyCode: number, 7 | } | { 8 | type: "mute" | "unmute" | "load" | "pause" | "play" | "cc" | "disable-cc" | "zoom-100" | "zoom-150" | "zoom-200", 9 | }; 10 | -------------------------------------------------------------------------------- /public/video_list.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | データ放送ブラウザ 7 | 8 | 9 | 10 | 11 |

12 | データ放送ブラウザ 13 |

14 | 再生 15 |
16 | 読み込み中... 17 |
18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /client/util/trace.ts: -------------------------------------------------------------------------------- 1 | 2 | export function getTrace(_channel: string): (message?: any, ...optionalParams: any[]) => void { 3 | if (!localStorage.getItem("trace")) { 4 | return () => {}; 5 | } 6 | return console.debug; 7 | } 8 | 9 | export function getLog(_channel: string): (message?: any, ...optionalParams: any[]) => void { 10 | return console.log; 11 | } 12 | -------------------------------------------------------------------------------- /client/player/mp4.ts: -------------------------------------------------------------------------------- 1 | import { VideoPlayer } from "./video_player"; 2 | 3 | export class MP4VideoPlayer extends VideoPlayer { 4 | public setSource(source: string): void { 5 | this.video.innerHTML = ""; 6 | const sourceElement = document.createElement("source"); 7 | sourceElement.type = "video/mp4"; 8 | sourceElement.src = source + ".mp4"; 9 | this.video.appendChild(sourceElement); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /client/player/null.ts: -------------------------------------------------------------------------------- 1 | import { VideoPlayer } from "./video_player"; 2 | 3 | export class NullVideoPlayer extends VideoPlayer { 4 | public setSource(source: string): void { 5 | this.video.innerHTML = ""; 6 | const sourceElement = document.createElement("source"); 7 | sourceElement.type = "video/mp4"; 8 | sourceElement.src = source + ".null"; 9 | this.video.appendChild(sourceElement); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /es2/README.md: -------------------------------------------------------------------------------- 1 | ## ES2 2 | 3 | ECMAScript実装 4 | 5 | のbmlブランチの`src/index.ts`が配置されています。 6 | 7 | DOMについてはIDLからインタプリタとのバインディングを自動生成しています。 8 | 9 | IDLを更新した場合 10 | ```sh 11 | git clone --branch bml https://github.com/otya128/es2 12 | cd es2 13 | npm ci 14 | npm run build 15 | echo 'import { BML } from "../interface/DOM";' | node ./build/omg-idl/idl2ts.js ../../es2 "BML." - ../../idl/bml.idl ../../idl/html1.idl ../../idl/dom.idl > ../../client/interpreter/es2_dom_binding.ts 16 | ``` 17 | -------------------------------------------------------------------------------- /fonts/bold.py: -------------------------------------------------------------------------------- 1 | import fontforge 2 | font = fontforge.open("./KosugiMaru-Regular.woff2") 3 | font.familyname = "Kosugi Maru Bold" 4 | font.fontname = "KosugiMaru-Bold" 5 | font.fullname = "Kosugi Maru Bold" 6 | for glyph in font.glyphs(): 7 | width = glyph.width 8 | vwidth = glyph.vwidth 9 | if width == 512: 10 | glyph.transform([0.8, 0, 0, 1, 0, 0]) 11 | glyph.changeWeight(50) 12 | glyph.width = width 13 | glyph.vwidth = vwidth 14 | 15 | font.generate("KosugiMaru-Bold.woff2", flags = ("no-FFTM-table")) 16 | -------------------------------------------------------------------------------- /fonts/CONTRIBUTORS.txt: -------------------------------------------------------------------------------- 1 | # This is the list of people who have contributed to this project, 2 | # and includes those not listed in AUTHORS.txt because they are not 3 | # copyright authors. For example, company employees may be listed 4 | # here because their company holds the copyright and is listed there. 5 | # 6 | # When adding J Random Contributor's name to this file, either J's 7 | # name or J's organization's name should be added to AUTHORS.txt 8 | # 9 | # Names should be added to this file as: 10 | # Name 11 | 12 | Motoya Font -------------------------------------------------------------------------------- /JS-Interpreter/README.md: -------------------------------------------------------------------------------- 1 | JS-Interpreter 2 | ============== 3 | 4 | A sandboxed JavaScript interpreter in JavaScript. Execute arbitrary JavaScript 5 | code line by line in isolation and safety. 6 | 7 | Live demo: 8 | https://neil.fraser.name/software/JS-Interpreter/ 9 | 10 | Documentation: 11 | https://neil.fraser.name/software/JS-Interpreter/docs.html 12 | 13 | Developers using JS-Interpreter should subscribe to the announcement newsgroup. 14 | Security issues and major changes will be posted here: 15 | https://groups.google.com/g/js-interpreter-announce 16 | -------------------------------------------------------------------------------- /client/interpreter/interpreter.ts: -------------------------------------------------------------------------------- 1 | import { EPG } from "../bml_browser"; 2 | import { BrowserAPI } from "../browser"; 3 | import { Content } from "../content"; 4 | import { Resources } from "../resource"; 5 | 6 | export interface Interpreter { 7 | reset(): void; 8 | // trueが返った場合launchDocumentなどで実行が終了した 9 | addScript(script: string, src?: string): Promise; 10 | // trueが返った場合launchDocumentなどで実行が終了した 11 | runEventHandler(funcName: string): Promise; 12 | destroyStack(): void; 13 | resetStack(): void; 14 | get isExecuting(): boolean; 15 | setupEnvironment(browserAPI: BrowserAPI, resources: Resources, content: Content, epg: EPG): void; 16 | } 17 | -------------------------------------------------------------------------------- /server/stream/hls_stream.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from "stream"; 2 | import { LiveStream } from "./live_stream"; 3 | import ID3MetadataTransform from 'arib-subtitle-timedmetadater'; 4 | 5 | export class HLSLiveStream extends LiveStream { 6 | id3MetadataTransoform: Transform; 7 | public constructor(ffmpeg: string, args: string[], tsStream: Transform) { 8 | const id3MetadataTransoform = new ID3MetadataTransform(); 9 | tsStream.pipe(id3MetadataTransoform); 10 | super(ffmpeg, args, id3MetadataTransoform); 11 | this.id3MetadataTransoform = id3MetadataTransoform; 12 | } 13 | 14 | public destroy(): void { 15 | super.destroy(); 16 | this.id3MetadataTransoform.unpipe(); 17 | } 18 | } -------------------------------------------------------------------------------- /client/text.ts: -------------------------------------------------------------------------------- 1 | import { decodeEUCJP, encodeEUCJP } from "./euc_jp"; 2 | import { decodeShiftJIS, encodeShiftJIS } from "./shift_jis"; 3 | import { Profile } from "./resource"; 4 | 5 | export type TextDecodeFunction = (input: Uint8Array) => string; 6 | export type TextEncodeFunction = (input: string) => Uint8Array; 7 | 8 | export function getTextDecoder(profile: Profile | undefined): TextDecodeFunction { 9 | if (profile === Profile.TrProfileC) { 10 | return decodeShiftJIS; 11 | } else { 12 | return decodeEUCJP; 13 | } 14 | } 15 | 16 | export function getTextEncoder(profile: Profile | undefined): TextEncodeFunction { 17 | if (profile === Profile.TrProfileC) { 18 | return encodeShiftJIS; 19 | } else { 20 | return encodeEUCJP; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /client/player/video_player.ts: -------------------------------------------------------------------------------- 1 | export abstract class VideoPlayer { 2 | protected video: HTMLVideoElement; 3 | protected container: HTMLElement; 4 | constructor(video: HTMLVideoElement, container: HTMLElement) { 5 | this.video = video; 6 | this.container = container; 7 | } 8 | public abstract setSource(source: string): void; 9 | public play() { 10 | this.video.play(); 11 | } 12 | public pause() { 13 | this.video.pause(); 14 | } 15 | public mute() { 16 | this.video.muted = true; 17 | } 18 | public unmute() { 19 | this.video.muted = false; 20 | } 21 | public showCC() { 22 | } 23 | public hideCC() { 24 | } 25 | public scale(_factor: number) { 26 | } 27 | 28 | public setPRAAudioNode(_audioNode?: AudioNode): void { 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /client/date.ts: -------------------------------------------------------------------------------- 1 | export function toString(this: Date): string { 2 | return ( 3 | Math.abs(this.getFullYear()).toString().padStart(4, "0") + "-" + (this.getMonth() + 1).toString().padStart(2, "0") + "-" + this.getDate().toString().padStart(2, "0") 4 | + "T" + 5 | this.getHours().toString().padStart(2, "0") + ":" + this.getMinutes().toString().padStart(2, "0") + ":" + this.getSeconds().toString().padStart(2, "0") 6 | ); 7 | }; 8 | 9 | export function toUTCString(this: Date): string { 10 | return ( 11 | Math.abs(this.getUTCFullYear()).toString().padStart(4, "0") + "-" + (this.getUTCMonth() + 1).toString().padStart(2, "0") + "-" + this.getUTCDate().toString().padStart(2, "0") 12 | + "T" + 13 | this.getUTCHours().toString().padStart(2, "0") + ":" + this.getUTCMinutes().toString().padStart(2, "0") + ":" + this.getUTCSeconds().toString().padStart(2, "0") 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /fonts/Dockerfile: -------------------------------------------------------------------------------- 1 | # 古いバージョンじゃないと「始 (U+59CB)」や「螢 (U+87A2)」がおかしくなる 2 | FROM debian:bullseye AS builder 3 | 4 | COPY bold.py / 5 | RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ 6 | --mount=type=cache,target=/var/lib/apt,sharing=locked \ 7 | rm /etc/apt/apt.conf.d/docker-clean && \ 8 | apt-get update && \ 9 | apt-get install --no-install-recommends -y \ 10 | python3 \ 11 | python3-fontforge \ 12 | ca-certificates \ 13 | curl && \ 14 | curl -L https://raw.githubusercontent.com/googlefonts/kosugi-maru/bd22c671a9ffc10cc4313e6f2fd75f2b86d6b14b/fonts/webfonts/KosugiMaru-Regular.woff2 -o KosugiMaru-Regular.woff2 && \ 15 | curl -L https://raw.githubusercontent.com/googlefonts/kosugi/75171a2738135ab888549e76a9037e826094f0ce/fonts/webfonts/Kosugi-Regular.woff2 -o Kosugi-Regular.woff2 && \ 16 | SOURCE_DATE_EPOCH=0 python3 /bold.py && \ 17 | apt-get purge -y python3 python3-fontforge ca-certificates curl && \ 18 | apt-get autoremove -y 19 | 20 | FROM scratch 21 | 22 | COPY --from=builder /*.ttf /*.woff2 . 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 otya 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/broadcaster_6.ts: -------------------------------------------------------------------------------- 1 | // スカパー!CS1(広帯域CSデジタル放送) 2 | export const broadcaster6 = { 3 | "services": { 4 | "1": { 5 | "broadcasterId": 4 6 | }, 7 | "55": { 8 | "broadcasterId": 8 9 | }, 10 | "101": { 11 | "broadcasterId": 4 12 | }, 13 | "218": { 14 | "broadcasterId": 22 15 | }, 16 | "219": { 17 | "broadcasterId": 21 18 | }, 19 | "296": { 20 | "broadcasterId": 17 21 | }, 22 | "298": { 23 | "broadcasterId": 11 24 | }, 25 | "299": { 26 | "broadcasterId": 11 27 | }, 28 | "317": { 29 | "broadcasterId": 4 30 | }, 31 | "318": { 32 | "broadcasterId": 16 33 | }, 34 | "339": { 35 | "broadcasterId": 11 36 | }, 37 | "349": { 38 | "broadcasterId": 8 39 | }, 40 | "800": { 41 | "broadcasterId": 4 42 | }, 43 | "801": { 44 | "broadcasterId": 4 45 | } 46 | }, 47 | "lastUpdated": 1647695700000 48 | }; 49 | -------------------------------------------------------------------------------- /idl/dom.idl: -------------------------------------------------------------------------------- 1 | interface DOMImplementation { 2 | boolean hasFeature(in DOMString feature, 3 | in DOMString version); 4 | }; 5 | 6 | interface Document : Node { 7 | readonly attribute DOMImplementation implementation; 8 | readonly attribute Element documentElement; 9 | }; 10 | 11 | interface Node { 12 | readonly attribute Node parentNode; 13 | readonly attribute Node firstChild; 14 | readonly attribute Node lastChild; 15 | readonly attribute Node previousSibling; 16 | readonly attribute Node nextSibling; 17 | }; 18 | 19 | interface CharacterData : Node { 20 | attribute DOMString data; 21 | // raises(DOMException) on setting 22 | // raises(DOMException) on retrieval 23 | readonly attribute unsigned long length; 24 | }; 25 | 26 | interface Element : Node { 27 | readonly attribute DOMString tagName; 28 | }; 29 | 30 | interface Text : CharacterData { 31 | }; 32 | 33 | interface CDATASection : Text { 34 | }; 35 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-trixie AS deps 2 | WORKDIR /app 3 | COPY package.json package-lock.json ./ 4 | COPY node-aribts/package.json ./node-aribts/package.json 5 | RUN npm ci 6 | 7 | FROM node:22-trixie AS builder 8 | WORKDIR /app 9 | COPY --from=deps /app/node_modules ./node_modules 10 | COPY --from=deps /app/node-aribts ./node-aribts 11 | COPY . . 12 | ENV NODE_ENV=production 13 | RUN npm -w @chinachu/aribts run build && npm run build 14 | 15 | FROM node:22-trixie AS runner 16 | RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ 17 | --mount=type=cache,target=/var/lib/apt,sharing=locked \ 18 | rm -f /etc/apt/apt.conf.d/docker-clean && \ 19 | apt-get update && \ 20 | apt-get install -y ffmpeg 21 | WORKDIR /app 22 | COPY --from=builder /app/node-aribts/lib ./node-aribts/lib 23 | COPY --from=builder /app/node-aribts/node_modules ./node-aribts/node_modules 24 | COPY --from=builder /app/node-aribts/package.json ./node-aribts 25 | COPY --from=builder /app/build ./build 26 | COPY --from=builder /app/dist ./dist 27 | COPY --from=builder /app/public ./public 28 | COPY --from=deps /app/node_modules ./node_modules 29 | 30 | EXPOSE 23234 31 | 32 | ENV HOST=0.0.0.0 33 | 34 | CMD ["node", "build/index.js"] 35 | -------------------------------------------------------------------------------- /server/stream/live_stream.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcessByStdio, spawn } from "child_process"; 2 | import { Transform, Readable, Writable } from "stream"; 3 | import { WebSocket } from "ws"; 4 | 5 | export type DataBroadcastingStream = { 6 | id: string, 7 | registeredAt: Date, 8 | readStream: Readable, 9 | tsStream: Transform, 10 | size: number, 11 | ws: WebSocket, 12 | liveStream?: LiveStream, 13 | source: string, 14 | // 多重化されているストリームを入力する際のserviceId 15 | serviceId?: number, 16 | }; 17 | 18 | export class LiveStream { 19 | encoderProcess: ChildProcessByStdio; 20 | public constructor(ffmpeg: string, args: string[], tsStream: Transform) { 21 | this.encoderProcess = spawn(ffmpeg, args, { 22 | stdio: ["pipe", "pipe", process.env.FFMPEG_OUTPUT == "1" ? "inherit" : "ignore"] 23 | }); 24 | tsStream.unpipe(); 25 | tsStream.pipe(this.encoderProcess.stdin); 26 | this.encoderProcess.stdin.on("error", (err) => { 27 | console.error("enc stdin err", err); 28 | }); 29 | tsStream.resume(); 30 | } 31 | 32 | public destroy() { 33 | this.encoderProcess.stdout.unpipe(); 34 | this.encoderProcess.kill(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-bml", 3 | "version": "1.0.0", 4 | "main": "server/index.ts", 5 | "license": "MIT", 6 | "dependencies": { 7 | "arib-subtitle-timedmetadater": "^4.0.10", 8 | "aribb24.js": "^1.10.9", 9 | "assert": "^2.0.0", 10 | "browserify-zlib": "^0.2.0", 11 | "buffer": "^6.0.3", 12 | "crc-32": "^1.2.2", 13 | "css": "git+https://github.com/otya128/reworkcss-css.git#b29fc79faf40c8164b20a74ca818c28fab19cf71", 14 | "dotenv": "^16.0.3", 15 | "fast-xml-parser": "^4.0.11", 16 | "hls.js": "^1.2.1", 17 | "koa": "^2.13.4", 18 | "koa-bodyparser": "^4.3.0", 19 | "koa-easy-ws": "^1.3.1", 20 | "koa-json": "^2.0.2", 21 | "koa-logger": "^3.2.1", 22 | "koa-router": "^10.1.1", 23 | "mpegts.js": "^1.6.10", 24 | "process": "^0.11.10", 25 | "react": "^18.2.0", 26 | "react-dom": "^18.2.0", 27 | "stream-browserify": "^3.0.0", 28 | "util": "^0.12.4" 29 | }, 30 | "scripts": { 31 | "start": "node build/index.js", 32 | "build": "webpack --mode=production && tsc -p tsconfig.server.json", 33 | "watch:client": "webpack --mode=development -w", 34 | "dev": "ts-node server/index.ts" 35 | }, 36 | "devDependencies": { 37 | "@types/css": "^0.0.38", 38 | "@types/koa": "^2.13.4", 39 | "@types/koa-router": "^7.4.4", 40 | "@types/node": "^24.3.0", 41 | "@types/react": "^18.0.18", 42 | "@types/react-dom": "^18.0.6", 43 | "@types/ws": "^8.18.1", 44 | "ts-loader": "^9.4.1", 45 | "ts-node": "^10.9.1", 46 | "typescript": "^5.9.2", 47 | "webpack": "^5.74.0", 48 | "webpack-cli": "^4.10.0" 49 | }, 50 | "private": true, 51 | "workspaces": [ 52 | "./node-aribts" 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack') 3 | 4 | module.exports = { 5 | entry: { 6 | arib: './client/index.ts', 7 | play_local: './client/play_local.ts', 8 | video_list: './client/video_list.tsx', 9 | }, 10 | output: { 11 | path: path.resolve(__dirname, 'dist'), 12 | filename: '[name].js', 13 | }, 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.tsx?$/, 18 | use: 'ts-loader', 19 | }, 20 | { 21 | test: /.*\.css$/, 22 | type: 'asset/source', 23 | } 24 | ], 25 | }, 26 | resolve: { 27 | extensions: [ 28 | '.ts', '.js', '.tsx' 29 | ], 30 | fallback: { 31 | fs: false, 32 | path: false, 33 | url: false, 34 | vm: false, 35 | process: require.resolve('process/browser'), 36 | Buffer: require.resolve('buffer'), 37 | stream: require.resolve('stream-browserify'), 38 | zlib: require.resolve('browserify-zlib'), 39 | assert: require.resolve('assert'), 40 | util: require.resolve('util'), 41 | }, 42 | }, 43 | devtool: 'source-map', 44 | // babelのため 45 | plugins: [ 46 | new webpack.ProvidePlugin({ 47 | process: 'process/browser', 48 | Buffer: ['buffer', 'Buffer'], 49 | }), 50 | new webpack.ProvidePlugin({ 51 | acorn: path.resolve(__dirname, 'JS-Interpreter', 'acorn.js') 52 | }), 53 | ], 54 | }; 55 | 56 | if (process.env.NODE_ENV == null) { 57 | module.exports.mode = "development"; 58 | } 59 | -------------------------------------------------------------------------------- /client/player/hls.ts: -------------------------------------------------------------------------------- 1 | import Hls from "hls.js"; 2 | import * as aribb24js from "aribb24.js"; 3 | import { VideoPlayer } from "./video_player"; 4 | import { playRomSound } from "../romsound"; 5 | 6 | export class HLSVideoPlayer extends VideoPlayer { 7 | captionRenderer: aribb24js.SVGRenderer | null = null; 8 | 9 | private PRACallback = (index: number): void => { 10 | if (this.audioNode == null || this.container.style.display === "none") { 11 | return; 12 | } 13 | playRomSound(index, this.audioNode); 14 | } 15 | 16 | public setSource(source: string): void { 17 | const captionOption: aribb24js.SVGRendererOption = { 18 | normalFont: "丸ゴシック", 19 | enableAutoInBandMetadataTextTrackDetection: !Hls.isSupported(), 20 | forceStrokeColor: true, 21 | PRACallback: this.PRACallback, 22 | }; 23 | const renderer = new aribb24js.SVGRenderer(captionOption); 24 | this.captionRenderer = renderer; 25 | renderer.attachMedia(this.video); 26 | if (Hls.isSupported()) { 27 | var hls = new Hls({ 28 | manifestLoadingTimeOut: 60 * 1000, 29 | }); 30 | hls.on(Hls.Events.FRAG_PARSING_METADATA, (_event, data) => { 31 | for (const sample of data.samples) { 32 | renderer.pushID3v2Data(sample.pts, sample.data); 33 | } 34 | }); 35 | hls.loadSource(source + ".m3u8"); 36 | hls.attachMedia(this.video); 37 | } else if (this.video.canPlayType('application/vnd.apple.mpegurl')) { 38 | this.video.src = source + ".m3u8"; 39 | } 40 | } 41 | 42 | public showCC(): void { 43 | this.captionRenderer?.show(); 44 | } 45 | 46 | public hideCC(): void { 47 | this.captionRenderer?.hide(); 48 | } 49 | 50 | private audioNode?: AudioNode; 51 | 52 | public override setPRAAudioNode(audioNode?: AudioNode): void { 53 | this.audioNode = audioNode; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /client/arib_jpeg.ts: -------------------------------------------------------------------------------- 1 | // データ放送に含まれるJPEGのカラリメトリはBT.709だが通常JPEGはBT601なので色がおかしくなる(TR-B14 第二分冊 3.2.1 JPEG、ITU-T Rec. T.871 6.2 Colour space参照) 2 | // 一旦BT.601としてRGBからYCBCRに変換しそれをBT.709としてRGBに変換することで修正 3 | export async function convertJPEG(image: ImageBitmap): Promise<{ blobUrl: string, width: number, height: number }> { 4 | const canvas = document.createElement("canvas"); 5 | canvas.width = image.width; 6 | canvas.height = image.height; 7 | const context = canvas.getContext("2d")!; 8 | context.drawImage(image, 0, 0); 9 | const imageData = context.getImageData(0, 0, image.width, image.height); 10 | const { width, height, data } = imageData; 11 | for (let imageY = 0; imageY < height; imageY++) { 12 | for (let imageX = 0; imageX < width; imageX++) { 13 | const r = data[(imageX + imageY * width) * 4 + 0]; 14 | const g = data[(imageX + imageY * width) * 4 + 1]; 15 | const b = data[(imageX + imageY * width) * 4 + 2]; 16 | // 以下を展開 17 | // const y = 0.299 * r + 0.587 * g + 0.114 * b; 18 | // const cb = -0.168735892 * r - 0.331264108 * g + 0.5 * b + 128; 19 | // const cr = 0.5 * r - 0.418687589 * g - 0.081312411 * b + 128; 20 | // data[(imageX + imageY * width) * 4 + 0] = (y - 16) * 1.16438 + 1.79274 * (cr - 128); 21 | // data[(imageX + imageY * width) * 4 + 1] = (y - 16) * 1.16438 + -0.213249 * (cb - 128) - 0.532909 * (cr - 128); 22 | // data[(imageX + imageY * width) * 4 + 2] = (y - 16) * 1.16438 + 2.1124 * (cb - 128); 23 | data[(imageX + imageY * width) * 4 + 0] = 1.24452 * r - 0.0671069 * g - 0.0130327 * b - 18.6301; 24 | data[(imageX + imageY * width) * 4 + 1] = 0.117678 * r + 0.977255 * g + 0.0694469 * b - 18.6301; 25 | data[(imageX + imageY * width) * 4 + 2] = -0.00828808 * r - 0.0162712 * g + 1.18894 * b - 18.6301 26 | } 27 | } 28 | context.putImageData(imageData, 0, 0); 29 | return new Promise((resolve, reject) => { 30 | canvas.toBlob((blob) => { 31 | if (blob == null) { 32 | reject("toBlob failed"); 33 | return; 34 | } 35 | resolve({ blobUrl: URL.createObjectURL(blob), width, height }); 36 | }, "image/png"); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /client/string.ts: -------------------------------------------------------------------------------- 1 | // STD-B24 第二編 付属2 5.4.3.4で定められている挙動をするようにする 2 | // 比較もEUC-JPベースでやる必要はある 3 | import { unicodeToJISMap } from "./unicode_to_jis_map"; 4 | import { jisToUnicodeMap } from "./jis_to_unicode_map"; 5 | import { decodeShiftJIS, encodeShiftJIS } from "./shift_jis"; 6 | export const originalCharCodeAt = String.prototype.charCodeAt; 7 | export const originalFromCharCode = String.fromCharCode; 8 | 9 | export function eucJPCharCodeAt(this: string, index: number): number { 10 | const orig = originalCharCodeAt.call(this, index); 11 | if (Number.isNaN(orig)) { 12 | return orig; 13 | } 14 | const jis = unicodeToJISMap[orig]; 15 | if (jis == null) { 16 | return orig; 17 | } 18 | return jis + (0xa0a0 - 0x2020); 19 | } 20 | 21 | export function eucJPFromCharCode(...codes: number[]): string { 22 | return originalFromCharCode(...codes.flatMap(code => { 23 | const code1 = (code >> 8) & 0xff; 24 | const code2 = code & 0xff; 25 | if (code1 >= 0xa1 && code1 <= 0xfe) { 26 | if (code2 >= 0xa1 && code2 <= 0xfe) { 27 | const j = jisToUnicodeMap[(code1 - 0xa1) * 94 + code2 - 0xa1]; 28 | if (typeof j === "number") { 29 | return [j]; 30 | } 31 | return j; 32 | } 33 | } 34 | return [code]; 35 | })); 36 | } 37 | 38 | export function shiftJISCharCodeAt(this: string, index: number): number { 39 | const orig = originalCharCodeAt.call(this, index); 40 | if (Number.isNaN(orig)) { 41 | return orig; 42 | } 43 | const result = encodeShiftJIS(originalFromCharCode(orig)); 44 | if (result.length >= 2) { 45 | return (result[0] << 8) | result[1]; 46 | } 47 | return result[0]; 48 | } 49 | 50 | export function shiftJISFromCharCode(...codes: number[]): string { 51 | return originalFromCharCode(...codes.flatMap(code => { 52 | const code2 = (code >> 8) & 0xff; 53 | const code1 = code & 0xff; 54 | if (code2 !== 0) { 55 | return [decodeShiftJIS(new Uint8Array([code2, code1])).charCodeAt(0)]; 56 | } else if (code >= 0x80) { 57 | return [decodeShiftJIS(new Uint8Array([code])).charCodeAt(0)]; 58 | } else { 59 | return [code]; 60 | } 61 | })); 62 | } 63 | -------------------------------------------------------------------------------- /client/clut.ts: -------------------------------------------------------------------------------- 1 | import { defaultCLUT } from './default_clut'; 2 | export function readCLUT(clut: Buffer): number[][] { 3 | let table = defaultCLUT.slice(); 4 | const prevLength = table.length; 5 | table.length = 256; 6 | table = table.fill([0, 0, 0, 255], prevLength, 256); 7 | // STD-B24 第二分冊(2/2) A3 5.1.7 表5-8参照 8 | // clut_typeは0(YCbCr)のみ運用される 9 | const clutType = clut[0] & 0x80; 10 | // depthは8ビット(1)のみが運用される 11 | const depth = (clut[0] & 0x60) >> 5; 12 | // region_flagは0のみが運用される 13 | const regionFlag = clut[0] & 0x10; 14 | // start_end_flagは1のみが運用される 15 | const startEndFlag = clut[0] & 0x8; 16 | let index = 1; 17 | if (regionFlag) { 18 | index += 2; 19 | index += 2; 20 | index += 2; 21 | index += 2; 22 | // 運用されない 23 | console.error("region is not operated"); 24 | } 25 | let startIndex: number; 26 | let endIndex: number; 27 | if (startEndFlag) { 28 | if (depth == 0) { 29 | startIndex = clut[index] >> 4; 30 | endIndex = clut[index] & 15; 31 | index++; 32 | } else if (depth == 1) { 33 | // start_indexは17のみが運用される 34 | startIndex = clut[index++]; 35 | // end_ndexは223のみが運用される 36 | endIndex = clut[index++]; 37 | } else if (depth == 2) { 38 | startIndex = clut[index++]; 39 | startIndex = (startIndex << 8) | clut[index++]; 40 | endIndex = clut[index++]; 41 | endIndex = (endIndex << 8) | clut[index++]; 42 | } else { 43 | throw new Error("unexpected"); 44 | } 45 | for (let i = startIndex; i <= endIndex; i++) { 46 | let R: number; 47 | let G: number; 48 | let B: number; 49 | if (clutType == 0) { 50 | const Y = clut[index++]; 51 | const Cb = clut[index++]; 52 | const Cr = clut[index++]; 53 | R = Math.max(0, Math.min(255, Math.floor((Y - 16) * 1.16438 + 1.79274 * (Cr - 128)))); 54 | G = Math.max(0, Math.min(255, Math.floor((Y - 16) * 1.16438 + -0.213249 * (Cb - 128) -0.532909 * (Cr - 128)))); 55 | B = Math.max(0, Math.min(255, Math.floor((Y - 16) * 1.16438 + 2.1124 * (Cb - 128)))); 56 | } else { 57 | R = clut[index++]; 58 | G = clut[index++]; 59 | B = clut[index++]; 60 | } 61 | // Aは0以外が運用される 62 | const A = clut[index++]; 63 | table[i] = [R, G, B, A]; 64 | } 65 | } else { 66 | // 運用されない 67 | throw new Error("start_end_flag = 0 is not operated"); 68 | } 69 | return table; 70 | } 71 | -------------------------------------------------------------------------------- /client/arib_png.ts: -------------------------------------------------------------------------------- 1 | import CRC32 from "crc-32"; 2 | import { Buffer } from "buffer"; 3 | 4 | export function preparePLTE(clut: number[][]): Buffer { 5 | const plte = Buffer.alloc(4 /* PLTE */ + 4 /* size */ + clut.length * 3 + 4 /* CRC32 */); 6 | let off = 0; 7 | off = plte.writeUInt32BE(clut.length * 3, off); 8 | off += plte.write("PLTE", off); 9 | for (const entry of clut) { 10 | off = plte.writeUInt8(entry[0], off); 11 | off = plte.writeUInt8(entry[1], off); 12 | off = plte.writeUInt8(entry[2], off); 13 | } 14 | plte.writeInt32BE(CRC32.buf(plte.slice(4, off), 0), off); 15 | return plte; 16 | } 17 | 18 | export function prepareTRNS(clut: number[][]): Buffer { 19 | const trns = Buffer.alloc(4 /* PLTE */ + 4 /* size */ + clut.length + 4 /* CRC32 */); 20 | let off = 0; 21 | off = trns.writeUInt32BE(clut.length, off); 22 | off += trns.write("tRNS", off); 23 | for (const entry of clut) { 24 | off = trns.writeUInt8(entry[3], off); 25 | } 26 | trns.writeInt32BE(CRC32.buf(trns.slice(4, off), 0), off); 27 | return trns; 28 | } 29 | 30 | function replacePLTE(png: Buffer, plte: Buffer, trns: Buffer): Buffer { 31 | const output = Buffer.alloc(png.length + plte.length + trns.length); 32 | let inOff = 0, outOff = 0; 33 | // header 34 | png.copy(output, outOff, inOff, 8); 35 | inOff += 8; 36 | outOff += 8; 37 | while (inOff < png.byteLength) { 38 | let chunkLength = png.readUInt32BE(inOff); 39 | let chunkType = png.toString("ascii", inOff + 4, inOff + 8); 40 | if (chunkType === "PLTE" || chunkType == "tRNS") { 41 | // PLTEとtRNSは削除 42 | } else { 43 | outOff += png.copy(output, outOff, inOff, inOff + chunkLength + 4 + 4 + 4); 44 | if (chunkType === "IHDR") { 45 | // type = 3 (パレット) 以外は運用されない 46 | if (png[inOff + 0x11] != 3) { 47 | return png; 48 | } 49 | outOff += plte.copy(output, outOff); 50 | outOff += trns.copy(output, outOff); 51 | } 52 | } 53 | inOff += chunkLength + 4 + 4 + 4; 54 | } 55 | return output.subarray(0, outOff); 56 | } 57 | 58 | export function aribPNGToPNG(png: Buffer, clut: number[][]): { data: Buffer, width?: number, height?: number } { 59 | const plte = preparePLTE(clut); 60 | const trns = prepareTRNS(clut); 61 | const data = replacePLTE(png, plte, trns); 62 | // IHDR 63 | const width = png.length >= 33 ? png.readUInt32BE(8 + 8) : undefined; 64 | const height = png.length >= 33 ? png.readUInt32BE(8 + 12) : undefined; 65 | return { data, width, height }; 66 | } 67 | -------------------------------------------------------------------------------- /client/player/webm.ts: -------------------------------------------------------------------------------- 1 | import { VideoPlayer } from "./video_player"; 2 | import * as aribb24js from "aribb24.js"; 3 | import { playRomSound } from "../romsound"; 4 | 5 | export class WebmVideoPlayer extends VideoPlayer { 6 | captionRenderer: aribb24js.SVGRenderer | null = null; 7 | superimposeRenderer: aribb24js.SVGRenderer | null = null; 8 | 9 | private PRACallback = (index: number): void => { 10 | if (this.audioNode == null || this.container.style.display === "none") { 11 | return; 12 | } 13 | playRomSound(index, this.audioNode); 14 | } 15 | 16 | public setSource(source: string): void { 17 | this.video.innerHTML = ""; 18 | const sourceElement = document.createElement("source"); 19 | sourceElement.type = "video/webm"; 20 | sourceElement.src = source + ".webm"; 21 | this.video.appendChild(sourceElement); 22 | 23 | const captionOption: aribb24js.SVGRendererOption = { 24 | normalFont: "丸ゴシック", 25 | forceStrokeColor: true, 26 | PRACallback: this.PRACallback, 27 | }; 28 | captionOption.data_identifier = 0x80; 29 | const captionRenderer = new aribb24js.SVGRenderer(captionOption); 30 | const superimposeOption: aribb24js.SVGRendererOption = { 31 | normalFont: "丸ゴシック", 32 | forceStrokeColor: true, 33 | PRACallback: this.PRACallback, 34 | }; 35 | superimposeOption.data_identifier = 0x81; 36 | const superimposeRenderer = new aribb24js.SVGRenderer(superimposeOption); 37 | this.captionRenderer = captionRenderer; 38 | this.superimposeRenderer = superimposeRenderer; 39 | captionRenderer.attachMedia(this.video); 40 | superimposeRenderer.attachMedia(this.video); 41 | this.container.appendChild(captionRenderer.getSVG()); 42 | this.container.appendChild(superimposeRenderer.getSVG()); 43 | } 44 | 45 | public push(streamId: number, data: Uint8Array, pts?: number) { 46 | if (streamId == 0xbd && pts != null) { 47 | this.captionRenderer?.pushData(0, data, (pts % Math.pow(2, 33)) / 90 / 1000); 48 | } else if (streamId == 0xbf) { 49 | this.superimposeRenderer?.pushData(0, data, this.video.currentTime); 50 | } 51 | } 52 | 53 | public showCC(): void { 54 | this.captionRenderer?.show(); 55 | this.superimposeRenderer?.show(); 56 | this.container.style.display = ""; 57 | } 58 | 59 | public hideCC(): void { 60 | this.captionRenderer?.hide(); 61 | this.superimposeRenderer?.hide(); 62 | this.container.style.display = "none"; 63 | } 64 | 65 | private audioNode?: AudioNode; 66 | 67 | public override setPRAAudioNode(audioNode?: AudioNode): void { 68 | this.audioNode = audioNode; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /idl/html1.idl: -------------------------------------------------------------------------------- 1 | interface HTMLDocument : Document { 2 | Element getElementById(in DOMString elementId); 3 | }; 4 | 5 | interface HTMLElement : Element { 6 | readonly attribute DOMString id; 7 | readonly attribute DOMString className; 8 | }; 9 | 10 | interface HTMLHtmlElement : HTMLElement { 11 | }; 12 | 13 | interface HTMLHeadElement : HTMLElement { 14 | }; 15 | 16 | interface HTMLTitleElement : HTMLElement { 17 | readonly attribute DOMString text; 18 | }; 19 | 20 | interface HTMLMetaElement : HTMLElement { 21 | readonly attribute DOMString content; 22 | readonly attribute DOMString name; 23 | }; 24 | 25 | interface HTMLStyleElement : HTMLElement { 26 | }; 27 | 28 | interface HTMLBodyElement : HTMLElement { 29 | }; 30 | 31 | interface HTMLInputElement : HTMLElement { 32 | readonly attribute DOMString defaultValue; 33 | readonly attribute DOMString accessKey; 34 | attribute boolean disabled; 35 | readonly attribute long maxLength; 36 | attribute boolean readOnly; 37 | readonly attribute DOMString type; 38 | attribute DOMString value; 39 | void blur(); 40 | void focus(); 41 | }; 42 | 43 | interface HTMLDivElement : HTMLElement { 44 | }; 45 | 46 | interface HTMLParagraphElement : HTMLElement { 47 | }; 48 | 49 | interface HTMLBRElement : HTMLElement { 50 | }; 51 | 52 | interface HTMLAnchorElement : HTMLElement { 53 | readonly attribute DOMString accessKey; 54 | attribute DOMString href; 55 | void blur(); 56 | void focus(); 57 | }; 58 | 59 | interface HTMLObjectElement : HTMLElement { 60 | attribute DOMString data; 61 | readonly attribute DOMString type; 62 | }; 63 | 64 | interface HTMLScriptElement : HTMLElement { 65 | }; 66 | 67 | interface HTMLPreElement : HTMLElement { 68 | }; 69 | 70 | interface HTMLFormElement : HTMLElement { 71 | attribute DOMString action; 72 | readonly attribute DOMString method; 73 | void submit(); 74 | }; 75 | 76 | interface HTMLTextAreaElement : HTMLElement { 77 | readonly attribute DOMString defaultValue; 78 | readonly attribute HTMLFormElement form; 79 | readonly attribute DOMString accessKey; 80 | readonly attribute DOMString name; 81 | attribute boolean readOnly; 82 | attribute DOMString value; 83 | }; 84 | 85 | interface HTMLImageElement : HTMLElement { 86 | readonly attribute DOMString alt; 87 | attribute DOMString src; 88 | }; 89 | 90 | interface HTMLLinkElement : HTMLElement { 91 | }; 92 | -------------------------------------------------------------------------------- /client/euc_jp.ts: -------------------------------------------------------------------------------- 1 | import { jisToUnicodeMap } from "./jis_to_unicode_map"; 2 | import { unicodeToJISMap } from "./unicode_to_jis_map"; 3 | 4 | // EUC-JPからstringに変換する 5 | export function decodeEUCJP(input: Uint8Array): string { 6 | if (input.length === 0) { 7 | return ""; 8 | } 9 | const replacementCharacter = "\ufffd"; // � 10 | let buffer = ""; 11 | for (let i = 0; i < input.length; i++) { 12 | if (input[i] >= 0xa1 && input[i] <= 0xfe) { 13 | const ku = input[i] - 0xa0; 14 | i++; 15 | if (i >= input.length) { 16 | buffer += replacementCharacter; 17 | break; 18 | } 19 | if (input[i] < 0xa1 || input[i] > 0xfe) { 20 | buffer += replacementCharacter; 21 | continue; 22 | } 23 | const ten = input[i] - 0xa0; 24 | const uni = jisToUnicodeMap[(ku - 1) * 94 + (ten - 1)]; 25 | if (typeof uni === "number") { 26 | if (uni >= 0) { 27 | buffer += String.fromCharCode(uni); 28 | } else { 29 | buffer += replacementCharacter; 30 | } 31 | } else { 32 | for (const u of uni) { 33 | buffer += String.fromCharCode(u); 34 | } 35 | } 36 | } else if (input[i] < 0x80) { 37 | buffer += String.fromCharCode(input[i]); 38 | } else if (input[i] === 0x8e) { 39 | // 半角カナカナは運用しない (STD-B24 第二分冊(2/2) 第二編 付属1 13.2.1 表13-1, TR-B14 第二分冊 3.4.1.2 表3-12) 40 | buffer += replacementCharacter; 41 | i++; 42 | } else if (input[i] === 0x8f) { 43 | // 3バイト文字(JIS X 0212-1990)は運用しない (STD-B24 第二分冊(2/2) 第二編 付属1 13.2.1 表13-1, TR-B14 第二分冊 3.4.1.2 表3-12) 44 | buffer += replacementCharacter; 45 | i += 2; 46 | } else { 47 | buffer += replacementCharacter; 48 | } 49 | } 50 | return buffer; 51 | } 52 | 53 | export function encodeEUCJP(input: string): Uint8Array { 54 | const buf = new Uint8Array(input.length * 2); 55 | let off = 0; 56 | for (let i = 0; i < input.length; i++) { 57 | const c = input.charCodeAt(i); 58 | const a = unicodeToJISMap[c]; 59 | if (a == null && c < 0x80) { 60 | buf[off++] = c; 61 | continue; 62 | } 63 | const jis = (a ?? 0x222e) + (0xa0a0 - 0x2020); // 〓 64 | if (jis >= 0x100) { 65 | buf[off++] = jis >> 8; 66 | buf[off++] = jis & 0xff; 67 | } else { 68 | buf[off++] = jis; 69 | } 70 | } 71 | return buf.subarray(0, off); 72 | } 73 | 74 | export function stripStringEUCJP(input: string, maxBytes: number): string { 75 | // 1, 2バイト文字しか存在しない 76 | if (input.length * 2 < maxBytes) { 77 | return input; 78 | } 79 | let bytes = 0; 80 | for (let i = 0; i < input.length; i++) { 81 | const c = input.charCodeAt(i); 82 | const size = c < 0x80 ? 1 : 2; 83 | if (bytes + size > maxBytes) { 84 | return input.substring(0, i); 85 | } 86 | bytes += size; 87 | } 88 | return input; 89 | } 90 | -------------------------------------------------------------------------------- /client/overlay_input.ts: -------------------------------------------------------------------------------- 1 | import { InputApplication, InputApplicationLaunchOptions, InputCancelReason, InputCharacterType } from "./bml_browser"; 2 | 3 | // Y=110未満には表示してはいけない (TR-B14 第二分冊 1.6) 4 | export class OverlayInputApplication implements InputApplication { 5 | private readonly container: HTMLElement; 6 | private readonly input: HTMLInputElement; 7 | private readonly submitButton: HTMLButtonElement; 8 | private readonly cancelButton: HTMLButtonElement; 9 | private callback?: (value: string) => void; 10 | 11 | public constructor(container: HTMLElement) { 12 | this.container = container; 13 | this.input = container.querySelector("input")!; 14 | this.submitButton = container.querySelector("button.submit")!; 15 | this.cancelButton = container.querySelector("button.cancel")!; 16 | this.input.addEventListener("keydown", (e) => { 17 | if (e.key === "Enter") { 18 | this.submit(); 19 | } 20 | }); 21 | this.submitButton.addEventListener("click", () => { 22 | this.submit(); 23 | }); 24 | this.cancelButton.addEventListener("click", () => { 25 | this.cancel("other"); 26 | }); 27 | } 28 | 29 | private submit(): void { 30 | if (this.callback != null) { 31 | if (this.input.reportValidity()) { 32 | this.callback(this.input.value); 33 | this.callback = undefined; 34 | } else { 35 | return; 36 | } 37 | } 38 | this.container.style.display = "none"; 39 | } 40 | 41 | public launch({ characterType, allowedCharacters, maxLength, value, inputMode, callback }: InputApplicationLaunchOptions): void { 42 | this.input.maxLength = maxLength; 43 | this.input.value = value; 44 | this.input.inputMode = inputMode; 45 | if (allowedCharacters != null) { 46 | this.input.pattern = "[" + allowedCharacters.replace(/([\\\[\]])/g, "\\$1") + "]*"; 47 | } else { 48 | this.input.pattern = ".*"; 49 | } 50 | switch (characterType) { 51 | case "number": 52 | this.input.title = "半角数字を入力"; 53 | break; 54 | case "alphabet": 55 | this.input.title = "半角英字または半角記号を入力"; 56 | break; 57 | case "hankaku": 58 | this.input.title = "半角英数または半角記号を入力"; 59 | break; 60 | case "zenkaku": 61 | this.input.title = "全角ひらがな、かたかな、英数、記号を入力"; 62 | break; 63 | case "katakana": 64 | this.input.title = "全角かたかな、記号を入力"; 65 | break; 66 | case "hiragana": 67 | this.input.title = "全角ひらがな、記号を入力"; 68 | break; 69 | default: 70 | this.input.title = ""; 71 | } 72 | this.container.style.display = ""; 73 | this.callback = callback; 74 | this.input.focus(); 75 | } 76 | 77 | public cancel(_reason: InputCancelReason): void { 78 | this.container.style.display = "none"; 79 | this.callback = undefined; 80 | } 81 | 82 | public get isLaunching(): boolean { 83 | return !!this.callback; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # web-bml 2 | 3 | Webブラウザで動作するデータ放送ブラウザ(BMLブラウザ) 4 | 5 | デモ https://otya128.github.io/web-bml 6 | 7 | ![screenshot](https://user-images.githubusercontent.com/4075988/159119988-d57b4d1b-6940-45d5-8d54-87acb2f75781.png) 8 | 9 | ## 動作環境 10 | 11 | * LinuxまたはWindows上のNode.js (v22とv24で動作確認済) 12 | * 新しめのFirefoxまたはChromium系ブラウザ 13 | * ffmpeg (動画視聴する場合) 14 | 15 | ## 使い方 16 | 17 | git clone --recursiveするかサブモジュールを初期化 18 | 19 | ```sh 20 | git submodule init 21 | git submodule update 22 | ``` 23 | 24 | Mirakurunからの放送、EPGStationからの録画または引数に与えたファイルを再生できます。 25 | 26 | ### 実行方法 27 | 28 | ```sh 29 | npm i 30 | npm -w @chinachu/aribts run build 31 | npm run build 32 | npm run start [input.ts] 33 | ``` 34 | 35 | localhost:23234 36 | 37 | ### 実行方法(コンテナ) 38 | 39 | ```sh 40 | docker build -t web-bml . 41 | docker run --rm --name web-bml -e MIRAK_URL=http://localhost:40772 -e EPG_URL=http://localhost:8888 -p 23234:23234 web-bml 42 | ``` 43 | 44 | ファイルを試す場合: 45 | ```sh 46 | docker run --rm --name web-bml --mount "type=bind,source=PATH-TO-TS.ts,target=/app/input.ts,readonly" -e INPUT_FILE=input.ts -p 23234:23234 web-bml 47 | ``` 48 | 49 | MirakurunとEPGStationが既にコンテナで動いている場合: 50 | 51 | ```sh 52 | docker run --rm --name web-bml --net=host -e MIRAK_URL=http://localhost:40772 -e EPG_URL=http://localhost:8888 web-bml 53 | ``` 54 | 55 | ```sh 56 | docker run --rm --name web-bml --net=xxxx -e MIRAK_URL=http://mirakurun:40772 -e EPG_URL=http://epgstation:8888 -p 23234:23234 web-bml 57 | ``` 58 | 59 | 60 | ### 動画形式 61 | 62 | null以外はffmpegが必須 63 | 64 | `?format=...&demultiplexServiceId=...&seek=bytes` 65 | 66 | * null 67 | * ブラウザ向けに動画をエンコードせずデータカルーセルのみをデコードする 68 | * データ放送のみのサービス(NHK BS1 707ヘルプチャンネルなど)はこれじゃないと表示できないことがある 69 | * `/channels/BS/BS15_0/stream?demultiplexServiceId=707&format=null` 70 | * mp4 71 | * <video>のみで再生する 72 | * 字幕非対応 73 | * h264-mpegts 74 | * デフォルト 75 | * mpegts.jsを利用 76 | * 低遅延で再生されるまでが速い 77 | * aribb24.jsによる字幕に対応 78 | * 生配信にのみ対応 79 | * ただしffmpegのオプションに-reを付けて等速にしているため録画でも一応は動く 80 | * hls 81 | * hls.jsを利用 82 | * aribb24.jsによる字幕に対応 83 | * 特に理由がなければh264-mpegtsで良い 84 | 85 | ## 設定 86 | 87 | 環境変数か.envファイルに以下のように記述 88 | 89 | ``` 90 | MIRAK_URL=http://localhost:30772/ 91 | ``` 92 | 93 | ### MIRAK_URL 94 | 95 | MirakurunのURL 96 | 97 | デフォルト値 `http://localhost:40772/` 98 | 99 | ### EPG_URL 100 | 101 | EPGStationのURL 102 | 103 | デフォルト値 `http://localhost:8888/` 104 | 105 | ### FFMPEG 106 | 107 | ffmpegの実行ファイル 108 | 109 | デフォルト値 `ffmpeg` 110 | 111 | ### FFMPEG_OUTPUT 112 | 113 | 1であればffmpegの標準エラー出力をリダイレクト 114 | 115 | デフォルト値 `0` 116 | 117 | ### HLS_DIR 118 | 119 | HLSを使う場合の一時出力先 120 | 121 | デフォルト値 `./hls` 122 | 123 | ### PORT 124 | 125 | 待ち受けるポート番号 126 | 127 | デフォルト値 `23234` 128 | 129 | ### HOST 130 | 131 | 待ち受けるホスト 132 | 133 | デフォルト値 `localhost` 134 | 135 | ### INPUT_FILE 136 | 137 | 入力ファイル 138 | 139 | ## フォント 140 | 141 | 丸ゴシック用にKosugiMaru(モトヤLマルベリ3等幅)、太丸ゴシック用に丸ゴシック用フォントを太らせたもの、ゴシック用にKosugi(モトヤLシーダ3等幅)が含まれています。 142 | 143 | 制約が厳しいBMLではスペースによるレイアウトがよく使われるためフォントは仕様で規定されている通り等幅であることが必須です。 (UnicodeではなくJISコード基準なので記号なども全角幅であるべき) 144 | 145 | ## 実装 146 | 147 | STD-B24, TR-B14, TR-B15の仕様を部分的に実装しています。 148 | 149 | 一部のイベント(CCStatusChanged、MediaStopped)やAPIなどは現状未実装です。 150 | -------------------------------------------------------------------------------- /client/broadcaster_7.ts: -------------------------------------------------------------------------------- 1 | // スカパー!CS2(広帯域CSデジタル放送) 2 | export const broadcaster7 = { 3 | "services": { 4 | "100": { 5 | "broadcasterId": 4 6 | }, 7 | "161": { 8 | "broadcasterId": 17 9 | }, 10 | "223": { 11 | "broadcasterId": 18 12 | }, 13 | "227": { 14 | "broadcasterId": 2 15 | }, 16 | "240": { 17 | "broadcasterId": 13 18 | }, 19 | "250": { 20 | "broadcasterId": 3 21 | }, 22 | "254": { 23 | "broadcasterId": 1 24 | }, 25 | "257": { 26 | "broadcasterId": 18 27 | }, 28 | "262": { 29 | "broadcasterId": 13 30 | }, 31 | "290": { 32 | "broadcasterId": 14 33 | }, 34 | "292": { 35 | "broadcasterId": 9 36 | }, 37 | "293": { 38 | "broadcasterId": 2 39 | }, 40 | "294": { 41 | "broadcasterId": 5 42 | }, 43 | "295": { 44 | "broadcasterId": 18 45 | }, 46 | "297": { 47 | "broadcasterId": 17 48 | }, 49 | "300": { 50 | "broadcasterId": 18 51 | }, 52 | "301": { 53 | "broadcasterId": 6 54 | }, 55 | "305": { 56 | "broadcasterId": 13 57 | }, 58 | "307": { 59 | "broadcasterId": 16 60 | }, 61 | "308": { 62 | "broadcasterId": 16 63 | }, 64 | "309": { 65 | "broadcasterId": 16 66 | }, 67 | "310": { 68 | "broadcasterId": 2 69 | }, 70 | "311": { 71 | "broadcasterId": 7 72 | }, 73 | "312": { 74 | "broadcasterId": 4 75 | }, 76 | "314": { 77 | "broadcasterId": 13 78 | }, 79 | "316": { 80 | "broadcasterId": 18 81 | }, 82 | "321": { 83 | "broadcasterId": 18 84 | }, 85 | "322": { 86 | "broadcasterId": 16 87 | }, 88 | "323": { 89 | "broadcasterId": 11 90 | }, 91 | "324": { 92 | "broadcasterId": 11 93 | }, 94 | "325": { 95 | "broadcasterId": 15 96 | }, 97 | "329": { 98 | "broadcasterId": 5 99 | }, 100 | "330": { 101 | "broadcasterId": 19 102 | }, 103 | "331": { 104 | "broadcasterId": 4 105 | }, 106 | "333": { 107 | "broadcasterId": 13 108 | }, 109 | "340": { 110 | "broadcasterId": 16 111 | }, 112 | "341": { 113 | "broadcasterId": 16 114 | }, 115 | "342": { 116 | "broadcasterId": 13 117 | }, 118 | "343": { 119 | "broadcasterId": 4 120 | }, 121 | "351": { 122 | "broadcasterId": 17 123 | }, 124 | "353": { 125 | "broadcasterId": 11 126 | }, 127 | "354": { 128 | "broadcasterId": 11 129 | }, 130 | "363": { 131 | "broadcasterId": 20 132 | } 133 | }, 134 | "lastUpdated": 1647610100264 135 | }; 136 | -------------------------------------------------------------------------------- /client/video_list.tsx: -------------------------------------------------------------------------------- 1 | import * as EPGStationAPI from "./api/epgstation"; 2 | import * as MirakAPI from "./api/mirakurun"; 3 | import * as ReactDOM from "react-dom"; 4 | 5 | type EPGRecords = EPGStationAPI.components["schemas"]["Records"]; 6 | type EPGRecordedItem = EPGStationAPI.components["schemas"]["RecordedItem"]; 7 | type MirakChannel = MirakAPI.definitions["Channel"]; 8 | type MirakService = MirakAPI.definitions["Service"]; 9 | async function fetchRecorded(): Promise { 10 | const res = await fetch("/api/recorded?isHalfWidth=true&offset=0&limit=1000&hasOriginalFile=true"); 11 | const recordedJson = await res.json(); 12 | return recordedJson as EPGRecords; 13 | } 14 | 15 | async function fetchChannels(): Promise { 16 | const res = await fetch("/api/channels"); 17 | const channelsJson = await res.json(); 18 | return channelsJson as MirakChannel[]; 19 | }; 20 | 21 | function Record({ record }: { record: EPGRecordedItem }) { 22 | const videoId = record.videoFiles?.find(file => file.type == "ts")?.id; 23 | const title = `${record.name.length === 0 ? "番組名なし" : record.name} ${new Date(record.startAt).toLocaleString()}` 24 | if (videoId == null) { 25 | return title; 26 | } 27 | return ( 28 | 29 | {title} 30 | 31 | ); 32 | } 33 | 34 | function Records({ records: { records } }: { records: EPGRecords }) { 35 | return ( 36 |
    37 | {records.map(record =>
  • )} 38 |
39 | ); 40 | } 41 | 42 | function Service({ channel, service }: { channel: MirakChannel, service: MirakService }) { 43 | return ( 44 | 45 | {service.name} ({service.serviceId.toString(16).padStart(4, "0")} {service.networkId.toString(16).padStart(4, "0")}) 46 | 47 | ); 48 | } 49 | 50 | function Channel({ channel }: { channel: MirakChannel }) { 51 | if (channel.services == null) { 52 | return null; 53 | } 54 | return ( 55 |
  • 56 | 57 | {channel.channel} {channel.name} 58 | 59 |
      60 | {channel.services?.map(service =>
    • )} 61 |
    62 |
  • 63 | ); 64 | } 65 | 66 | function Channels({ channels }: { channels: MirakChannel[] }) { 67 | return ( 68 |
      69 | {channels.map(channel => )} 70 |
    71 | ); 72 | } 73 | 74 | async function VideoList() { 75 | try { 76 | var records = await fetchRecorded(); 77 | } catch { 78 | records = { records: [], total: 0 }; 79 | } 80 | try { 81 | var channels = await fetchChannels(); 82 | } catch { 83 | channels = []; 84 | } 85 | return
    86 |

    87 | チャンネル一覧 88 |

    89 | 90 |

    91 | 録画一覧 92 |

    93 | 94 |
    95 | 96 | } 97 | 98 | async function main() { 99 | ReactDOM.render(await VideoList(), document.getElementById("ui-main")); 100 | } 101 | 102 | main(); 103 | -------------------------------------------------------------------------------- /documents/nvram.md: -------------------------------------------------------------------------------- 1 | # nvramの運用規定 2 | 3 | STD-B24, TR-B14, TR-B15を参照 4 | 5 | ## 地上波 6 | 7 | TR-B14 第二分冊 5.2 表5-1 8 | 9 | ### 地上デジタルテレビジョン放送事業者系列専用領域 10 | 11 | BS: 読み書き(BML3.0, 地上の拡張ブロードキャスタ記述子で同系列と定義された場合), CS: 不可, 地上: 読み書き 12 | 13 | 系列ごとに64バイトの固定長ブロック*64 14 | 15 | 系列数は8以上 16 | 17 | `nvram://;group/` 18 | 19 | TR-B14 第五分冊 表9-4参照 20 | 21 | |affiliation_id|系列名| 22 | |-|-| 23 | |00|NHK総合| 24 | |01|NHK教育| 25 | |02|日本テレビ放送網| 26 | |03|TBSテレビ| 27 | |04|フジテレビ| 28 | |05|テレビ朝日| 29 | |06|テレビ東京| 30 | |07|サンテレビジョン| 31 | 32 | NHKは総合, 教育, BS1, BSP全て0と1に含まれる 33 | 34 | ### 地上デジタルテレビジョン放送事業者専用領域 35 | 36 | BS: 不可, CS: 不可, 地上: 読み書き 37 | 38 | 事業者ごとに64バイトの固定長ブロック*64 39 | 40 | 事業者数は12以上 41 | 42 | `nvram://[;]local/` 43 | 44 | ### 地上デジタルテレビジョン放送事業者専用放送通信共通領域 45 | 46 | BS: 不可, CS: 不可, 地上: 読み書き 47 | 48 | 事業者ごとに64バイトの固定長ブロック*32 49 | 50 | 事業者数は12以上 51 | 52 | `nvram://[;]local_web/` 53 | 54 | ### 地上デジタルテレビジョン放送事業者共通領域 55 | 56 | BS: 読み出し, CS: 不可, 地上: 読み書き 57 | 58 | 64バイトの固定長ブロック*32 59 | 60 | ブロックごとにフォーマットが定められている(5.2.8 表5-3参照) 61 | 62 | `nvram://tr_common/` 63 | 64 | ## 共用 65 | 66 | TR-B14 第二分冊, TR-B15 第一分冊, 第四分冊参照 67 | 68 | ### ブックマーク領域 69 | 70 | BS: 読み書き(レベル2, 3のみ), CS: 読み書き, 地上: 読み書き 71 | 72 | 最大320バイトの可変長ブロック*50以上(地上) 73 | 74 | 最大320バイトの可変長ブロック*30以上(CS) 75 | 76 | TR-B14(地上)とTR-B15(CS)でブロック数に違いがある 77 | 78 | 地上とBS/CSで共通でないかもしれない 79 | 80 | `nvram://bookmark/` 81 | 82 | ### 登録発呼領域 83 | 84 | BS: 読み書き(BML3.0), CS: 読み書き(BML3.0), 地上: 読み書き 85 | 最大1.5KBの可変長ブロック*50以上 86 | 87 | `nvram://denbun/` 88 | 89 | ### 視聴者居住地域情報 90 | 91 | `nvram://receiverinfo/` 92 | 93 | TR-B14 5.2.7 表5-2参照 94 | 95 | ## BS 96 | 97 | TR-B15 第一分冊, 第三分冊, 第四分冊参照 98 | 99 | ### BS事業者共通領域 100 | 101 | TR-B15 第三分冊 6.3.5参照 102 | 103 | BS: 読み書き, CS: 不可, 地上: 読み出し 104 | 105 | 全ての事業者で共通 106 | 107 | 64バイト*16 108 | 109 | `nvram://common/` 110 | 111 | ### BS事業者専用領域 112 | 113 | BS: 読み書き, CS: 読み書き(オプション、許可された場合のみ), 地上: 不可 114 | 115 | 事業者ごとに64バイト*64 116 | 117 | 事業者数は23 118 | 119 | `getBrowserSupport("nvram", "BSspecifiedExtension", "48")`が1を返せば64ブロック以上, 0を返せば16ブロック 120 | 121 | `getBrowserSupport("nvram", "NumberOfBSBroadcasters", "23")`が1を返せば23事業者以上 122 | 123 | broadcaster_idごと 124 | 125 | `nvram://~/` 126 | 127 | `nvram://~/ext/` 128 | 129 | ### BSデジタル放送事業者専用放送通信共通領域(オプション) 130 | 131 | BS: 読み書き, CS: 不可, 地上: 不可 132 | 133 | TR-B15 第一分冊 10.3.2参照 134 | 135 | 事業者ごとに64バイト*32ブロック 136 | 137 | 事業者数は20 138 | 139 | broadcaster_idは常に省略 140 | 141 | `nvram://[;]local_web/` 142 | 143 | ## CS 144 | 145 | TR-B15 第四分冊参照 146 | 147 | ### 広帯域CSデジタル放送事業者共通領域 148 | 149 | BS: 不可, CS: 読み書き, 地上: 不可 150 | 151 | TR-B15 第四分冊参照 8.10.1参照 152 | 153 | ネットワークごとに64バイト*32 154 | 155 | ネットワーク数は2 156 | 157 | `nvram://[;]cs_common/` 158 | 159 | original_network_idは常に省略 160 | 161 | 地上, BSと違い中身はネットワークごとに規定される 162 | 163 | ### 広帯域CSデジタル放送事業者専用領域 164 | 165 | BS: 不可, CS: 読み書き, 地上: 不可 166 | 167 | 事業者ごとに64バイト*47 168 | 169 | `getBrowserSupport("nvram", "NumberOfCSBroadcasters", "23")`が1を返せば23事業者以上 170 | 171 | `nvrams://[;]~/` 172 | 173 | original_network_idは常に省略 174 | 175 | broadcaster_idごと 176 | 177 | nvram://は運用されない 178 | 179 | ### 広帯域CSデジタル放送事業者専用放送通信共通領域 180 | 181 | BS: 不可, CS: 読み書き, 地上: 不可 182 | 183 | TR-B15 第四分冊 11.5.7.1参照 184 | 185 | 事業者ごとに64バイト*46 186 | 187 | broadcaster_idは常に省略 188 | 189 | `nvram://[;]local_web/` 190 | 191 | 192 | ## STD-B24 193 | 194 | STD-B24 第二編(1/2) 解説7 参照 195 | 196 | ### 事業者専用領域 197 | 198 | 通常領域 199 | 200 | `nvram://~/` 201 | 202 | ### BS共通領域, 広帯域CS事業者共通領域 203 | 204 | ネットワーク毎の共通領域 205 | `nvram://common/` 206 | 207 | original_network_idは常に省略 208 | -------------------------------------------------------------------------------- /client/default_clut.ts: -------------------------------------------------------------------------------- 1 | // STD-B24 第二分冊(2/2) A3 5.1.7 表5-7参照 2 | export const defaultCLUT: number[][] = [ 3 | [0 , 0 , 0 , 255], 4 | [255, 0 , 0 , 255], 5 | [0 , 255, 0 , 255], 6 | [255, 255, 0 , 255], 7 | [0 , 0 , 255, 255], 8 | [255, 0 , 255, 255], 9 | [0 , 255, 255, 255], 10 | [255, 255, 255, 255], 11 | [0 , 0 , 0 , 0 ], 12 | [170, 0 , 0 , 255], 13 | [0 , 170, 0 , 255], 14 | [170, 170, 0 , 255], 15 | [0 , 0 , 170, 255], 16 | [170, 0 , 170, 255], 17 | [0 , 170, 170, 255], 18 | [170, 170, 170, 255], 19 | [0 , 0 , 85 , 255], 20 | [0 , 85 , 0 , 255], 21 | [0 , 85 , 85 , 255], 22 | [0 , 85 , 170, 255], 23 | [0 , 85 , 255, 255], 24 | [0 , 170, 85 , 255], 25 | [0 , 170, 255, 255], 26 | [0 , 255, 85 , 255], 27 | [0 , 255, 170, 255], 28 | [85 , 0 , 0 , 255], 29 | [85 , 0 , 85 , 255], 30 | [85 , 0 , 170, 255], 31 | [85 , 0 , 255, 255], 32 | [85 , 85 , 0 , 255], 33 | [85 , 85 , 85 , 255], 34 | [85 , 85 , 170, 255], 35 | [85 , 85 , 255, 255], 36 | [85 , 170, 0 , 255], 37 | [85 , 170, 85 , 255], 38 | [85 , 170, 170, 255], 39 | [85 , 170, 255, 255], 40 | [85 , 255, 0 , 255], 41 | [85 , 255, 85 , 255], 42 | [85 , 255, 170, 255], 43 | [85 , 255, 255, 255], 44 | [170, 0 , 85 , 255], 45 | [170, 0 , 255, 255], 46 | [170, 85 , 0 , 255], 47 | [170, 85 , 85 , 255], 48 | [170, 85 , 170, 255], 49 | [170, 85 , 255, 255], 50 | [170, 170, 85 , 255], 51 | [170, 170, 255, 255], 52 | [170, 255, 0 , 255], 53 | [170, 255, 85 , 255], 54 | [170, 255, 170, 255], 55 | [170, 255, 255, 255], 56 | [255, 0 , 85 , 255], 57 | [255, 0 , 170, 255], 58 | [255, 85 , 0 , 255], 59 | [255, 85 , 85 , 255], 60 | [255, 85 , 170, 255], 61 | [255, 85 , 255, 255], 62 | [255, 170, 0 , 255], 63 | [255, 170, 85 , 255], 64 | [255, 170, 170, 255], 65 | [255, 170, 255, 255], 66 | [255, 255, 85 , 255], 67 | [255, 255, 170, 255], 68 | [0 , 0 , 0 , 128], 69 | [255, 0 , 0 , 128], 70 | [0 , 255, 0 , 128], 71 | [255, 255, 0 , 128], 72 | [0 , 0 , 255, 128], 73 | [255, 0 , 255, 128], 74 | [0 , 255, 255, 128], 75 | [255, 255, 255, 128], 76 | [170, 0 , 0 , 128], 77 | [0 , 170, 0 , 128], 78 | [170, 170, 0 , 128], 79 | [0 , 0 , 170, 128], 80 | [170, 0 , 170, 128], 81 | [0 , 170, 170, 128], 82 | [170, 170, 170, 128], 83 | [0 , 0 , 85 , 128], 84 | [0 , 85 , 0 , 128], 85 | [0 , 85 , 85 , 128], 86 | [0 , 85 , 170, 128], 87 | [0 , 85 , 255, 128], 88 | [0 , 170, 85 , 128], 89 | [0 , 170, 255, 128], 90 | [0 , 255, 85 , 128], 91 | [0 , 255, 170, 128], 92 | [85 , 0 , 0 , 128], 93 | [85 , 0 , 85 , 128], 94 | [85 , 0 , 170, 128], 95 | [85 , 0 , 255, 128], 96 | [85 , 85 , 0 , 128], 97 | [85 , 85 , 85 , 128], 98 | [85 , 85 , 170, 128], 99 | [85 , 85 , 255, 128], 100 | [85 , 170, 0 , 128], 101 | [85 , 170, 85 , 128], 102 | [85 , 170, 170, 128], 103 | [85 , 170, 255, 128], 104 | [85 , 255, 0 , 128], 105 | [85 , 255, 85 , 128], 106 | [85 , 255, 170, 128], 107 | [85 , 255, 255, 128], 108 | [170, 0 , 85 , 128], 109 | [170, 0 , 255, 128], 110 | [170, 85 , 0 , 128], 111 | [170, 85 , 85 , 128], 112 | [170, 85 , 170, 128], 113 | [170, 85 , 255, 128], 114 | [170, 170, 85 , 128], 115 | [170, 170, 255, 128], 116 | [170, 255, 0 , 128], 117 | [170, 255, 85 , 128], 118 | [170, 255, 170, 128], 119 | [170, 255, 255, 128], 120 | [255, 0 , 85 , 128], 121 | [255, 0 , 170, 128], 122 | [255, 85 , 0 , 128], 123 | [255, 85 , 85 , 128], 124 | [255, 85 , 170, 128], 125 | [255, 85 , 255, 128], 126 | [255, 170, 0 , 128], 127 | [255, 170, 85 , 128], 128 | [255, 170, 170, 128], 129 | [255, 170, 255, 128], 130 | [255, 255, 85 , 128], 131 | ]; 132 | -------------------------------------------------------------------------------- /client/shift_jis.ts: -------------------------------------------------------------------------------- 1 | import { jisToUnicodeMap } from "./jis_to_unicode_map"; 2 | import { unicodeToJISMap } from "./unicode_to_jis_map"; 3 | 4 | export function decodeShiftJIS(input: Uint8Array): string { 5 | if (input.length === 0) { 6 | return ""; 7 | } 8 | const replacementCharacter = "\ufffd"; // � 9 | let buffer = ""; 10 | for (let i = 0; i < input.length; i++) { 11 | if (input[i] >= 0x81 && input[i] <= 0xef) { 12 | let ku; 13 | if (input[i] >= 0x81 && input[i] <= 0x9f) { 14 | ku = (input[i] - 0x81) * 2 + 1; 15 | } else if (input[i] >= 0xe0 && input[i] <= 0xef) { 16 | ku = (input[i] - 0xe0) * 2 + 63; 17 | } else if (input[i] >= 0xa1 && input[i] <= 0xdf) { 18 | buffer += String.fromCharCode(input[i] - 0xa0 + 0xff60); 19 | continue; 20 | } else { 21 | buffer += replacementCharacter; 22 | continue; 23 | } 24 | i++; 25 | if (i >= input.length) { 26 | buffer += replacementCharacter; 27 | break; 28 | } 29 | let ten; 30 | if (input[i] >= 0x40 && input[i] <= 0x7e) { 31 | ten = input[i] - 0x40 + 1; 32 | } else if (input[i] >= 0x80 && input[i] <= 0x9e) { 33 | ten = input[i] - 0x80 + 64; 34 | } else if (input[i] >= 0x9f && input[i] <= 0xfc) { 35 | ku++; 36 | ten = input[i] - 0x9f + 1; 37 | } else { 38 | buffer += replacementCharacter; 39 | continue; 40 | } 41 | const uni = jisToUnicodeMap[(ku - 1) * 94 + (ten - 1)]; 42 | if (typeof uni === "number") { 43 | if (uni >= 0) { 44 | buffer += String.fromCharCode(uni); 45 | } else { 46 | buffer += replacementCharacter; 47 | } 48 | } else { 49 | for (const u of uni) { 50 | buffer += String.fromCharCode(u); 51 | } 52 | } 53 | } else if (input[i] < 0x80) { 54 | buffer += String.fromCharCode(input[i]); 55 | } else { 56 | buffer += replacementCharacter; 57 | } 58 | } 59 | return buffer; 60 | } 61 | 62 | export function encodeShiftJIS(input: string): Uint8Array { 63 | const buf = new Uint8Array(input.length * 2); 64 | let off = 0; 65 | for (let i = 0; i < input.length; i++) { 66 | const c = input.charCodeAt(i); 67 | if (c >= 0xff61 && c <= 0xff9f) { 68 | buf[off++] = c - 0xff60 + 0xa0; 69 | continue; 70 | } 71 | const a = unicodeToJISMap[c]; 72 | if (a == null && c < 0x80) { 73 | buf[off++] = c; 74 | continue; 75 | } 76 | const jis = (a ?? 0x222e) - 0x2020; // 〓 77 | const ku = jis >>> 8; 78 | const ten = jis & 0xff; 79 | if (ku >= 1 && ku <= 62) { 80 | buf[off++] = (ku >>> 1) + 0x81 - 1; 81 | } else if (ku >= 63 && ku <= 94) { 82 | buf[off++] = (ku >>> 1) - 63 + 0xe0; 83 | } 84 | if (ku % 2 === 1) { 85 | if (ten >= 1 && ten <= 63) { 86 | buf[off++] = ten + 0x40 - 1; 87 | } else { 88 | buf[off++] = ten + 0x80 - 64; 89 | } 90 | } else { 91 | buf[off++] = ten + 0x9f - 1; 92 | } 93 | } 94 | return buf.subarray(0, off); 95 | } 96 | 97 | export function stripStringShiftJIS(input: string, maxBytes: number): string { 98 | // 1, 2バイト文字しか存在しない 99 | if (input.length * 2 < maxBytes) { 100 | return input; 101 | } 102 | let bytes = 0; 103 | for (let i = 0; i < input.length; i++) { 104 | const c = input.charCodeAt(i); 105 | const size = c < 0x80 || (c >= 0xff61 && c <= 0xff9f) ? 1 : 2; 106 | if (bytes + size > maxBytes) { 107 | return input.substring(0, i); 108 | } 109 | bytes += size; 110 | } 111 | return input; 112 | } 113 | -------------------------------------------------------------------------------- /client/arib_aiff.ts: -------------------------------------------------------------------------------- 1 | // STD-B24 TR-B14 TR-B15で規定されるAIFFのサブセットを再生する 2 | // 12 kHz 1ch 16-bit 3 | 4 | type COMM = { 5 | numChannels: number, 6 | numSampleFrames: number, 7 | sampleSize: number, 8 | sampleRate: number, 9 | }; 10 | 11 | function decodeAIFF(aiff: Buffer): { comm: COMM, soundData: Buffer } | null { 12 | let off = 0; 13 | const ckID = aiff.toString("ascii", off, off + 4); 14 | if (ckID !== "FORM") { 15 | return null; 16 | } 17 | off += 4; 18 | const ckDataSize = aiff.readUInt32BE(off); 19 | off += 4; 20 | const endOffset = Math.min(off + ckDataSize, aiff.length); 21 | const formType = aiff.toString("ascii", off, off + 4); 22 | if (formType !== "AIFC") { 23 | return null; 24 | } 25 | off += 4; 26 | let comm: COMM | undefined; 27 | let soundData: Buffer | undefined; 28 | while (off < endOffset) { 29 | const ckID = aiff.toString("ascii", off, off + 4); 30 | off += 4; 31 | const ckDataSize = aiff.readUInt32BE(off); 32 | off += 4; 33 | const nextOff = off + ckDataSize; 34 | if (ckID === "COMM") { 35 | const numChannels = aiff.readUInt16BE(off); 36 | off += 2; 37 | const numSampleFrames = aiff.readUInt32BE(off); // samples/channel 38 | off += 4; 39 | const sampleSize = aiff.readUInt16BE(off); // bits/sample 40 | off += 2; 41 | soundData = Buffer.alloc((numSampleFrames * numChannels * sampleSize + 7) / 8); 42 | const sampleRateRaw = aiff.subarray(off, off + 10); // sample_frames/sec 43 | const exponent = (sampleRateRaw.readUInt16BE(0) & 0x7fff) - 16383 - 63; 44 | let fraction = sampleRateRaw.readBigUInt64BE(2); 45 | if (sampleRateRaw[0] & 0x80) { 46 | fraction = -fraction; 47 | } 48 | let sampleRate = fraction; 49 | if (exponent > 0) { 50 | sampleRate *= BigInt(Math.pow(2, exponent)); 51 | } else if (exponent < 0) { 52 | sampleRate /= BigInt(Math.pow(2, -exponent)); 53 | } 54 | comm = { 55 | numChannels, 56 | numSampleFrames, 57 | sampleSize, 58 | sampleRate: Number(sampleRate), 59 | }; 60 | off += 10; 61 | const compressionType = aiff.toString("ascii", off, off + 4); 62 | if (compressionType !== "NONE") { 63 | return null; 64 | } 65 | // compressionName 66 | } else if (ckID === "SSND") { 67 | if (comm == null) { 68 | return null; 69 | } 70 | if (soundData == null) { 71 | return null; 72 | } 73 | const offset = aiff.readUInt32BE(off); 74 | off += 4; 75 | const blockSize = aiff.readUInt32BE(off); 76 | off += 4; 77 | aiff.copy(soundData, offset, off, nextOff); 78 | } 79 | off = nextOff; 80 | } 81 | if (comm == null || soundData == null) { 82 | return null; 83 | } 84 | return { comm, soundData }; 85 | } 86 | 87 | export function playAIFF(destination: AudioNode, aiff: Buffer): AudioBufferSourceNode | null { 88 | const a = decodeAIFF(aiff); 89 | if (a == null) { 90 | return null; 91 | } 92 | const { comm, soundData } = a; 93 | // 12 kHz 1ch 16-bitで運用される 94 | if (comm.numChannels !== 1) { 95 | return null; 96 | } 97 | if (comm.sampleSize !== 16) { 98 | return null; 99 | } 100 | const soundDataF32 = new Float32Array(comm.numSampleFrames); 101 | for (let i = 0; i < comm.numSampleFrames; i++) { 102 | soundDataF32[i] = (((soundData[i * 2] << 8) | (soundData[i * 2 + 1])) << 16 >> 16) / 32768; 103 | } 104 | const buffer = destination.context.createBuffer(1, soundDataF32.length, comm.sampleRate); 105 | buffer.copyToChannel(soundDataF32, 0); 106 | const source = destination.context.createBufferSource(); 107 | source.buffer = buffer; 108 | source.connect(destination.context.destination); 109 | source.start(0); 110 | return source; 111 | } 112 | -------------------------------------------------------------------------------- /client/player/mpegts.ts: -------------------------------------------------------------------------------- 1 | import Mpegts from "mpegts.js"; 2 | import * as aribb24js from "aribb24.js"; 3 | import { VideoPlayer } from "./video_player"; 4 | import { playRomSound } from "../romsound"; 5 | 6 | // Based on EPGStation 7 | 8 | /** 9 | * 字幕スーパー用の処理 10 | * 元のソースは下記参照 11 | * https://twitter.com/magicxqq/status/1381813912539066373 12 | * https://github.com/l3tnun/EPGStation/commit/352bf9a69fdd0848295afb91859e1a402b623212#commitcomment-50407815 13 | */ 14 | function parseMalformedPES(data: any): any { 15 | let pes_header_data_length = data[2]; 16 | let payload_start_index = 3 + pes_header_data_length; 17 | let payload_length = data.byteLength - payload_start_index; 18 | let payload = data.subarray(payload_start_index, payload_start_index + payload_length); 19 | return payload; 20 | } 21 | 22 | export class MPEGTSVideoPlayer extends VideoPlayer { 23 | captionRenderer: aribb24js.SVGRenderer | null = null; 24 | superimposeRenderer: aribb24js.SVGRenderer | null = null; 25 | 26 | private PRACallback = (index: number): void => { 27 | if (this.audioNode == null || this.container.style.display === "none") { 28 | return; 29 | } 30 | playRomSound(index, this.audioNode); 31 | } 32 | 33 | public setSource(source: string): void { 34 | if (Mpegts.getFeatureList().mseLivePlayback) { 35 | var player = Mpegts.createPlayer({ 36 | type: "mse", 37 | isLive: true, 38 | url: new URL(source + ".h264.m2ts", location.href).toString(), 39 | }, { 40 | enableWorker: true, 41 | // liveBufferLatencyChasing: true, 42 | liveBufferLatencyMinRemain: 1.0, 43 | liveBufferLatencyMaxLatency: 2.0, 44 | }); 45 | player.attachMediaElement(this.video); 46 | player.load(); 47 | player.play(); 48 | 49 | // 字幕対応 50 | const captionOption: aribb24js.SVGRendererOption = { 51 | normalFont: "丸ゴシック", 52 | forceStrokeColor: true, 53 | PRACallback: this.PRACallback, 54 | }; 55 | captionOption.data_identifier = 0x80; 56 | const captionRenderer = new aribb24js.SVGRenderer(captionOption); 57 | const superimposeOption: aribb24js.SVGRendererOption = { 58 | normalFont: "丸ゴシック", 59 | forceStrokeColor: true, 60 | PRACallback: this.PRACallback, 61 | }; 62 | superimposeOption.data_identifier = 0x81; 63 | const superimposeRenderer = new aribb24js.SVGRenderer(superimposeOption); 64 | this.captionRenderer = captionRenderer; 65 | this.superimposeRenderer = superimposeRenderer; 66 | captionRenderer.attachMedia(this.video); 67 | superimposeRenderer.attachMedia(this.video); 68 | this.container.appendChild(captionRenderer.getSVG()); 69 | this.container.appendChild(superimposeRenderer.getSVG()); 70 | /** 71 | * 字幕スーパー用の処理 72 | * 元のソースは下記参照 73 | * https://twitter.com/magicxqq/status/1381813912539066373 74 | * https://github.com/l3tnun/EPGStation/commit/352bf9a69fdd0848295afb91859e1a402b623212#commitcomment-50407815 75 | */ 76 | player.on(Mpegts.Events.PES_PRIVATE_DATA_ARRIVED, data => { 77 | if (data.stream_id === 0xbd && data.data[0] === 0x80 && captionRenderer !== null) { 78 | // private_stream_1, caption 79 | captionRenderer.pushData(data.pid, data.data, data.pts / 1000); 80 | } else if (data.stream_id === 0xbf && superimposeRenderer !== null) { 81 | // private_stream_2, superimpose 82 | let payload = data.data; 83 | if (payload[0] !== 0x81) { 84 | payload = parseMalformedPES(data.data); 85 | } 86 | if (payload[0] !== 0x81) { 87 | return; 88 | } 89 | superimposeRenderer.pushData(data.pid, payload, data.nearest_pts / 1000); 90 | } 91 | }); 92 | } 93 | } 94 | 95 | public showCC(): void { 96 | this.captionRenderer?.show(); 97 | this.superimposeRenderer?.show(); 98 | this.container.style.display = ""; 99 | } 100 | 101 | public hideCC(): void { 102 | this.captionRenderer?.hide(); 103 | this.superimposeRenderer?.hide(); 104 | this.container.style.display = "none"; 105 | } 106 | 107 | private audioNode?: AudioNode; 108 | 109 | public override setPRAAudioNode(audioNode?: AudioNode): void { 110 | this.audioNode = audioNode; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /client/remote_controller_client.ts: -------------------------------------------------------------------------------- 1 | import { Indicator } from "./bml_browser"; 2 | import { AribKeyCode, keyCodeToAribKey, Content } from "./content"; 3 | import { VideoPlayer } from "./player/video_player"; 4 | import { RemoteControllerMessage } from "./remote_controller"; 5 | 6 | export class RemoteControl implements Indicator { 7 | public content?: Content; 8 | public player?: VideoPlayer; 9 | element: HTMLElement; 10 | receivingStatusElement: HTMLElement; 11 | networkingStatusElement?: HTMLElement; 12 | constructor(element: HTMLElement, receivingStatusElement: HTMLElement, networkingStatusElement?: HTMLElement, content?: Content, player?: VideoPlayer) { 13 | this.content = content; 14 | this.player = player; 15 | this.element = element; 16 | this.receivingStatusElement = receivingStatusElement; 17 | this.networkingStatusElement = networkingStatusElement; 18 | 19 | this.element.querySelectorAll("button").forEach(x => { 20 | x.addEventListener("click", () => { 21 | if (x.id === "unmute" || x.id === "mute" || x.id === "play" || x.id === "pause" || x.id === "cc" || x.id === "disable-cc" || x.id === "zoom-100" || x.id === "zoom-150" || x.id === "zoom-200") { 22 | this.process({ type: x.id }); 23 | } else { 24 | this.process({ type: "button", keyCode: Number.parseInt(x.id.split("key")[1]) }); 25 | } 26 | }); 27 | }); 28 | } 29 | private process(remoteController: RemoteControllerMessage) { 30 | if (remoteController != null) { 31 | if (remoteController.type === "unmute") { 32 | this.player?.unmute(); 33 | } else if (remoteController.type === "mute") { 34 | this.player?.mute(); 35 | } else if (remoteController.type === "pause") { 36 | this.player?.pause(); 37 | } else if (remoteController.type === "play") { 38 | this.player?.play(); 39 | } else if (remoteController.type === "cc") { 40 | this.player?.showCC(); 41 | } else if (remoteController.type === "disable-cc") { 42 | this.player?.hideCC(); 43 | } else if (remoteController.type === "zoom-100") { 44 | document.documentElement.style.transform = ""; 45 | this.player?.scale(1); 46 | } else if (remoteController.type === "zoom-150") { 47 | document.documentElement.style.transform = "scale(150%)"; 48 | document.documentElement.style.transformOrigin = "left top"; 49 | this.player?.scale(1.5); 50 | } else if (remoteController.type === "zoom-200") { 51 | document.documentElement.style.transform = "scale(200%)"; 52 | document.documentElement.style.transformOrigin = "left top"; 53 | this.player?.scale(2); 54 | } else if (remoteController.type === "button") { 55 | this.content?.processKeyDown(remoteController.keyCode as AribKeyCode); 56 | this.content?.processKeyUp(remoteController.keyCode as AribKeyCode); 57 | } else if (remoteController.type === "keydown") { 58 | const k = keyCodeToAribKey(remoteController.key); 59 | if (k != -1) { 60 | this.content?.processKeyDown(k); 61 | } 62 | } else if (remoteController.type === "keyup") { 63 | const k = keyCodeToAribKey(remoteController.key); 64 | if (k != -1) { 65 | this.content?.processKeyUp(k); 66 | } 67 | } 68 | } 69 | } 70 | url = ""; 71 | receiving = false; 72 | networkingPost = false; 73 | eventName: string | null = ""; 74 | loading = false; 75 | private update() { 76 | const indicator = this.element.querySelector(".remote-control-indicator"); 77 | if (indicator != null) { 78 | indicator.textContent = this.url + (this.loading ? "を読み込み中..." : "") + "\n" + (this.eventName ?? "番組名未取得"); 79 | } 80 | if (this.receiving) { 81 | this.receivingStatusElement.style.display = ""; 82 | } else { 83 | this.receivingStatusElement.style.display = "none"; 84 | } 85 | if (this.networkingStatusElement != null) { 86 | if (this.networkingPost) { 87 | this.networkingStatusElement.style.display = ""; 88 | } else { 89 | this.networkingStatusElement.style.display = "none"; 90 | } 91 | } 92 | } 93 | public setUrl(name: string, loading: boolean): void { 94 | this.url = name; 95 | this.loading = loading; 96 | this.update(); 97 | } 98 | public setReceivingStatus(receiving: boolean): void { 99 | this.receiving = receiving; 100 | this.update(); 101 | } 102 | public setNetworkingPostStatus(post: boolean): void { 103 | this.networkingPost = post; 104 | this.update(); 105 | } 106 | public setNetworkingGetStatus(get: boolean): void { 107 | } 108 | public setEventName(eventName: string | null): void { 109 | this.eventName = eventName; 110 | this.update(); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /client/broadcaster_4.ts: -------------------------------------------------------------------------------- 1 | // BSデジタル放送 2 | export const broadcaster4 = { 3 | "services": { 4 | "101": { 5 | "broadcasterId": 1 6 | }, 7 | "102": { 8 | "broadcasterId": 1 9 | }, 10 | "103": { 11 | "broadcasterId": 1 12 | }, 13 | "104": { 14 | "broadcasterId": 1 15 | }, 16 | "141": { 17 | "broadcasterId": 2 18 | }, 19 | "142": { 20 | "broadcasterId": 2 21 | }, 22 | "143": { 23 | "broadcasterId": 2 24 | }, 25 | "144": { 26 | "broadcasterId": 2 27 | }, 28 | "151": { 29 | "broadcasterId": 3 30 | }, 31 | "152": { 32 | "broadcasterId": 3 33 | }, 34 | "153": { 35 | "broadcasterId": 3 36 | }, 37 | "161": { 38 | "broadcasterId": 4 39 | }, 40 | "162": { 41 | "broadcasterId": 4 42 | }, 43 | "163": { 44 | "broadcasterId": 4 45 | }, 46 | "169": { 47 | "broadcasterId": 4 48 | }, 49 | "171": { 50 | "broadcasterId": 5 51 | }, 52 | "172": { 53 | "broadcasterId": 5 54 | }, 55 | "173": { 56 | "broadcasterId": 5 57 | }, 58 | "179": { 59 | "broadcasterId": 5 60 | }, 61 | "181": { 62 | "broadcasterId": 6 63 | }, 64 | "182": { 65 | "broadcasterId": 6 66 | }, 67 | "183": { 68 | "broadcasterId": 6 69 | }, 70 | "188": { 71 | "broadcasterId": 6 72 | }, 73 | "189": { 74 | "broadcasterId": 6 75 | }, 76 | "191": { 77 | "broadcasterId": 7 78 | }, 79 | "192": { 80 | "broadcasterId": 7 81 | }, 82 | "193": { 83 | "broadcasterId": 7 84 | }, 85 | "200": { 86 | "broadcasterId": 8 87 | }, 88 | "201": { 89 | "broadcasterId": 8 90 | }, 91 | "202": { 92 | "broadcasterId": 8 93 | }, 94 | "211": { 95 | "broadcasterId": 20 96 | }, 97 | "222": { 98 | "broadcasterId": 10 99 | }, 100 | "231": { 101 | "broadcasterId": 9 102 | }, 103 | "232": { 104 | "broadcasterId": 9 105 | }, 106 | "234": { 107 | "broadcasterId": 11 108 | }, 109 | "236": { 110 | "broadcasterId": 12 111 | }, 112 | "241": { 113 | "broadcasterId": 16 114 | }, 115 | "242": { 116 | "broadcasterId": 17 117 | }, 118 | "243": { 119 | "broadcasterId": 17 120 | }, 121 | "244": { 122 | "broadcasterId": 17 123 | }, 124 | "245": { 125 | "broadcasterId": 17 126 | }, 127 | "251": { 128 | "broadcasterId": 22 129 | }, 130 | "252": { 131 | "broadcasterId": 19 132 | }, 133 | "255": { 134 | "broadcasterId": 23 135 | }, 136 | "256": { 137 | "broadcasterId": 18 138 | }, 139 | "260": { 140 | "broadcasterId": 26 141 | }, 142 | "263": { 143 | "broadcasterId": 25 144 | }, 145 | "265": { 146 | "broadcasterId": 24 147 | }, 148 | "531": { 149 | "broadcasterId": 9 150 | }, 151 | "700": { 152 | "broadcasterId": 1 153 | }, 154 | "701": { 155 | "broadcasterId": 1 156 | }, 157 | "707": { 158 | "broadcasterId": 1 159 | }, 160 | "744": { 161 | "broadcasterId": 2 162 | }, 163 | "745": { 164 | "broadcasterId": 2 165 | }, 166 | "746": { 167 | "broadcasterId": 2 168 | }, 169 | "753": { 170 | "broadcasterId": 3 171 | }, 172 | "755": { 173 | "broadcasterId": 3 174 | }, 175 | "756": { 176 | "broadcasterId": 3 177 | }, 178 | "757": { 179 | "broadcasterId": 3 180 | }, 181 | "766": { 182 | "broadcasterId": 4 183 | }, 184 | "768": { 185 | "broadcasterId": 4 186 | }, 187 | "777": { 188 | "broadcasterId": 5 189 | }, 190 | "778": { 191 | "broadcasterId": 5 192 | }, 193 | "780": { 194 | "broadcasterId": 6 195 | }, 196 | "781": { 197 | "broadcasterId": 6 198 | }, 199 | "791": { 200 | "broadcasterId": 7 201 | }, 202 | "792": { 203 | "broadcasterId": 7 204 | }, 205 | "800": { 206 | "broadcasterId": 8 207 | }, 208 | "840": { 209 | "broadcasterId": 16 210 | }, 211 | "841": { 212 | "broadcasterId": 16 213 | }, 214 | "929": { 215 | "broadcasterId": 15 216 | } 217 | }, 218 | "lastUpdated": 1647611239938 219 | }; 220 | -------------------------------------------------------------------------------- /client/romsound.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * 受信機内蔵音 TR-B14 第二分冊 第三編 第2部 3.3.5 3 | * 0: 速報チャイム1 4 | * 1: 速報チャイム2 5 | * 2: 速報チャイム3 6 | * 3: 速報チャイム4 7 | * 4: 速報チャイム5 8 | * 5: ボタン操作音1 9 | * 6: ボタン操作音2 10 | * 7: ボタン操作音3 11 | * 8: ボタン操作音4 12 | * 9: ボタン操作音5 13 | * 10:ボタン操作音6 14 | * 11:ボタン操作音7 15 | * 12:ボタン操作音8 16 | * 13:アラート音 17 | * 14: 18 | * 15: 19 | **/ 20 | 21 | import { romsoundData } from "./romsound_data"; 22 | 23 | const sampleRate = 12000 * 2; 24 | 25 | function playBuffer(destination: AudioNode, buf: Float32Array, sampleRate: number) { 26 | const buffer = destination.context.createBuffer(1, buf.length, sampleRate) 27 | buffer.copyToChannel(buf, 0) 28 | const source = destination.context.createBufferSource(); 29 | source.buffer = buffer; 30 | source.connect(destination); 31 | source.start(0); 32 | } 33 | 34 | function sine(sampleRate: number, i: number, freq: number) { 35 | var sampleFreq = sampleRate / freq; 36 | return Math.sin(i / (sampleFreq / (Math.PI * 2))); 37 | } 38 | 39 | const romSoundCache = new Map, sampleRate: number }>(); 40 | 41 | export function playRomSound(soundId: number, destination: AudioNode) { 42 | let cache = romSoundCache.get(soundId); 43 | if (cache == null) { 44 | switch (soundId) { 45 | case 5: 46 | cache = { buffer: generateSound5(sampleRate), sampleRate }; 47 | break; 48 | case 6: 49 | cache = { buffer: repeatSound(generateSound5(sampleRate), 3, -2000), sampleRate }; 50 | break; 51 | case 7: 52 | cache = { buffer: generateSound7(sampleRate), sampleRate }; 53 | break; 54 | case 8: 55 | cache = { buffer: repeatSound(generateSound7(sampleRate), 3, -2000), sampleRate }; 56 | break; 57 | case 9: 58 | cache = { buffer: generateSound9(sampleRate), sampleRate }; 59 | break; 60 | case 10: 61 | cache = { buffer: repeatSound(generateSound9(sampleRate), 3, 0), sampleRate }; 62 | break; 63 | default: 64 | const data = romsoundData[soundId]; 65 | if (data != null) { 66 | const buffer = Buffer.from(data, "base64").buffer; 67 | destination.context.decodeAudioData(buffer).then((audioBuffer) => { 68 | const cache = { buffer: audioBuffer.getChannelData(0), sampleRate: audioBuffer.sampleRate }; 69 | romSoundCache.set(soundId, cache); 70 | playBuffer(destination, cache.buffer, cache.sampleRate); 71 | }); 72 | } 73 | break; 74 | } 75 | if (cache != null) { 76 | romSoundCache.set(soundId, cache); 77 | } 78 | } 79 | if (cache != null) { 80 | playBuffer(destination, cache.buffer, cache.sampleRate); 81 | return; 82 | } 83 | } 84 | 85 | // 選択音 86 | function generateSound5(sampleRate: number): Float32Array { 87 | const buf = new Float32Array(sampleRate * 0.2), volume = 0.1; 88 | const len = sampleRate * 0.2; 89 | for (let i = 0; i < len; i++) { 90 | buf[i] += sine(sampleRate, i, 4680) * volume * (1 - i / len); // envelope 91 | } 92 | for (let i = 0; i < len; i++) { 93 | buf[i] += sine(sampleRate, i, 4160) * volume * (1 - i / len); 94 | } 95 | for (let i = 0; i < len; i++) { 96 | buf[i] += sine(sampleRate, i, 3640) * volume * 0.25 * (1 - i / len); 97 | } 98 | for (let i = 0; i < len; i++) { 99 | buf[i] += sine(sampleRate, i, 520) * volume * (1 - i / len); 100 | } 101 | return buf; 102 | } 103 | 104 | // 6は5の連続 105 | 106 | function repeatSound(buf: Float32Array, repeatCount: number, intervalSample: number): Float32Array { 107 | const repeated = new Float32Array(buf.length * repeatCount + intervalSample * (repeatCount - 1)); 108 | for (let i = 0; i < repeatCount; i++) { 109 | repeated.set(buf, (buf.length + intervalSample) * i); 110 | } 111 | return repeated; 112 | } 113 | 114 | // 決定音 115 | function generateSound7(sampleRate: number): Float32Array { 116 | const buf = new Float32Array(sampleRate * 0.2), volume = 0.1; 117 | for (let i = 0; i < sampleRate * 0.05; i++) { 118 | buf[i] += sine(sampleRate, i, 2100) * volume; 119 | } 120 | for (let i = sampleRate * 0.04; i < sampleRate * 0.2; i++) { 121 | buf[i] += sine(sampleRate, i, 1400) * volume; 122 | } 123 | for (let i = sampleRate * 0.04; i < sampleRate * 0.2; i++) { 124 | buf[i] += sine(sampleRate, i, 4200) * volume * 0.6; 125 | } 126 | return buf; 127 | } 128 | 129 | // 8は7の連続 130 | 131 | // 選択音 132 | function generateSound9(sampleRate: number): Float32Array { 133 | const buf = new Float32Array(sampleRate * 0.09), volume = 0.1; 134 | const len = sampleRate * 0.09; 135 | for (let i = 0; i < len; i++) { 136 | buf[i] += sine(sampleRate, i, 5200) * volume * (1 - i / len); // envelope 137 | } 138 | for (let i = 0; i < len; i++) { 139 | buf[i] += sine(sampleRate, i, 3120) * volume * (1 - i / len); 140 | } 141 | for (let i = 0; i < len; i++) { 142 | buf[i] += sine(sampleRate, i, 1040) * volume * (1 - i / len); 143 | } 144 | return buf; 145 | } 146 | 147 | // 10は9の連続 148 | -------------------------------------------------------------------------------- /public/default_c.css: -------------------------------------------------------------------------------- 1 | /* margin */ 2 | div, p, pre, form, input, textarea, object, img { margin: 0 !important } 3 | /* padding */ 4 | div, form, object, img { padding-top: 0 !important; padding-right: 0 !important; padding-bottom: 0 !important; padding-left: 0 !important } 5 | /* border */ 6 | :where(div, p, pre, form, input, textarea) { border-width: 0; border-top-color: transparent; border-right-color: transparent; border-bottom-color: transparent; border-left-color: transparent; } 7 | object, img { border-width: 0 !important; border-style: none !important } 8 | /* display */ 9 | /* html, */head, title, meta, script, link, bevent, beitem { display: none !important } 10 | body, div, pre, form, input, textarea, object, img { display: block !important } 11 | :where(p) { display: block } 12 | br, span, a { display: inline !important } 13 | /* position */ 14 | div, p, pre, form, input, textarea, object, img { position: absolute !important } 15 | br, span, a { position: static !important } 16 | /* top, left, width, height */ 17 | :where(div, p, pre, form, input, textarea, object, img) { top: 0; left: 0; width: 0; height: 0 } 18 | /* z-index */ 19 | body, div, p, pre, br, span, a, form, input, textarea, object, img { z-index: auto !important } 20 | /* line-height */ 21 | /* br, span, a { line-height: inherit !important } */ 22 | /* visibility */ 23 | body { visibility: visible !important } 24 | span, a { visibility: inherit !important } 25 | /* overflow */ 26 | div, p, pre, form, input, textarea, object, img { overflow: hidden !important } 27 | /* color */ 28 | :where(p, pre, input, textarea) { color: black; --color: black; } 29 | :where(span, a) { color: inherit } 30 | /* background-color */ 31 | object, img { background-color: transparent !important; --background-color: transparent !important; --background-color-inherit: transparent !important; } 32 | :where(body) { background-color: white; --background-color: white; --background-color-inherit: white; } 33 | /* background-repeat */ 34 | body { background-repeat: repeat !important } 35 | /* font-family */ 36 | p, pre, span, a, input, textarea { font-family: "丸ゴシック" !important } 37 | /* text-align */ 38 | :where(p, input, textarea) { text-align: left } 39 | /* white-space */ 40 | /* p, input { white-space: normal !important } */ 41 | pre, textarea { white-space: pre !important } 42 | /* resolution */ 43 | body { --resolution: 240x480 !important } 44 | /* marquee */ 45 | :where(p) { ---wap-marquee-loop: 1; } 46 | p { ---wap-marquee-dir: rtl !important } 47 | 48 | /* reset UA CSS */ 49 | :where(p) { 50 | margin-block-start: 0; 51 | margin-block-end: 0; 52 | margin-inline-start: 0; 53 | margin-inline-end: 0; 54 | } 55 | p { 56 | line-break: anywhere !important; 57 | } 58 | body { 59 | padding: 0!important; /* NHK BS1とかbodyにpadding: 6pt;があって崩れる? */ 60 | margin: 0!important; 61 | } 62 | 63 | :where(body) { 64 | font-family: "丸ゴシック", monospace; 65 | } 66 | 67 | arib-style { 68 | display: none; 69 | } 70 | 71 | arib-script { 72 | display: none; 73 | } 74 | 75 | body[arib-loading] { display: none !important; } 76 | 77 | :where(html, bml) { 78 | line-height: 1; /* Firefox */ 79 | --line-height: 1; 80 | --line-height-raw: normal; 81 | } 82 | 83 | html { 84 | position: absolute; 85 | top: 0px; 86 | left: 0px; 87 | /* TR-B14 第三分冊 8.1.9.2 表8-1 */ 88 | --small: 16px; 89 | --medium: 20px; 90 | --large: 30px; 91 | /* タブは空白一文字分 (TR-B14 第三分冊 7.7.3 注4) */ 92 | tab-size: 1; 93 | --font-size: var(--medium); 94 | --font-size-raw: medium; 95 | } 96 | 97 | /* 継承しない拡張特性の初期値 */ 98 | :where(*) { 99 | --used-key-list: basic; 100 | --background-color: ; 101 | } 102 | 103 | arib-bg { 104 | background-color: var(--background-color-inherit) !important; /* 全称セレクタ対策 */ 105 | background-image: var(--background-image2) !important; 106 | width: 100% !important; 107 | height: 100% !important; 108 | position: absolute !important; 109 | left: 0px !important; 110 | top: 0px !important; 111 | } 112 | 113 | arib-text, arib-cdata { 114 | display: inline !important; 115 | font: inherit !important; 116 | letter-spacing: inherit !important; 117 | text-align: inherit !important; 118 | visibility: unset !important; 119 | border: none !important; 120 | background: none !important; 121 | padding: 0px !important; 122 | margin: 0px !important; 123 | } 124 | 125 | arib-cdata { 126 | white-space: break-spaces !important; 127 | } 128 | 129 | :where(a[web-bml-state="focus"], a[web-bml-state="active"]) { 130 | /* background-color: var(--color) !important; 131 | color: var(--background-color-inherit) !important; */ 132 | background-color: blue; 133 | color: white !important; 134 | outline: 1px blue dotted; 135 | outline-offset: -1px; 136 | } 137 | 138 | :where(p) { 139 | ---wap-marquee-speed: normal; 140 | ---wap-marquee-style: scroll; 141 | } 142 | 143 | :where(input, textarea) { 144 | ---wap-input-format: *M; 145 | } 146 | 147 | :where(html) { 148 | --background-color-inherit: white; 149 | } 150 | 151 | input[web-bml-state="focus"], input[web-bml-state="active"] { 152 | background-color: #a3ceff !important; 153 | } 154 | 155 | p, input { 156 | white-space: break-spaces !important; 157 | } 158 | 159 | textarea { 160 | resize: none; 161 | } 162 | 163 | object[web-bml-state="focus"]>img, object[web-bml-state="active"]>img, img[web-bml-state="focus"], img[web-bml-state="active"] { 164 | outline: 1px blue dotted; 165 | outline-offset: -1px; 166 | } 167 | -------------------------------------------------------------------------------- /client/player/caption_player.ts: -------------------------------------------------------------------------------- 1 | import { SVGProvider, SVGProviderOption } from "aribb24.js"; 2 | import { playRomSound } from "../romsound"; 3 | import { VideoPlayer } from "./video_player"; 4 | 5 | // 別途PESを受け取って字幕を描画する 6 | export class CaptionPlayer extends VideoPlayer { 7 | svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); 8 | superSVG = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); 9 | captionOption: SVGProviderOption; 10 | public constructor(video: HTMLVideoElement, container: HTMLElement) { 11 | super(video, container); 12 | this.scale(1); 13 | this.container.append(this.svg); 14 | this.container.append(this.superSVG); 15 | this.captionOption = { 16 | normalFont: "丸ゴシック", 17 | forceStrokeColor: true, 18 | }; 19 | } 20 | 21 | public setSource(_source: string): void { 22 | } 23 | 24 | pes: Uint8Array | undefined; 25 | pts: number | undefined; 26 | endTime: number | undefined; 27 | superEndPCR: number | undefined; 28 | 29 | peses: { 30 | pes: Uint8Array, 31 | pts: number, 32 | endTime: number, 33 | }[] = []; 34 | 35 | pcr: number | undefined; 36 | 37 | public updateTime(pcr: number) { 38 | this.pcr = pcr; 39 | if (this.pes != null && this.pts != null && this.endTime != null && this.pcr != null && this.pts + this.endTime < this.pcr) { 40 | // CS 41 | this.svg.replaceChildren(); 42 | this.pes = undefined; 43 | this.pts = undefined; 44 | this.endTime = undefined; 45 | } 46 | if (this.superEndPCR != null && this.superEndPCR < this.pcr) { 47 | this.superSVG.replaceChildren(); 48 | } 49 | let pesIndex: number = this.peses.findIndex(x => x.pts > pcr); 50 | if (pesIndex === -1) { 51 | pesIndex = this.peses.length; 52 | } 53 | if (pesIndex > 0) { 54 | const pes = this.peses[pesIndex - 1]; 55 | this.pes = pes.pes; 56 | this.pts = pes.pts; 57 | this.endTime = pes.endTime; 58 | if (this.peses.splice(0, pesIndex).find(x => x.pts <= pcr + x.endTime) != null) { 59 | this.svg.replaceChildren(); 60 | } 61 | this.render(); 62 | } 63 | } 64 | 65 | public push(streamId: number, pes: Uint8Array, pts?: number) { 66 | if (pts != null && streamId === 0xbd) { 67 | pts /= 90; 68 | if (this.pcr == null) { 69 | return; 70 | } 71 | const provider: SVGProvider = new SVGProvider(pes, 0); 72 | const estimate = provider.render({ 73 | ...this.captionOption, 74 | }); 75 | if (estimate == null) { 76 | return; 77 | } 78 | // 3分以上未受信ならば初期化する(TR-B14 第一分冊7.2.5.1) 79 | this.peses.push({ pes, pts, endTime: Math.min(Number.isFinite(estimate.endTime) ? estimate.endTime * 1000 : Number.MAX_SAFE_INTEGER, 3 * 60 * 1000) }); 80 | this.peses.sort((a, b) => a.pts - b.pts); 81 | } else if (streamId === 0xbf) { 82 | if (this.pcr == null) { 83 | return; 84 | } 85 | const estimate = new SVGProvider(pes, 0).render({ 86 | ...this.captionOption, 87 | data_identifier: 0x81, 88 | }); 89 | if (estimate == null) { 90 | return; 91 | } 92 | const svgProvider = new SVGProvider(pes, 0); 93 | const result = svgProvider.render({ 94 | ...this.captionOption, 95 | data_identifier: 0x81, 96 | svg: this.superSVG, 97 | }); 98 | if (result?.PRA != null && this.audioNode != null && this.container.style.display !== "none") { 99 | playRomSound(result.PRA, this.audioNode); 100 | } 101 | this.superSVG.style.transform = `scaleY(${this.container.clientHeight / this.superSVG.clientHeight})`; 102 | this.superSVG.style.transformOrigin = `0px 0px`; 103 | this.superSVG.style.position = "absolute"; 104 | this.superSVG.style.left = "0px"; 105 | this.superSVG.style.top = "0px"; 106 | this.superEndPCR = this.pcr + Math.min(Number.isFinite(estimate.endTime) ? estimate.endTime * 1000 : Number.MAX_SAFE_INTEGER, 3 * 60 * 1000); 107 | } 108 | } 109 | 110 | private render() { 111 | if (this.pes != null && this.pts != null && this.endTime != null && this.pcr != null) { 112 | const svgProvider = new SVGProvider(this.pes, this.pts); 113 | const result = svgProvider.render({ 114 | ...this.captionOption, 115 | svg: this.svg, 116 | }); 117 | this.svg.style.transform = `scaleY(${this.container.clientHeight / this.svg.clientHeight})`; 118 | this.svg.style.transformOrigin = `0px 0px`; 119 | if (result?.PRA != null && this.audioNode != null && this.container.style.display !== "none") { 120 | playRomSound(result.PRA, this.audioNode); 121 | } 122 | } 123 | } 124 | 125 | public showCC(): void { 126 | this.container.style.display = ""; 127 | } 128 | 129 | public hideCC(): void { 130 | this.container.style.display = "none"; 131 | } 132 | 133 | private audioNode?: AudioNode; 134 | 135 | public override setPRAAudioNode(audioNode?: AudioNode): void { 136 | this.audioNode = audioNode; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /server/ws_api.ts: -------------------------------------------------------------------------------- 1 | // /api/ws?param=JSON 2 | 3 | // Mirakurun系のAPIを使ってtsを取得 4 | // /api/channels/{type}/{channel}/services/{id}/stream 5 | export type MirakLiveParam = { 6 | type: "mirakLive", 7 | channelType: "GR" | "BS" | "CS" | "SKY", 8 | channel: string, 9 | serviceId?: number, 10 | }; 11 | 12 | // EPGStationのAPIを使ってtsを取得 13 | export type EPGStationRecordedParam = { 14 | type: "epgStationRecorded" 15 | videoFileId: number, 16 | }; 17 | 18 | export type BaseParam = { demultiplexServiceId?: number, seek?: number }; 19 | 20 | export type Param = (MirakLiveParam | EPGStationRecordedParam) & BaseParam; 21 | 22 | export type RequestMessage = {}; 23 | 24 | export type ComponentPMT = { 25 | pid: number, 26 | componentId: number, 27 | bxmlInfo?: AdditionalAribBXMLInfo, 28 | streamType: number, 29 | // STD-B10 第2部 付録J 表J-1参照 30 | dataComponentId?: number, 31 | }; 32 | 33 | export type AdditionalAribBXMLInfo = { 34 | transmissionFormat: number, 35 | entryPointFlag: boolean, 36 | entryPointInfo?: AdditionalAribBXMLEntryPointInfo, 37 | additionalAribCarouselInfo?: AdditionalAribCarouselInfo, 38 | }; 39 | 40 | export type AdditionalAribBXMLEntryPointInfo = { 41 | autoStartFlag: boolean, 42 | documentResolution: number, 43 | useXML: boolean, 44 | defaultVersionFlag: boolean, 45 | independentFlag: boolean, 46 | styleForTVFlag: boolean, 47 | bmlMajorVersion: number, 48 | bmlMinorVersion: number, 49 | bxmlMajorVersion?: number 50 | bxmlMinorVersion?: number, 51 | }; 52 | 53 | export type AdditionalAribCarouselInfo = { 54 | dataEventId: number, 55 | eventSectionFlag: boolean, 56 | ondemandRetrievalFlag: boolean, 57 | fileStorableFlag: boolean, 58 | startPriority: number, 59 | }; 60 | 61 | export type PMTMessage = { 62 | type: "pmt", 63 | components: ComponentPMT[], 64 | }; 65 | 66 | import { MediaType as EMediaType } from "./entity_parser"; 67 | 68 | export type MediaType = EMediaType; 69 | export type ModuleFile = { 70 | contentLocation: string | null, 71 | contentType: MediaType, 72 | dataBase64: string, 73 | }; 74 | 75 | export type ModuleDownloadedMessage = { 76 | type: "moduleDownloaded", 77 | componentId: number, 78 | moduleId: number, 79 | files: ModuleFile[], 80 | version: number, 81 | dataEventId: number, 82 | }; 83 | 84 | export type ModuleListEntry = { 85 | id: number, 86 | version: number, 87 | size: number, 88 | }; 89 | 90 | export type ModuleListUpdatedMessage = { 91 | type: "moduleListUpdated", 92 | componentId: number, 93 | modules: ModuleListEntry[], 94 | dataEventId: number, 95 | returnToEntryFlag?: boolean, 96 | }; 97 | 98 | export type ESEvent = ESImmediateEvent | ESNPTEvent | NPTReference; 99 | 100 | export type ESImmediateEvent = { 101 | type: "immediateEvent", 102 | eventMessageGroupId: number, 103 | timeMode: 0, 104 | eventMessageType: number, 105 | eventMessageId: number, 106 | privateDataByte: number[], 107 | }; 108 | 109 | export type ESNPTEvent = { 110 | type: "nptEvent", 111 | eventMessageGroupId: number, 112 | timeMode: 2, 113 | eventMessageNPT: number, 114 | eventMessageType: number, 115 | eventMessageId: number, 116 | privateDataByte: number[], 117 | }; 118 | 119 | export type NPTReference = { 120 | type: "nptReference", 121 | postDiscontinuityIndicator: boolean, 122 | dsmContentId: number, 123 | STCReference: number, 124 | NPTReference: number, 125 | scaleNumerator: number, 126 | scaleDenominator: number, 127 | }; 128 | 129 | export type ESEventUpdatedMessage = { 130 | type: "esEventUpdated", 131 | componentId: number, 132 | events: ESEvent[], 133 | dataEventId: number, 134 | }; 135 | 136 | export type ProgramInfoMessage = { 137 | type: "programInfo", 138 | originalNetworkId: number | null, 139 | transportStreamId: number | null, 140 | serviceId: number | null, 141 | eventId: number | null, 142 | eventName: string | null, 143 | startTimeUnixMillis: number | null, 144 | durationSeconds: number | null, 145 | indefiniteDuration: boolean | null, 146 | networkId: number | null, 147 | }; 148 | 149 | export type CurrentTime = { 150 | type: "currentTime", 151 | timeUnixMillis: number, 152 | }; 153 | 154 | export type VideoStreamUrlMessage = { 155 | type: "videoStreamUrl", 156 | videoStreamUrl: string, 157 | }; 158 | 159 | export type ErrorMessage = { 160 | type: "error", 161 | message: string, 162 | }; 163 | 164 | export type BITExtendedBroadcaster = { 165 | originalNetworkId: number, 166 | broadcasterId: number, 167 | }; 168 | 169 | export type BITService = { 170 | serviceType: number, 171 | serviceId: number, 172 | }; 173 | 174 | export type BITBroadcaster = { 175 | broadcasterId: number, 176 | broadcasterName: string | null, 177 | services: BITService[], 178 | affiliations: number[], 179 | affiliationBroadcasters: BITExtendedBroadcaster[], 180 | terrestrialBroadcasterId?: number, 181 | }; 182 | 183 | export type BITMessage = { 184 | type: "bit", 185 | originalNetworkId: number, 186 | broadcasters: BITBroadcaster[], 187 | }; 188 | 189 | export type PCRMessage = { 190 | type: "pcr", 191 | // 33-bit 192 | pcrBase: number, 193 | pcrExtension: number, 194 | }; 195 | 196 | // parsePESを指定したときのみ 197 | export type PESMessage = { 198 | type: "pes", 199 | streamId: number, 200 | // 33-bit 201 | pts?: number, 202 | data: number[], 203 | }; 204 | 205 | export type ResponseMessage = PMTMessage | 206 | ModuleDownloadedMessage | 207 | ModuleListUpdatedMessage | 208 | ProgramInfoMessage | 209 | CurrentTime | 210 | VideoStreamUrlMessage | 211 | ErrorMessage | 212 | ESEventUpdatedMessage | 213 | BITMessage | 214 | PCRMessage | 215 | PESMessage; 216 | -------------------------------------------------------------------------------- /client/zip_code.ts: -------------------------------------------------------------------------------- 1 | export type ZipRange = { 2 | from: number, 3 | to: number, 4 | } 5 | 6 | export type ZipCode = { 7 | list: ZipRange[], 8 | excludeList: ZipRange[], 9 | } 10 | 11 | function decodeZipList(buffer: Uint8Array, length: number): ZipRange[] { 12 | let result: ZipRange[] = []; 13 | let off = 0; 14 | let prevFlag = 0; 15 | while (off < length) { 16 | if (buffer[off] & 0x80) { 17 | let digits = buffer[off] & 0x7f; 18 | off++; 19 | let flag = buffer[off] >> 4; 20 | let a = buffer[off] & 0xf; 21 | off++; 22 | let b = buffer[off] >> 4; 23 | let c = buffer[off] & 0xf; 24 | off++; 25 | let d = buffer[off] >> 4; 26 | let e = buffer[off] & 0xf; 27 | let digitList = [a, b, c, d, e]; 28 | off++; 29 | switch (flag) { 30 | // 3digit list 31 | case 0x8: 32 | for (const d of digitList) { 33 | if (d >= 10 && d <= 0xe) { 34 | throw new Error("d >= 10 && d <= 0xe 3digit list"); 35 | } 36 | if (d === 0xf) { 37 | continue; 38 | } 39 | result.push({ from: (digits * 10 + d) * 10000, to: (digits * 10 + d + 1) * 10000 - 1}); 40 | } 41 | break; 42 | // 3digit range 43 | case 0x9: 44 | if (a !== 0xf) { 45 | throw new Error("a !== 0xf 3digit range"); 46 | } 47 | if (b === 0xf || c === 0xf) { 48 | throw new Error("b === 0xf || c === 0xf 3digit range"); 49 | } 50 | result.push({ from: (digits * 10 + b) * 10000, to: (digits * 10 + c + 1) * 10000 - 1 }); 51 | if (d === 0xf || e === 0xf) { 52 | } else if (d !== 0xf && e !== 0xf) { 53 | result.push({ from: (digits * 10 + d) * 10000, to: (digits * 10 + e + 1) * 10000 - 1 }); 54 | } else { 55 | throw new Error("not allowed d, e 3digit range"); 56 | } 57 | break; 58 | // 5digit list 59 | case 0xA: 60 | if (a === 0xf || b === 0xf || c === 0xf) { 61 | throw new Error("a === 0xf || b === 0xf || c === 0xf 5digit list"); 62 | } 63 | result.push({ from: (digits * 1000 + a * 100 + b * 10 + c) * 100, to: (digits * 1000 + a * 100 + b * 10 + c + 1) * 100 - 1 }); 64 | if (d === 0xf || e === 0xf) { 65 | } else if (d !== 0xf && e !== 0xf) { 66 | result.push({ from: (digits * 1000 + a * 100 + d * 10 + e) * 100, to: (digits * 1000 + a * 100 + d * 10 + e + 1) * 100 - 1}); 67 | } else { 68 | throw new Error("not allowed d, e 5digit range"); 69 | } 70 | break; 71 | // 5digit range From 72 | case 0xB: 73 | if (a === 0xf || b === 0xf || c === 0xf || d !== 0xf || e !== 0xf) { 74 | throw new Error("a === 0xf || b === 0xf || c === 0xf || d !== 0xf || e !== 0xf 5digit range"); 75 | } 76 | result.push({ from: (digits * 1000 + a * 100 + b * 10 + c) * 100, to: (digits * 1000 + a * 100 + b * 10 + c) * 100 }); 77 | break; 78 | // 5digit range To 79 | case 0xC: 80 | if (prevFlag !== 0xB) { 81 | throw new Error("prevFlag !== 0xB 5digit range"); 82 | } 83 | if (a === 0xf || b === 0xf || c === 0xf || d !== 0xf || e !== 0xf) { 84 | throw new Error("a === 0xf || b === 0xf || c === 0xf || d !== 0xf || e !== 0xf 5digit range"); 85 | } 86 | result[result.length - 1].to = (digits * 1000 + a * 100 + b * 10 + c + 1) * 100 - 1; 87 | break; 88 | // 7digit range From 89 | case 0xD: 90 | case 0xF: 91 | if (a === 0xf || b === 0xf || c === 0xf || d === 0xf || e === 0xf) { 92 | throw new Error("a === 0xf || b === 0xf || c === 0xf || d === 0xf || e === 0xf 7digit range/list"); 93 | } 94 | result.push({ from: digits * 100000 + a * 10000 + b * 1000 + c * 100 + d * 10 + e, to: digits * 100000 + a * 10000 + b * 1000 + c * 100 + d * 10 + e }); 95 | break; 96 | // 7digit range To 97 | case 0xE: 98 | if (prevFlag !== 0xD) { 99 | throw new Error("prevFlag !== 0xD 7digit range"); 100 | } 101 | if (a === 0xf || b === 0xf || c === 0xf || d === 0xf || e === 0xf) { 102 | throw new Error("a === 0xf || b === 0xf || c === 0xf || d === 0xf || e === 0xf 7digit range"); 103 | } 104 | result[result.length - 1].to = digits * 100000 + a * 10000 + b * 1000 + c * 100 + d * 10 + e; 105 | break; 106 | } 107 | prevFlag = flag; 108 | } else { 109 | let from = buffer[off] & 0x7f; 110 | off++; 111 | let to = buffer[off] & 0x7f; 112 | result.push({ from: from * 100000, to: (to + 1) * 100000 - 1 }); 113 | off++; 114 | if (buffer[off] & 0x80) { 115 | let from2 = buffer[off] & 0x7f; 116 | off++; 117 | let to2 = buffer[off] & 0x7f; 118 | off++; 119 | result.push({ from: from2 * 100000, to: (to2 + 1) * 100000 - 1 }); 120 | } else { 121 | off += 2; 122 | } 123 | } 124 | } 125 | return result; 126 | } 127 | 128 | export function decodeZipCode(buffer: Uint8Array): ZipCode { 129 | let length = buffer[0]; 130 | let excludeListLength = buffer[1]; 131 | return { 132 | excludeList: decodeZipList(buffer.slice(2), excludeListLength), 133 | list: decodeZipList(buffer.slice(2 + excludeListLength, 1 + length), length - 1 - excludeListLength) 134 | }; 135 | } 136 | 137 | function zipRangeInclude(zipRange: ZipRange[], compared: number): boolean { 138 | return zipRange.some(zip => zip.from <= compared && zip.to >= compared); 139 | } 140 | 141 | export function zipCodeInclude(zipCode: ZipCode, compared: number): boolean { 142 | return zipRangeInclude(zipCode.list, compared) && !zipRangeInclude(zipCode.excludeList, compared); 143 | } 144 | -------------------------------------------------------------------------------- /client/bml_to_xhtml.ts: -------------------------------------------------------------------------------- 1 | import { XMLBuilder, XMLParser } from "fast-xml-parser"; 2 | 3 | function findXmlNode(xml: any[], nodeName: string): any { 4 | const result = []; 5 | for (const i of xml) { 6 | if (getXmlNodeName(i) === nodeName) { 7 | result.push(i); 8 | break; 9 | } 10 | } 11 | return result; 12 | } 13 | 14 | function renameXmlNode(node: any, name: string) { 15 | const oldName = getXmlNodeName(node); 16 | if (!oldName) { 17 | return; 18 | } 19 | node[name] = node[oldName]; 20 | delete node[oldName]; 21 | } 22 | 23 | function getXmlNodeName(node: any): string | null { 24 | for (const k of Object.getOwnPropertyNames(node)) { 25 | if (k === ":@") { 26 | continue; 27 | } 28 | return k; 29 | } 30 | return null; 31 | } 32 | 33 | function getXmlChildren(node: any): any[] { 34 | const name = getXmlNodeName(node); 35 | if (!name || name?.startsWith("#")) { 36 | return [] 37 | } 38 | return node[name]; 39 | } 40 | 41 | function visitXmlNodes(node: any, callback: (node: any) => void) { 42 | callback(node); 43 | for (const child of getXmlChildren(node)) { 44 | visitXmlNodes(child, callback); 45 | } 46 | } 47 | 48 | export function bmlToXHTMLFXP(data: string, cProfile: boolean): string { 49 | const opts = { 50 | ignoreAttributes: false, 51 | attributeNamePrefix: "@_", 52 | preserveOrder: true, 53 | cdataPropName: "#cdata", 54 | trimValues: false, 55 | parseTagValue: false, 56 | }; 57 | const parser = new XMLParser(opts); 58 | const parsed = parser.parse(data); 59 | const bmlRoot = findXmlNode(parsed, "bml")[0] ?? findXmlNode(parsed, "html")[0]; 60 | const htmlChildren = bmlRoot["bml"] ?? bmlRoot["html"]; 61 | visitXmlNodes(bmlRoot, (node) => { 62 | const children = getXmlChildren(node); 63 | const nodeName = getXmlNodeName(node); 64 | for (let i = 0; i < children.length; i++) { 65 | const c: { "#text": string } | {} = children[i]; 66 | const prev = i > 0 ? getXmlNodeName(children[i - 1]) : ""; 67 | const next = i + 1 < children.length ? getXmlNodeName(children[i + 1]) : ""; 68 | // STD-B24 第二分冊(2/2) 第二編 付属2 5.3.2参照 69 | if ("#text" in c) { 70 | // STD-B24 第二分冊(2/2) 付属4 5.3.2 p, span, a以外も付属2 5.3.2と同様の処理 71 | // STD-B24 第二分冊(2/2) 付属4 5.3.3 pre要素はxml:space="preserve" 72 | // TR-B14 第三分冊 7.7.3 表7-5 注3 textareaもpreと同様 73 | if (cProfile && (nodeName === "pre" || nodeName === "textarea")) { 74 | if (prev == "") { 75 | c["#text"] = c["#text"].replace(/^([ \t\n\r]+)/g, ""); 76 | } 77 | if (next == "") { 78 | c["#text"] = c["#text"].replace(/([ \t\n\r]+)$/g, ""); 79 | } 80 | continue; 81 | } 82 | if ((prev === "span" || prev === "a") && nodeName === "p") { 83 | c["#text"] = c["#text"].replace(/^([ \t\n\r]+)/g, " "); 84 | if ((next === "span" || next === "a" || next === "br") && nodeName === "p") { 85 | c["#text"] = c["#text"].replace(/([ \t\n\r]+)$/g, " "); 86 | c["#text"] = c["#text"].replace(/([\u0100-\uffff])[ \t\n\r]+(?=[\u0100-\uffff])/g, (_, group1: string) => group1); 87 | } 88 | } else if ((next === "span" || next === "a" || next === "br") && nodeName === "p") { 89 | c["#text"] = c["#text"].replace(/([ \t\n\r]+)$/g, " "); 90 | c["#text"] = c["#text"].replace(/^([ \t\n\r]+)|([\u0100-\uffff])[ \t\n\r]+(?=[\u0100-\uffff])/g, (_, _group1, group2: string | undefined) => (group2 ?? "")); 91 | } else { 92 | // 制御符号は0x20, 0x0d, 0x0a, 0x09のみ 93 | // 2バイト文字と2バイト文字との間の制御符号は削除する 94 | c["#text"] = c["#text"].replace(/^([ \t\n\r]+)|([ \t\n\r]+)$|([\u0100-\uffff])[ \t\n\r]+(?=[\u0100-\uffff])/g, (_, _group1, _group2, group3: string | undefined) => (group3 ?? "")); 95 | } 96 | // 制御符号のみの文字列に対してはテキストノードは生成しない 97 | c["#text"] = c["#text"].replace(/^[ \t\n\r]$/, ""); 98 | } 99 | } 100 | if (nodeName == "bml:beitem") { 101 | // Cプロファイル 102 | renameXmlNode(node, "beitem"); 103 | } else if (nodeName == "bml:bevent") { 104 | // Cプロファイル 105 | renameXmlNode(node, "bevent"); 106 | } else if (nodeName == "script") { 107 | renameXmlNode(node, "arib-script"); 108 | } else if (nodeName == "style") { 109 | renameXmlNode(node, "arib-style"); 110 | } else if (nodeName == "link") { 111 | renameXmlNode(node, "arib-link"); 112 | } 113 | if (nodeName === "a" && node[":@"] != null) { 114 | if (node[":@"]["@_href"] != null) { 115 | node[":@"]["@_bml-href"] = node[":@"]["@_href"]; 116 | delete node[":@"]["@_href"]; 117 | } 118 | } 119 | // Cプロファイル 120 | if (node[":@"] != null) { 121 | for (const a of Object.getOwnPropertyNames(node[":@"])) { 122 | if (a.startsWith("@_bml:")) { 123 | node[":@"]["@_" + a.substring("@_bml:".length)] = node[":@"][a]; 124 | delete node[":@"][a]; 125 | } else if (a.startsWith("@_xml:")) { 126 | /* xml:space */ 127 | node[":@"]["@_xml-" + a.substring("@_xml:".length)] = node[":@"][a]; 128 | delete node[":@"][a]; 129 | } 130 | } 131 | } 132 | if (nodeName == "object" && node[":@"] != null) { 133 | node[":@"]["@_arib-type"] = node[":@"]["@_type"]; 134 | delete node[":@"]["@_type"]; 135 | if (node[":@"]["@_data"]) { 136 | const data = node[":@"]["@_data"]; 137 | node[":@"]["@_arib-data"] = data; 138 | delete node[":@"]["@_data"]; 139 | } 140 | } 141 | if (nodeName == "img" && node[":@"] != null) { 142 | node[":@"]["@_arib-src"] = node[":@"]["@_src"]; 143 | delete node[":@"]["@_src"]; 144 | } 145 | if (node[":@"] && node[":@"]["@_onload"]) { 146 | if (node[":@"] && node[":@"]["@_onload"]) { 147 | const data = node[":@"]["@_onload"]; 148 | node[":@"]["@_arib-onload"] = data; 149 | delete node[":@"]["@_onload"]; 150 | } 151 | } 152 | if (node[":@"] && node[":@"]["@_onunload"]) { 153 | if (node[":@"] && node[":@"]["@_onunload"]) { 154 | const data = node[":@"]["@_onunload"]; 155 | node[":@"]["@_arib-onunload"] = data; 156 | delete node[":@"]["@_onunload"]; 157 | } 158 | } 159 | }); 160 | const builder = new XMLBuilder(opts); 161 | return builder.build(htmlChildren); 162 | } 163 | -------------------------------------------------------------------------------- /client/interpreter/es2_interpreter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Completion, 3 | Context, 4 | createGlobalContext, 5 | InterpreterObject, 6 | Interruption, 7 | parse, 8 | Program, 9 | run, 10 | } from "../../es2"; 11 | import { Interpreter } from "./interpreter"; 12 | import { Content } from "../content"; 13 | import { getTrace } from "../util/trace"; 14 | import { Resources } from "../resource"; 15 | import { BrowserAPI } from "../browser"; 16 | import { EPG } from "../bml_browser"; 17 | import { define, wrap } from "./es2_dom_binding"; 18 | import { defineBuiltinBinding, defineBrowserBinding, defineBinaryTableBinding } from "./es2_binding"; 19 | 20 | // const domTrace = getTrace("js-interpreter.dom"); 21 | // const eventTrace = getTrace("js-interpreter.event"); 22 | const interpreterTrace = getTrace("js-interpreter"); 23 | 24 | const LAUNCH_DOCUMENT_CALLED = { type: "launchDocumentCalled" } as const; 25 | 26 | export class ES2Interpreter implements Interpreter { 27 | context: Context = null!; // lazyinit 28 | prototypes: Map = new Map(); 29 | map: WeakMap = new WeakMap(); 30 | 31 | public reset() { 32 | const context = createGlobalContext(); 33 | this.context = context; 34 | this.prototypes = new Map(); 35 | this.map = new WeakMap(); 36 | const prototypes = this.prototypes; 37 | const map = this.map; 38 | // DOMのバインディングを定義 39 | define(this.context, prototypes, map); 40 | for (const p of prototypes.values()) { 41 | p.internalProperties.class = "hostobject"; 42 | } 43 | context.realm.globalObject.properties.set("document", { 44 | readOnly: false, 45 | dontEnum: false, 46 | dontDelete: true, 47 | value: wrap(prototypes, map, this.content.bmlDocument), 48 | }); 49 | defineBuiltinBinding(context, this.resources); 50 | defineBrowserBinding(context, this.resources, this.browserAPI, this.content, this.epg); 51 | defineBinaryTableBinding(context, this.resources); 52 | this.resetStack(); 53 | } 54 | 55 | private _isExecuting: boolean; 56 | // lazyinit 57 | private browserAPI: BrowserAPI = null!; 58 | private resources: Resources = null!; 59 | private content: Content = null!; 60 | private epg: EPG = null!; 61 | public constructor() { 62 | this._isExecuting = false; 63 | } 64 | 65 | public setupEnvironment(browserAPI: BrowserAPI, resources: Resources, content: Content, epg: EPG): void { 66 | this.browserAPI = browserAPI; 67 | this._isExecuting = false; 68 | this.resources = resources; 69 | this.content = content; 70 | this.epg = epg; 71 | this.reset(); 72 | } 73 | 74 | public addScript(script: string, src?: string): Promise { 75 | let program: Program; 76 | try { 77 | program = parse(script, { name: src ?? "anonymous", source: script }); 78 | } catch (e) { 79 | console.error("failed to parse script", src, e); 80 | return Promise.resolve(false); 81 | } 82 | return this.runScript(program); 83 | } 84 | 85 | private exeNum: number = 0; 86 | 87 | async runScript(program: Program): Promise { 88 | if (this.isExecuting) { 89 | throw new Error("this.isExecuting"); 90 | } 91 | const prevContext = this.content.context; 92 | const context = this.context; 93 | let exit = false; 94 | const exeNum = this.exeNum++; 95 | interpreterTrace("runScript()", exeNum, prevContext, this.content.context); 96 | try { 97 | this._isExecuting = true; 98 | while (true) { 99 | interpreterTrace("RUN SCRIPT", exeNum, prevContext, this.content.context); 100 | try { 101 | let executionStartTime = performance.now(); 102 | // 50ms実行し続けると一旦中断 103 | const shouldInterrupt = () => { 104 | return performance.now() - executionStartTime > 50; 105 | }; 106 | const iter = run(program, { ...context, shouldInterrupt }) as Generator | Interruption | typeof LAUNCH_DOCUMENT_CALLED, Completion>; 107 | let lastResult: any = undefined; 108 | while (true) { 109 | executionStartTime = performance.now(); 110 | const { value, done } = iter.next(lastResult); 111 | lastResult = undefined; 112 | if (typeof value === "object" && value != null && "type" in value && value.type === "launchDocumentCalled") { 113 | interpreterTrace("browser.launchDocument called."); 114 | exit = true; 115 | break; 116 | } else if (typeof value === "object" && value != null && "type" in value && value.type === "interruption") { 117 | // 中断したら50ms待って再開 118 | console.warn("script execution timeout"); 119 | await new Promise((resolve) => { 120 | setTimeout(() => { 121 | resolve(true); 122 | }, 50); 123 | }); 124 | } else if (value instanceof Promise) { 125 | lastResult = await value; 126 | } 127 | if (done) { 128 | break; 129 | } 130 | } 131 | interpreterTrace("RETURN RUN SCRIPT", exeNum, prevContext, this.content.context); 132 | } catch (e) { 133 | console.error("unhandled error", exeNum, prevContext, this.content.context, e); 134 | } 135 | if (this.content.context !== prevContext) { 136 | console.error("context switched", this.content.context, prevContext); 137 | exit = true; 138 | } 139 | break; 140 | } 141 | if (!exit && this.content.context !== prevContext) { 142 | console.error("context switched", this.content.context, prevContext); 143 | exit = true; 144 | } 145 | } finally { 146 | interpreterTrace("leave runScript()", exeNum, exit, prevContext, this.content.context); 147 | if (exit) { 148 | return true; 149 | } else { 150 | this._isExecuting = false; 151 | } 152 | } 153 | return false; 154 | } 155 | 156 | public get isExecuting() { 157 | return this._isExecuting; 158 | } 159 | 160 | public async runEventHandler(funcName: string): Promise { 161 | return await this.addScript(`${funcName}();`, `eventHandler:${funcName}`); 162 | } 163 | 164 | public destroyStack(): void { 165 | } 166 | 167 | public resetStack(): void { 168 | this._isExecuting = false; 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /client/arib_mng.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from "buffer"; 2 | import { preparePLTE, prepareTRNS } from "./arib_png"; 3 | 4 | type MHDR = { 5 | frameWidth: number, 6 | frameHeight: number, 7 | ticksPerSecond: number, 8 | nominalLayerCount: number, 9 | nominalFrameCount: number, 10 | nominalPlayTime: number, 11 | simplicityProfile: number, 12 | }; 13 | 14 | type TERM = { 15 | terminationAction: number, 16 | actionAfterIterations: number, 17 | delay: number, 18 | iterationMax: number, 19 | }; 20 | 21 | type FRAM = { 22 | framingMode: number, 23 | interframeDelay: number, 24 | }; 25 | 26 | type DEFI = { 27 | objectId: number, 28 | doNotShowFlag: number, 29 | concreteFlag: number, 30 | xLocation: number, 31 | yLocation: number, 32 | }; 33 | 34 | type Frame = { 35 | image: string, 36 | delay: number, 37 | keep: boolean, 38 | x: number, 39 | y: number, 40 | }; 41 | 42 | export type MNGAnimation = { keyframes: Keyframe[], options: KeyframeAnimationOptions, width: number, height: number, blobs: string[] }; 43 | 44 | export function aribMNGToCSSAnimation(mng: Buffer, clut: number[][]): MNGAnimation | null { 45 | const frames: Frame[] = []; 46 | const plte = preparePLTE(clut); 47 | const trns = prepareTRNS(clut); 48 | // 臼NG\r\n\x1a\n 49 | const pngSignature = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); 50 | let inOff = 0; 51 | inOff += 8; 52 | let mhdr: MHDR | undefined; 53 | let term: TERM | undefined; 54 | let fram: FRAM = { 55 | framingMode: 1, 56 | interframeDelay: 1, 57 | }; 58 | let defi: DEFI = { 59 | objectId: 0, 60 | doNotShowFlag: 0, 61 | concreteFlag: 0, 62 | xLocation: 0, 63 | yLocation: 0, 64 | }; 65 | let ihdr: Buffer | undefined; 66 | let animationLength = 0; 67 | while (inOff < mng.byteLength) { 68 | let chunkLength = mng.readUInt32BE(inOff); 69 | let chunkType = mng.toString("ascii", inOff + 4, inOff + 8); 70 | if (chunkType === "PLTE" || chunkType == "tRNS") { 71 | // PLTEとtRNSは削除 72 | } else if (chunkType === "MHDR") { 73 | mhdr = { 74 | frameWidth: mng.readUInt32BE(inOff + 8 + 0), 75 | frameHeight: mng.readUInt32BE(inOff + 8 + 4), 76 | ticksPerSecond: mng.readUInt32BE(inOff + 8 + 8), // 0以外 77 | nominalLayerCount: mng.readUInt32BE(inOff + 8 + 12), // 0に固定 78 | nominalFrameCount: mng.readUInt32BE(inOff + 8 + 16), // 0に固定 79 | nominalPlayTime: mng.readUInt32BE(inOff + 8 + 20), // 0に固定 80 | simplicityProfile: mng.readUInt32BE(inOff + 8 + 24), // 0に固定 81 | }; 82 | } else if (chunkType === "MEND") { 83 | } else if (chunkType === "TERM") { 84 | term = { 85 | terminationAction: mng.readUInt8(inOff + 8 + 0), // 3に固定 86 | actionAfterIterations: mng.readUInt8(inOff + 8 + 1), // 0に固定 87 | delay: mng.readUInt32BE(inOff + 8 + 2), // 0に固定 88 | iterationMax: mng.readUInt32BE(inOff + 8 + 6), 89 | }; 90 | } else if (chunkType === "FRAM") { 91 | let framingMode = mng.readUInt8(inOff + 8 + 0); // 0, 1, 3 92 | if (framingMode !== 0) { 93 | fram.framingMode = framingMode; 94 | } 95 | if (chunkLength >= 10) { 96 | const subframeName = mng.readUInt8(inOff + 8 + 1); // 0に固定 ("") 97 | const changeInterfameName = mng.readUInt8(inOff + 8 + 2); // 2に固定 interframeDelayのデフォルトを設定する 98 | const changeSyncTimeoutAndTermination = mng.readUInt8(inOff + 8 + 3); // 0に固定 変更しない 99 | const changeSubframeClippingBoundaries = mng.readUInt8(inOff + 8 + 4); // 0に固定 変更しない 100 | const changeSyncIdList = mng.readUInt8(inOff + 8 + 5); // 0に固定 変更しない 101 | const interframeDelay = mng.readUInt32BE(inOff + 8 + 6); // tick 102 | if (changeInterfameName == 2) { 103 | fram.interframeDelay = interframeDelay; 104 | } 105 | } 106 | } else if (chunkType === "DEFI") { 107 | defi = { 108 | objectId: mng.readUInt16BE(inOff + 8 + 0), // 0に固定 109 | doNotShowFlag: mng.readUInt8(inOff + 8 + 2), // 0に固定 110 | concreteFlag: mng.readUInt8(inOff + 8 + 3), // 0に固定 111 | xLocation: mng.readUInt32BE(inOff + 8 + 4), 112 | yLocation: mng.readUInt32BE(inOff + 8 + 8), 113 | }; 114 | } else if (chunkType === "IHDR") { 115 | ihdr = mng.subarray(inOff, inOff + chunkLength + 4 + 4 + 4); 116 | } else if (chunkType === "IDAT") { 117 | if (ihdr != null) { 118 | const idat = mng.subarray(inOff, inOff + chunkLength + 4 + 4 + 4); 119 | const frameImage = new Blob([pngSignature, ihdr, plte, trns, idat], { type: "image/png" }); 120 | const image = URL.createObjectURL(frameImage); 121 | 122 | // 初回アニメーションのフレーム遷移時に一瞬何も表示されなくなりちらつきが発生してしまうためとりあえずあらかじめ画像を読んでデコードされることを期待しておく 123 | // ChromeとFirefoxで動くので大丈夫そう 124 | new Image().src = image; 125 | 126 | frames.push({ 127 | delay: fram.interframeDelay, 128 | x: defi.xLocation, 129 | y: defi.yLocation, 130 | image, 131 | // framing mode = 1 単純に上書き 132 | // framing mode = 3 透明色で消去 133 | keep: fram.framingMode !== 3, 134 | }); 135 | animationLength += fram.interframeDelay; 136 | } 137 | } else { 138 | } 139 | inOff += chunkLength + 4 + 4 + 4; 140 | } 141 | const keyframes: Keyframe[] = []; 142 | const backgroundImage: string[] = []; 143 | const backgroundPosition: string[] = []; 144 | let offset = 0; 145 | for (const frame of frames) { 146 | backgroundImage.unshift("url(" + CSS.escape(frame.image) + ")"); 147 | backgroundPosition.unshift(`${frame.x}px ${frame.y}px`); 148 | keyframes.push({ 149 | backgroundImage: backgroundImage.join(","), 150 | backgroundPosition: backgroundPosition.join(","), 151 | backgroundRepeat: "no-repeat", 152 | offset: offset / animationLength, 153 | easing: "step-end", 154 | }); 155 | if (!frame.keep) { 156 | backgroundImage.length = 0; 157 | backgroundPosition.length = 0; 158 | } 159 | offset += frame.delay; 160 | } 161 | let options: KeyframeAnimationOptions = {}; 162 | if (term == null) { 163 | // termination action = 0 ループしない 164 | options.iterations = 1; 165 | } else if (term.iterationMax !== 0x7fffffff) { 166 | options.iterations = term.iterationMax; 167 | } else { 168 | options.iterations = Infinity; 169 | } 170 | if (mhdr == null) { 171 | return null; 172 | } 173 | options.duration = (1000 * animationLength) / mhdr.ticksPerSecond; 174 | options.fill = "forwards"; 175 | return { keyframes, options, width: mhdr.frameWidth, height: mhdr.frameHeight, blobs: frames.map(x => x.image) }; 176 | } 177 | -------------------------------------------------------------------------------- /idl/bml.idl: -------------------------------------------------------------------------------- 1 | interface BMLDocument : HTMLDocument { 2 | readonly attribute BMLEvent currentEvent; 3 | readonly attribute HTMLElement currentFocus; 4 | }; 5 | 6 | interface BMLDivElement : HTMLDivElement { 7 | readonly attribute DOMString accessKey; 8 | readonly attribute BMLCSS2Properties normalStyle; 9 | readonly attribute BMLCSS2Properties focusStyle; 10 | readonly attribute BMLCSS2Properties activeStyle; 11 | void focus(); 12 | void blur(); 13 | }; 14 | 15 | interface BMLSpanElement : HTMLElement { 16 | readonly attribute DOMString accessKey; 17 | readonly attribute BMLCSS2Properties normalStyle; 18 | readonly attribute BMLCSS2Properties focusStyle; 19 | readonly attribute BMLCSS2Properties activeStyle; 20 | void focus(); 21 | void blur(); 22 | }; 23 | 24 | interface BMLParagraphElement : HTMLParagraphElement { 25 | readonly attribute DOMString accessKey; 26 | readonly attribute BMLCSS2Properties normalStyle; 27 | readonly attribute BMLCSS2Properties focusStyle; 28 | readonly attribute BMLCSS2Properties activeStyle; 29 | void focus(); 30 | void blur(); 31 | }; 32 | 33 | interface BMLBRElement : HTMLBRElement { 34 | readonly attribute BMLCSS2Properties normalStyle; 35 | }; 36 | 37 | interface BMLAnchorElement : HTMLAnchorElement { 38 | readonly attribute BMLCSS2Properties normalStyle; 39 | readonly attribute BMLCSS2Properties focusStyle; 40 | readonly attribute BMLCSS2Properties activeStyle; 41 | }; 42 | 43 | interface BMLInputElement : HTMLInputElement { 44 | readonly attribute BMLCSS2Properties normalStyle; 45 | readonly attribute BMLCSS2Properties focusStyle; 46 | readonly attribute BMLCSS2Properties activeStyle; 47 | }; 48 | 49 | interface BMLObjectElement : HTMLObjectElement { 50 | readonly attribute BMLCSS2Properties normalStyle; 51 | readonly attribute BMLCSS2Properties focusStyle; 52 | readonly attribute BMLCSS2Properties activeStyle; 53 | attribute boolean remain; 54 | attribute long streamPosition; 55 | attribute DOMString streamStatus; 56 | boolean setMainAudioStream(in DOMString audio_ref); 57 | DOMString getMainAudioStream(); 58 | readonly attribute DOMString accessKey; 59 | void focus(); 60 | void blur(); 61 | }; 62 | 63 | interface BMLBodyElement : HTMLBodyElement { 64 | readonly attribute BMLCSS2Properties normalStyle; 65 | attribute boolean invisible; 66 | }; 67 | 68 | interface BMLBmlElement : HTMLHtmlElement { 69 | }; 70 | 71 | interface BMLBeventElement : HTMLElement { 72 | }; 73 | 74 | interface BMLBeitemElement : HTMLElement { 75 | readonly attribute DOMString type; 76 | attribute DOMString esRef; 77 | attribute unsigned short messageId; 78 | attribute unsigned short messageVersion; 79 | readonly attribute unsigned short messageGroupId; 80 | attribute DOMString moduleRef; 81 | attribute unsigned short languageTag; 82 | readonly attribute DOMString timeMode; 83 | attribute DOMString timeValue; 84 | attribute DOMString objectId; 85 | attribute DOMString segmentId; 86 | attribute boolean subscribe; 87 | }; 88 | 89 | interface BMLEvent { 90 | readonly attribute DOMString type; 91 | readonly attribute HTMLElement target; 92 | }; 93 | 94 | interface BMLIntrinsicEvent : BMLEvent { 95 | readonly attribute unsigned long keyCode; 96 | }; 97 | 98 | interface BMLBeventEvent : BMLEvent { 99 | readonly attribute short status; 100 | readonly attribute DOMString privateData; 101 | readonly attribute DOMString esRef; 102 | readonly attribute unsigned short messageId; 103 | readonly attribute unsigned short messageVersion; 104 | readonly attribute unsigned short messageGroupId; 105 | readonly attribute DOMString moduleRef; 106 | readonly attribute unsigned short languageTag; 107 | readonly attribute BMLObjectElement object; 108 | }; 109 | 110 | interface BMLCSS2Properties { 111 | // CSS2準拠特性 112 | // ボックスモデル 113 | readonly attribute DOMString paddingTop; // padding-top 114 | readonly attribute DOMString paddingRight; // padding-right 115 | readonly attribute DOMString paddingBottom; // padding-bottom 116 | readonly attribute DOMString paddingLeft; // padding-left 117 | readonly attribute DOMString borderWidth; // border-width 118 | readonly attribute DOMString borderStyle; // border-style 119 | // 視覚整形モデル 120 | attribute DOMString left; // left 121 | attribute DOMString top; // top 122 | attribute DOMString width; // width 123 | attribute DOMString height; // height 124 | readonly attribute DOMString lineHeight; // line-height 125 | // その他の視覚効果 126 | attribute DOMString visibility; // visibility 127 | // 生成内容・自動番号振り・リスト 128 | // ページ媒体 129 | // 背景 130 | // フォント 131 | attribute DOMString fontFamily; // font-family 132 | attribute DOMString fontSize; // font-size 133 | attribute DOMString fontWeight; // font-weight 134 | // テキスト 135 | readonly attribute DOMString textAlign; // text-align 136 | readonly attribute DOMString letterSpacing; // letter-spacing 137 | // 表関係 138 | // ユーザインタフェース 139 | // 音声スタイルシート 140 | // BML拡張特性 141 | readonly attribute DOMString clut; // clut 142 | attribute DOMString colorIndex; // color-index 143 | attribute DOMString backgroundColorIndex; //background-color-index 144 | attribute DOMString borderTopColorIndex; // border-top-color-index 145 | attribute DOMString borderRightColorIndex; //border-right-color-index 146 | attribute DOMString borderBottomColorIndex; //border-bottom-color-index 147 | attribute DOMString borderLeftColorIndex; //border-left-color-index 148 | readonly attribute DOMString resolution; // resolution 149 | readonly attribute DOMString displayAspectRatio; // display-aspect-ratio 150 | attribute DOMString grayscaleColorIndex; // grayscale-color-index 151 | readonly attribute DOMString navIndex; // nav-index 152 | readonly attribute DOMString navUp; // nav-up 153 | readonly attribute DOMString navDown; // nav-down 154 | readonly attribute DOMString navLeft; // nav-left 155 | readonly attribute DOMString navRight; // nav-right 156 | attribute DOMString usedKeyList; // used-key-list 157 | 158 | // Cプロファイル 159 | attribute DOMString borderTopColor; // border-top-color 160 | attribute DOMString borderRightColor; // border-right-color 161 | attribute DOMString borderBottomColor; // border-bottom-color 162 | attribute DOMString borderLeftColor; // border-left-color 163 | attribute DOMString backgroundColor; // background-color 164 | attribute DOMString color; // color 165 | readonly attribute DOMString WapMarqueeStyle; // wap-marquee-style 166 | readonly attribute DOMString WapMarqueeLoop; // wap-marquee-loop 167 | readonly attribute DOMString WapMarqueeSpeed; // wap-marquee-speed 168 | readonly attribute DOMString WapInputFormat; // wap-input-format 169 | }; 170 | 171 | interface BMLPreElement : HTMLPreElement { 172 | readonly attribute BMLCSS2Properties normalStyle; 173 | }; 174 | 175 | interface BMLFormElement : HTMLFormElement { 176 | readonly attribute BMLCSS2Properties normalStyle; 177 | }; 178 | 179 | interface BMLTextAreaElement : HTMLTextAreaElement { 180 | readonly attribute BMLCSS2Properties normalStyle; 181 | }; 182 | 183 | interface BMLImageElement : HTMLImageElement { 184 | readonly attribute BMLCSS2Properties normalStyle; 185 | }; 186 | -------------------------------------------------------------------------------- /client/play_local.ts: -------------------------------------------------------------------------------- 1 | import { ResponseMessage } from "../server/ws_api"; 2 | import { BMLBrowser, BMLBrowserFontFace, EPG } from "./bml_browser"; 3 | import { RemoteControl } from "./remote_controller_client"; 4 | import { keyCodeToAribKey } from "./content"; 5 | import { decodeTS } from "../server/decode_ts"; 6 | import { CaptionPlayer } from "./player/caption_player"; 7 | import { OverlayInputApplication } from "./overlay_input"; 8 | 9 | // BML文書と動画と字幕が入る要素 10 | const browserElement = document.getElementById("data-broadcasting-browser")!; 11 | // 動画が入っている要素 12 | const videoContainer = browserElement.querySelector(".arib-video-container") as HTMLElement; 13 | // BMLが非表示になっているときに動画を前面に表示するための要素 14 | const invisibleVideoContainer = browserElement.querySelector(".arib-video-invisible-container") as HTMLElement; 15 | // BML文書が入る要素 16 | const contentElement = browserElement.querySelector(".data-broadcasting-browser-content") as HTMLElement; 17 | // BML用フォント 18 | const roundGothic: BMLBrowserFontFace = { source: "url('/KosugiMaru-Regular.woff2'), local('MS Gothic')" }; 19 | const boldRoundGothic: BMLBrowserFontFace = { source: "url('/KosugiMaru-Bold.woff2'), local('MS Gothic')" }; 20 | const squareGothic: BMLBrowserFontFace = { source: "url('/Kosugi-Regular.woff2'), local('MS Gothic')" }; 21 | 22 | // リモコン 23 | const remoteControl = new RemoteControl(document.getElementById("remote-control")!, browserElement.querySelector(".remote-control-receiving-status")!); 24 | 25 | const epg: EPG = { 26 | tune(originalNetworkId, transportStreamId, serviceId) { 27 | console.error("tune", originalNetworkId, transportStreamId, serviceId); 28 | return false; 29 | } 30 | }; 31 | 32 | const inputApplication = new OverlayInputApplication(browserElement.querySelector(".overlay-input-container") as HTMLElement); 33 | 34 | const bmlBrowser = new BMLBrowser({ 35 | containerElement: contentElement, 36 | mediaElement: videoContainer, 37 | indicator: remoteControl, 38 | fonts: { 39 | roundGothic, 40 | boldRoundGothic, 41 | squareGothic 42 | }, 43 | epg, 44 | inputApplication, 45 | }); 46 | 47 | remoteControl.content = bmlBrowser.content; 48 | // trueであればデータ放送の上に動画を表示させる非表示状態 49 | bmlBrowser.addEventListener("invisible", (evt) => { 50 | console.log("invisible", evt.detail); 51 | const s = invisibleVideoContainer.style; 52 | if (evt.detail) { 53 | s.display = "block"; 54 | invisibleVideoContainer.appendChild(videoContainer); 55 | } else { 56 | s.display = "none"; 57 | const obj = bmlBrowser.getVideoElement(); 58 | if (obj != null) { 59 | obj.appendChild(videoContainer); 60 | } 61 | } 62 | }); 63 | 64 | bmlBrowser.addEventListener("load", (evt) => { 65 | console.log("load", evt.detail); 66 | browserElement.style.width = evt.detail.resolution.width + "px"; 67 | browserElement.style.height = evt.detail.resolution.height + "px"; 68 | ccContainer.style.width = evt.detail.resolution.width + "px"; 69 | ccContainer.style.height = evt.detail.resolution.height + "px"; 70 | }); 71 | 72 | window.addEventListener("keydown", (event) => { 73 | if (inputApplication.isLaunching) { 74 | return; 75 | } 76 | if (event.altKey || event.ctrlKey || event.metaKey) { 77 | return; 78 | } 79 | const k = keyCodeToAribKey(event.key); 80 | if (k == -1) { 81 | return; 82 | } 83 | event.preventDefault(); 84 | bmlBrowser.content.processKeyDown(k); 85 | }); 86 | 87 | window.addEventListener("keyup", (event) => { 88 | const k = keyCodeToAribKey(event.key); 89 | if (k == -1) { 90 | return; 91 | } 92 | if (!event.altKey && !event.ctrlKey && !event.metaKey) { 93 | event.preventDefault(); 94 | } 95 | bmlBrowser.content.processKeyUp(k); 96 | }); 97 | 98 | const videoElement = videoContainer.querySelector("video") as HTMLVideoElement; 99 | const ccContainer = browserElement.querySelector(".arib-video-cc-container") as HTMLElement; 100 | const player = new CaptionPlayer(videoElement, ccContainer); 101 | let pcr: number | undefined; 102 | let baseTime: number | undefined; 103 | let basePCR: number | undefined; 104 | remoteControl.player = player; 105 | player.setPRAAudioNode(new AudioContext().destination); 106 | 107 | function onMessage(msg: ResponseMessage) { 108 | if (msg.type === "pes") { 109 | player.push(msg.streamId, Uint8Array.from(msg.data), msg.pts); 110 | } else if (msg.type === "pcr") { 111 | pcr = (msg.pcrBase + msg.pcrExtension / 300) / 90; 112 | } 113 | bmlBrowser.emitMessage(msg); 114 | } 115 | 116 | const tsInput = document.getElementById("ts") as HTMLInputElement; 117 | const tsUrl = document.getElementById("url") as HTMLInputElement; 118 | const tsUrlSubmit = document.getElementById("url-submit") as HTMLInputElement; 119 | const tsUrlErr = document.getElementById("url-err") as HTMLPreElement; 120 | 121 | async function delayAsync(msec: number): Promise { 122 | return new Promise((resolve, _) => { 123 | setTimeout(resolve, msec); 124 | }); 125 | } 126 | 127 | async function openReadableStream(stream: ReadableStream) { 128 | const reader = stream.getReader(); 129 | const params = new URLSearchParams(location.search); 130 | const serviceId = Number.parseInt(params.get("demultiplexServiceId") ?? ""); 131 | const tsStream = decodeTS({ sendCallback: onMessage, parsePES: true, serviceId: isNaN(serviceId) ? undefined : serviceId }); 132 | tsStream.on("data", () => { }); 133 | while (true) { 134 | const r = await reader.read(); 135 | if (r.done) { 136 | return; 137 | } 138 | const chunk = r.value; 139 | // 1秒分くらい一気にデコードしてしまうので100パケット程度に分割 140 | const chunkSize = 188 * 100; 141 | for (let i = 0; i < chunk.length; i += chunkSize) { 142 | const prevPCR = pcr; 143 | tsStream._transform(chunk.subarray(i, i + chunkSize), null, () => { }); 144 | const curPCR = pcr; 145 | const nowTime = performance.now(); 146 | if (prevPCR == null) { 147 | baseTime = nowTime; 148 | basePCR = curPCR; 149 | } else if (curPCR != null && prevPCR < curPCR && baseTime != null && basePCR != null) { 150 | const playingSpeed = 1.0; 151 | const delay = ((curPCR - basePCR) / playingSpeed) - (nowTime - baseTime); 152 | if (delay >= 1) { 153 | await delayAsync(Math.min(delay, 10000)); 154 | } else if (delay < -1000) { 155 | // あまりにずれた場合基準を再設定する 156 | baseTime = nowTime; 157 | basePCR = curPCR; 158 | } 159 | } else if (curPCR != null && prevPCR > curPCR) { 160 | baseTime = nowTime; 161 | basePCR = curPCR; 162 | } 163 | if (prevPCR !== curPCR && curPCR != null) { 164 | player.updateTime(curPCR); 165 | } 166 | } 167 | } 168 | } 169 | tsInput.addEventListener("change", () => { 170 | const file = tsInput.files?.item(0); 171 | if (file == null) { 172 | return; 173 | } 174 | tsInput.disabled = true; 175 | const stream = file.stream(); 176 | openReadableStream(stream); 177 | }); 178 | if (tsUrlSubmit != null) { 179 | tsUrlSubmit.addEventListener("click", async () => { 180 | if (!tsUrl.value.startsWith("http://") && !tsUrl.value.startsWith("https://")) { 181 | tsUrlErr.textContent = "URLが不正です"; 182 | return; 183 | } 184 | tsUrlErr.textContent = ""; 185 | tsUrlSubmit.disabled = true; 186 | try { 187 | var response = await fetch(tsUrl.value); 188 | } catch (e) { 189 | tsUrlSubmit.disabled = false; 190 | console.error(e); 191 | tsUrlErr.textContent = String(e); 192 | return; 193 | } 194 | const stream = response.body; 195 | if (stream == null) { 196 | tsUrlSubmit.disabled = false; 197 | } else { 198 | await openReadableStream(stream); 199 | } 200 | }); 201 | } 202 | -------------------------------------------------------------------------------- /client/broadcaster_database.ts: -------------------------------------------------------------------------------- 1 | // NVRAMに保存するためにはBS/CSの場合broadcaster_idが必要になりBS/地上波の場合affiliation_idが必要となる 2 | // BSの場合original_network_idとservice_idの組からbroadcaster_idはほぼ固定なのであらかじめ用意する 3 | // 地上波の場合多様なのでBITを受信するとlocalStorageに保存する 4 | import { ResponseMessage } from "../server/ws_api"; 5 | import * as resource from "./resource"; 6 | 7 | type Broadcaster = { 8 | services: { 9 | [serviceId: number]: { 10 | broadcasterId: number, 11 | } 12 | }, 13 | terrestrialBroadcasterId?: number, 14 | lastUpdated: number, 15 | }; 16 | 17 | type Affiliation = { 18 | affiliations: number[], 19 | lastUpdated: number, 20 | }; 21 | 22 | function broadcasterEquals(lhs: Broadcaster, rhs: Broadcaster): boolean { 23 | if (lhs.terrestrialBroadcasterId != rhs.terrestrialBroadcasterId) { 24 | return false; 25 | } 26 | const lhsEntries = Object.entries(lhs.services); 27 | const rhsEntries = Object.entries(rhs.services); 28 | if (lhsEntries.length !== rhsEntries.length) { 29 | return false; 30 | } 31 | return JSON.stringify(lhsEntries.sort(([a], [b]) => Number.parseInt(a) - Number.parseInt(b))) === JSON.stringify(rhsEntries.sort(([a], [b]) => Number.parseInt(a) - Number.parseInt(b))) 32 | } 33 | 34 | function affiliationsEquals(lhs: number[] | undefined, rhs: number[]) { 35 | if (lhs == null) { 36 | return false; 37 | } 38 | return lhs.length === rhs.length && lhs.every(x => rhs.indexOf(x) !== -1); 39 | } 40 | 41 | import { broadcaster4 } from "./broadcaster_4"; 42 | import { broadcaster6 } from "./broadcaster_6"; 43 | import { broadcaster7 } from "./broadcaster_7"; 44 | 45 | export class BroadcasterDatabase { 46 | private resources: resource.Resources; // iru? 47 | private prefix: string; 48 | public constructor(resources: resource.Resources, prefix?: string) { 49 | this.resources = resources; 50 | this.prefix = prefix ?? ""; 51 | this.broadcastersPrefix = this.prefix + "broadcasters_"; 52 | this.affiliationsPrefix = this.prefix + "affiliations_"; 53 | } 54 | private broadcastersPrefix = "broadcasters_"; 55 | private affiliationsPrefix = "affiliations_"; 56 | // 録画再生時に上書きしたら困るので分ける 57 | private broadcasters = new Map(); 58 | private affiliations = new Map(); 59 | private localStorageBroadcasters = new Map(); 60 | private localStorageAffiliations = new Map(); 61 | 62 | public getBroadcasterId(originalNetworkId?: number | null, serviceId?: number | null): number | null { 63 | if (originalNetworkId == null || serviceId == null) { 64 | return null; 65 | } 66 | const broadcaster = this.broadcasters.get(originalNetworkId); 67 | if (broadcaster == null) { 68 | return null; 69 | } 70 | const service = broadcaster.services[serviceId]; 71 | return service?.broadcasterId; 72 | } 73 | 74 | public getAffiliationIdList(originalNetworkId?: number | null, broadcasterId?: number | null): number[] | null { 75 | if (originalNetworkId == null) { 76 | return null; 77 | } 78 | const bid = broadcasterId ?? 255; 79 | return this.affiliations.get(`${originalNetworkId}.${bid}`)?.affiliations ?? null; 80 | } 81 | 82 | private seedDatabase() { 83 | if (!localStorage.getItem(this.broadcastersPrefix + "4")) { 84 | localStorage.setItem(this.broadcastersPrefix + "4", JSON.stringify(broadcaster4)); 85 | } 86 | if (!localStorage.getItem(this.broadcastersPrefix + "6")) { 87 | localStorage.setItem(this.broadcastersPrefix + "6", JSON.stringify(broadcaster6)); 88 | } 89 | if (!localStorage.getItem(this.broadcastersPrefix + "7")) { 90 | localStorage.setItem(this.broadcastersPrefix + "7", JSON.stringify(broadcaster7)); 91 | } 92 | // BS向けにaffiliationsを提示しているのは全国に地上局を持っているNHKだけ? 93 | if (!localStorage.getItem(this.affiliationsPrefix + "4.1")) { 94 | localStorage.setItem(this.affiliationsPrefix + "4.1", "{\"affiliations\":[0,1],\"lastUpdated\":1647613392353}"); 95 | } 96 | } 97 | private loadDatabase() { 98 | for (let i = 0; i < localStorage.length; i++) { 99 | const key = localStorage.key(i); 100 | if (key?.startsWith(this.broadcastersPrefix)) { 101 | const n = Number.parseInt(key.substring(this.broadcastersPrefix.length)); 102 | if (!Number.isInteger(n)) { 103 | continue; 104 | } 105 | const v = localStorage.getItem(key); 106 | if (v == null) { 107 | continue; 108 | } 109 | const broadcaster = JSON.parse(v) as Broadcaster; 110 | this.localStorageBroadcasters.set(n, { services: Object.fromEntries(Object.entries(broadcaster.services).map(([k, v]) => [Number(k), v])), lastUpdated: broadcaster.lastUpdated }); 111 | } 112 | if (key?.startsWith(this.affiliationsPrefix)) { 113 | const k = key.substring(this.affiliationsPrefix.length); 114 | const v = localStorage.getItem(key); 115 | if (v == null) { 116 | continue; 117 | } 118 | const affiliation = JSON.parse(v) as Affiliation; 119 | this.localStorageAffiliations.set(k, affiliation); 120 | } 121 | } 122 | this.broadcasters = new Map(this.localStorageBroadcasters.entries()); 123 | this.affiliations = new Map(this.localStorageAffiliations.entries()); 124 | } 125 | public openDatabase() { 126 | this.seedDatabase(); 127 | this.loadDatabase(); 128 | // broadcasters_ 129 | // affiliations_. 130 | } 131 | public onMessage(msg: ResponseMessage) { 132 | if (msg.type === "bit") { 133 | const lastUpdated = this.resources.currentTimeUnixMillis; 134 | for (const broadcaster of msg.broadcasters) { 135 | if (broadcaster.broadcasterId === 255) { 136 | const key = `${msg.originalNetworkId}.${broadcaster.broadcasterId}`; 137 | const v = { affiliations: broadcaster.affiliations, lastUpdated: lastUpdated ?? new Date().getTime() }; 138 | this.affiliations.set(key, v); 139 | if (lastUpdated != null) { 140 | const prev = this.localStorageAffiliations.get(key); 141 | if (prev == null || !affiliationsEquals(prev.affiliations, v.affiliations)) { 142 | this.localStorageAffiliations.set(key, v); 143 | if (prev == null || prev.lastUpdated < lastUpdated) { 144 | localStorage.setItem(this.affiliationsPrefix + key, JSON.stringify(v)); 145 | } 146 | } 147 | } 148 | for (const b of broadcaster.affiliationBroadcasters) { 149 | const key = `${b.originalNetworkId}.${b.broadcasterId}`; 150 | const v = { affiliations: broadcaster.affiliations, lastUpdated: lastUpdated ?? new Date().getTime() }; 151 | this.affiliations.set(key, v); 152 | if (lastUpdated != null) { 153 | const prev = this.localStorageAffiliations.get(key); 154 | if (prev == null || !affiliationsEquals(prev.affiliations, v.affiliations)) { 155 | this.localStorageAffiliations.set(key, v); 156 | if (prev == null || prev.lastUpdated < lastUpdated) { 157 | localStorage.setItem(this.affiliationsPrefix + key, JSON.stringify(v)); 158 | } 159 | } 160 | } 161 | } 162 | continue; 163 | } 164 | } 165 | const key = `${this.broadcastersPrefix}${msg.originalNetworkId}`; 166 | const tbid = msg.broadcasters.filter(x => x.terrestrialBroadcasterId != null); 167 | if (tbid.length > 1) { 168 | console.error(tbid); 169 | } 170 | const v: Broadcaster = { 171 | services: Object.fromEntries(msg.broadcasters.flatMap(x => x.services.map(y => [`${y.serviceId}`, { broadcasterId: x.broadcasterId }]))), 172 | terrestrialBroadcasterId: tbid[0]?.terrestrialBroadcasterId, 173 | lastUpdated: lastUpdated ?? new Date().getTime(), 174 | }; 175 | this.broadcasters.set(msg.originalNetworkId, v); 176 | if (lastUpdated != null) { 177 | const prev = this.localStorageBroadcasters.get(msg.originalNetworkId); 178 | if (prev == null || !broadcasterEquals(v, prev)) { 179 | this.localStorageBroadcasters.set(msg.originalNetworkId, v); 180 | if (prev == null || prev.lastUpdated < lastUpdated) { 181 | localStorage.setItem(key, JSON.stringify(v)); 182 | } 183 | } 184 | } 185 | } 186 | } 187 | } 188 | 189 | 190 | -------------------------------------------------------------------------------- /client/index.ts: -------------------------------------------------------------------------------- 1 | import { BaseParam, EPGStationRecordedParam, MirakLiveParam, Param, ResponseMessage } from "../server/ws_api"; 2 | import { MP4VideoPlayer } from "./player/mp4"; 3 | import { MPEGTSVideoPlayer } from "./player/mpegts"; 4 | import { HLSVideoPlayer } from "./player/hls"; 5 | import { NullVideoPlayer } from "./player/null"; 6 | import { BMLBrowser, BMLBrowserFontFace, EPG, IP } from "./bml_browser"; 7 | import { VideoPlayer } from "./player/video_player"; 8 | import { RemoteControl } from "./remote_controller_client"; 9 | import { keyCodeToAribKey } from "./content"; 10 | import { OverlayInputApplication } from "./overlay_input"; 11 | import { WebmVideoPlayer } from "./player/webm"; 12 | 13 | function getParametersFromUrl(urlString: string): Param | {} { 14 | const url = new URL(urlString); 15 | const pathname = url.pathname; 16 | const params = url.searchParams; 17 | const demultiplexServiceId = Number.parseInt(params.get("demultiplexServiceId") ?? ""); 18 | const seek = Number.parseInt(params.get("seek") ?? ""); 19 | const baseParam: BaseParam = {}; 20 | if (Number.isInteger(demultiplexServiceId)) { 21 | baseParam.demultiplexServiceId = demultiplexServiceId; 22 | } 23 | if (Number.isInteger(seek)) { 24 | baseParam.seek = seek; 25 | } 26 | const mirakGroups = /^\/channels\/(?.+?)\/(?.+?)\/(services\/(?.+?)\/)?stream\/*$/.exec(pathname)?.groups; 27 | if (mirakGroups != null) { 28 | const type = decodeURIComponent(mirakGroups.type); 29 | const channel = decodeURIComponent(mirakGroups.channel); 30 | const serviceId = Number.parseInt(decodeURIComponent(mirakGroups.serviceId)); 31 | return { 32 | type: "mirakLive", 33 | channel, 34 | channelType: type, 35 | serviceId: Number.isNaN(serviceId) ? undefined : serviceId, 36 | ...baseParam 37 | } as MirakLiveParam; 38 | } else { 39 | const epgGroups = /^\/videos\/(?.+?)\/*$/.exec(pathname)?.groups; 40 | if (epgGroups != null) { 41 | const videoFileId = Number.parseInt(decodeURIComponent(epgGroups.videoId)); 42 | if (!Number.isNaN(videoFileId)) { 43 | return { 44 | type: "epgStationRecorded", 45 | videoFileId, 46 | ...baseParam 47 | } as EPGStationRecordedParam; 48 | } 49 | } 50 | } 51 | return baseParam; 52 | } 53 | 54 | const format = new URLSearchParams(location.search).get("format"); 55 | const ws = new WebSocket((location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/api/ws?param=" + encodeURIComponent(JSON.stringify(getParametersFromUrl(location.href)))); 56 | 57 | let player: VideoPlayer | undefined; 58 | // BML文書と動画と字幕が入る要素 59 | const browserElement = document.getElementById("data-broadcasting-browser")!; 60 | // 動画が入っている要素 61 | const videoContainer = browserElement.querySelector(".arib-video-container") as HTMLElement; 62 | // BMLが非表示になっているときに動画を前面に表示するための要素 63 | const invisibleVideoContainer = browserElement.querySelector(".arib-video-invisible-container") as HTMLElement; 64 | const onesegVideoContainer = document.querySelector(".oneseg-video-container") as HTMLElement; 65 | // BML文書が入る要素 66 | const contentElement = browserElement.querySelector(".data-broadcasting-browser-content") as HTMLElement; 67 | // BML用フォント 68 | const roundGothic: BMLBrowserFontFace = { source: "url('/KosugiMaru-Regular.woff2'), local('MS Gothic')" }; 69 | const boldRoundGothic: BMLBrowserFontFace = { source: "url('/KosugiMaru-Bold.woff2'), local('MS Gothic')" }; 70 | const squareGothic: BMLBrowserFontFace = { source: "url('/Kosugi-Regular.woff2'), local('MS Gothic')" }; 71 | 72 | // リモコン 73 | const remoteControl = new RemoteControl(document.getElementById("remote-control")!, browserElement.querySelector(".remote-control-receiving-status")!, browserElement.querySelector(".remote-control-networking-status") as HTMLElement); 74 | 75 | const epg: EPG = { 76 | tune(originalNetworkId, transportStreamId, serviceId) { 77 | console.error("tune", originalNetworkId, transportStreamId, serviceId); 78 | return false; 79 | } 80 | }; 81 | 82 | const apiIP: IP = { 83 | getConnectionType() { 84 | return 403; 85 | }, 86 | isIPConnected() { 87 | return 1; 88 | }, 89 | async transmitTextDataOverIP(uri, body) { 90 | try { 91 | const res = await window.fetch("/api/post/" + uri, { 92 | method: "POST", 93 | body, 94 | }); 95 | return { resultCode: 1, statusCode: res.status.toString(), response: new Uint8Array(await res.arrayBuffer()) }; 96 | } catch { 97 | return { resultCode: NaN, statusCode: "", response: new Uint8Array() }; 98 | } 99 | }, 100 | async get(uri) { 101 | try { 102 | const res = await window.fetch("/api/get/" + uri, { 103 | method: "GET", 104 | }); 105 | return { statusCode: res.status, headers: res.headers, response: new Uint8Array(await res.arrayBuffer()) }; 106 | } catch { 107 | return {}; 108 | } 109 | }, 110 | async confirmIPNetwork(destination, isICMP, timeoutMillis) { 111 | try { 112 | const res = await window.fetch("/api/confirm?" + new URLSearchParams({ 113 | destination, 114 | isICMP: isICMP ? "true" : "false", 115 | timeoutMillis: timeoutMillis.toString() 116 | }), { 117 | method: "GET", 118 | }); 119 | const result = await res.json(); 120 | return result; 121 | } catch { 122 | return null; 123 | } 124 | } 125 | }; 126 | 127 | const inputApplication = new OverlayInputApplication(browserElement.querySelector(".overlay-input-container") as HTMLElement); 128 | 129 | const bmlBrowser = new BMLBrowser({ 130 | containerElement: contentElement, 131 | mediaElement: videoContainer, 132 | indicator: remoteControl, 133 | fonts: { 134 | roundGothic, 135 | boldRoundGothic, 136 | squareGothic 137 | }, 138 | epg, 139 | ip: apiIP, 140 | inputApplication, 141 | }); 142 | 143 | remoteControl.content = bmlBrowser.content; 144 | // trueであればデータ放送の上に動画を表示させる非表示状態 145 | bmlBrowser.addEventListener("invisible", (evt) => { 146 | console.log("invisible", evt.detail); 147 | const s = invisibleVideoContainer.style; 148 | if (evt.detail) { 149 | s.display = "block"; 150 | invisibleVideoContainer.appendChild(videoContainer); 151 | } else { 152 | s.display = "none"; 153 | const obj = bmlBrowser.getVideoElement(); 154 | if (obj != null) { 155 | obj.appendChild(videoContainer); 156 | } 157 | } 158 | }); 159 | 160 | bmlBrowser.addEventListener("load", (evt) => { 161 | console.log("load", evt.detail); 162 | browserElement.style.width = evt.detail.resolution.width + "px"; 163 | browserElement.style.height = evt.detail.resolution.height + "px"; 164 | if (evt.detail.profile === "C") { 165 | onesegVideoContainer.style.display = ""; 166 | onesegVideoContainer.appendChild(videoContainer); 167 | } 168 | }); 169 | 170 | window.addEventListener("keydown", (event) => { 171 | if (inputApplication.isLaunching) { 172 | return; 173 | } 174 | if (event.altKey || event.ctrlKey || event.metaKey) { 175 | return; 176 | } 177 | const k = keyCodeToAribKey(event.key); 178 | if (k == -1) { 179 | return; 180 | } 181 | event.preventDefault(); 182 | bmlBrowser.content.processKeyDown(k); 183 | }); 184 | 185 | window.addEventListener("keyup", (event) => { 186 | const k = keyCodeToAribKey(event.key); 187 | if (k == -1) { 188 | return; 189 | } 190 | if (!event.altKey && !event.ctrlKey && !event.metaKey) { 191 | event.preventDefault(); 192 | } 193 | bmlBrowser.content.processKeyUp(k); 194 | }); 195 | 196 | function onMessage(msg: ResponseMessage) { 197 | bmlBrowser.emitMessage(msg); 198 | if (msg.type === "videoStreamUrl") { 199 | const videoElement = videoContainer.querySelector("video") as HTMLVideoElement; 200 | const ccContainer = browserElement.querySelector(".arib-video-cc-container") as HTMLElement; 201 | switch (format) { 202 | case "mp4": 203 | player = new MP4VideoPlayer(videoElement, ccContainer); 204 | break; 205 | case "webm": 206 | player = new WebmVideoPlayer(videoElement, ccContainer); 207 | break; 208 | case "hls": 209 | player = new HLSVideoPlayer(videoElement, ccContainer); 210 | break; 211 | case "null": 212 | player = new NullVideoPlayer(videoElement, ccContainer); 213 | break; 214 | case "mpegts-h264": 215 | default: 216 | player = new MPEGTSVideoPlayer(videoElement, ccContainer); 217 | break; 218 | } 219 | player.setSource(msg.videoStreamUrl); 220 | player.setPRAAudioNode(new AudioContext().destination); 221 | player.play(); 222 | videoElement.style.display = ""; 223 | remoteControl.player = player; 224 | } else if (msg.type === "pes") { 225 | if (player instanceof WebmVideoPlayer) { 226 | player.push(msg.streamId, Uint8Array.from(msg.data), msg.pts); 227 | } 228 | } 229 | } 230 | 231 | ws.addEventListener("message", (event) => { 232 | const msg = JSON.parse(event.data) as ResponseMessage; 233 | onMessage(msg); 234 | }); 235 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | データ放送ブラウザ 7 | 8 | 9 | 77 | 78 | 79 | 80 |

    81 | データ放送ブラウザ 82 |

    83 | 84 |
    85 |
    86 | 87 |
    88 |
    89 |
    91 |
    93 | 96 |
    97 |
    98 | 101 | 109 |
    110 | 113 | 116 |
    117 |
    118 |
    119 | 120 |
    121 |
    123 |
    124 | 125 | 126 | 127 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 162 | 165 | 168 | 169 | 170 | 173 | 176 | 179 | 180 | 181 | 184 | 187 | 190 | 191 | 192 | 195 | 198 | 201 | 202 | 203 | 206 | 209 | 212 | 213 | 214 | 217 | 220 | 221 | 222 | 225 | 228 | 229 | 230 | 233 | 236 | 239 | 240 | 241 |
    128 | 136 |
    160 | 161 | 163 | 164 | 166 | 167 |
    171 | 172 | 174 | 175 | 177 | 178 |
    182 | 183 | 185 | 186 | 188 | 189 |
    193 | 194 | 196 | 197 | 199 | 200 |
    204 | 205 | 207 | 208 | 210 | 211 |
    215 | 216 | 218 | 219 |
    223 | 224 | 226 | 227 |
    231 | 232 | 234 | 235 | 237 | 238 |
    242 |
    243 |
    244 | 245 | 246 | 247 | -------------------------------------------------------------------------------- /public/default.css: -------------------------------------------------------------------------------- 1 | /* STD-B24 Annex A Default Style Sheet */ 2 | /* margin */ 3 | div, p, input, object {margin: 0 !important} 4 | /* padding */ 5 | div, object {padding-top : 0 !important; padding-right : 0 !important; padding-bottom : 0 !important; padding-left : 0 !important; } 6 | /* border */ 7 | :where(div, p, span, a, input) { border-width: 0; } 8 | object {border-width : 0 !important; border-style : none !important} 9 | /* display */ 10 | meta, title, script, style, head, bml, bevent, beitem { display: none !important } 11 | body, div, p, object, input { display : block !important } 12 | br, span, a { display : inline !important } 13 | /* position */ 14 | p, div, object, input { position : absolute !important } 15 | br, span, a { position : static !important } 16 | /* top left width height*/ 17 | :where(p, div, input, object) { top: 0; left: 0; width: 0; height: 0; } 18 | /* z-index */ 19 | div, p, br, span, a, input, object, body { z-index : auto !important } 20 | /* line-height */ 21 | /* br, span, a { line-height : inherit !important } */ 22 | /* visibility */ 23 | body { visibility : visible !important } 24 | span, a { visibility : inherit !important } 25 | /* overflow */ 26 | p, div, input, object { overflow : hidden !important } 27 | /* background-repeat */ 28 | body { background-repeat : repeat !important } 29 | /* text */ 30 | :where(p, input) { font-family: "丸ゴシック"; --font-size: 24px; --font-size-raw: 24px; } 31 | :where(span, a) { font-family: inherit; } 32 | :where(p, input) { text-align: left; } 33 | /* letter-spacing */ 34 | span, a { letter-spacing : inherit !important } 35 | /* white-space */ 36 | /* CDATA? */ 37 | /* p, input { white-space : normal !important } */ 38 | /* background-color-index */ 39 | :where(body) { 40 | --resolution: 960x540; 41 | /* 960x540の解像度のときは静止画プレーンの幅と高さは文字図形プレーンの半分 */ 42 | --still-picture-plane-scale: 0.5; 43 | --clut-color-0: #000000ff; 44 | --clut-color-1: #ff0000ff; 45 | --clut-color-2: #00ff00ff; 46 | --clut-color-3: #ffff00ff; 47 | --clut-color-4: #0000ffff; 48 | --clut-color-5: #ff00ffff; 49 | --clut-color-6: #00ffffff; 50 | --clut-color-7: #ffffffff; 51 | --clut-color-8: #00000000; 52 | --clut-color-9: #aa0000ff; 53 | --clut-color-10: #00aa00ff; 54 | --clut-color-11: #aaaa00ff; 55 | --clut-color-12: #0000aaff; 56 | --clut-color-13: #aa00aaff; 57 | --clut-color-14: #00aaaaff; 58 | --clut-color-15: #aaaaaaff; 59 | --clut-color-16: #000055ff; 60 | --clut-color-17: #005500ff; 61 | --clut-color-18: #005555ff; 62 | --clut-color-19: #0055aaff; 63 | --clut-color-20: #0055ffff; 64 | --clut-color-21: #00aa55ff; 65 | --clut-color-22: #00aaffff; 66 | --clut-color-23: #00ff55ff; 67 | --clut-color-24: #00ffaaff; 68 | --clut-color-25: #550000ff; 69 | --clut-color-26: #550055ff; 70 | --clut-color-27: #5500aaff; 71 | --clut-color-28: #5500ffff; 72 | --clut-color-29: #555500ff; 73 | --clut-color-30: #555555ff; 74 | --clut-color-31: #5555aaff; 75 | --clut-color-32: #5555ffff; 76 | --clut-color-33: #55aa00ff; 77 | --clut-color-34: #55aa55ff; 78 | --clut-color-35: #55aaaaff; 79 | --clut-color-36: #55aaffff; 80 | --clut-color-37: #55ff00ff; 81 | --clut-color-38: #55ff55ff; 82 | --clut-color-39: #55ffaaff; 83 | --clut-color-40: #55ffffff; 84 | --clut-color-41: #aa0055ff; 85 | --clut-color-42: #aa00ffff; 86 | --clut-color-43: #aa5500ff; 87 | --clut-color-44: #aa5555ff; 88 | --clut-color-45: #aa55aaff; 89 | --clut-color-46: #aa55ffff; 90 | --clut-color-47: #aaaa55ff; 91 | --clut-color-48: #aaaaffff; 92 | --clut-color-49: #aaff00ff; 93 | --clut-color-50: #aaff55ff; 94 | --clut-color-51: #aaffaaff; 95 | --clut-color-52: #aaffffff; 96 | --clut-color-53: #ff0055ff; 97 | --clut-color-54: #ff00aaff; 98 | --clut-color-55: #ff5500ff; 99 | --clut-color-56: #ff5555ff; 100 | --clut-color-57: #ff55aaff; 101 | --clut-color-58: #ff55ffff; 102 | --clut-color-59: #ffaa00ff; 103 | --clut-color-60: #ffaa55ff; 104 | --clut-color-61: #ffaaaaff; 105 | --clut-color-62: #ffaaffff; 106 | --clut-color-63: #ffff55ff; 107 | --clut-color-64: #ffffaaff; 108 | --clut-color-65: #00000080; 109 | --clut-color-66: #ff000080; 110 | --clut-color-67: #00ff0080; 111 | --clut-color-68: #ffff0080; 112 | --clut-color-69: #0000ff80; 113 | --clut-color-70: #ff00ff80; 114 | --clut-color-71: #00ffff80; 115 | --clut-color-72: #ffffff80; 116 | --clut-color-73: #aa000080; 117 | --clut-color-74: #00aa0080; 118 | --clut-color-75: #aaaa0080; 119 | --clut-color-76: #0000aa80; 120 | --clut-color-77: #aa00aa80; 121 | --clut-color-78: #00aaaa80; 122 | --clut-color-79: #aaaaaa80; 123 | --clut-color-80: #00005580; 124 | --clut-color-81: #00550080; 125 | --clut-color-82: #00555580; 126 | --clut-color-83: #0055aa80; 127 | --clut-color-84: #0055ff80; 128 | --clut-color-85: #00aa5580; 129 | --clut-color-86: #00aaff80; 130 | --clut-color-87: #00ff5580; 131 | --clut-color-88: #00ffaa80; 132 | --clut-color-89: #55000080; 133 | --clut-color-90: #55005580; 134 | --clut-color-91: #5500aa80; 135 | --clut-color-92: #5500ff80; 136 | --clut-color-93: #55550080; 137 | --clut-color-94: #55555580; 138 | --clut-color-95: #5555aa80; 139 | --clut-color-96: #5555ff80; 140 | --clut-color-97: #55aa0080; 141 | --clut-color-98: #55aa5580; 142 | --clut-color-99: #55aaaa80; 143 | --clut-color-100: #55aaff80; 144 | --clut-color-101: #55ff0080; 145 | --clut-color-102: #55ff5580; 146 | --clut-color-103: #55ffaa80; 147 | --clut-color-104: #55ffff80; 148 | --clut-color-105: #aa005580; 149 | --clut-color-106: #aa00ff80; 150 | --clut-color-107: #aa550080; 151 | --clut-color-108: #aa555580; 152 | --clut-color-109: #aa55aa80; 153 | --clut-color-110: #aa55ff80; 154 | --clut-color-111: #aaaa5580; 155 | --clut-color-112: #aaaaff80; 156 | --clut-color-113: #aaff0080; 157 | --clut-color-114: #aaff5580; 158 | --clut-color-115: #aaffaa80; 159 | --clut-color-116: #aaffff80; 160 | --clut-color-117: #ff005580; 161 | --clut-color-118: #ff00aa80; 162 | --clut-color-119: #ff550080; 163 | --clut-color-120: #ff555580; 164 | --clut-color-121: #ff55aa80; 165 | --clut-color-122: #ff55ff80; 166 | --clut-color-123: #ffaa0080; 167 | --clut-color-124: #ffaa5580; 168 | --clut-color-125: #ffaaaa80; 169 | --clut-color-126: #ffaaff80; 170 | --clut-color-127: #ffff5580; 171 | background-color: var(--clut-color-0); 172 | --background-color: var(--clut-color-0); 173 | --background-color-index: 0; 174 | } 175 | :where(div, p, span, a, input) { 176 | background-color: var(--clut-color-8); 177 | --background-color: var(--clut-color-8); 178 | --background-color-index: 8; 179 | } 180 | /* ?? これだとobjectが透過できなくなる */ 181 | /* object { background-color : var(--clut-color-0) !important, --background-color: 0 !important;} */ 182 | /* grayscale-color-index */ 183 | :where(p, input) { --grayscale-color-index: 30 15; } 184 | 185 | /* reset UA CSS */ 186 | :where(p) { 187 | margin-block-start: 0; 188 | margin-block-end: 0; 189 | margin-inline-start: 0; 190 | margin-inline-end: 0; 191 | } 192 | p { 193 | line-break: anywhere !important; 194 | } 195 | body { 196 | padding: 0!important; /* NHK BS1とかbodyにpadding: 6pt;があって崩れる? */ 197 | margin: 0!important; 198 | } 199 | 200 | :where(body) { 201 | font-family: "丸ゴシック", monospace; 202 | } 203 | 204 | object[type="audio/X-arib-mpeg2-aac"] { 205 | visibility: hidden; 206 | } 207 | 208 | object[type="image/X-arib-png"] { 209 | visibility: hidden !important; 210 | } 211 | 212 | arib-style { 213 | display: none; 214 | } 215 | 216 | arib-script { 217 | display: none; 218 | } 219 | 220 | body[arib-loading] { display: none !important; } 221 | 222 | :where(html, bml) { 223 | line-height: 1; /* Firefox */ 224 | --line-height: 1; 225 | --line-height-raw: normal; 226 | } 227 | 228 | html { 229 | position: absolute; 230 | top: 0px; 231 | left: 0px; 232 | /* タブは空白一文字分 (STD-B24 第二分冊(2/2) 付属2 5.3.2 表5-12) */ 233 | tab-size: 1; 234 | } 235 | 236 | /* 237 | 238 | 239 | 244 | 245 | 246 | */ 247 | 248 | /* 249 | Firefoxだと上手く動かない? 250 | object[type="image/jpeg"] { 251 | filter: url('data:image/svg+xml, #bt601bt709'); 252 | } 253 | */ 254 | 255 | /* 継承しない拡張特性の初期値 */ 256 | :where(*) { 257 | --used-key-list: basic data-button; 258 | --border-left-color-index: 0; 259 | --border-right-color-index: 0; 260 | --border-top-color-index: 0; 261 | --border-bottom-color-index: 0; 262 | } 263 | 264 | arib-bg { 265 | background-color: var(--background-color) !important; /* 全称セレクタ対策 */ 266 | background-image: var(--background-image2) !important; 267 | background-size: var(--background-size) !important; 268 | width: 100% !important; 269 | height: 100% !important; 270 | position: absolute !important; 271 | left: 0px !important; 272 | top: 0px !important; 273 | right: 0px !important; 274 | bottom: 0px !important; 275 | padding: 0px !important; 276 | margin: 0px !important; 277 | } 278 | 279 | arib-text, arib-cdata { 280 | display: inline !important; 281 | font: inherit !important; 282 | letter-spacing: inherit !important; 283 | text-align: inherit !important; 284 | visibility: unset !important; 285 | border: none !important; 286 | background: none !important; 287 | padding: 0px !important; 288 | margin: 0px !important; 289 | } 290 | 291 | p, input { 292 | white-space: break-spaces !important; 293 | } 294 | 295 | html { 296 | width: unset !important; 297 | height: unset !important; 298 | color-scheme: initial; 299 | } 300 | -------------------------------------------------------------------------------- /public/play_local.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | データ放送ブラウザ 7 | 8 | 9 | 68 | 69 | 70 | 71 |

    72 | データ放送ブラウザ 73 |

    74 | 75 |

    76 | ファイルを指定するとそこに含まれるデータ放送を閲覧することが出来ます。TSファイルが外部にアップロードされることはありません。 77 |

    78 |

    79 | https://github.com/otya128/web-bml 80 |

    81 |

    82 | TVTest用プラグイン: TVTDataBroadcastingWV2 83 |

    84 |

    85 | 86 |

    87 | 99 | 100 |
    101 |
    102 | 103 |
    104 |
    105 |
    107 |
    109 | 112 |
    113 |
    114 | 117 | 125 |
    126 | 129 | 132 |
    133 |
    134 |
    135 | 136 |
    137 |
    139 |
    140 | 141 | 142 | 143 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 178 | 181 | 184 | 185 | 186 | 189 | 192 | 195 | 196 | 197 | 200 | 203 | 206 | 207 | 208 | 211 | 214 | 217 | 218 | 219 | 222 | 225 | 228 | 229 | 230 | 233 | 236 | 237 | 238 | 241 | 244 | 245 | 246 | 249 | 252 | 255 | 256 | 257 |
    144 | 152 |
    176 | 177 | 179 | 180 | 182 | 183 |
    187 | 188 | 190 | 191 | 193 | 194 |
    198 | 199 | 201 | 202 | 204 | 205 |
    209 | 210 | 212 | 213 | 215 | 216 |
    220 | 221 | 223 | 224 | 226 | 227 |
    231 | 232 | 234 | 235 |
    239 | 240 | 242 | 243 |
    247 | 248 | 250 | 251 | 253 | 254 |
    258 |
    259 |
    260 | 261 | 262 | 263 | 264 | -------------------------------------------------------------------------------- /server/generate_jis_map.ts: -------------------------------------------------------------------------------- 1 | import https from "https"; 2 | // 84区までが第2水準 3 | // 85区から86区 ARIB追加漢字 4 | // 90区から93区 ARIB追加記号 5 | // 外字とのマッピング 6 | // E800-F8FFの0x1100=4352文字 7 | // 87区と88区がBML中のDRCSとして使われる (STD-B24 第二分冊 (2/2) 第二編付属2 4.1.2参照) 8 | // STD-B24 第一分冊 付録規定E EC区00点から 9 | // とりあえず87区から88区 94*2=188文字を0xEC00-0xECBBまで割り当てることとする 10 | 11 | // STD-B24 6.4 第一分冊 表7-19 追加記号集合の符号値 12 | const arib = [ 13 | // 85区 14 | 0x3402, 0xE081, 0x4EFD, 0x4EFF, 0x4F9A, 0x4FC9, 0x509C, 0x511E, 0x51BC, 0x351F, 0x5307, 0x5361, 0x536C, 0x8A79, 0xE084, 0x544D, 0x5496, 0x549C, 0x54A9, 0x550E, 0x554A, 0x5672, 0x56E4, 0x5733, 0x5734, 0xFA10, 0x5880, 0x59E4, 0x5A23, 0x5A55, 0x5BEC, 0xFA11, 0x37E2, 0x5EAC, 0x5F34, 0x5F45, 0x5FB7, 0x6017, 0xFA6B, 0x6130, 0x6624, 0x66C8, 0x66D9, 0x66FA, 0x66FB, 0x6852, 0x9FC4, 0x6911, 0x693B, 0x6A45, 0x6A91, 0x6ADB, 0xE08A, 0xE08B, 0xE08C, 0x6BF1, 0x6CE0, 0x6D2E, 0xFA45, 0x6DBF, 0x6DCA, 0x6DF8, 0xFA46, 0x6F5E, 0x6FF9, 0x7064, 0xFA6C, 0xE08E, 0x7147, 0x71C1, 0x7200, 0x739F, 0x73A8, 0x73C9, 0x73D6, 0x741B, 0x7421, 0xFA4A, 0x7426, 0x742A, 0x742C, 0x7439, 0x744B, 0x3EDA, 0x7575, 0x7581, 0x7772, 0x4093, 0x78C8, 0x78E0, 0x7947, 0x79AE, 0x9FC6, 0x4103, 15 | 0x9FC5, 0x79DA, 0x7A1E, 0x7B7F, 0x7C31, 0x4264, 0x7D8B, 0x7FA1, 0x8118, 0x813A, 0xFA6D, 0x82AE, 0x845B, 0x84DC, 0x84EC, 0x8559, 0x85CE, 0x8755, 0x87EC, 0x880B, 0x88F5, 0x89D2, 0x8AF6, 0x8DCE, 0x8FBB, 0x8FF6, 0x90DD, 0x9127, 0x912D, 0x91B2, 0x9233, 0x9288, 0x9321, 0x9348, 0x9592, 0x96DE, 0x9903, 0x9940, 0x9AD9, 0x9BD6, 0x9DD7, 0x9EB4, 0x9EB5, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 16 | 17 | // loadDRCSで使われる外字領域 87区-88区 => U+EC00-U+ED19 18 | 0xEC00, 0xEC01, 0xEC02, 0xEC03, 0xEC04, 0xEC05, 0xEC06, 0xEC07, 0xEC08, 0xEC09, 0xEC0A, 0xEC0B, 0xEC0C, 0xEC0D, 0xEC0E, 0xEC0F, 0xEC10, 0xEC11, 0xEC12, 0xEC13, 0xEC14, 0xEC15, 0xEC16, 0xEC17, 0xEC18, 0xEC19, 0xEC1A, 0xEC1B, 0xEC1C, 0xEC1D, 0xEC1E, 0xEC1F, 0xEC20, 0xEC21, 0xEC22, 0xEC23, 0xEC24, 0xEC25, 0xEC26, 0xEC27, 0xEC28, 0xEC29, 0xEC2A, 0xEC2B, 0xEC2C, 0xEC2D, 0xEC2E, 0xEC2F, 0xEC30, 0xEC31, 0xEC32, 0xEC33, 0xEC34, 0xEC35, 0xEC36, 0xEC37, 0xEC38, 0xEC39, 0xEC3A, 0xEC3B, 0xEC3C, 0xEC3D, 0xEC3E, 0xEC3F, 0xEC40, 0xEC41, 0xEC42, 0xEC43, 0xEC44, 0xEC45, 0xEC46, 0xEC47, 0xEC48, 0xEC49, 0xEC4A, 0xEC4B, 0xEC4C, 0xEC4D, 0xEC4E, 0xEC4F, 0xEC50, 0xEC51, 0xEC52, 0xEC53, 0xEC54, 0xEC55, 0xEC56, 0xEC57, 0xEC58, 0xEC59, 0xEC5A, 0xEC5B, 0xEC5C, 0xEC5D, 19 | 0xEC5E, 0xEC5F, 0xEC60, 0xEC61, 0xEC62, 0xEC63, 0xEC64, 0xEC65, 0xEC66, 0xEC67, 0xEC68, 0xEC69, 0xEC6A, 0xEC6B, 0xEC6C, 0xEC6D, 0xEC6E, 0xEC6F, 0xEC70, 0xEC71, 0xEC72, 0xEC73, 0xEC74, 0xEC75, 0xEC76, 0xEC77, 0xEC78, 0xEC79, 0xEC7A, 0xEC7B, 0xEC7C, 0xEC7D, 0xEC7E, 0xEC7F, 0xEC80, 0xEC81, 0xEC82, 0xEC83, 0xEC84, 0xEC85, 0xEC86, 0xEC87, 0xEC88, 0xEC89, 0xEC8A, 0xEC8B, 0xEC8C, 0xEC8D, 0xEC8E, 0xEC8F, 0xEC90, 0xEC91, 0xEC92, 0xEC93, 0xEC94, 0xEC95, 0xEC96, 0xEC97, 0xEC98, 0xEC99, 0xEC9A, 0xEC9B, 0xEC9C, 0xEC9D, 0xEC9E, 0xEC9F, 0xECA0, 0xECA1, 0xECA2, 0xECA3, 0xECA4, 0xECA5, 0xECA6, 0xECA7, 0xECA8, 0xECA9, 0xECAA, 0xECAB, 0xECAC, 0xECAD, 0xECAE, 0xECAF, 0xECB0, 0xECB1, 0xECB2, 0xECB3, 0xECB4, 0xECB5, 0xECB6, 0xECB7, 0xECB8, 0xECB9, 0xECBA, 0xECBB, 20 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 21 | 22 | 0x26CC, 0x26CD, 0x2757, 0x26CF, 0x26D0, 0x26D1, -1, 0x26D2, 0x26D5, 0x26D3, 0x26D4, -1, -1, -1, -1, 0xE0D8, 0xE0D9, -1, -1, 0x26D6, 0x26D7, 0x26D8, 0x26D9, 0x26DA, 0x26DB, 0x26DC, 0x26DD, 0x26DE, 0x26DF, 0x26E0, 0x26E1, 0x2B55, 0x3248, 0x3249, 0x324A, 0x324B, 0x324C, 0x324D, 0x324E, 0x324F, -1, -1, -1, -1, 0x2491, 0x2492, 0x2493, 0xE0F8, 0xE0F9, 0xE0FA, 0xE0FB, 0xE0FC, 0xE0FD, 0xE0FE, 0xE0FF, 0xE180, 0xE181, 0xE182, 0xE183, 0xE184, 0xE185, 0xE186, 0xE187, 0x2B1B, 0x2B24, 0xE18A, 0xE18B, 0xE18C, 0xE18D, 0xE18E, 0x26BF, 0xE190, 0xE191, 0xE192, 0xE193, 0xE194, 0xE195, 0xE196, 0xE197, 0xE198, 0xE199, 0xE19A, 0x3299, 0xE19C, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 23 | 0x26E3, 0x2B56, 0x2B57, 0x2B58, 0x2B59, 0x2613, 0x328B, 0xE1AE, 0x26E8, 0x3246, 0x3245, 0x26E9, 0x0FD6, 0x26EA, 0x26EB, 0x26EC, 0x2668, 0x26ED, 0x26EE, 0x26EF, 0x2693, 0x2708, 0x26F0, 0x26F1, 0x26F2, 0x26F3, 0x26F4, 0x26F5, 0xE1C3, 0x24B9, 0x24C8, 0x26F6, 0xE1C7, 0xE1C8, 0xE1C9, 0xE1CA, 0xE1CB, 0x26F7, 0x26F8, 0x26F9, 0x26FA, 0xE1D0, 0x260E, 0x26FB, 0x26FC, 0x26FD, 0x26FE, 0xE1D6, 0x26FF, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 24 | 0x27A1, 0x2B05, 0x2B06, 0x2B07, 0x2B2F, 0x2B2E, 0xE28B, 0xE28C, 0xE28D, 0xE28E, 0x33A1, 0x33A5, 0x339D, 0x33A0, 0x33A4, 0xE28F, 0x2488, 0x2489, 0x248A, 0x248B, 0x248C, 0x248D, 0x248E, 0x248F, 0x2490, 0xE290, 0xE291, 0xE292, 0xE293, 0xE294, 0xE295, 0xE296, 0xE297, 0xE298, 0xE299, 0xE29A, 0xE29B, 0xE29C, 0xE29D, 0xE29E, 0xE29F, 0x3233, 0x3236, 0x3232, 0x3231, 0x3239, 0x3244, 0x25B6, 0x25C0, 0x3016, 0x3017, 0x27D0, 0x00B2, 0x00B3, 0xE2A4, 0xE2A5, 0xE2A6, 0xE2A7, 0xE2A8, 0xE2A9, 0xE2AA, 0xE2AB, 0xE2AC, 0xE2AD, 0xE2AE, 0xE2AF, 0xE2B0, 0xE2B1, 0xE2B2, 0xE2B3, 0xE2B4, 0xE2B5, 0xE2B6, 0xE2B7, 0xE2B8, 0xE2B9, 0xE2BA, 0xE2BB, 0xE2BC, 0xE2BD, 0xE2BE, 0xE2BF, 0xE2C0, 0xE2C1, 0xE2C2, 0xE3A7, 0xE3A8, 0x3247, 0xE2C4, 0xE2C5, 0x213B, -1, -1, -1, 25 | 0x322A, 0x322B, 0x322C, 0x322D, 0x322E, 0x322F, 0x3230, 0x3237, 0x337E, 0x337D, 0x337C, 0x337B, 0x2116, 0x2121, 0x3036, 0x26BE, 0xE2CD, 0xE2CE, 0xE2CF, 0xE2D0, 0xE2D1, 0xE2D2, 0xE2D3, 0xE2D4, 0xE2D5, 0xE2D6, 0xE2D7, 0xE2D8, 0xE2D9, 0xE2DA, 0xE2DB, 0xE2DC, 0xE2DD, 0xE2DE, 0xE2DF, 0xE2E0, 0xE2E1, 0xE2E2, 0x2113, 0x338F, 0x3390, 0x33CA, 0x339E, 0x33A2, 0x3371, -1, -1, 0x00BD, 0x2189, 0x2153, 0x2154, 0x00BC, 0x00BE, 0x2155, 0x2156, 0x2157, 0x2158, 0x2159, 0x215A, 0x2150, 0x215B, 0x2151, 0x2152, 0x2600, 0x2601, 0x2602, 0x26C4, 0x2616, 0x2617, 0x26C9, 0x26CA, 0x2666, 0x2665, 0x2663, 0x2660, 0x26CB, 0x2A00, 0x203C, 0x2049, 0x26C5, 0x2614, 0x26C6, 0x2603, 0x26C7, 0x26A1, 0x26C8, -1, 0x269E, 0x269F, 0x266C, 0xE2FB, -1, -1, -1, 26 | 0x2160, 0x2161, 0x2162, 0x2163, 0x2164, 0x2165, 0x2166, 0x2167, 0x2168, 0x2169, 0x216A, 0x216B, 0x2470, 0x2471, 0x2472, 0x2473, 0x2474, 0x2475, 0x2476, 0x2477, 0x2478, 0x2479, 0x247A, 0x247B, 0x247C, 0x247D, 0x247E, 0x247F, 0x3251, 0x3252, 0x3253, 0x3254, 0xE383, 0xE384, 0xE385, 0xE386, 0xE387, 0xE388, 0xE389, 0xE38A, 0xE38B, 0xE38C, 0xE38D, 0xE38E, 0xE38F, 0xE390, 0xE391, 0xE392, 0xE393, 0xE394, 0xE395, 0xE396, 0xE397, 0xE398, 0xE399, 0xE39A, 0xE39B, 0xE39C, 0x3255, 0x3256, 0x3257, 0x3258, 0x3259, 0x325A, 0x2460, 0x2461, 0x2462, 0x2463, 0x2464, 0x2465, 0x2466, 0x2467, 0x2468, 0x2469, 0x246A, 0x246B, 0x246C, 0x246D, 0x246E, 0x246F, 0x2776, 0x2777, 0x2778, 0x2779, 0x277A, 0x277B, 0x277C, 0x277D, 0x277E, 0x277F, 0x24EB, 0x24EC, 0x325B, -1, 27 | ]; 28 | https.request("https://encoding.spec.whatwg.org/index-jis0208.txt", async (res) => { 29 | const chunks: Buffer[] = []; 30 | for await (const chunk of res) { 31 | chunks.push(Buffer.from(chunk)); 32 | } 33 | const index = Buffer.concat(chunks).toString("utf-8"); 34 | const table: (number | undefined)[] = new Array(94 * 94); 35 | for (const line of index.split("\n")) { 36 | const groups = line.match(/^\s*(?\d+)\s+0x(?[0-9A-F]+)/i)?.groups; 37 | if (groups == null) { 38 | continue; 39 | } 40 | const jis = Number.parseInt(groups.jis, 10); 41 | const unicode = Number.parseInt(groups.unicode, 16); 42 | const ku = Math.floor(jis / 94) + 1; 43 | if (ku === 13) { 44 | // NEC特殊文字は含めない 45 | continue; 46 | } 47 | table[jis] = unicode; 48 | } 49 | table.splice(94 * (85 - 1), arib.length, ...arib); 50 | const unicodeToJISMap = new Map(); 51 | console.log("export const jisToUnicodeMap: (number | number[])[] = ["); 52 | for (let ku = 1; ku <= 94; ku++) { 53 | let line1: String[] = []; 54 | let line2: String[] = []; 55 | for (let ten = 1; ten <= 94; ten++) { 56 | const index = (ku - 1) * 94 + ten - 1; 57 | const unicode = table[index]; 58 | if (unicode != null && unicode !== -1) { 59 | if (unicode >= 0xEC00 && unicode <= 0xF8FF) { 60 | line1.push("'\\u" + unicode.toString(16).padStart(4, "0") + "'"); 61 | } else { 62 | line1.push(`'${String.fromCharCode(unicode)}'`); 63 | } 64 | line2.push("0x" + unicode.toString(16).padStart(4, "0")); 65 | if (unicodeToJISMap.has(unicode)) { 66 | const prev = unicodeToJISMap.get(unicode)! - 0x2020; 67 | const prevKu = prev >> 8; 68 | const prevTen = prev & 0xff; 69 | console.error(`${prevKu}-${prevTen} ${ku}-${ten} U+${unicode.toString(16).padStart(4, "0")} ${String.fromCharCode(unicode)}`); 70 | } else { 71 | // GL 72 | unicodeToJISMap.set(unicode, ((ku << 8) | ten) + 0x2020); 73 | } 74 | } else { 75 | line1.push("''"); 76 | line2.push("-1"); 77 | } 78 | } 79 | console.log(" // " + line1.join(", ") + ","); 80 | console.log(" " + line2.join(", ") + ","); 81 | } 82 | let line4: String[] = []; 83 | let line3: String[][] = [line4]; 84 | for (const unicode of [...unicodeToJISMap.keys()].sort((a, b) => a - b)) { 85 | const jis = unicodeToJISMap.get(unicode)!; 86 | line4.push(`0x${unicode.toString(16)}: 0x${jis.toString(16)}`); 87 | if (line4.length == 11) { 88 | line4 = []; 89 | line3.push(line4); 90 | } 91 | } 92 | console.log("];"); 93 | console.log("export const unicodeToJISMap: { [unicode: number]: number } = {"); 94 | console.log(line3.map(x => " " + x.join(", ")).join(",\n") + ","); 95 | console.log("};"); 96 | }).end(); 97 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | "lib": ["DOM", "DOM.Iterable", "ES2022"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | "jsx": "react-jsx", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 22 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | 26 | /* Modules */ 27 | "module": "commonjs", /* Specify what module code is generated. */ 28 | // "rootDir": "./", /* Specify the root folder within your source files. */ 29 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 30 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 31 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 32 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 33 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 34 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 35 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 36 | // "resolveJsonModule": true, /* Enable importing .json files */ 37 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 38 | 39 | /* JavaScript Support */ 40 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 41 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 42 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 43 | 44 | /* Emit */ 45 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 46 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 47 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 48 | "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 49 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 50 | "outDir": "./build", /* Specify an output folder for all emitted files. */ 51 | // "removeComments": true, /* Disable emitting comments. */ 52 | // "noEmit": true, /* Disable emitting files from a compilation. */ 53 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 54 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 55 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 56 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 59 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 60 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 61 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 62 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 63 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 64 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 65 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 66 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 67 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 68 | 69 | /* Interop Constraints */ 70 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 71 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 72 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ 73 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 74 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 75 | 76 | /* Type Checking */ 77 | "strict": true, /* Enable all strict type-checking options. */ 78 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 79 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 80 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 81 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 82 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 83 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 84 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 85 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 86 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 87 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 88 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 89 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 90 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 91 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 92 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 93 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 94 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 95 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 96 | 97 | /* Completeness */ 98 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 99 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 100 | }, 101 | "include": [ 102 | "./server/**/*", 103 | "./client/**/*" 104 | ], 105 | } 106 | --------------------------------------------------------------------------------