disaster-broadcast-studio 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -7
- package/assets/README.md +1 -1
- package/assets/audio/README.md +16 -4
- package/assets/audio/alert-critical.mjs +4 -0
- package/assets/audio/alert-critical.wav +0 -0
- package/assets/audio/alert-critical.wav.json +15 -0
- package/assets/audio/alert-urgent.mjs +4 -0
- package/assets/audio/alert-urgent.wav +0 -0
- package/assets/audio/alert-urgent.wav.json +15 -0
- package/assets/audio/bulletin.mjs +9 -0
- package/assets/audio/bulletin.wav +0 -0
- package/assets/audio/bulletin.wav.json +15 -0
- package/assets/audio/news.mjs +4 -0
- package/assets/audio/news.wav +0 -0
- package/assets/audio/news.wav.json +15 -0
- package/assets/audio/notification.mjs +24 -9
- package/assets/audio/notification.wav +0 -0
- package/assets/audio/notification.wav.json +15 -0
- package/assets/audio/status.mjs +5 -0
- package/assets/audio/status.wav +0 -0
- package/assets/audio/status.wav.json +15 -0
- package/assets/audio/tones.mjs +17 -0
- package/assets/workspace/README.md +3 -0
- package/dist/cli.js +20 -1
- package/dist/credits.js +2 -2
- package/dist/load.js +8 -7
- package/dist/notifications.d.ts +40 -3
- package/dist/notifications.js +34 -6
- package/dist/preview/browser.d.ts +1 -0
- package/dist/preview/browser.js +87 -0
- package/dist/preview/http.d.ts +3 -0
- package/dist/preview/http.js +78 -0
- package/dist/preview/page.d.ts +1 -0
- package/dist/preview/page.js +30 -0
- package/dist/preview/project.d.ts +22 -0
- package/dist/preview/project.js +81 -0
- package/dist/preview/server.d.ts +6 -0
- package/dist/preview/server.js +57 -0
- package/dist/render.js +4 -40
- package/dist/scenario.d.ts +32 -4
- package/dist/scenario.js +59 -50
- package/dist/timeline.d.ts +68 -5
- package/dist/timeline.js +29 -9
- package/dist/video/Alerts.js +49 -49
- package/dist/video/Broadcast.js +82 -96
- package/dist/video/Bulletin.d.ts +2 -2
- package/dist/video/Bulletin.js +13 -18
- package/dist/video/Chat.d.ts +9 -0
- package/dist/video/Chat.js +34 -0
- package/dist/video/Information.d.ts +10 -0
- package/dist/video/Information.js +100 -0
- package/dist/video/Map.js +28 -12
- package/dist/video/TimeJump.d.ts +8 -0
- package/dist/video/TimeJump.js +42 -0
- package/dist/video/layout.d.ts +5 -0
- package/dist/video/layout.js +2 -0
- package/dist/video/prepare.d.ts +4 -0
- package/dist/video/prepare.js +47 -0
- package/dist/video/transitions.d.ts +609 -0
- package/dist/video/transitions.js +22 -0
- package/dist/video/useBundledFonts.js +16 -7
- package/package.json +2 -1
- package/skills/scenario-writing/SKILL.md +12 -5
- package/skills/scenario-writing/references/README.md +4 -4
- package/skills/scenario-writing/references/audio.md +7 -1
- package/skills/scenario-writing/references/direction.md +31 -7
- package/skills/scenario-writing/references/format.md +63 -12
- package/skills/scenario-writing/references/news-writing.md +10 -0
- package/skills/scenario-writing/references/status-systems.md +4 -2
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { stat } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
const contentTypes = {
|
|
6
|
+
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.mjs': 'text/javascript',
|
|
7
|
+
'.css': 'text/css', '.woff': 'font/woff', '.woff2': 'font/woff2',
|
|
8
|
+
'.wav': 'audio/wav', '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.aac': 'audio/aac',
|
|
9
|
+
'.ogg': 'audio/ogg', '.flac': 'audio/flac', '.mp4': 'video/mp4',
|
|
10
|
+
};
|
|
11
|
+
async function serveFile(request, response, root, relative) {
|
|
12
|
+
const file = path.resolve(root, relative);
|
|
13
|
+
if (!file.startsWith(`${path.resolve(root)}${path.sep}`)) {
|
|
14
|
+
response.writeHead(404).end();
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
let info;
|
|
18
|
+
try {
|
|
19
|
+
info = await stat(file);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
response.writeHead(404).end();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (!info.isFile()) {
|
|
26
|
+
response.writeHead(404).end();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
response.setHeader('Content-Type', contentTypes[path.extname(file).toLowerCase()] ?? 'application/octet-stream');
|
|
30
|
+
response.setHeader('Accept-Ranges', 'bytes');
|
|
31
|
+
let start = 0, end = info.size - 1;
|
|
32
|
+
if (request.headers.range) {
|
|
33
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(request.headers.range);
|
|
34
|
+
if (match && (match[1] || match[2])) {
|
|
35
|
+
start = match[1] ? Number(match[1]) : Math.max(0, info.size - Number(match[2]));
|
|
36
|
+
end = match[1] && match[2] ? Math.min(Number(match[2]), end) : end;
|
|
37
|
+
}
|
|
38
|
+
if (!match || (!match[1] && !match[2]) || start > end || start >= info.size) {
|
|
39
|
+
response.writeHead(416, { 'Content-Range': `bytes */${info.size}` }).end();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
response.statusCode = 206;
|
|
43
|
+
response.setHeader('Content-Range', `bytes ${start}-${end}/${info.size}`);
|
|
44
|
+
}
|
|
45
|
+
response.setHeader('Content-Length', Math.max(0, end - start + 1));
|
|
46
|
+
if (request.method === 'HEAD' || !info.size) {
|
|
47
|
+
response.end();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const stream = createReadStream(file, { start, end });
|
|
51
|
+
stream.on('error', () => response.destroy());
|
|
52
|
+
response.on('close', () => stream.destroy());
|
|
53
|
+
stream.pipe(response);
|
|
54
|
+
}
|
|
55
|
+
export function createPreviewServer(project, publicDir, bundleDir) {
|
|
56
|
+
return createServer((request, response) => {
|
|
57
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
58
|
+
response.setHeader('X-Content-Type-Options', 'nosniff');
|
|
59
|
+
void (async () => {
|
|
60
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
61
|
+
response.writeHead(405, { Allow: 'GET, HEAD' }).end();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const pathname = decodeURIComponent(new URL(request.url ?? '/', 'http://localhost').pathname);
|
|
65
|
+
if (pathname === '/api/status' || pathname === '/api/scenario') {
|
|
66
|
+
const body = JSON.stringify(pathname === '/api/status' ? project.status() : project.current());
|
|
67
|
+
response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
68
|
+
response.end(request.method === 'HEAD' ? undefined : body);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (pathname.startsWith('/public/'))
|
|
72
|
+
await serveFile(request, response, publicDir, pathname.slice('/public/'.length));
|
|
73
|
+
else
|
|
74
|
+
await serveFile(request, response, bundleDir, pathname === '/' ? 'index.html' : pathname.slice(1));
|
|
75
|
+
})().catch(() => { if (!response.headersSent)
|
|
76
|
+
response.writeHead(400); response.end(); });
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const previewPage = "<!doctype html>\n<html lang=\"ko\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n <title>\uC7AC\uB09C\uBC29\uC1A1\uC2E4 \u00B7 \uBBF8\uB9AC\uBCF4\uAE30</title>\n <style>\n :root { color-scheme: dark; font-family: system-ui, sans-serif; background: #101417; color: #dce1e5; }\n * { box-sizing: border-box; }\n body { margin: 0; }\n .preview { max-width: 1500px; margin: auto; padding: 24px; }\n .preview > header, .tools { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; }\n .preview > header { justify-content: space-between; margin-bottom: 18px; }\n .preview > header h1 { font-size: 22px; margin: 0; }\n .preview > p { color: #aab5bd; line-height: 1.6; }\n .player { background: #000; border: 1px solid #3c464e; }\n .tools { padding: 16px 0; }\n .tools button, .tools input, .tools select { font: inherit; padding: 7px 10px; background: #202a31; color: #e4e9ed; border: 1px solid #53616b; border-radius: 4px; }\n .tools button { cursor: pointer; }\n .tools input { width: 110px; }\n .tools label { display: flex; align-items: center; gap: 8px; }\n .tools output { font-variant-numeric: tabular-nums; }\n .status { font-size: 14px; color: #aab5bd; }\n .preview .error { color: #ffd0c8; background: #3c2423; border: 1px solid #8f5952; padding: 14px; white-space: pre-wrap; overflow-wrap: anywhere; }\n .preview .warnings { color: #e4ce9e; white-space: pre-wrap; }\n @media (max-width: 700px) { .preview { padding: 12px; } }\n </style>\n</head>\n<body><div id=\"root\"></div><script>window.remotion_staticBase = '/public';</script><script src=\"/bundle.js\"></script></body>\n</html>";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const previewPage = `<!doctype html>
|
|
2
|
+
<html lang="ko">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<title>재난방송실 · 미리보기</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #101417; color: #dce1e5; }
|
|
9
|
+
* { box-sizing: border-box; }
|
|
10
|
+
body { margin: 0; }
|
|
11
|
+
.preview { max-width: 1500px; margin: auto; padding: 24px; }
|
|
12
|
+
.preview > header, .tools { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; }
|
|
13
|
+
.preview > header { justify-content: space-between; margin-bottom: 18px; }
|
|
14
|
+
.preview > header h1 { font-size: 22px; margin: 0; }
|
|
15
|
+
.preview > p { color: #aab5bd; line-height: 1.6; }
|
|
16
|
+
.player { background: #000; border: 1px solid #3c464e; }
|
|
17
|
+
.tools { padding: 16px 0; }
|
|
18
|
+
.tools button, .tools input, .tools select { font: inherit; padding: 7px 10px; background: #202a31; color: #e4e9ed; border: 1px solid #53616b; border-radius: 4px; }
|
|
19
|
+
.tools button { cursor: pointer; }
|
|
20
|
+
.tools input { width: 110px; }
|
|
21
|
+
.tools label { display: flex; align-items: center; gap: 8px; }
|
|
22
|
+
.tools output { font-variant-numeric: tabular-nums; }
|
|
23
|
+
.status { font-size: 14px; color: #aab5bd; }
|
|
24
|
+
.preview .error { color: #ffd0c8; background: #3c2423; border: 1px solid #8f5952; padding: 14px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
25
|
+
.preview .warnings { color: #e4ce9e; white-space: pre-wrap; }
|
|
26
|
+
@media (max-width: 700px) { .preview { padding: 12px; } }
|
|
27
|
+
</style>
|
|
28
|
+
</head>
|
|
29
|
+
<body><div id="root"></div><script>window.remotion_staticBase = '/public';</script><script src="/bundle.js"></script></body>
|
|
30
|
+
</html>`;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { BroadcastProps } from '../video/Broadcast.js';
|
|
2
|
+
export type PreviewStatus = {
|
|
3
|
+
revision: number;
|
|
4
|
+
warnings: string[];
|
|
5
|
+
error: string | null;
|
|
6
|
+
};
|
|
7
|
+
export type PreviewSnapshot = PreviewStatus & {
|
|
8
|
+
props: BroadcastProps;
|
|
9
|
+
};
|
|
10
|
+
export declare class PreviewProject {
|
|
11
|
+
private file;
|
|
12
|
+
private publicDir;
|
|
13
|
+
private signature;
|
|
14
|
+
private pending;
|
|
15
|
+
private snapshot;
|
|
16
|
+
private error;
|
|
17
|
+
constructor(file: string, publicDir: string);
|
|
18
|
+
status(): PreviewStatus;
|
|
19
|
+
current(): PreviewSnapshot;
|
|
20
|
+
refresh(): Promise<void>;
|
|
21
|
+
private update;
|
|
22
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile, rm, stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { parseDocument } from 'yaml';
|
|
5
|
+
import { loadScenario } from '../load.js';
|
|
6
|
+
import { prepareBroadcast } from '../video/prepare.js';
|
|
7
|
+
// Read the current document's paths even when validation fails, so creating a
|
|
8
|
+
// missing asset or repairing a custom map also recovers the preview.
|
|
9
|
+
async function fingerprint(file) {
|
|
10
|
+
const source = await readFile(file, 'utf8');
|
|
11
|
+
const files = new Set();
|
|
12
|
+
try {
|
|
13
|
+
const document = parseDocument(source).toJS();
|
|
14
|
+
if (typeof document?.map?.regions === 'string')
|
|
15
|
+
files.add(document.map.regions);
|
|
16
|
+
for (const asset of Object.values(document?.assets ?? {}))
|
|
17
|
+
if (typeof asset?.path === 'string')
|
|
18
|
+
files.add(asset.path);
|
|
19
|
+
}
|
|
20
|
+
catch { /* Validation reports malformed documents. */ }
|
|
21
|
+
const stamps = await Promise.all([...files].sort().map(async (relative) => {
|
|
22
|
+
try {
|
|
23
|
+
const info = await stat(path.resolve(path.dirname(file), relative), { bigint: true });
|
|
24
|
+
return `${relative}:${info.mtimeNs}:${info.ctimeNs}:${info.size}:${info.ino}`;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return `${relative}:missing`;
|
|
28
|
+
}
|
|
29
|
+
}));
|
|
30
|
+
return createHash('sha256').update(source).update(JSON.stringify(stamps)).digest('hex');
|
|
31
|
+
}
|
|
32
|
+
export class PreviewProject {
|
|
33
|
+
file;
|
|
34
|
+
publicDir;
|
|
35
|
+
signature = '';
|
|
36
|
+
pending;
|
|
37
|
+
snapshot;
|
|
38
|
+
error = null;
|
|
39
|
+
constructor(file, publicDir) {
|
|
40
|
+
this.file = file;
|
|
41
|
+
this.publicDir = publicDir;
|
|
42
|
+
}
|
|
43
|
+
status() {
|
|
44
|
+
return { revision: this.snapshot?.revision ?? 0, warnings: this.snapshot?.warnings ?? [], error: this.error };
|
|
45
|
+
}
|
|
46
|
+
current() {
|
|
47
|
+
if (!this.snapshot)
|
|
48
|
+
throw Error(this.error ?? 'Preview is not ready');
|
|
49
|
+
return { ...this.snapshot, error: this.error };
|
|
50
|
+
}
|
|
51
|
+
refresh() {
|
|
52
|
+
if (this.pending)
|
|
53
|
+
return this.pending;
|
|
54
|
+
this.pending = this.update().finally(() => { this.pending = undefined; });
|
|
55
|
+
return this.pending;
|
|
56
|
+
}
|
|
57
|
+
async update() {
|
|
58
|
+
let revision = 0;
|
|
59
|
+
let fingerprinted = false;
|
|
60
|
+
try {
|
|
61
|
+
const signature = await fingerprint(this.file);
|
|
62
|
+
fingerprinted = true;
|
|
63
|
+
if (signature === this.signature)
|
|
64
|
+
return;
|
|
65
|
+
this.signature = signature;
|
|
66
|
+
const loaded = await loadScenario(this.file);
|
|
67
|
+
revision = (this.snapshot?.revision ?? 0) + 1;
|
|
68
|
+
const props = await prepareBroadcast(loaded, this.publicDir, `audio/${revision}`);
|
|
69
|
+
this.snapshot = { props, revision, warnings: loaded.warnings, error: null };
|
|
70
|
+
this.error = null;
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
74
|
+
// Retry preparation failures even if the input has not changed.
|
|
75
|
+
if (revision || !fingerprinted)
|
|
76
|
+
this.signature = '';
|
|
77
|
+
}
|
|
78
|
+
if (revision && this.error)
|
|
79
|
+
await rm(path.join(this.publicDir, 'audio', String(revision)), { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { bundle } from '@remotion/bundler';
|
|
2
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { packageRoot } from '../assets.js';
|
|
6
|
+
import { prepareStaticAssets } from '../video/prepare.js';
|
|
7
|
+
import { PreviewProject } from './project.js';
|
|
8
|
+
import { previewPage } from './page.js';
|
|
9
|
+
import { createPreviewServer } from './http.js';
|
|
10
|
+
export async function startPreview(file, options = {}) {
|
|
11
|
+
const port = options.port ?? 3000;
|
|
12
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
13
|
+
throw Error('--port: expected 0..65535');
|
|
14
|
+
const absolute = path.resolve(file);
|
|
15
|
+
const temp = await mkdtemp(path.join(tmpdir(), 'dbs-preview-'));
|
|
16
|
+
const publicDir = path.join(temp, 'public');
|
|
17
|
+
const bundleDir = path.join(temp, 'bundle');
|
|
18
|
+
const project = new PreviewProject(absolute, publicDir);
|
|
19
|
+
const server = createPreviewServer(project, publicDir, bundleDir);
|
|
20
|
+
let timer;
|
|
21
|
+
let closing;
|
|
22
|
+
const close = () => closing ??= (async () => {
|
|
23
|
+
clearInterval(timer);
|
|
24
|
+
if (server.listening) {
|
|
25
|
+
const stopped = new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
|
|
26
|
+
server.closeAllConnections();
|
|
27
|
+
await stopped;
|
|
28
|
+
}
|
|
29
|
+
await project.refresh(); // Wait for any in-flight preparation before cleanup.
|
|
30
|
+
await rm(temp, { recursive: true, force: true });
|
|
31
|
+
})();
|
|
32
|
+
try {
|
|
33
|
+
await project.refresh();
|
|
34
|
+
project.current(); // Invalid initial input fails before opening a port.
|
|
35
|
+
await prepareStaticAssets(publicDir);
|
|
36
|
+
const entryPoint = path.join(packageRoot, 'dist/preview/browser.js');
|
|
37
|
+
await bundle({
|
|
38
|
+
entryPoint, publicDir, outDir: bundleDir, rootDir: packageRoot,
|
|
39
|
+
ignoreRegisterRootWarning: true,
|
|
40
|
+
webpackOverride: config => ({
|
|
41
|
+
...config, entry: entryPoint,
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
await writeFile(path.join(bundleDir, 'index.html'), previewPage);
|
|
45
|
+
await new Promise((resolve, reject) => {
|
|
46
|
+
server.once('error', reject);
|
|
47
|
+
server.listen(port, '127.0.0.1', () => { server.off('error', reject); resolve(); });
|
|
48
|
+
});
|
|
49
|
+
timer = setInterval(() => { void project.refresh(); }, 750);
|
|
50
|
+
const address = server.address();
|
|
51
|
+
return { url: `http://127.0.0.1:${address.port}`, close };
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
await close();
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
package/dist/render.js
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import { bundle } from '@remotion/bundler';
|
|
2
2
|
import { renderStill, renderMedia, selectComposition } from '@remotion/renderer';
|
|
3
|
-
import {
|
|
4
|
-
import { createRequire } from 'node:module';
|
|
3
|
+
import { writeFile } from 'node:fs/promises';
|
|
5
4
|
import path from 'node:path';
|
|
6
5
|
import { withOutputFiles } from './output.js';
|
|
7
|
-
import {
|
|
6
|
+
import { prepareStaticAssets, prepareBroadcast } from './video/prepare.js';
|
|
8
7
|
import { readBundledJson, packageRoot } from './assets.js';
|
|
9
8
|
import { makeCredits } from './credits.js';
|
|
10
|
-
const require = createRequire(import.meta.url);
|
|
11
9
|
export async function render(loaded, options) {
|
|
12
10
|
const out = path.resolve(options.out), last = loaded.timeline.durationInFrames - 1;
|
|
13
11
|
const still = options.frame !== undefined, from = options.from ?? 0, to = options.to ?? last;
|
|
@@ -22,42 +20,8 @@ export async function render(loaded, options) {
|
|
|
22
20
|
throw Error('--concurrency: expected positive integer');
|
|
23
21
|
await withOutputFiles(out, { overwrite: options.overwrite, sidecars: ['.json', '.credits.txt'] }, async (pending, temp) => {
|
|
24
22
|
const publicDir = path.join(temp, 'public');
|
|
25
|
-
await
|
|
26
|
-
|
|
27
|
-
await copyFile(require.resolve(`maplibre-gl/dist/${name}`), path.join(publicDir, name));
|
|
28
|
-
for (const font of fonts) {
|
|
29
|
-
const fontRoot = path.dirname(require.resolve(`${font.package}/package.json`));
|
|
30
|
-
for (const weight of font.weights) {
|
|
31
|
-
const css = await readFile(path.join(fontRoot, `${weight}.css`), 'utf8');
|
|
32
|
-
await writeFile(path.join(publicDir, 'fonts', `${font.id}-${weight}.css`), css);
|
|
33
|
-
for (const match of css.matchAll(/url\(([^)]+)\)/g)) {
|
|
34
|
-
const relative = match[1].replace(/['"]/g, '');
|
|
35
|
-
await copyFile(path.join(fontRoot, relative), path.join(publicDir, 'fonts', relative));
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
const audio = {};
|
|
40
|
-
for (const [id, file] of Object.entries(loaded.assetPaths)) {
|
|
41
|
-
const name = `audio/${id}${path.extname(file)}`;
|
|
42
|
-
await mkdir(path.join(publicDir, 'audio'), { recursive: true });
|
|
43
|
-
await copyFile(file, path.join(publicDir, name));
|
|
44
|
-
audio[id] = name;
|
|
45
|
-
}
|
|
46
|
-
const countries = await readBundledJson('countries.geojson.gz');
|
|
47
|
-
const defaultRegions = !loaded.timeline.scenario.map.regions;
|
|
48
|
-
// The full SGIS polygons supply the Korean coastline and administrative fills.
|
|
49
|
-
if (defaultRegions)
|
|
50
|
-
countries.features = countries.features.filter((f) => f.properties?.code !== 'KOR');
|
|
51
|
-
const provinces = defaultRegions
|
|
52
|
-
? await readBundledJson('provinces.geojson.gz')
|
|
53
|
-
: { type: 'FeatureCollection', features: [] };
|
|
54
|
-
const inputProps = {
|
|
55
|
-
timeline: loaded.timeline,
|
|
56
|
-
regions: loaded.regions,
|
|
57
|
-
countries,
|
|
58
|
-
provinces,
|
|
59
|
-
audio,
|
|
60
|
-
};
|
|
23
|
+
await prepareStaticAssets(publicDir);
|
|
24
|
+
const inputProps = await prepareBroadcast(loaded, publicDir);
|
|
61
25
|
const serveUrl = await bundle({
|
|
62
26
|
entryPoint: path.join(packageRoot, 'dist/video/index.js'),
|
|
63
27
|
publicDir,
|
package/dist/scenario.d.ts
CHANGED
|
@@ -48,15 +48,18 @@ export declare const scenarioSchema: z.ZodObject<{
|
|
|
48
48
|
duration: z.ZodNumber;
|
|
49
49
|
volume: z.ZodDefault<z.ZodNumber>;
|
|
50
50
|
}, z.core.$strict>]>>;
|
|
51
|
-
corrects: z.ZodOptional<z.ZodString>;
|
|
52
51
|
occurredAt: z.ZodOptional<z.ZodString>;
|
|
53
52
|
publishedAt: z.ZodOptional<z.ZodString>;
|
|
54
|
-
pageSeconds: z.ZodDefault<z.ZodNumber>;
|
|
55
53
|
id: z.ZodString;
|
|
56
54
|
cut: z.ZodString;
|
|
57
55
|
at: z.ZodNumber;
|
|
58
56
|
}, z.core.$strict>, z.ZodObject<{
|
|
59
57
|
type: z.ZodLiteral<"news">;
|
|
58
|
+
sound: z.ZodDefault<z.ZodDefault<z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodLiteral<"default">, z.ZodObject<{
|
|
59
|
+
asset: z.ZodString;
|
|
60
|
+
duration: z.ZodNumber;
|
|
61
|
+
volume: z.ZodDefault<z.ZodNumber>;
|
|
62
|
+
}, z.core.$strict>]>>>;
|
|
60
63
|
source: z.ZodString;
|
|
61
64
|
headline: z.ZodString;
|
|
62
65
|
publishedAt: z.ZodOptional<z.ZodString>;
|
|
@@ -67,6 +70,11 @@ export declare const scenarioSchema: z.ZodObject<{
|
|
|
67
70
|
at: z.ZodNumber;
|
|
68
71
|
}, z.core.$strict>, z.ZodObject<{
|
|
69
72
|
type: z.ZodLiteral<"status">;
|
|
73
|
+
sound: z.ZodDefault<z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodLiteral<"default">, z.ZodObject<{
|
|
74
|
+
asset: z.ZodString;
|
|
75
|
+
duration: z.ZodNumber;
|
|
76
|
+
volume: z.ZodDefault<z.ZodNumber>;
|
|
77
|
+
}, z.core.$strict>]>>;
|
|
70
78
|
items: z.ZodArray<z.ZodObject<{
|
|
71
79
|
text: z.ZodString;
|
|
72
80
|
color: z.ZodString;
|
|
@@ -78,16 +86,37 @@ export declare const scenarioSchema: z.ZodObject<{
|
|
|
78
86
|
at: z.ZodNumber;
|
|
79
87
|
}, z.core.$strict>, z.ZodObject<{
|
|
80
88
|
type: z.ZodLiteral<"bulletin">;
|
|
89
|
+
sound: z.ZodDefault<z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodLiteral<"default">, z.ZodObject<{
|
|
90
|
+
asset: z.ZodString;
|
|
91
|
+
duration: z.ZodNumber;
|
|
92
|
+
volume: z.ZodDefault<z.ZodNumber>;
|
|
93
|
+
}, z.core.$strict>]>>;
|
|
81
94
|
source: z.ZodString;
|
|
82
95
|
heading: z.ZodString;
|
|
83
96
|
text: z.ZodString;
|
|
84
97
|
color: z.ZodDefault<z.ZodString>;
|
|
85
|
-
pageSeconds: z.ZodDefault<z.ZodNumber>;
|
|
86
98
|
duration: z.ZodNumber;
|
|
87
99
|
carry: z.ZodDefault<z.ZodBoolean>;
|
|
88
100
|
id: z.ZodString;
|
|
89
101
|
cut: z.ZodString;
|
|
90
102
|
at: z.ZodNumber;
|
|
103
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
104
|
+
type: z.ZodLiteral<"chat">;
|
|
105
|
+
title: z.ZodDefault<z.ZodString>;
|
|
106
|
+
duration: z.ZodNumber;
|
|
107
|
+
carry: z.ZodDefault<z.ZodBoolean>;
|
|
108
|
+
id: z.ZodString;
|
|
109
|
+
cut: z.ZodString;
|
|
110
|
+
at: z.ZodNumber;
|
|
111
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
112
|
+
type: z.ZodLiteral<"chat-message">;
|
|
113
|
+
chat: z.ZodString;
|
|
114
|
+
author: z.ZodString;
|
|
115
|
+
text: z.ZodString;
|
|
116
|
+
color: z.ZodDefault<z.ZodString>;
|
|
117
|
+
id: z.ZodString;
|
|
118
|
+
cut: z.ZodString;
|
|
119
|
+
at: z.ZodNumber;
|
|
91
120
|
}, z.core.$strict>, z.ZodObject<{
|
|
92
121
|
type: z.ZodLiteral<"zone">;
|
|
93
122
|
points: z.ZodArray<z.ZodTuple<[z.ZodNumber, z.ZodNumber], null>>;
|
|
@@ -252,5 +281,4 @@ export type Timeline = {
|
|
|
252
281
|
export declare const FPS = 60;
|
|
253
282
|
export declare const frames: (seconds: number) => number;
|
|
254
283
|
export declare const alertText: (sender: string, text: string) => string;
|
|
255
|
-
export declare const pages: (text: string) => string[];
|
|
256
284
|
export declare function compile(scenario: Scenario, regionIds: Set<string>): Timeline;
|
package/dist/scenario.js
CHANGED
|
@@ -4,6 +4,8 @@ const seconds = z.number().finite().nonnegative();
|
|
|
4
4
|
const color = z.string().regex(/^#[0-9a-fA-F]{6}$/);
|
|
5
5
|
const position = z.tuple([z.number().min(-180).max(180), z.number().min(-85).max(85)]);
|
|
6
6
|
const timestamp = z.string().datetime({ offset: true });
|
|
7
|
+
const fitsText = (text, columns, lines) => text.split(/\r\n|\r|\n/).reduce((count, line) => count + Math.max(1, Math.ceil(Array.from(line.replace(/\t/g, ' ')).length / columns)), 0) <= lines;
|
|
8
|
+
const shortText = (characters, columns, lines) => z.string().trim().min(1).max(characters).refine((text) => fitsText(text, columns, lines), 'Shorten the text or remove line breaks so the full message fits at once');
|
|
7
9
|
const camera = z
|
|
8
10
|
.object({
|
|
9
11
|
center: position,
|
|
@@ -18,12 +20,25 @@ const span = {
|
|
|
18
20
|
duration: z.number().positive(),
|
|
19
21
|
carry: z.boolean().default(false),
|
|
20
22
|
};
|
|
23
|
+
const notificationSound = z
|
|
24
|
+
.union([
|
|
25
|
+
z.literal(false),
|
|
26
|
+
z.literal('default'),
|
|
27
|
+
z
|
|
28
|
+
.object({
|
|
29
|
+
asset: id,
|
|
30
|
+
duration: z.number().positive(),
|
|
31
|
+
volume: z.number().min(0).max(2).default(0.8),
|
|
32
|
+
})
|
|
33
|
+
.strict(),
|
|
34
|
+
])
|
|
35
|
+
.default('default');
|
|
21
36
|
const alert = z
|
|
22
37
|
.object({
|
|
23
38
|
...timed,
|
|
24
39
|
type: z.literal('alert'),
|
|
25
40
|
sender: id,
|
|
26
|
-
text:
|
|
41
|
+
text: shortText(120, 16, 8),
|
|
27
42
|
level: z.enum(['안전안내', '긴급재난', '위급재난']).default('안전안내'),
|
|
28
43
|
regions: z.array(z.string()).default([]),
|
|
29
44
|
mapHighlight: z
|
|
@@ -37,31 +52,18 @@ const alert = z
|
|
|
37
52
|
.strict(),
|
|
38
53
|
])
|
|
39
54
|
.default({ duration: 2.4, color: '#d6e2ec' }),
|
|
40
|
-
sound:
|
|
41
|
-
.union([
|
|
42
|
-
z.literal(false),
|
|
43
|
-
z.literal('default'),
|
|
44
|
-
z
|
|
45
|
-
.object({
|
|
46
|
-
asset: id,
|
|
47
|
-
duration: z.number().positive(),
|
|
48
|
-
volume: z.number().min(0).max(2).default(0.8),
|
|
49
|
-
})
|
|
50
|
-
.strict(),
|
|
51
|
-
])
|
|
52
|
-
.default('default'),
|
|
53
|
-
corrects: id.optional(),
|
|
55
|
+
sound: notificationSound,
|
|
54
56
|
occurredAt: timestamp.optional(),
|
|
55
57
|
publishedAt: timestamp.optional(),
|
|
56
|
-
pageSeconds: z.number().min(3).default(8),
|
|
57
58
|
})
|
|
58
59
|
.strict();
|
|
59
60
|
const news = z
|
|
60
61
|
.object({
|
|
61
62
|
...span,
|
|
62
63
|
type: z.literal('news'),
|
|
64
|
+
sound: notificationSound.default(false),
|
|
63
65
|
source: id,
|
|
64
|
-
headline:
|
|
66
|
+
headline: shortText(64, 32, 2),
|
|
65
67
|
publishedAt: timestamp.optional(),
|
|
66
68
|
})
|
|
67
69
|
.strict();
|
|
@@ -69,6 +71,7 @@ const status = z
|
|
|
69
71
|
.object({
|
|
70
72
|
...span,
|
|
71
73
|
type: z.literal('status'),
|
|
74
|
+
sound: notificationSound,
|
|
72
75
|
items: z.array(z.object({ text: z.string().min(1).max(80), color }).strict()),
|
|
73
76
|
})
|
|
74
77
|
.strict();
|
|
@@ -76,11 +79,28 @@ const bulletin = z
|
|
|
76
79
|
.object({
|
|
77
80
|
...span,
|
|
78
81
|
type: z.literal('bulletin'),
|
|
82
|
+
sound: notificationSound,
|
|
79
83
|
source: id,
|
|
80
|
-
heading:
|
|
81
|
-
text:
|
|
84
|
+
heading: shortText(40, 26, 2),
|
|
85
|
+
text: shortText(180, 30, 6),
|
|
82
86
|
color: color.default('#b7c1c8'),
|
|
83
|
-
|
|
87
|
+
})
|
|
88
|
+
.strict();
|
|
89
|
+
const chat = z
|
|
90
|
+
.object({
|
|
91
|
+
...span,
|
|
92
|
+
type: z.literal('chat'),
|
|
93
|
+
title: z.string().trim().min(1).max(30).regex(/^[^\r\n]+$/, 'Use a single line').default('실시간 채팅'),
|
|
94
|
+
})
|
|
95
|
+
.strict();
|
|
96
|
+
const chatMessage = z
|
|
97
|
+
.object({
|
|
98
|
+
...timed,
|
|
99
|
+
type: z.literal('chat-message'),
|
|
100
|
+
chat: id,
|
|
101
|
+
author: z.string().trim().min(1).max(20).regex(/^[^\r\n]+$/, 'Use a single line'),
|
|
102
|
+
text: z.string().trim().min(1).max(80).regex(/^[^\r\n]+$/, 'Use a single line'),
|
|
103
|
+
color: color.default('#9fb8cb'),
|
|
84
104
|
})
|
|
85
105
|
.strict();
|
|
86
106
|
const zone = z
|
|
@@ -231,6 +251,8 @@ export const scenarioSchema = z
|
|
|
231
251
|
news,
|
|
232
252
|
status,
|
|
233
253
|
bulletin,
|
|
254
|
+
chat,
|
|
255
|
+
chatMessage,
|
|
234
256
|
zone,
|
|
235
257
|
line,
|
|
236
258
|
highlight,
|
|
@@ -248,24 +270,6 @@ export const scenarioSchema = z
|
|
|
248
270
|
export const FPS = 60;
|
|
249
271
|
export const frames = (seconds) => Math.round(seconds * FPS);
|
|
250
272
|
export const alertText = (sender, text) => text.includes(`[${sender}]`) ? text : `[${sender}] ${text}`;
|
|
251
|
-
export const pages = (text) => {
|
|
252
|
-
const result = [];
|
|
253
|
-
let remaining = Array.from(new Intl.Segmenter('ko', { granularity: 'grapheme' }).segment(text), (x) => x.segment);
|
|
254
|
-
while (remaining.length > 100) {
|
|
255
|
-
const part = remaining.slice(0, 100);
|
|
256
|
-
let boundary = 99;
|
|
257
|
-
for (let i = 99; i >= 60; i--)
|
|
258
|
-
if (/[\s]/.test(part[i])) {
|
|
259
|
-
boundary = i;
|
|
260
|
-
break;
|
|
261
|
-
}
|
|
262
|
-
result.push(remaining.slice(0, boundary + 1).join(''));
|
|
263
|
-
remaining = remaining.slice(boundary + 1);
|
|
264
|
-
}
|
|
265
|
-
if (remaining.length)
|
|
266
|
-
result.push(remaining.join(''));
|
|
267
|
-
return result;
|
|
268
|
-
};
|
|
269
273
|
export function compile(scenario, regionIds) {
|
|
270
274
|
const errors = [];
|
|
271
275
|
try {
|
|
@@ -322,22 +326,23 @@ export function compile(scenario, regionIds) {
|
|
|
322
326
|
for (const r of e.regions)
|
|
323
327
|
if (!regionIds.has(r))
|
|
324
328
|
errors.push(`${path}.regions: unknown region ${r}`);
|
|
329
|
+
if ('sound' in e && typeof e.sound === 'object' && !Object.hasOwn(scenario.assets, e.sound.asset))
|
|
330
|
+
errors.push(`${path}.sound.asset: unknown asset ${e.sound.asset}`);
|
|
325
331
|
if (e.type === 'alert') {
|
|
326
|
-
if (typeof e.sound === 'object' && !Object.hasOwn(scenario.assets, e.sound.asset))
|
|
327
|
-
errors.push(`${path}.sound.asset: unknown asset ${e.sound.asset}`);
|
|
328
332
|
if (!Object.hasOwn(scenario.agencies, e.sender))
|
|
329
333
|
errors.push(`${path}.sender: unknown agency ${e.sender}`);
|
|
330
|
-
if (e.
|
|
331
|
-
|
|
332
|
-
if (!prev || prev.type !== 'alert')
|
|
333
|
-
errors.push(`${path}.corrects: unknown alert ${e.corrects}`);
|
|
334
|
-
}
|
|
334
|
+
else if (!fitsText(alertText(scenario.agencies[e.sender], e.text), 16, 8))
|
|
335
|
+
errors.push(`${path}.text: shorten the message including its sender so the full text fits at once`);
|
|
335
336
|
}
|
|
336
337
|
if (e.type === 'zone' &&
|
|
337
338
|
(e.points[0][0] !== e.points.at(-1)[0] || e.points[0][1] !== e.points.at(-1)[1]))
|
|
338
339
|
errors.push(`${path}.points: polygon ring must be closed`);
|
|
339
340
|
if ((e.type === 'news' || e.type === 'bulletin') && !Object.hasOwn(scenario.agencies, e.source))
|
|
340
341
|
errors.push(`${path}.source: unknown agency ${e.source}`);
|
|
342
|
+
else if (e.type === 'news' && !fitsText(scenario.agencies[e.source], 10, 2))
|
|
343
|
+
errors.push(`${path}.source: shorten the display name so the full source fits beside the headline`);
|
|
344
|
+
else if (e.type === 'bulletin' && !fitsText(scenario.agencies[e.source], 48, 2))
|
|
345
|
+
errors.push(`${path}.source: shorten the display name so the full source fits above the notice`);
|
|
341
346
|
if (e.type === 'audio') {
|
|
342
347
|
if (!Object.hasOwn(scenario.assets, e.asset))
|
|
343
348
|
errors.push(`${path}.asset: unknown asset ${e.asset}`);
|
|
@@ -347,10 +352,14 @@ export function compile(scenario, regionIds) {
|
|
|
347
352
|
return [{ ...e, start, end, order: i }];
|
|
348
353
|
});
|
|
349
354
|
for (const e of events) {
|
|
350
|
-
if (e.type === '
|
|
351
|
-
const
|
|
352
|
-
if (
|
|
353
|
-
errors.push(
|
|
355
|
+
if (e.type === 'chat-message') {
|
|
356
|
+
const panel = events.find((x) => x.id === e.chat && x.type === 'chat');
|
|
357
|
+
if (!panel)
|
|
358
|
+
errors.push(`events[${e.order}] (${e.id}).chat: unknown chat ${e.chat}`);
|
|
359
|
+
else if (e.start < panel.start || e.start >= panel.end)
|
|
360
|
+
errors.push(`events[${e.order}] (${e.id}).at: outside chat ${e.chat}`);
|
|
361
|
+
else
|
|
362
|
+
e.end = panel.end;
|
|
354
363
|
}
|
|
355
364
|
}
|
|
356
365
|
for (const cut of cuts) {
|
|
@@ -360,7 +369,7 @@ export function compile(scenario, regionIds) {
|
|
|
360
369
|
errors.push(`cuts (${cut.id}).clockTransition: requires a previous cut`);
|
|
361
370
|
}
|
|
362
371
|
// Single-owner tracks cannot have ambiguous concurrent values. Independent layers can overlap.
|
|
363
|
-
for (const type of ['camera', 'inset', 'news', 'status', 'bulletin', 'legend']) {
|
|
372
|
+
for (const type of ['camera', 'inset', 'news', 'status', 'bulletin', 'legend', 'chat']) {
|
|
364
373
|
const track = events.filter((e) => e.type === type).sort((a, b) => a.start - b.start);
|
|
365
374
|
for (let i = 1; i < track.length; i++)
|
|
366
375
|
if (track[i].start < track[i - 1].end)
|