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.
Files changed (69) hide show
  1. package/README.md +19 -7
  2. package/assets/README.md +1 -1
  3. package/assets/audio/README.md +16 -4
  4. package/assets/audio/alert-critical.mjs +4 -0
  5. package/assets/audio/alert-critical.wav +0 -0
  6. package/assets/audio/alert-critical.wav.json +15 -0
  7. package/assets/audio/alert-urgent.mjs +4 -0
  8. package/assets/audio/alert-urgent.wav +0 -0
  9. package/assets/audio/alert-urgent.wav.json +15 -0
  10. package/assets/audio/bulletin.mjs +9 -0
  11. package/assets/audio/bulletin.wav +0 -0
  12. package/assets/audio/bulletin.wav.json +15 -0
  13. package/assets/audio/news.mjs +4 -0
  14. package/assets/audio/news.wav +0 -0
  15. package/assets/audio/news.wav.json +15 -0
  16. package/assets/audio/notification.mjs +24 -9
  17. package/assets/audio/notification.wav +0 -0
  18. package/assets/audio/notification.wav.json +15 -0
  19. package/assets/audio/status.mjs +5 -0
  20. package/assets/audio/status.wav +0 -0
  21. package/assets/audio/status.wav.json +15 -0
  22. package/assets/audio/tones.mjs +17 -0
  23. package/assets/workspace/README.md +3 -0
  24. package/dist/cli.js +20 -1
  25. package/dist/credits.js +2 -2
  26. package/dist/load.js +8 -7
  27. package/dist/notifications.d.ts +40 -3
  28. package/dist/notifications.js +34 -6
  29. package/dist/preview/browser.d.ts +1 -0
  30. package/dist/preview/browser.js +87 -0
  31. package/dist/preview/http.d.ts +3 -0
  32. package/dist/preview/http.js +78 -0
  33. package/dist/preview/page.d.ts +1 -0
  34. package/dist/preview/page.js +30 -0
  35. package/dist/preview/project.d.ts +22 -0
  36. package/dist/preview/project.js +81 -0
  37. package/dist/preview/server.d.ts +6 -0
  38. package/dist/preview/server.js +57 -0
  39. package/dist/render.js +4 -40
  40. package/dist/scenario.d.ts +32 -4
  41. package/dist/scenario.js +59 -50
  42. package/dist/timeline.d.ts +68 -5
  43. package/dist/timeline.js +29 -9
  44. package/dist/video/Alerts.js +49 -49
  45. package/dist/video/Broadcast.js +82 -96
  46. package/dist/video/Bulletin.d.ts +2 -2
  47. package/dist/video/Bulletin.js +13 -18
  48. package/dist/video/Chat.d.ts +9 -0
  49. package/dist/video/Chat.js +34 -0
  50. package/dist/video/Information.d.ts +10 -0
  51. package/dist/video/Information.js +100 -0
  52. package/dist/video/Map.js +28 -12
  53. package/dist/video/TimeJump.d.ts +8 -0
  54. package/dist/video/TimeJump.js +42 -0
  55. package/dist/video/layout.d.ts +5 -0
  56. package/dist/video/layout.js +2 -0
  57. package/dist/video/prepare.d.ts +4 -0
  58. package/dist/video/prepare.js +47 -0
  59. package/dist/video/transitions.d.ts +609 -0
  60. package/dist/video/transitions.js +22 -0
  61. package/dist/video/useBundledFonts.js +16 -7
  62. package/package.json +2 -1
  63. package/skills/scenario-writing/SKILL.md +12 -5
  64. package/skills/scenario-writing/references/README.md +4 -4
  65. package/skills/scenario-writing/references/audio.md +7 -1
  66. package/skills/scenario-writing/references/direction.md +31 -7
  67. package/skills/scenario-writing/references/format.md +63 -12
  68. package/skills/scenario-writing/references/news-writing.md +10 -0
  69. 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,6 @@
1
+ export declare function startPreview(file: string, options?: {
2
+ port?: number;
3
+ }): Promise<{
4
+ url: string;
5
+ close: () => Promise<void>;
6
+ }>;
@@ -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 { mkdir, copyFile, readFile, writeFile } from 'node:fs/promises';
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 { fonts } from './video/fonts.js';
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 mkdir(path.join(publicDir, 'fonts', 'files'), { recursive: true });
26
- for (const name of ['maplibre-gl-worker.mjs', 'maplibre-gl-shared.mjs'])
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,
@@ -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: z.string().min(1).max(4000),
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: z
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: z.string().min(1).max(160),
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: z.string().min(1).max(80),
81
- text: z.string().min(1).max(4000),
84
+ heading: shortText(40, 26, 2),
85
+ text: shortText(180, 30, 6),
82
86
  color: color.default('#b7c1c8'),
83
- pageSeconds: z.number().min(3).default(12),
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.corrects) {
331
- const prev = scenario.events.find((x) => x.id === e.corrects);
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 === 'alert' && e.corrects) {
351
- const prior = events.find((x) => x.id === e.corrects);
352
- if (prior && prior.start >= e.start)
353
- errors.push(`${e.id}.corrects: correction must follow original alert`);
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)