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
package/README.md CHANGED
@@ -6,11 +6,11 @@ YAML/JSON 시나리오를 한국형 재난방송 호러 영상으로 만드는 C
6
6
 
7
7
  ## 할 수 있는 일
8
8
 
9
- - **방송 화면:** 최신순 재난문자·정정 이력, 독립 뉴스, 상태 박스, 장문 방송 안내.
9
+ - **방송 화면:** 최신순 재난문자·정정 이력, 독립 뉴스, 상태 박스, 장문 방송 안내. 뉴스·문자·상태 변경을 알리는 기본 전환 효과.
10
10
  - **지도 연출:** 실제 시도·시군구 경계, 채우기·윤곽선 중첩, 범례·지점·영역·진행선, 카메라 이동·보조 지도.
11
11
  - **시간 연출:** 컷 안에서는 실시간으로, 컷 사이에서는 지정한 이야기 시각으로 전환. 임의 프레임에서도 같은 상태를 복원.
12
- - **음향·효과:** 공통 수신음, 로컬 음원, Tone.js 코드의 WAV 합성, 페이드·침묵·신호 간섭·송출 종료.
13
- - **텍스트 제작 흐름:** 검증, 렌더 없는 상태 조회, PNG 미리보기, 구간·전체 영상 출력, 게시용 출처 생성.
12
+ - **음향·효과:** 상태·방송 안내·등급별 문자 기본음, 로컬 음원, Tone.js 코드의 WAV 합성, 페이드·침묵·신호 간섭·송출 종료.
13
+ - **텍스트 제작 흐름:** 검증, 웹 실시간 미리보기, 렌더 없는 상태 조회, PNG 확인, 구간·전체 영상 출력, 게시용 출처 생성.
14
14
 
15
15
  실제 긴급 서비스에 연결하거나 문자를 발송하지 않습니다. 작품 제목과 필수 허구 고지는 영상 첫 5초에 표시합니다.
16
16
 
@@ -39,6 +39,9 @@ npx dbs init
39
39
  # 입력·참조·음원·시간 검증
40
40
  npx dbs validate scenario.yaml
41
41
 
42
+ # 영상을 출력하지 않고 브라우저에서 재생·탐색
43
+ npx dbs preview scenario.yaml
44
+
42
45
  # 8초 지점의 상태와 화면 확인
43
46
  npx dbs inspect scenario.yaml --frame 480
44
47
  npx dbs frame scenario.yaml --frame 480 --out preview.png
@@ -50,6 +53,8 @@ npx dbs render scenario.yaml --out film.mp4
50
53
 
51
54
  프레임은 **0부터 시작**하며 구간의 **끝 프레임도 포함**합니다. 300~599는 60fps에서 5초입니다. 컷 안의 시간은 실제 재생 시간과 같고, 컷 경계에서만 시간을 생략할 수 있습니다. 오프닝 이후에 중요한 사건을 배치하고, 읽기 시간 경고가 나오면 문자 사이 간격을 늘리세요.
52
55
 
56
+ `preview`가 출력한 로컬 주소(기본 `http://127.0.0.1:3000`)를 브라우저에서 여세요. 화면·음향 재생, 탐색 막대, 프레임 단위 이동과 컷 선택을 제공합니다. 시나리오·사용자 지도·참조 음원을 저장하면 현재 위치에서 일시정지한 뒤 자동 반영합니다. 잘못된 입력은 마지막 정상 화면을 유지하면서 오류를 표시합니다. 포트는 `--port 3100`으로 바꾸고, 종료는 터미널에서 Ctrl+C를 누릅니다. MP4나 PNG를 생성하거나 Headless Chromium을 설치하지 않으며, 초기 웹 번들 준비 후 로컬 자산으로 재생합니다. 지도와 글꼴을 준비하는 동안은 재생을 기다리고, 실제 재생 성능은 브라우저·GPU에 따라 달라집니다.
57
+
53
58
  첫 렌더에서는 Remotion이 Chrome Headless Shell을 다운로드할 수 있습니다. `--browser /path/to/chromium`으로 설치된 브라우저를 지정할 수도 있습니다. 준비 후에는 로컬 지도·글꼴·음원으로 렌더합니다.
54
59
 
55
60
  Linux에서 소프트웨어 WebGL을 사용하려면 `frame`·`render` 명령에 **`--gl swangle`**을 추가합니다. 기본값은 `--gl angle`, 동시 렌더 수는 `--concurrency 2`입니다. 60fps는 결과 영상 규격이며 렌더 속도는 환경에 따라 달라집니다.
@@ -76,7 +81,7 @@ npx dbs regions --search 철원 --json
76
81
  npx dbs credits scenario.yaml --out description-credits.txt
77
82
  ```
78
83
 
79
- 문자 수신음은 기본 제공하며 문자별로 끄거나 교체할 수 있습니다. 작품 고유 음향은 사용권이 있는 로컬 파일을 연결하거나, [Tone.js 음향 가이드](skills/scenario-writing/references/audio.md)에 따라 ES 모듈을 작성해 WAV로 합성합니다.
84
+ 상태 변경·방송 안내·등급별 재난문자 효과음을 기본 제공합니다. 뉴스는 기본 무음으로 화면 전환만 사용합니다. 안전안내는 휴대폰 진동형 수신음, 긴급·위급재난은 더 크고 긴 경보음으로 구분하며 이벤트별로 끄거나 교체할 수 있습니다. 작품 고유 음향은 사용권이 있는 로컬 파일을 연결하거나, [Tone.js 음향 가이드](skills/scenario-writing/references/audio.md)에 따라 ES 모듈을 작성해 WAV로 합성합니다.
80
85
 
81
86
  ```sh
82
87
  npx dbs audio audio-code/sound.mjs --duration 3 --seed 731 --out audio/sound.wav
@@ -107,4 +112,4 @@ npm pack
107
112
 
108
113
  ## 이용조건
109
114
 
110
- 코드는 [MIT](LICENSE)입니다. 지도·글꼴·공통 수신음은 [자산 출처와 이용조건](assets/README.md)을 확인하세요. Remotion에는 [별도 라이선스 조건](https://www.remotion.dev/pricing)이 적용됩니다.
115
+ 코드는 [MIT](LICENSE)입니다. 지도·글꼴·기본 효과음은 [자산 출처와 이용조건](assets/README.md)을 확인하세요. Remotion에는 [별도 라이선스 조건](https://www.remotion.dev/pricing)이 적용됩니다.
package/assets/README.md CHANGED
@@ -19,7 +19,7 @@ node scripts/build-map-assets.mjs /path/HangJeongDong_ver20260701.geojson /path/
19
19
 
20
20
  공공누리 1유형은 저작자·출처를 요구하지만 영상 내 고정 위치를 지정하지 않는다. [CC BY 4.0 제3조(a)(2)](https://creativecommons.org/licenses/by/4.0/legalcode)는 매체·상황에 합리적인 방식의 표시와 링크를 허용한다. [CC BY 3.0 IGO 제4조(b)](https://creativecommons.org/licenses/by/3.0/igo/legalcode)도 매체에 합리적인 저작자·출처·가공 표시를 요구하며, 공동 기여자 크레딧을 별도로 넣으면 그 안에서 동등한 비중으로 표시해야 한다. 이 배포물의 기본 지도에는 영상 위 고정 워터마크 의무가 확인되지 않아 공개 설명란 크레딧 방식을 사용한다. 기계 메타데이터만 남기고 게시 출처를 생략하지 않는다. Natural Earth는 public domain이며 OFL은 폰트 파일 재배포 조건이다. 사용자 경계 파일은 WGS84 Polygon/MultiPolygon FeatureCollection이며 `map.attribution`에 필요한 출처표시를 지정한다. 지도는 한반도와 주변 동아시아를 대상으로 한다.
21
21
 
22
- 공통 문자 수신음은 직접 합성한 CC0 음원이며 [생성 코드·이용조건](audio/README.md)을 동봉한다. 실제 기관의 경보음을 모방하지 않는다. 작품 고유의 로컬 음원에는 각각 `source`, `license`를 기록한다. 기술 검증 스크립트의 사인파도 직접 생성한 CC0 음원이다.
22
+ 상태·방송 안내·등급별 재난문자 기본음과 선택용 뉴스 음원은 직접 합성한 CC0 음원이며 [생성 코드·이용조건](audio/README.md)을 동봉한다. 긴급·위급재난은 EAS풍 경보의 불협화음과 반복 패턴을 사용한다. 작품 고유의 로컬 음원에는 각각 `source`, `license`를 기록한다. 기술 검증 스크립트의 사인파도 직접 생성한 CC0 음원이다.
23
23
 
24
24
  Remotion은 [사용 주체별 라이선스 조건](https://www.remotion.dev/pricing)이 적용된다. MapLibre GL JS는 BSD-3-Clause, React는 MIT다.
25
25
 
@@ -1,9 +1,21 @@
1
- # 공통 수신음
1
+ # 기본 효과음
2
2
 
3
- `notification.wav`는 이 프로젝트가 Tone.js로 직접 합성한 창작 알림음이다. 실제 재난 경보음을 재현하지 않는다. PCM16·48kHz·모노·0.55초. CC0-1.0.
3
+ 프로젝트에서 직접 합성한 CC0-1.0 음향이다. PCM16·48kHz·모노 WAV를 패키지에 포함하며 영상 출력 때는 파일을 재생한다. 긴급·위급재난은 EAS풍의 두 주파수 불협화음과 반복 패턴을 사용한다.
4
4
 
5
- 소스는 `notification.mjs`다. 패키지 루트에서 재생성한다.
5
+ | 파일 | 용도 | 길이 | 엔진 볼륨 |
6
+ | --- | --- | --- | --- |
7
+ | `notification.wav` | 안전안내, 휴대폰 진동형 짧은 떨림 2회 | 0.55초 | 0.65 |
8
+ | `alert-urgent.wav` | 긴급재난, 경보 3회 | 2.4초 | 0.85 |
9
+ | `alert-critical.wav` | 위급재난, 빠른 경보 3회와 지속음 | 4.2초 | 0.95 |
10
+ | `status.wav` | 상태 등장·변경·해제, 같은 높이의 짧은 전자음 2회 | 0.3초 | 0.75 |
11
+ | `news.wav` | 뉴스에서 명시적으로 켤 때만 쓰는 짧은 단음 1회 | 0.28초 | 0.65 |
12
+ | `bulletin.wav` | 방송 안내 시작, 크고 묵직한 알림음 2회 | 1초 | 0.9 |
13
+
14
+ 같은 이름의 `.mjs`가 합성 소스이고 `tones.mjs`는 공통 파형·엔벌로프다. `.wav.json`에는 합성 조건·버전·음량 통계를 기록한다. 파일·길이·재생 볼륨의 기준은 `src/notifications.ts`다. 패키지 루트에서 전체를 재생성한다.
6
15
 
7
16
  ```sh
8
- node dist/cli.js audio assets/audio/notification.mjs --duration 0.55 --channels 1 --seed 731 --out assets/audio/notification.wav --overwrite
17
+ npm run build
18
+ node scripts/build-audio-assets.mjs
9
19
  ```
20
+
21
+ 뉴스는 기본 무음이다. `news.sound: default`를 명시하면 선택용 뉴스 음원을 재생한다. `alert`, `status`, `news`, `bulletin`의 `sound: false`로 끄거나 `{asset, duration, volume}`으로 교체한다. 같은 상태의 연속 표시는 재알림하지 않고, 교체 시 새 상태의 설정을 따른다. `silence`는 기본 효과음도 음소거한다.
@@ -0,0 +1,4 @@
1
+ import { alarm } from './tones.mjs';
2
+ export default function ({ Tone }) {
3
+ alarm(Tone, [[0, 0.42], [0.54, 0.42], [1.08, 0.42], [1.7, 2.35]], 0.38);
4
+ }
Binary file
@@ -0,0 +1,15 @@
1
+ {
2
+ "duration": 4.2,
3
+ "sampleRate": 48000,
4
+ "channels": 1,
5
+ "seed": 731,
6
+ "peak": 0.7694605588912964,
7
+ "clippedSamples": 0,
8
+ "rms": 0.34600100160898734,
9
+ "frames": 201600,
10
+ "browser": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/158.0.8127.2 Safari/537.36",
11
+ "program": "alert-critical.mjs",
12
+ "tone": "15.1.22",
13
+ "format": "PCM16LE",
14
+ "output": "alert-critical.wav"
15
+ }
@@ -0,0 +1,4 @@
1
+ import { alarm } from './tones.mjs';
2
+ export default function ({ Tone }) {
3
+ alarm(Tone, [[0, 0.65], [0.83, 0.65], [1.66, 0.65]], 0.30);
4
+ }
Binary file
@@ -0,0 +1,15 @@
1
+ {
2
+ "duration": 2.4,
3
+ "sampleRate": 48000,
4
+ "channels": 1,
5
+ "seed": 731,
6
+ "peak": 0.6074642539024353,
7
+ "clippedSamples": 0,
8
+ "rms": 0.2632769684819564,
9
+ "frames": 115200,
10
+ "browser": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/158.0.8127.2 Safari/537.36",
11
+ "program": "alert-urgent.mjs",
12
+ "tone": "15.1.22",
13
+ "format": "PCM16LE",
14
+ "output": "alert-urgent.wav"
15
+ }
@@ -0,0 +1,9 @@
1
+ import { tone } from './tones.mjs';
2
+ export default function ({ Tone }) {
3
+ // Two firm attention calls, with a longer second call to hold attention.
4
+ for (const [start, length] of [[0, 0.3], [0.44, 0.46]]) {
5
+ tone(Tone, 540, start, length, 0.4);
6
+ tone(Tone, 270, start, length, 0.12);
7
+ tone(Tone, 1080, start, length, 0.06);
8
+ }
9
+ }
Binary file
@@ -0,0 +1,15 @@
1
+ {
2
+ "duration": 1,
3
+ "sampleRate": 48000,
4
+ "channels": 1,
5
+ "seed": 731,
6
+ "peak": 0.4915826916694641,
7
+ "clippedSamples": 0,
8
+ "rms": 0.2467313742837686,
9
+ "frames": 48000,
10
+ "browser": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/158.0.8127.2 Safari/537.36",
11
+ "program": "bulletin.mjs",
12
+ "tone": "15.1.22",
13
+ "format": "PCM16LE",
14
+ "output": "bulletin.wav"
15
+ }
@@ -0,0 +1,4 @@
1
+ import { tone } from './tones.mjs';
2
+ export default function ({ Tone }) {
3
+ tone(Tone, 520, 0, 0.22, 0.22);
4
+ }
Binary file
@@ -0,0 +1,15 @@
1
+ {
2
+ "duration": 0.28,
3
+ "sampleRate": 48000,
4
+ "channels": 1,
5
+ "seed": 731,
6
+ "peak": 0.2199999988079071,
7
+ "clippedSamples": 0,
8
+ "rms": 0.12542024316787065,
9
+ "frames": 13440,
10
+ "browser": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/158.0.8127.2 Safari/537.36",
11
+ "program": "news.mjs",
12
+ "tone": "15.1.22",
13
+ "format": "PCM16LE",
14
+ "output": "news.wav"
15
+ }
@@ -1,10 +1,25 @@
1
- // Original notification sound, CC0-1.0. Not a reproduction of an official alert.
2
- export default function ({Tone, duration}) {
3
- const gain = new Tone.Gain(0).toDestination();
4
- gain.gain.setValueAtTime(0, 0);
5
- gain.gain.linearRampToValueAtTime(0.42, 0.025);
6
- gain.gain.setValueAtTime(0.42, 0.34);
7
- gain.gain.linearRampToValueAtTime(0, 0.42);
8
- const osc = new Tone.Oscillator(680, 'sine').connect(gain).start(0).stop(duration);
9
- osc.frequency.setValueAtTime(510, 0.16);
1
+ // Original phone-like vibration, CC0-1.0: motor hum and a light contact rattle.
2
+ export default function ({ Tone, context, duration, sampleRate, random }) {
3
+ const buffer = context.createBuffer(1, Math.round(duration * sampleRate), sampleRate);
4
+ const data = buffer.getChannelData(0);
5
+ for (const start of [0, 0.28]) {
6
+ let phase = 0, noise = 0;
7
+ const length = 0.22;
8
+ for (let i = 0; i < Math.round(length * sampleRate); i++) {
9
+ const at = Math.round(start * sampleRate) + i;
10
+ if (at >= data.length) break;
11
+ const t = i / sampleRate;
12
+ const attack = Math.min(1, t / 0.022);
13
+ const release = Math.min(1, (length - t) / 0.045);
14
+ const envelope = Math.sin(attack * Math.PI / 2) ** 2 * release ** 2;
15
+ const frequency = 125 + 25 * attack + 3 * Math.sin(2 * Math.PI * 32 * t);
16
+ phase += 2 * Math.PI * frequency / sampleRate;
17
+ noise = noise * 0.75 + (random() * 2 - 1) * 0.25;
18
+ const motor = 0.24 * Math.sin(phase) + 0.1 * Math.sin(2 * phase) +
19
+ 0.045 * Math.sin(3 * phase) + 0.025 * Math.sin(6 * phase);
20
+ const flutter = 0.85 + 0.15 * Math.sin(2 * Math.PI * 36 * t);
21
+ data[at] = envelope * flutter * (motor + noise * 0.07);
22
+ }
23
+ }
24
+ new Tone.Player(buffer).toDestination().start(0);
10
25
  }
Binary file
@@ -0,0 +1,15 @@
1
+ {
2
+ "duration": 0.55,
3
+ "sampleRate": 48000,
4
+ "channels": 1,
5
+ "seed": 731,
6
+ "peak": 0.33578595519065857,
7
+ "clippedSamples": 0,
8
+ "rms": 0.1262915088128416,
9
+ "frames": 26400,
10
+ "browser": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/158.0.8127.2 Safari/537.36",
11
+ "program": "notification.mjs",
12
+ "tone": "15.1.22",
13
+ "format": "PCM16LE",
14
+ "output": "notification.wav"
15
+ }
@@ -0,0 +1,5 @@
1
+ import { tone } from './tones.mjs';
2
+ export default function ({ Tone }) {
3
+ tone(Tone, 720, 0, 0.1, 0.24);
4
+ tone(Tone, 720, 0.14, 0.1, 0.24);
5
+ }
Binary file
@@ -0,0 +1,15 @@
1
+ {
2
+ "duration": 0.3,
3
+ "sampleRate": 48000,
4
+ "channels": 1,
5
+ "seed": 731,
6
+ "peak": 0.23999999463558197,
7
+ "clippedSamples": 0,
8
+ "rms": 0.10910543337277971,
9
+ "frames": 14400,
10
+ "browser": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/158.0.8127.2 Safari/537.36",
11
+ "program": "status.mjs",
12
+ "tone": "15.1.22",
13
+ "format": "PCM16LE",
14
+ "output": "status.wav"
15
+ }
@@ -0,0 +1,17 @@
1
+ // Original synthesized effects, CC0-1.0. Shared envelopes avoid clicks.
2
+ export function tone(Tone, frequency, start, length, level, type = 'sine') {
3
+ const gain = new Tone.Gain(0).toDestination();
4
+ gain.gain.setValueAtTime(0, start);
5
+ gain.gain.linearRampToValueAtTime(level, start + 0.012);
6
+ gain.gain.setValueAtTime(level, start + length - 0.045);
7
+ gain.gain.linearRampToValueAtTime(0, start + length);
8
+ new Tone.Oscillator(frequency, type).connect(gain).start(start).stop(start + length);
9
+ }
10
+
11
+ export function alarm(Tone, pulses, level) {
12
+ for (const [start, length] of pulses) {
13
+ tone(Tone, 850, start, length, level);
14
+ tone(Tone, 960, start, length, level);
15
+ tone(Tone, 1700, start, length, level * 0.12);
16
+ }
17
+ }
@@ -4,11 +4,14 @@
4
4
 
5
5
  ```sh
6
6
  npx dbs validate scenario.yaml
7
+ npx dbs preview scenario.yaml
7
8
  npx dbs regions --search 철원
8
9
  npx dbs frame scenario.yaml --frame 600 --out preview.png
9
10
  npx dbs render scenario.yaml --out film.mp4
10
11
  ```
11
12
 
13
+ `preview`의 로컬 주소를 브라우저에서 열면 영상 출력 없이 화면·음향을 재생하고 프레임·컷을 탐색할 수 있다. 시나리오·지도·음원 저장 시 현재 위치에서 일시정지해 반영하며, 오류가 있으면 마지막 정상 화면을 유지한다. 종료는 터미널에서 Ctrl+C다.
14
+
12
15
  문자 도착 시 엔진의 공통 수신음이 재생된다. `alert.sound: false`로 끄거나 사용자 음원으로 바꾼다. `audio/`는 작품 고유 음원, `audio-code/`는 필요한 합성 코드를 두는 폴더다. [음향 가이드](https://github.com/neuwcodebox/disaster-broadcast-studio/blob/main/skills/scenario-writing/references/audio.md)를 참고한다.
13
16
 
14
17
  지역·기관·시각·문안을 작품에 맞게 바꾼다. 컷 내부는 실시간이며 시간 도약은 새 컷으로 지정한다. 결과와 함께 생성한 `.credits.txt`를 영상 게시 설명에 포함한다. 초기화는 npm 설치나 영상 게시를 수행하지 않는다.
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.1.1');
14
+ .version('0.2.0');
15
15
  program
16
16
  .command('init [directory]')
17
17
  .description('Create a scenario workspace with YAML and audio folders; never overwrite files')
@@ -81,6 +81,24 @@ program
81
81
  warnings,
82
82
  }, null, 2));
83
83
  });
84
+ program
85
+ .command('preview <scenario>')
86
+ .description('Preview in a local browser with playback, seeking and automatic file updates')
87
+ .option('--port <number>', 'Local HTTP port; 0 selects an available port', '3000')
88
+ .action(async (file, opts) => {
89
+ const { startPreview } = await import('./preview/server.js');
90
+ console.error('Preparing browser preview…');
91
+ const preview = await startPreview(file, { port: Number(opts.port) });
92
+ console.log(`Preview: ${preview.url}\nOpen this URL in your browser. Press Ctrl+C to stop.`);
93
+ const stop = () => {
94
+ void preview.close().then(() => {
95
+ process.off('SIGINT', stop);
96
+ process.off('SIGTERM', stop);
97
+ }).catch(error => { console.error(error); process.exitCode = 1; });
98
+ };
99
+ process.on('SIGINT', stop);
100
+ process.on('SIGTERM', stop);
101
+ });
84
102
  program
85
103
  .command('credits <scenario>')
86
104
  .description('Prepare source credits for the video description')
package/dist/credits.js CHANGED
@@ -13,8 +13,8 @@ export function makeCredits(scenario, bundled) {
13
13
  sections.unshift(`사용자 지정 지도 경계\n${customMap}`);
14
14
  for (const [id, asset] of Object.entries(scenario.assets))
15
15
  sections.push(`음원 ${id}\n출처: ${asset.source}\n이용조건: ${asset.license}`);
16
- if (scenario.events.some(e => e.type === 'alert' && e.sound === 'default'))
17
- sections.push('공통 문자 수신음\n출처: 재난방송실에서 직접 합성한 창작 알림음. 실제 재난 경보음 아님.\n이용조건: CC0 1.0');
16
+ if (scenario.events.some(e => 'sound' in e && e.sound === 'default'))
17
+ sections.push('기본 알림·경보 효과음\n출처: 재난방송실에서 직접 합성한 창작 음향.\n이용조건: CC0 1.0');
18
18
  const text = `자료 출처\n\n${sections.join('\n\n')}\n\n원자료는 각 제공자의 조건에 따라 있는 그대로 제공됩니다. 제공기관은 이 창작물의 내용을 보증하거나 후원하지 않습니다.\n`;
19
19
  return { entries, customMap, text };
20
20
  }
package/dist/load.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFile, stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { notificationAsset } from './notifications.js';
3
+ import { defaultSounds, notificationSounds } from './notifications.js';
4
4
  import { readBundledJson, packageRoot } from './assets.js';
5
5
  import { parseDocument } from 'yaml';
6
6
  import { compile, scenarioSchema, alertText, pages, FPS } from './scenario.js';
@@ -70,8 +70,10 @@ export async function loadScenario(file) {
70
70
  const assetPaths = {};
71
71
  for (const [key, asset] of Object.entries(scenario.assets))
72
72
  assetPaths[key] = await localFile(root, asset.path, `assets.${key}.path`);
73
- if (timeline.events.some((e) => e.type === 'alert' && e.sound === 'default'))
74
- assetPaths[notificationAsset] = path.join(packageRoot, 'assets/audio/notification.wav');
73
+ const usedSounds = new Set(notificationSounds(timeline).map((sound) => sound.asset));
74
+ for (const sound of Object.values(defaultSounds))
75
+ if (usedSounds.has(sound.asset))
76
+ assetPaths[sound.asset] = path.join(packageRoot, 'assets/audio', sound.file);
75
77
  const warnings = [];
76
78
  const alerts = timeline.events.filter((e) => e.type === 'alert');
77
79
  for (let i = 0; i < alerts.length; i++) {
@@ -1,6 +1,43 @@
1
- import type { Timeline } from './scenario.js';
2
- export declare const notificationAsset = "__dbs_notification";
3
- export declare function notificationSounds(t: Timeline): {
1
+ import { type Timeline } from './scenario.js';
2
+ export declare const defaultSounds: {
3
+ readonly 안전안내: {
4
+ readonly asset: "__dbs_notification";
5
+ readonly file: "notification.wav";
6
+ readonly duration: 0.55;
7
+ readonly volume: 0.65;
8
+ };
9
+ readonly 긴급재난: {
10
+ readonly asset: "__dbs_alert_urgent";
11
+ readonly file: "alert-urgent.wav";
12
+ readonly duration: 2.4;
13
+ readonly volume: 0.85;
14
+ };
15
+ readonly 위급재난: {
16
+ readonly asset: "__dbs_alert_critical";
17
+ readonly file: "alert-critical.wav";
18
+ readonly duration: 4.2;
19
+ readonly volume: 0.95;
20
+ };
21
+ readonly status: {
22
+ readonly asset: "__dbs_status";
23
+ readonly file: "status.wav";
24
+ readonly duration: 0.3;
25
+ readonly volume: 0.75;
26
+ };
27
+ readonly news: {
28
+ readonly asset: "__dbs_news";
29
+ readonly file: "news.wav";
30
+ readonly duration: 0.28;
31
+ readonly volume: 0.65;
32
+ };
33
+ readonly bulletin: {
34
+ readonly asset: "__dbs_bulletin";
35
+ readonly file: "bulletin.wav";
36
+ readonly duration: 1;
37
+ readonly volume: 0.9;
38
+ };
39
+ };
40
+ export declare function notificationSounds(timeline: Timeline): {
4
41
  id: string;
5
42
  start: number;
6
43
  frames: number;
@@ -1,8 +1,36 @@
1
1
  import { FPS } from './scenario.js';
2
- export const notificationAsset = '__dbs_notification';
3
- export function notificationSounds(t) {
4
- return t.events.flatMap(e => e.type !== 'alert' || e.sound === false ? [] : [{
5
- id: `sound-${e.id}`, start: e.start, frames: Math.min(t.durationInFrames - e.start, Math.round((e.sound === 'default' ? .55 : e.sound.duration) * FPS)),
6
- asset: e.sound === 'default' ? notificationAsset : e.sound.asset, volume: e.sound === 'default' ? .65 : e.sound.volume,
7
- }]);
2
+ export const defaultSounds = {
3
+ '안전안내': { asset: '__dbs_notification', file: 'notification.wav', duration: 0.55, volume: 0.65 },
4
+ '긴급재난': { asset: '__dbs_alert_urgent', file: 'alert-urgent.wav', duration: 2.4, volume: 0.85 },
5
+ '위급재난': { asset: '__dbs_alert_critical', file: 'alert-critical.wav', duration: 4.2, volume: 0.95 },
6
+ status: { asset: '__dbs_status', file: 'status.wav', duration: 0.3, volume: 0.75 },
7
+ news: { asset: '__dbs_news', file: 'news.wav', duration: 0.28, volume: 0.65 },
8
+ bulletin: { asset: '__dbs_bulletin', file: 'bulletin.wav', duration: 1, volume: 0.9 },
9
+ };
10
+ export function notificationSounds(timeline) {
11
+ const statuses = timeline.events.filter((event) => event.type === 'status');
12
+ return timeline.events.flatMap((event) => {
13
+ if (!('sound' in event) || event.sound === false)
14
+ return [];
15
+ const sound = event.sound === 'default'
16
+ ? defaultSounds[event.type === 'alert' ? event.level : event.type]
17
+ : event.sound;
18
+ const cue = (start, suffix = '') => ({
19
+ id: `sound-${event.id}${suffix}`,
20
+ start,
21
+ frames: Math.min(timeline.durationInFrames - start, Math.max(1, Math.round(sound.duration * FPS))),
22
+ asset: sound.asset,
23
+ volume: sound.volume,
24
+ });
25
+ if (event.type !== 'status')
26
+ return [cue(event.start)];
27
+ const previous = statuses.find((other) => other.end === event.start);
28
+ const sameItems = JSON.stringify(previous?.items ?? []) === JSON.stringify(event.items);
29
+ const sounds = sameItems ? [] : [cue(event.start)];
30
+ // An adjacent status owns the change; emit only one cue at a replacement/clear.
31
+ if (event.items.length && event.end < timeline.durationInFrames &&
32
+ !statuses.some((other) => other.start === event.end))
33
+ sounds.push(cue(event.end, '/end'));
34
+ return sounds;
35
+ }).sort((a, b) => a.start - b.start);
8
36
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,87 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { Player } from '@remotion/player';
5
+ import { Broadcast } from '../video/Broadcast.js';
6
+ import { FPS } from '../scenario.js';
7
+ import { notificationSounds } from '../notifications.js';
8
+ async function readJson(url, signal) {
9
+ const response = await fetch(url, { signal });
10
+ if (!response.ok)
11
+ throw Error(`미리보기를 불러오지 못했습니다 (${response.status}).`);
12
+ return response.json();
13
+ }
14
+ function Preview() {
15
+ const player = useRef(null);
16
+ const revision = useRef(0);
17
+ const [snapshot, setSnapshot] = useState();
18
+ const [error, setError] = useState(null);
19
+ const [connected, setConnected] = useState(true);
20
+ const [initialFrame, setInitialFrame] = useState(0);
21
+ const [frame, setFrame] = useState(0);
22
+ const [target, setTarget] = useState('0');
23
+ const audioTags = useMemo(() => {
24
+ if (!snapshot)
25
+ return 5;
26
+ const timeline = snapshot.props.timeline;
27
+ const spans = [
28
+ ...notificationSounds(timeline).map(sound => ({ start: sound.start, end: sound.start + sound.frames })),
29
+ ...timeline.events.filter(event => event.type === 'audio'),
30
+ ];
31
+ const changes = spans.flatMap(span => [[span.start, 1], [span.end, -1]])
32
+ .sort((a, b) => a[0] - b[0] || a[1] - b[1]);
33
+ let active = 0, maximum = 5;
34
+ for (const [, delta] of changes)
35
+ maximum = Math.max(maximum, active += delta);
36
+ return maximum;
37
+ }, [snapshot]);
38
+ useEffect(() => {
39
+ const controller = new AbortController();
40
+ let timer;
41
+ const poll = async () => {
42
+ try {
43
+ const status = await readJson('/api/status', controller.signal);
44
+ setError(status.error);
45
+ if (status.revision !== revision.current) {
46
+ const next = await readJson('/api/scenario', controller.signal);
47
+ const position = Math.min(player.current?.getCurrentFrame() ?? 0, next.props.timeline.durationInFrames - 1);
48
+ player.current?.pause();
49
+ setInitialFrame(position);
50
+ setFrame(position);
51
+ setTarget(String(position));
52
+ revision.current = next.revision;
53
+ setSnapshot(next);
54
+ }
55
+ setConnected(true);
56
+ }
57
+ catch (error) {
58
+ if (controller.signal.aborted)
59
+ return;
60
+ setConnected(false);
61
+ }
62
+ if (!controller.signal.aborted)
63
+ timer = setTimeout(poll, 750);
64
+ };
65
+ void poll();
66
+ return () => { controller.abort(); clearTimeout(timer); };
67
+ }, []);
68
+ useEffect(() => {
69
+ const current = player.current;
70
+ if (!current)
71
+ return;
72
+ const update = ({ detail }) => setFrame(detail.frame);
73
+ current.addEventListener('frameupdate', update);
74
+ return () => current.removeEventListener('frameupdate', update);
75
+ }, [snapshot?.revision]);
76
+ const seek = (value) => {
77
+ if (!snapshot || !Number.isFinite(value))
78
+ return;
79
+ const position = Math.max(0, Math.min(Math.round(value), snapshot.props.timeline.durationInFrames - 1));
80
+ player.current?.pause();
81
+ player.current?.seekTo(position);
82
+ setFrame(position);
83
+ setTarget(String(position));
84
+ };
85
+ return _jsxs("main", { className: "preview", children: [_jsxs("header", { children: [_jsxs("h1", { children: [snapshot?.props.timeline.scenario.title ?? '재난방송실', " \u00B7 \uBBF8\uB9AC\uBCF4\uAE30"] }), _jsx("span", { role: "status", className: "status", children: !connected ? '서버 연결 끊김 · 재연결 대기 중' : error ? '수정 대기 중' : snapshot ? '저장하면 자동 반영' : '불러오는 중…' })] }), error && _jsx("p", { role: "alert", className: "error", children: `입력을 확인해 주세요. 마지막 정상 화면을 유지합니다.\n${error}` }), snapshot && _jsxs(_Fragment, { children: [_jsx("div", { className: "player", children: _jsx(Player, { ref: player, component: Broadcast, inputProps: snapshot.props, durationInFrames: snapshot.props.timeline.durationInFrames, compositionWidth: 1920, compositionHeight: 1080, fps: FPS, initialFrame: initialFrame, controls: true, clickToPlay: true, doubleClickToFullscreen: true, numberOfSharedAudioTags: audioTags, moveToBeginningWhenEnded: false, style: { width: '100%' }, errorFallback: ({ error }) => _jsx("div", { role: "alert", className: "error", children: `화면을 표시하지 못했습니다.\n${error.message}` }) }, snapshot.revision) }), _jsxs("div", { className: "tools", children: [_jsx("button", { onClick: () => seek(frame - 1), "aria-label": "\uC774\uC804 \uD504\uB808\uC784", children: "\u2190 1\uD504\uB808\uC784" }), _jsx("button", { onClick: () => seek(frame + 1), "aria-label": "\uB2E4\uC74C \uD504\uB808\uC784", children: "1\uD504\uB808\uC784 \u2192" }), _jsx("form", { onSubmit: event => { event.preventDefault(); seek(Number(target)); }, children: _jsxs("label", { children: ["\uD504\uB808\uC784 ", _jsx("input", { "aria-label": "\uC774\uB3D9\uD560 \uD504\uB808\uC784", type: "number", min: "0", max: snapshot.props.timeline.durationInFrames - 1, value: target, onChange: event => setTarget(event.target.value) }), _jsx("button", { type: "submit", children: "\uC774\uB3D9" })] }) }), _jsxs("label", { children: ["\uCEF7 ", _jsxs("select", { "aria-label": "\uCEF7\uC73C\uB85C \uC774\uB3D9", value: "", onChange: event => seek(Number(event.target.value)), children: [_jsx("option", { value: "", disabled: true, children: "\uCEF7 \uC120\uD0DD" }), snapshot.props.timeline.cuts.map(cut => _jsx("option", { value: cut.start, children: cut.id }, cut.id))] })] }), _jsxs("output", { "aria-label": "\uD604\uC7AC \uD504\uB808\uC784", children: [frame, " / ", snapshot.props.timeline.durationInFrames - 1, " \u00B7 ", (frame / FPS).toFixed(2), "\uCD08"] })] }), snapshot.warnings.length > 0 && _jsx("p", { className: "warnings", children: snapshot.warnings.join('\n') }), _jsx("p", { children: "\uC7AC\uC0DD \uBC84\uD2BC\uC73C\uB85C \uD654\uBA74\uACFC \uC74C\uD5A5\uC744 \uD655\uC778\uD558\uC138\uC694. \uC2DC\uB098\uB9AC\uC624\u00B7\uC9C0\uB3C4\u00B7\uC74C\uC6D0\uC744 \uC800\uC7A5\uD558\uBA74 \uD604\uC7AC \uC704\uCE58\uC5D0\uC11C \uC77C\uC2DC\uC815\uC9C0\uD55C \uB4A4 \uBC18\uC601\uD569\uB2C8\uB2E4. \uC885\uB8CC: \uD130\uBBF8\uB110\uC5D0\uC11C Ctrl+C" })] })] });
86
+ }
87
+ createRoot(document.getElementById('root')).render(_jsx(Preview, {}));
@@ -0,0 +1,3 @@
1
+ import { type IncomingMessage, type ServerResponse } from 'node:http';
2
+ import type { PreviewProject } from './project.js';
3
+ export declare function createPreviewServer(project: PreviewProject, publicDir: string, bundleDir: string): import("http").Server<typeof IncomingMessage, typeof ServerResponse>;