disaster-broadcast-studio 0.2.0 → 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 CHANGED
@@ -6,9 +6,10 @@ YAML/JSON 시나리오를 한국형 재난방송 호러 영상으로 만드는 C
6
6
 
7
7
  ## 할 수 있는 일
8
8
 
9
- - **방송 화면:** 최신순 재난문자·정정 이력, 독립 뉴스, 상태 박스, 장문 방송 안내. 뉴스·문자·상태 변경을 알리는 기본 전환 효과.
9
+ - **방송 화면:** 최신순 재난문자 이력, 독립 뉴스, 상태 박스, 방송 안내. 뉴스·문자·상태 변경을 알리는 기본 전환 효과.
10
+ - **시청자 반응:** 선택적 실시간 채팅 패널. 시나리오에 작성한 닉네임·대화가 지정 시점에 올라오고 이전 대화는 위로 이동.
10
11
  - **지도 연출:** 실제 시도·시군구 경계, 채우기·윤곽선 중첩, 범례·지점·영역·진행선, 카메라 이동·보조 지도.
11
- - **시간 연출:** 컷 안에서는 실시간으로, 컷 사이에서는 지정한 이야기 시각으로 전환. 임의 프레임에서도 같은 상태를 복원.
12
+ - **시간 연출:** 컷 안에서는 실시간으로, 컷 사이에서는 지정한 이야기 시각으로 전환. 흐릿한 빠른 재생 아이콘·잔상·주사선으로 시간 도약을 표시하며 임의 프레임에서도 같은 상태를 복원.
12
13
  - **음향·효과:** 상태·방송 안내·등급별 문자 기본음, 로컬 음원, Tone.js 코드의 WAV 합성, 페이드·침묵·신호 간섭·송출 종료.
13
14
  - **텍스트 제작 흐름:** 검증, 웹 실시간 미리보기, 렌더 없는 상태 조회, PNG 확인, 구간·전체 영상 출력, 게시용 출처 생성.
14
15
 
@@ -31,6 +32,12 @@ npx dbs init
31
32
 
32
33
  창작 AI와 함께 제작한다면 [창작 스킬 설치 안내](skills/README.md)를 따르세요. 스킬에는 집필·연출 지침, 공식 문안 참고자료, 음향 코드 예제가 포함되어 있습니다. 사용자 범위에 한 번 설치하며 `dbs init`과 별도로 관리합니다.
33
34
 
35
+ ### 0.3.0 업그레이드
36
+
37
+ 선택적 시청자 채팅, 모바일 가로 화면에 맞춘 큰 글자와 짧은 문안의 전문 표시, 시간 도약의 빠른 재생 연출을 포함합니다. 작성 주체·시청자 관점과 공식 상태 체계에 관한 창작 지침도 개정했습니다.
38
+
39
+ 기존 작품의 `pageSeconds`와 `corrects` 필드는 삭제하세요. 문안은 [작품 형식](skills/scenario-writing/references/format.md)의 길이·줄 수·표시명 한도에 맞춰 간결하게 수정한 뒤 `dbs validate`로 확인하세요. 문안은 자동으로 나뉘지 않고 전문이 한 번에 표시됩니다.
40
+
34
41
  ## 첫 영상 만들기
35
42
 
36
43
  `scenario.yaml`의 제목·기관·컷·이벤트를 수정하고 다음 순서로 확인합니다.
@@ -51,7 +58,7 @@ npx dbs render scenario.yaml --from 300 --to 599 --out clip.mp4
51
58
  npx dbs render scenario.yaml --out film.mp4
52
59
  ```
53
60
 
54
- 프레임은 **0부터 시작**하며 구간의 **끝 프레임도 포함**합니다. 300~599는 60fps에서 5초입니다. 컷 안의 시간은 실제 재생 시간과 같고, 컷 경계에서만 시간을 생략할 수 있습니다. 오프닝 이후에 중요한 사건을 배치하고, 읽기 시간 경고가 나오면 문자 사이 간격을 늘리세요.
61
+ 프레임은 **0부터 시작**하며 구간의 **끝 프레임도 포함**합니다. 300~599는 60fps에서 5초입니다. 컷 안의 시간은 실제 재생 시간과 같고, 컷 경계에서만 시간을 생략할 수 있습니다. 문안은 전문이 한 번에 읽힐 만큼 짧게 작성합니다. 오프닝 이후에 중요한 사건을 배치하고, 읽기 시간 경고가 나오면 문자 사이 간격을 늘리세요. 모바일 가로 전체화면을 기준으로 큰 본문과 명확한 정보 위계를 사용합니다.
55
62
 
56
63
  `preview`가 출력한 로컬 주소(기본 `http://127.0.0.1:3000`)를 브라우저에서 여세요. 화면·음향 재생, 탐색 막대, 프레임 단위 이동과 컷 선택을 제공합니다. 시나리오·사용자 지도·참조 음원을 저장하면 현재 위치에서 일시정지한 뒤 자동 반영합니다. 잘못된 입력은 마지막 정상 화면을 유지하면서 오류를 표시합니다. 포트는 `--port 3100`으로 바꾸고, 종료는 터미널에서 Ctrl+C를 누릅니다. MP4나 PNG를 생성하거나 Headless Chromium을 설치하지 않으며, 초기 웹 번들 준비 후 로컬 자산으로 재생합니다. 지도와 글꼴을 준비하는 동안은 재생을 기다리고, 실제 재생 성능은 브라우저·GPU에 따라 달라집니다.
57
64
 
package/dist/cli.js CHANGED
@@ -11,7 +11,7 @@ import { z } from 'zod';
11
11
  const program = new Command()
12
12
  .name('dbs')
13
13
  .description('Text-authored disaster broadcast fiction → 1080p60 video')
14
- .version('0.2.0');
14
+ .version('0.3.0');
15
15
  program
16
16
  .command('init [directory]')
17
17
  .description('Create a scenario workspace with YAML and audio folders; never overwrite files')
@@ -78,6 +78,7 @@ program
78
78
  camera: state.camera,
79
79
  visible: state.visible,
80
80
  alerts: state.alerts,
81
+ chat: state.chat,
81
82
  warnings,
82
83
  }, null, 2));
83
84
  });
package/dist/load.js CHANGED
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { defaultSounds, notificationSounds } from './notifications.js';
4
4
  import { readBundledJson, packageRoot } from './assets.js';
5
5
  import { parseDocument } from 'yaml';
6
- import { compile, scenarioSchema, alertText, pages, FPS } from './scenario.js';
6
+ import { compile, scenarioSchema, alertText, FPS } from './scenario.js';
7
7
  export async function localFile(root, value, label) {
8
8
  if (/^[a-z]+:\/\//i.test(value))
9
9
  throw Error(`${label}: use a local file, not a URL`);
@@ -84,11 +84,10 @@ export async function loadScenario(file) {
84
84
  if (nextAlert?.type === 'alert' &&
85
85
  nextAlert.text === a.text &&
86
86
  nextAlert.sender === a.sender &&
87
- nextAlert.level === a.level &&
88
- nextAlert.corrects === a.corrects)
87
+ nextAlert.level === a.level)
89
88
  continue;
90
89
  const next = nextAlert?.start ?? timeline.durationInFrames;
91
- const readingSeconds = pages(alertText(scenario.agencies[a.sender], a.text)).length * a.pageSeconds;
90
+ const readingSeconds = Math.max(8, Math.ceil(Array.from(alertText(scenario.agencies[a.sender], a.text)).length / 8));
92
91
  if (next - Math.max(a.start, 5 * FPS) < readingSeconds * FPS)
93
92
  warnings.push(`${a.id}: latest-message reading time is short; allow ${readingSeconds}s before the next alert/end`);
94
93
  }
@@ -48,10 +48,8 @@ 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;
@@ -97,12 +95,28 @@ export declare const scenarioSchema: z.ZodObject<{
97
95
  heading: z.ZodString;
98
96
  text: z.ZodString;
99
97
  color: z.ZodDefault<z.ZodString>;
100
- pageSeconds: z.ZodDefault<z.ZodNumber>;
101
98
  duration: z.ZodNumber;
102
99
  carry: z.ZodDefault<z.ZodBoolean>;
103
100
  id: z.ZodString;
104
101
  cut: z.ZodString;
105
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;
106
120
  }, z.core.$strict>, z.ZodObject<{
107
121
  type: z.ZodLiteral<"zone">;
108
122
  points: z.ZodArray<z.ZodTuple<[z.ZodNumber, z.ZodNumber], null>>;
@@ -267,5 +281,4 @@ export type Timeline = {
267
281
  export declare const FPS = 60;
268
282
  export declare const frames: (seconds: number) => number;
269
283
  export declare const alertText: (sender: string, text: string) => string;
270
- export declare const pages: (text: string) => string[];
271
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,
@@ -36,7 +38,7 @@ const alert = z
36
38
  ...timed,
37
39
  type: z.literal('alert'),
38
40
  sender: id,
39
- text: z.string().min(1).max(4000),
41
+ text: shortText(120, 16, 8),
40
42
  level: z.enum(['안전안내', '긴급재난', '위급재난']).default('안전안내'),
41
43
  regions: z.array(z.string()).default([]),
42
44
  mapHighlight: z
@@ -51,10 +53,8 @@ const alert = z
51
53
  ])
52
54
  .default({ duration: 2.4, color: '#d6e2ec' }),
53
55
  sound: notificationSound,
54
- corrects: id.optional(),
55
56
  occurredAt: timestamp.optional(),
56
57
  publishedAt: timestamp.optional(),
57
- pageSeconds: z.number().min(3).default(8),
58
58
  })
59
59
  .strict();
60
60
  const news = z
@@ -63,7 +63,7 @@ const news = z
63
63
  type: z.literal('news'),
64
64
  sound: notificationSound.default(false),
65
65
  source: id,
66
- headline: z.string().min(1).max(160),
66
+ headline: shortText(64, 32, 2),
67
67
  publishedAt: timestamp.optional(),
68
68
  })
69
69
  .strict();
@@ -81,10 +81,26 @@ const bulletin = z
81
81
  type: z.literal('bulletin'),
82
82
  sound: notificationSound,
83
83
  source: id,
84
- heading: z.string().min(1).max(80),
85
- text: z.string().min(1).max(4000),
84
+ heading: shortText(40, 26, 2),
85
+ text: shortText(180, 30, 6),
86
86
  color: color.default('#b7c1c8'),
87
- 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'),
88
104
  })
89
105
  .strict();
90
106
  const zone = z
@@ -235,6 +251,8 @@ export const scenarioSchema = z
235
251
  news,
236
252
  status,
237
253
  bulletin,
254
+ chat,
255
+ chatMessage,
238
256
  zone,
239
257
  line,
240
258
  highlight,
@@ -252,24 +270,6 @@ export const scenarioSchema = z
252
270
  export const FPS = 60;
253
271
  export const frames = (seconds) => Math.round(seconds * FPS);
254
272
  export const alertText = (sender, text) => text.includes(`[${sender}]`) ? text : `[${sender}] ${text}`;
255
- export const pages = (text) => {
256
- const result = [];
257
- let remaining = Array.from(new Intl.Segmenter('ko', { granularity: 'grapheme' }).segment(text), (x) => x.segment);
258
- while (remaining.length > 100) {
259
- const part = remaining.slice(0, 100);
260
- let boundary = 99;
261
- for (let i = 99; i >= 60; i--)
262
- if (/[\s]/.test(part[i])) {
263
- boundary = i;
264
- break;
265
- }
266
- result.push(remaining.slice(0, boundary + 1).join(''));
267
- remaining = remaining.slice(boundary + 1);
268
- }
269
- if (remaining.length)
270
- result.push(remaining.join(''));
271
- return result;
272
- };
273
273
  export function compile(scenario, regionIds) {
274
274
  const errors = [];
275
275
  try {
@@ -331,17 +331,18 @@ export function compile(scenario, regionIds) {
331
331
  if (e.type === 'alert') {
332
332
  if (!Object.hasOwn(scenario.agencies, e.sender))
333
333
  errors.push(`${path}.sender: unknown agency ${e.sender}`);
334
- if (e.corrects) {
335
- const prev = scenario.events.find((x) => x.id === e.corrects);
336
- if (!prev || prev.type !== 'alert')
337
- errors.push(`${path}.corrects: unknown alert ${e.corrects}`);
338
- }
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`);
339
336
  }
340
337
  if (e.type === 'zone' &&
341
338
  (e.points[0][0] !== e.points.at(-1)[0] || e.points[0][1] !== e.points.at(-1)[1]))
342
339
  errors.push(`${path}.points: polygon ring must be closed`);
343
340
  if ((e.type === 'news' || e.type === 'bulletin') && !Object.hasOwn(scenario.agencies, e.source))
344
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`);
345
346
  if (e.type === 'audio') {
346
347
  if (!Object.hasOwn(scenario.assets, e.asset))
347
348
  errors.push(`${path}.asset: unknown asset ${e.asset}`);
@@ -351,10 +352,14 @@ export function compile(scenario, regionIds) {
351
352
  return [{ ...e, start, end, order: i }];
352
353
  });
353
354
  for (const e of events) {
354
- if (e.type === 'alert' && e.corrects) {
355
- const prior = events.find((x) => x.id === e.corrects);
356
- if (prior && prior.start >= e.start)
357
- 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;
358
363
  }
359
364
  }
360
365
  for (const cut of cuts) {
@@ -364,7 +369,7 @@ export function compile(scenario, regionIds) {
364
369
  errors.push(`cuts (${cut.id}).clockTransition: requires a previous cut`);
365
370
  }
366
371
  // Single-owner tracks cannot have ambiguous concurrent values. Independent layers can overlap.
367
- for (const type of ['camera', 'inset', 'news', 'status', 'bulletin', 'legend']) {
372
+ for (const type of ['camera', 'inset', 'news', 'status', 'bulletin', 'legend', 'chat']) {
368
373
  const track = events.filter((e) => e.type === type).sort((a, b) => a.start - b.start);
369
374
  for (let i = 1; i < track.length; i++)
370
375
  if (track[i].start < track[i - 1].end)
@@ -1,5 +1,34 @@
1
1
  import { type Timeline, type Camera, type CompiledEvent } from './scenario.js';
2
2
  export declare const active: (e: CompiledEvent, frame: number) => boolean;
3
+ export declare function chatAt(t: Timeline, frame: number): {
4
+ panel: {
5
+ type: "chat";
6
+ title: string;
7
+ duration: number;
8
+ carry: boolean;
9
+ id: string;
10
+ cut: string;
11
+ at: number;
12
+ } & {
13
+ start: number;
14
+ end: number;
15
+ order: number;
16
+ };
17
+ messages: ({
18
+ type: "chat-message";
19
+ chat: string;
20
+ author: string;
21
+ text: string;
22
+ color: string;
23
+ id: string;
24
+ cut: string;
25
+ at: number;
26
+ } & {
27
+ start: number;
28
+ end: number;
29
+ order: number;
30
+ })[];
31
+ } | null;
3
32
  export declare const mix: (a: number, b: number, t: number) => number;
4
33
  export declare function notificationPulse(frame: number, start: number, end: number): number;
5
34
  export declare function cameraAt(t: Timeline, frame: number): Camera;
@@ -22,6 +51,10 @@ export declare function stateAt(t: Timeline, frame: number): {
22
51
  displayEpoch: number;
23
52
  jumping: boolean;
24
53
  clockPhase: string | null;
54
+ timeJump: {
55
+ progress: number;
56
+ elapsedFrames: number;
57
+ } | null;
25
58
  contentFrame: number;
26
59
  camera: {
27
60
  center: [number, number];
@@ -30,6 +63,35 @@ export declare function stateAt(t: Timeline, frame: number): {
30
63
  pitch: number;
31
64
  };
32
65
  alerts: CompiledEvent[];
66
+ chat: {
67
+ panel: {
68
+ type: "chat";
69
+ title: string;
70
+ duration: number;
71
+ carry: boolean;
72
+ id: string;
73
+ cut: string;
74
+ at: number;
75
+ } & {
76
+ start: number;
77
+ end: number;
78
+ order: number;
79
+ };
80
+ messages: ({
81
+ type: "chat-message";
82
+ chat: string;
83
+ author: string;
84
+ text: string;
85
+ color: string;
86
+ id: string;
87
+ cut: string;
88
+ at: number;
89
+ } & {
90
+ start: number;
91
+ end: number;
92
+ order: number;
93
+ })[];
94
+ } | null;
33
95
  visible: CompiledEvent[];
34
96
  news: ({
35
97
  type: "news";
package/dist/timeline.js CHANGED
@@ -1,5 +1,12 @@
1
1
  import { FPS } from './scenario.js';
2
2
  export const active = (e, frame) => e.start <= frame && frame < e.end;
3
+ export function chatAt(t, frame) {
4
+ const panel = t.events.find((e) => e.type === 'chat' && active(e, frame));
5
+ if (!panel || panel.type !== 'chat')
6
+ return null;
7
+ const messages = t.events.filter((e) => e.type === 'chat-message' && e.chat === panel.id && e.start <= frame);
8
+ return { panel, messages };
9
+ }
3
10
  export const mix = (a, b, t) => a + (b - a) * t;
4
11
  // Three smooth pulses, bounded by the authored frame interval.
5
12
  export function notificationPulse(frame, start, end) {
@@ -79,12 +86,15 @@ export function stateAt(t, frame) {
79
86
  const jumping = index > 0 && transition > 0 && frame < cut.start + transition;
80
87
  const contentFrame = jumping ? cut.start - 1 : frame;
81
88
  const previous = index > 0 ? t.cuts[index - 1] : null;
89
+ const previousEnd = previous
90
+ ? previous.epoch + ((previous.end - previous.start) * 1000) / FPS
91
+ : cut.epoch;
82
92
  const epoch = cut.epoch + ((frame - cut.start) * 1000) / FPS;
83
93
  const p = jumping ? (frame - cut.start) / transition : 1;
84
94
  const clockPhase = jumping ? (p < 0.15 ? 'lead' : p < 0.75 ? 'run' : 'hold') : null;
85
95
  const roll = Math.max(0, Math.min(1, (p - 0.15) / 0.6));
86
96
  const displayEpoch = jumping
87
- ? mix(previous.epoch + ((previous.end - previous.start) * 1000) / FPS, cut.epoch + (transition * 1000) / FPS, roll)
97
+ ? mix(previousEnd, cut.epoch + (transition * 1000) / FPS, roll)
88
98
  : epoch;
89
99
  const visible = t.events.filter((e) => active(e, contentFrame));
90
100
  return {
@@ -94,9 +104,16 @@ export function stateAt(t, frame) {
94
104
  displayEpoch,
95
105
  jumping,
96
106
  clockPhase,
107
+ timeJump: jumping && cut.epoch > previousEnd
108
+ ? {
109
+ progress: (frame - cut.start) / Math.max(1, transition - 1),
110
+ elapsedFrames: frame - cut.start,
111
+ }
112
+ : null,
97
113
  contentFrame,
98
114
  camera: cameraAt(t, contentFrame),
99
115
  alerts: t.events.filter((e) => e.type === 'alert' && e.start <= contentFrame).reverse(),
116
+ chat: chatAt(t, contentFrame),
100
117
  visible,
101
118
  news: visible.find((e) => e.type === 'news'),
102
119
  opacity: Math.min(1, cut.fadeIn ? (frame - cut.start) / FPS / cut.fadeIn : 1, cut.fadeOut ? (cut.end - 1 - frame) / FPS / cut.fadeOut : 1),
@@ -1,9 +1,8 @@
1
- import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { pages, alertText } from '../scenario.js';
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { alertText } from '../scenario.js';
3
3
  import { stateAt, clock } from '../timeline.js';
4
4
  import { numberFont } from './fonts.js';
5
- import { PagedText } from './Information.js';
6
- import { changeProgress, pageAt } from './transitions.js';
5
+ import { changeProgress } from './transitions.js';
7
6
  const colors = {
8
7
  안전안내: '#a0adb5',
9
8
  긴급재난: '#c2746e',
@@ -12,13 +11,11 @@ const colors = {
12
11
  function AlertCard({ event, emphasis, frame, timeline, }) {
13
12
  if (event.type !== 'alert')
14
13
  return null;
15
- const chunks = pages(alertText(timeline.scenario.agencies[event.sender], event.text));
16
- const { page } = pageAt(frame, event.start, event.pageSeconds, chunks.length);
17
14
  const arrival = changeProgress(frame, event.start);
18
15
  const latest = emphasis > 0;
19
16
  const received = stateAt(timeline, event.start).epoch;
20
17
  return (_jsx("div", { style: { display: 'grid', gridTemplateRows: `${arrival}fr` }, children: _jsx("div", { style: { minHeight: 0, overflow: 'hidden' }, children: _jsxs("article", { style: {
21
- padding: `${17 + 3 * emphasis}px 24px ${20 + 2 * emphasis}px`,
18
+ padding: '16px 24px',
22
19
  opacity: arrival,
23
20
  transform: `translateY(${-14 * (1 - arrival)}px)`,
24
21
  boxShadow: `inset 4px 0 ${colors[event.level]}${Math.round((1 - changeProgress(frame, event.start, 66)) * 180)
@@ -31,42 +28,29 @@ function AlertCard({ event, emphasis, frame, timeline, }) {
31
28
  display: 'flex',
32
29
  alignItems: 'center',
33
30
  gap: 10,
34
- fontSize: 17,
31
+ fontSize: 28,
35
32
  color: colors[event.level],
36
- marginBottom: 12,
37
- }, children: [_jsxs("b", { style: {
33
+ marginBottom: 10,
34
+ }, children: [_jsx("b", { style: {
38
35
  border: `1px solid ${colors[event.level]}`,
39
36
  padding: '2px 7px',
40
37
  fontWeight: 400,
41
- }, children: [event.corrects ? '정정 · ' : '', event.level] }), _jsxs("span", { style: {
38
+ }, children: event.level }), _jsxs("span", { style: {
42
39
  marginLeft: 'auto',
43
40
  fontFamily: numberFont,
44
41
  fontVariantNumeric: 'tabular-nums',
45
- fontSize: 17,
42
+ fontSize: 26,
46
43
  color: '#aab3b9',
47
- }, children: ["\uC218\uC2E0 ", clock(received, timeline.scenario.timezone)] })] }), (event.occurredAt || event.publishedAt) && (_jsxs("div", { style: { fontSize: 16, color: '#939fa7', marginBottom: 10 }, children: [event.occurredAt &&
44
+ }, children: ["\uC218\uC2E0 ", clock(received, timeline.scenario.timezone)] })] }), (event.occurredAt || event.publishedAt) && (_jsxs("div", { style: { fontSize: 26, color: '#b0bac2', marginBottom: 8 }, children: [event.occurredAt &&
48
45
  `발생 ${clock(Date.parse(event.occurredAt), timeline.scenario.timezone)} `, event.publishedAt &&
49
46
  `발표 ${clock(Date.parse(event.publishedAt), timeline.scenario.timezone)}`] })), _jsx("div", { style: {
50
- fontSize: 22 + 6 * emphasis,
51
- lineHeight: 1.65,
47
+ fontSize: 32 + 8 * emphasis,
48
+ lineHeight: 1.4,
52
49
  whiteSpace: 'pre-wrap',
53
- wordBreak: 'keep-all',
50
+ wordBreak: 'normal',
54
51
  overflowWrap: 'anywhere',
55
- color: latest ? '#e0e4e7' : '#aab5bd',
56
- }, children: _jsx(PagedText, { chunks: chunks, frame: frame, start: event.start, seconds: event.pageSeconds }) }), chunks.length > 1 && (_jsxs("div", { style: {
57
- fontFamily: numberFont,
58
- fontSize: 16,
59
- color: '#839099',
60
- marginTop: 12,
61
- textAlign: 'right',
62
- }, children: [page + 1, " / ", chunks.length] })), event.corrects && (_jsxs("div", { style: {
63
- fontSize: 16,
64
- color: '#a4afb6',
65
- marginTop: 12,
66
- borderTop: '1px solid #343a3f',
67
- paddingTop: 10,
68
- }, children: ["\uC774\uC804 \uBB38\uC790 \uC815\uC815 \u00B7", ' ', clock(stateAt(timeline, timeline.events.find((e) => e.id === event.corrects).start)
69
- .epoch, timeline.scenario.timezone)] }))] }) }) }));
52
+ color: latest ? '#edf0f2' : '#c0c9d0',
53
+ }, children: alertText(timeline.scenario.agencies[event.sender], event.text) })] }) }) }));
70
54
  }
71
55
  export function AlertHistory({ timeline, frame }) {
72
56
  const alerts = stateAt(timeline, frame).alerts;