disaster-broadcast-studio 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +10 -5
  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 +19 -1
  25. package/dist/credits.js +2 -2
  26. package/dist/load.js +5 -3
  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 +15 -0
  41. package/dist/scenario.js +19 -15
  42. package/dist/timeline.d.ts +6 -5
  43. package/dist/timeline.js +11 -8
  44. package/dist/video/Alerts.js +63 -47
  45. package/dist/video/Broadcast.js +14 -38
  46. package/dist/video/Bulletin.d.ts +2 -1
  47. package/dist/video/Bulletin.js +8 -4
  48. package/dist/video/Information.d.ts +16 -0
  49. package/dist/video/Information.js +104 -0
  50. package/dist/video/Map.js +26 -10
  51. package/dist/video/prepare.d.ts +4 -0
  52. package/dist/video/prepare.js +47 -0
  53. package/dist/video/transitions.d.ts +562 -0
  54. package/dist/video/transitions.js +31 -0
  55. package/dist/video/useBundledFonts.js +15 -6
  56. package/package.json +2 -1
  57. package/skills/scenario-writing/SKILL.md +2 -2
  58. package/skills/scenario-writing/references/audio.md +1 -1
  59. package/skills/scenario-writing/references/format.md +16 -7
@@ -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,
@@ -57,6 +57,11 @@ export declare const scenarioSchema: z.ZodObject<{
57
57
  at: z.ZodNumber;
58
58
  }, z.core.$strict>, z.ZodObject<{
59
59
  type: z.ZodLiteral<"news">;
60
+ sound: z.ZodDefault<z.ZodDefault<z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodLiteral<"default">, z.ZodObject<{
61
+ asset: z.ZodString;
62
+ duration: z.ZodNumber;
63
+ volume: z.ZodDefault<z.ZodNumber>;
64
+ }, z.core.$strict>]>>>;
60
65
  source: z.ZodString;
61
66
  headline: z.ZodString;
62
67
  publishedAt: z.ZodOptional<z.ZodString>;
@@ -67,6 +72,11 @@ export declare const scenarioSchema: z.ZodObject<{
67
72
  at: z.ZodNumber;
68
73
  }, z.core.$strict>, z.ZodObject<{
69
74
  type: z.ZodLiteral<"status">;
75
+ sound: z.ZodDefault<z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodLiteral<"default">, z.ZodObject<{
76
+ asset: z.ZodString;
77
+ duration: z.ZodNumber;
78
+ volume: z.ZodDefault<z.ZodNumber>;
79
+ }, z.core.$strict>]>>;
70
80
  items: z.ZodArray<z.ZodObject<{
71
81
  text: z.ZodString;
72
82
  color: z.ZodString;
@@ -78,6 +88,11 @@ export declare const scenarioSchema: z.ZodObject<{
78
88
  at: z.ZodNumber;
79
89
  }, z.core.$strict>, z.ZodObject<{
80
90
  type: z.ZodLiteral<"bulletin">;
91
+ sound: z.ZodDefault<z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodLiteral<"default">, z.ZodObject<{
92
+ asset: z.ZodString;
93
+ duration: z.ZodNumber;
94
+ volume: z.ZodDefault<z.ZodNumber>;
95
+ }, z.core.$strict>]>>;
81
96
  source: z.ZodString;
82
97
  heading: z.ZodString;
83
98
  text: z.ZodString;
package/dist/scenario.js CHANGED
@@ -18,6 +18,19 @@ const span = {
18
18
  duration: z.number().positive(),
19
19
  carry: z.boolean().default(false),
20
20
  };
21
+ const notificationSound = z
22
+ .union([
23
+ z.literal(false),
24
+ z.literal('default'),
25
+ z
26
+ .object({
27
+ asset: id,
28
+ duration: z.number().positive(),
29
+ volume: z.number().min(0).max(2).default(0.8),
30
+ })
31
+ .strict(),
32
+ ])
33
+ .default('default');
21
34
  const alert = z
22
35
  .object({
23
36
  ...timed,
@@ -37,19 +50,7 @@ const alert = z
37
50
  .strict(),
38
51
  ])
39
52
  .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
+ sound: notificationSound,
53
54
  corrects: id.optional(),
54
55
  occurredAt: timestamp.optional(),
55
56
  publishedAt: timestamp.optional(),
@@ -60,6 +61,7 @@ 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
66
  headline: z.string().min(1).max(160),
65
67
  publishedAt: timestamp.optional(),
@@ -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,6 +79,7 @@ const bulletin = z
76
79
  .object({
77
80
  ...span,
78
81
  type: z.literal('bulletin'),
82
+ sound: notificationSound,
79
83
  source: id,
80
84
  heading: z.string().min(1).max(80),
81
85
  text: z.string().min(1).max(4000),
@@ -322,9 +326,9 @@ 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
334
  if (e.corrects) {
@@ -1,6 +1,7 @@
1
1
  import { type Timeline, type Camera, type CompiledEvent } from './scenario.js';
2
2
  export declare const active: (e: CompiledEvent, frame: number) => boolean;
3
3
  export declare const mix: (a: number, b: number, t: number) => number;
4
+ export declare function notificationPulse(frame: number, start: number, end: number): number;
4
5
  export declare function cameraAt(t: Timeline, frame: number): Camera;
5
6
  export declare function receiptHighlights(t: Timeline, frame: number): Extract<CompiledEvent, {
6
7
  type: 'region';
@@ -8,11 +9,6 @@ export declare function receiptHighlights(t: Timeline, frame: number): Extract<C
8
9
  export declare function regionOpacity(e: Extract<CompiledEvent, {
9
10
  type: 'region';
10
11
  }>, frame: number, fill?: boolean): number;
11
- export declare function statusEntry(frame: number, start: number, duration?: number): {
12
- opacity: number;
13
- offset: number;
14
- glow: number;
15
- };
16
12
  export declare function shutdownAt(events: CompiledEvent[], frame: number): {
17
13
  y: number;
18
14
  x: number;
@@ -37,6 +33,11 @@ export declare function stateAt(t: Timeline, frame: number): {
37
33
  visible: CompiledEvent[];
38
34
  news: ({
39
35
  type: "news";
36
+ sound: false | "default" | {
37
+ asset: string;
38
+ duration: number;
39
+ volume: number;
40
+ };
40
41
  source: string;
41
42
  headline: string;
42
43
  duration: number;
package/dist/timeline.js CHANGED
@@ -1,6 +1,12 @@
1
1
  import { FPS } from './scenario.js';
2
2
  export const active = (e, frame) => e.start <= frame && frame < e.end;
3
3
  export const mix = (a, b, t) => a + (b - a) * t;
4
+ // Three smooth pulses, bounded by the authored frame interval.
5
+ export function notificationPulse(frame, start, end) {
6
+ if (frame < start || frame >= end)
7
+ return 0;
8
+ return Math.sin(((frame - start) / (end - start)) * Math.PI * 3) ** 2;
9
+ }
4
10
  export function cameraAt(t, frame) {
5
11
  let cam = { ...t.scenario.map.camera };
6
12
  for (const e of t.events) {
@@ -21,7 +27,8 @@ export function receiptHighlights(t, frame) {
21
27
  return t.events.flatMap((e) => {
22
28
  if (e.type !== 'alert' || !e.mapHighlight || !e.regions.length)
23
29
  return [];
24
- const end = Math.min(t.durationInFrames, e.start + Math.round(e.mapHighlight.duration * FPS));
30
+ const highlightEnd = e.start + Math.round(e.mapHighlight.duration * FPS);
31
+ const end = Math.min(t.durationInFrames, highlightEnd);
25
32
  if (frame < e.start || frame >= end)
26
33
  return [];
27
34
  return [
@@ -37,13 +44,13 @@ export function receiptHighlights(t, frame) {
37
44
  order: e.order,
38
45
  regions: e.regions,
39
46
  color: e.mapHighlight.color,
40
- fillOpacity: 0.28,
47
+ fillOpacity: 0.28 * notificationPulse(frame, e.start, highlightEnd),
41
48
  strokeColor: e.mapHighlight.color,
42
49
  strokeOpacity: 0,
43
50
  strokeWidth: 0,
44
51
  pulse: false,
45
- fadeIn: Math.min(0.2, e.mapHighlight.duration * 0.2),
46
- fadeOut: Math.min(0.7, e.mapHighlight.duration * 0.4),
52
+ fadeIn: Math.min(0.1, e.mapHighlight.duration / 12),
53
+ fadeOut: Math.min(0.1, e.mapHighlight.duration / 12),
47
54
  },
48
55
  ];
49
56
  });
@@ -52,10 +59,6 @@ export function regionOpacity(e, frame, fill = true) {
52
59
  const fade = envelope(frame, e.start, e.end, e.fadeIn, e.fadeOut);
53
60
  return (fade * (fill && e.pulse ? 0.88 + 0.12 * Math.cos(((frame - e.start) / FPS) * Math.PI * 0.9) : 1));
54
61
  }
55
- export function statusEntry(frame, start, duration = 0.55) {
56
- const p = Math.max(0, Math.min(1, (frame - start) / (duration * FPS)));
57
- return { opacity: 0.4 + 0.6 * p, offset: (1 - p) * -5, glow: (1 - p) * 0.22 };
58
- }
59
62
  export function shutdownAt(events, frame) {
60
63
  const e = events.find((e) => e.type === 'screen' && e.effect === 'shutdown' && e.intensity > 0 && active(e, frame));
61
64
  if (!e)