@tienne/gestalt 0.101.1 → 0.103.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.ko.md +2 -1
- package/README.md +2 -1
- package/dist/package.json +1 -1
- package/dist/plugin/role-agents/video-summarizer/AGENT.md +177 -113
- package/dist/plugin/skills/_shared/agent-delegation.md +1 -1
- package/dist/plugin/skills/_shared/proactive-routing.md +3 -2
- package/dist/plugin/skills/_shared/research-humanize.md +160 -0
- package/dist/plugin/skills/research/SKILL.md +20 -2
- package/dist/plugin/skills/research/references/design.md +14 -2
- package/dist/plugin/skills/reverse-plan/SKILL.md +3 -1
- package/dist/plugin/skills/ux-research/SKILL.md +3 -1
- package/dist/plugin/skills/video-digest/SKILL.md +408 -0
- package/dist/plugin/skills/video-digest/templates/background.md +15 -0
- package/dist/plugin/skills/video-digest/templates/blog.md +16 -0
- package/dist/plugin/skills/video-digest/templates/comments.md +19 -0
- package/dist/plugin/skills/video-digest/templates/counterpoints.md +15 -0
- package/dist/plugin/skills/video-digest/templates/flashcards.md +15 -0
- package/dist/plugin/skills/video-digest/templates/interview.md +15 -0
- package/dist/plugin/skills/video-digest/templates/meeting.md +15 -0
- package/dist/plugin/skills/video-digest/templates/mindmap.md +16 -0
- package/dist/plugin/skills/video-digest/templates/quiz.md +16 -0
- package/dist/plugin/skills/video-digest/templates/quotes.md +16 -0
- package/dist/plugin/skills/video-digest/templates/resources.md +14 -0
- package/dist/plugin/skills/video-digest/templates/timestamps.md +14 -0
- package/dist/src/cli/commands/digest.d.ts +57 -0
- package/dist/src/cli/commands/digest.d.ts.map +1 -0
- package/dist/src/cli/commands/digest.js +368 -0
- package/dist/src/cli/commands/digest.js.map +1 -0
- package/dist/src/cli/index.d.ts.map +1 -1
- package/dist/src/cli/index.js +77 -0
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/digest/blocks.d.ts +1853 -0
- package/dist/src/digest/blocks.d.ts.map +1 -0
- package/dist/src/digest/blocks.js +148 -0
- package/dist/src/digest/blocks.js.map +1 -0
- package/dist/src/digest/captions.d.ts +37 -0
- package/dist/src/digest/captions.d.ts.map +1 -0
- package/dist/src/digest/captions.js +138 -0
- package/dist/src/digest/captions.js.map +1 -0
- package/dist/src/digest/comments.d.ts +113 -0
- package/dist/src/digest/comments.d.ts.map +1 -0
- package/dist/src/digest/comments.js +153 -0
- package/dist/src/digest/comments.js.map +1 -0
- package/dist/src/digest/format.d.ts +9 -0
- package/dist/src/digest/format.d.ts.map +1 -0
- package/dist/src/digest/format.js +26 -0
- package/dist/src/digest/format.js.map +1 -0
- package/dist/src/digest/frames.d.ts +26 -0
- package/dist/src/digest/frames.d.ts.map +1 -0
- package/dist/src/digest/frames.js +109 -0
- package/dist/src/digest/frames.js.map +1 -0
- package/dist/src/digest/index.d.ts +25 -0
- package/dist/src/digest/index.d.ts.map +1 -0
- package/dist/src/digest/index.js +25 -0
- package/dist/src/digest/index.js.map +1 -0
- package/dist/src/digest/ingest.d.ts +65 -0
- package/dist/src/digest/ingest.d.ts.map +1 -0
- package/dist/src/digest/ingest.js +303 -0
- package/dist/src/digest/ingest.js.map +1 -0
- package/dist/src/digest/lint-extension.d.ts +57 -0
- package/dist/src/digest/lint-extension.d.ts.map +1 -0
- package/dist/src/digest/lint-extension.js +395 -0
- package/dist/src/digest/lint-extension.js.map +1 -0
- package/dist/src/digest/lint.d.ts +40 -0
- package/dist/src/digest/lint.d.ts.map +1 -0
- package/dist/src/digest/lint.js +517 -0
- package/dist/src/digest/lint.js.map +1 -0
- package/dist/src/digest/merge.d.ts +40 -0
- package/dist/src/digest/merge.d.ts.map +1 -0
- package/dist/src/digest/merge.js +104 -0
- package/dist/src/digest/merge.js.map +1 -0
- package/dist/src/digest/ocr.d.ts +96 -0
- package/dist/src/digest/ocr.d.ts.map +1 -0
- package/dist/src/digest/ocr.js +168 -0
- package/dist/src/digest/ocr.js.map +1 -0
- package/dist/src/digest/prose.d.ts +43 -0
- package/dist/src/digest/prose.d.ts.map +1 -0
- package/dist/src/digest/prose.js +112 -0
- package/dist/src/digest/prose.js.map +1 -0
- package/dist/src/digest/render-blocks.d.ts +32 -0
- package/dist/src/digest/render-blocks.d.ts.map +1 -0
- package/dist/src/digest/render-blocks.js +292 -0
- package/dist/src/digest/render-blocks.js.map +1 -0
- package/dist/src/digest/render-html.d.ts +22 -0
- package/dist/src/digest/render-html.d.ts.map +1 -0
- package/dist/src/digest/render-html.js +466 -0
- package/dist/src/digest/render-html.js.map +1 -0
- package/dist/src/digest/render-md.d.ts +18 -0
- package/dist/src/digest/render-md.d.ts.map +1 -0
- package/dist/src/digest/render-md.js +113 -0
- package/dist/src/digest/render-md.js.map +1 -0
- package/dist/src/digest/render.d.ts +47 -0
- package/dist/src/digest/render.d.ts.map +1 -0
- package/dist/src/digest/render.js +123 -0
- package/dist/src/digest/render.js.map +1 -0
- package/dist/src/digest/reports.d.ts +26 -0
- package/dist/src/digest/reports.d.ts.map +1 -0
- package/dist/src/digest/reports.js +70 -0
- package/dist/src/digest/reports.js.map +1 -0
- package/dist/src/digest/schema-base.d.ts +31 -0
- package/dist/src/digest/schema-base.d.ts.map +1 -0
- package/dist/src/digest/schema-base.js +19 -0
- package/dist/src/digest/schema-base.js.map +1 -0
- package/dist/src/digest/schema.d.ts +5713 -0
- package/dist/src/digest/schema.d.ts.map +1 -0
- package/dist/src/digest/schema.js +229 -0
- package/dist/src/digest/schema.js.map +1 -0
- package/dist/src/digest/server.d.ts +36 -0
- package/dist/src/digest/server.d.ts.map +1 -0
- package/dist/src/digest/server.js +278 -0
- package/dist/src/digest/server.js.map +1 -0
- package/dist/src/digest/slice.d.ts +20 -0
- package/dist/src/digest/slice.d.ts.map +1 -0
- package/dist/src/digest/slice.js +48 -0
- package/dist/src/digest/slice.js.map +1 -0
- package/dist/src/digest/store.d.ts +67 -0
- package/dist/src/digest/store.d.ts.map +1 -0
- package/dist/src/digest/store.js +152 -0
- package/dist/src/digest/store.js.map +1 -0
- package/dist/src/digest/svg.d.ts +44 -0
- package/dist/src/digest/svg.d.ts.map +1 -0
- package/dist/src/digest/svg.js +264 -0
- package/dist/src/digest/svg.js.map +1 -0
- package/dist/src/digest/templates.d.ts +38 -0
- package/dist/src/digest/templates.d.ts.map +1 -0
- package/dist/src/digest/templates.js +125 -0
- package/dist/src/digest/templates.js.map +1 -0
- package/dist/src/digest/translate.d.ts +46 -0
- package/dist/src/digest/translate.d.ts.map +1 -0
- package/dist/src/digest/translate.js +50 -0
- package/dist/src/digest/translate.js.map +1 -0
- package/dist/src/digest/viewer-js.d.ts +4 -0
- package/dist/src/digest/viewer-js.d.ts.map +1 -0
- package/dist/src/digest/viewer-js.js +710 -0
- package/dist/src/digest/viewer-js.js.map +1 -0
- package/dist/src/digest/viewer-logic.d.ts +46 -0
- package/dist/src/digest/viewer-logic.d.ts.map +1 -0
- package/dist/src/digest/viewer-logic.js +89 -0
- package/dist/src/digest/viewer-logic.js.map +1 -0
- package/dist/src/digest/ytdlp.d.ts +34 -0
- package/dist/src/digest/ytdlp.d.ts.map +1 -0
- package/dist/src/digest/ytdlp.js +64 -0
- package/dist/src/digest/ytdlp.js.map +1 -0
- package/dist/src/humanize/detectors.js +2 -2
- package/dist/src/humanize/detectors.js.map +1 -1
- package/dist/src/mcp/schemas.d.ts +78 -4
- package/dist/src/mcp/schemas.d.ts.map +1 -1
- package/dist/src/mcp/schemas.js +40 -2
- package/dist/src/mcp/schemas.js.map +1 -1
- package/dist/src/mcp/server.d.ts.map +1 -1
- package/dist/src/mcp/server.js +8 -2
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/mcp/tools/digest-passthrough.d.ts +3 -0
- package/dist/src/mcp/tools/digest-passthrough.d.ts.map +1 -0
- package/dist/src/mcp/tools/digest-passthrough.js +148 -0
- package/dist/src/mcp/tools/digest-passthrough.js.map +1 -0
- package/dist/src/mcp/tools/research-passthrough.d.ts.map +1 -1
- package/dist/src/mcp/tools/research-passthrough.js +56 -0
- package/dist/src/mcp/tools/research-passthrough.js.map +1 -1
- package/dist/src/research/index.d.ts +1 -0
- package/dist/src/research/index.d.ts.map +1 -1
- package/dist/src/research/index.js +1 -0
- package/dist/src/research/index.js.map +1 -1
- package/dist/src/research/prose.d.ts +60 -0
- package/dist/src/research/prose.d.ts.map +1 -0
- package/dist/src/research/prose.js +200 -0
- package/dist/src/research/prose.js.map +1 -0
- package/package.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -1
- package/plugin/mcp.json +1 -1
- package/plugin/role-agents/video-summarizer/AGENT.md +177 -113
- package/plugin/skills/_shared/agent-delegation.md +1 -1
- package/plugin/skills/_shared/proactive-routing.md +3 -2
- package/plugin/skills/_shared/research-humanize.md +160 -0
- package/plugin/skills/research/SKILL.md +20 -2
- package/plugin/skills/research/references/design.md +14 -2
- package/plugin/skills/reverse-plan/SKILL.md +3 -1
- package/plugin/skills/ux-research/SKILL.md +3 -1
- package/plugin/skills/video-digest/SKILL.md +408 -0
- package/plugin/skills/video-digest/templates/background.md +15 -0
- package/plugin/skills/video-digest/templates/blog.md +16 -0
- package/plugin/skills/video-digest/templates/comments.md +19 -0
- package/plugin/skills/video-digest/templates/counterpoints.md +15 -0
- package/plugin/skills/video-digest/templates/flashcards.md +15 -0
- package/plugin/skills/video-digest/templates/interview.md +15 -0
- package/plugin/skills/video-digest/templates/meeting.md +15 -0
- package/plugin/skills/video-digest/templates/mindmap.md +16 -0
- package/plugin/skills/video-digest/templates/quiz.md +16 -0
- package/plugin/skills/video-digest/templates/quotes.md +16 -0
- package/plugin/skills/video-digest/templates/resources.md +14 -0
- package/plugin/skills/video-digest/templates/timestamps.md +14 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../../../src/digest/format.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,8CAA8C;AAC9C,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,UAAQ,GAAG,MAAM,CAQjE;AAED,sCAAsC;AACtC,wBAAgB,UAAU,CAAC,IAAI,EAAE,UAAU,CAAC,MAAM,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAG/E;AAED,kDAAkD;AAClD,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,eAAe,CAAC,EAAE,MAAM,CAI7E,CAAC"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** 1시간이 넘는 영상만 시를 붙인다. 스크립트 패널 폭을 아끼려는 것이다 */
|
|
2
|
+
export function formatTime(sec, withHours = false) {
|
|
3
|
+
const s = Math.max(0, Math.floor(sec));
|
|
4
|
+
const h = Math.floor(s / 3600);
|
|
5
|
+
const m = Math.floor((s % 3600) / 60);
|
|
6
|
+
const r = String(s % 60).padStart(2, '0');
|
|
7
|
+
return h > 0 || withHours
|
|
8
|
+
? `${h}:${String(m).padStart(2, '0')}:${r}`
|
|
9
|
+
: `${String(m).padStart(2, '0')}:${r}`;
|
|
10
|
+
}
|
|
11
|
+
/** 유튜브 영상이면 그 시각으로 여는 주소, 아니면 null */
|
|
12
|
+
export function watchUrlAt(meta, sec) {
|
|
13
|
+
if (meta.source !== 'youtube' || !meta.videoId)
|
|
14
|
+
return null;
|
|
15
|
+
return `https://www.youtube.com/watch?v=${meta.videoId}&t=${Math.floor(Math.max(0, sec))}s`;
|
|
16
|
+
}
|
|
17
|
+
/** 작성자가 "1.2." 같은 번호를 붙여 와도 렌더러가 다시 매기므로 떼어 낸다 */
|
|
18
|
+
export function stripNumbering(title) {
|
|
19
|
+
return title.replace(/^\s*\d+(?:\.\d+)*\.?\s+/, '');
|
|
20
|
+
}
|
|
21
|
+
export const CAPTION_LABEL = {
|
|
22
|
+
manual: '수동 자막',
|
|
23
|
+
auto: '자동 자막',
|
|
24
|
+
whisper: '음성 인식(whisper)',
|
|
25
|
+
};
|
|
26
|
+
//# sourceMappingURL=format.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"format.js","sourceRoot":"","sources":["../../../src/digest/format.ts"],"names":[],"mappings":"AAEA,8CAA8C;AAC9C,MAAM,UAAU,UAAU,CAAC,GAAW,EAAE,SAAS,GAAG,KAAK;IACvD,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC;IACvC,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IAC/B,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;IACtC,MAAM,CAAC,GAAG,MAAM,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;IAC1C,OAAO,CAAC,GAAG,CAAC,IAAI,SAAS;QACvB,CAAC,CAAC,GAAG,CAAC,IAAI,MAAM,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,IAAI,CAAC,EAAE;QAC3C,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;AAC3C,CAAC;AAED,sCAAsC;AACtC,MAAM,UAAU,UAAU,CAAC,IAAwB,EAAE,GAAW;IAC9D,IAAI,IAAI,CAAC,MAAM,KAAK,SAAS,IAAI,CAAC,IAAI,CAAC,OAAO;QAAE,OAAO,IAAI,CAAC;IAC5D,OAAO,mCAAmC,IAAI,CAAC,OAAO,MAAM,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,GAAG,CAAC;AAC9F,CAAC;AAED,kDAAkD;AAClD,MAAM,UAAU,cAAc,CAAC,KAAa;IAC1C,OAAO,KAAK,CAAC,OAAO,CAAC,yBAAyB,EAAE,EAAE,CAAC,CAAC;AACtD,CAAC;AAED,MAAM,CAAC,MAAM,aAAa,GAAwD;IAChF,MAAM,EAAE,OAAO;IACf,IAAI,EAAE,OAAO;IACb,OAAO,EAAE,gBAAgB;CAC1B,CAAC"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type Digest, type Transcript } from './schema.js';
|
|
2
|
+
import { type RunCommand } from './ytdlp.js';
|
|
3
|
+
export interface FramesOptions {
|
|
4
|
+
repoRoot: string;
|
|
5
|
+
/** 이미 뽑아 둔 프레임도 다시 뽑는다 */
|
|
6
|
+
force?: boolean;
|
|
7
|
+
run?: RunCommand;
|
|
8
|
+
}
|
|
9
|
+
export interface FramesResult {
|
|
10
|
+
framesDir: string;
|
|
11
|
+
extracted: number;
|
|
12
|
+
skipped: number;
|
|
13
|
+
total: number;
|
|
14
|
+
/** ffmpeg가 오류 없이 끝났는데 파일이 안 생긴 frameSec. 영상 끝을 넘긴 시각이 주로 여기 온다 */
|
|
15
|
+
missing: number[];
|
|
16
|
+
}
|
|
17
|
+
/** 프레임 파일 이름. 렌더러가 frameSec으로 같은 이름을 찾는다 */
|
|
18
|
+
export declare function frameFileName(sec: number): string;
|
|
19
|
+
export declare function collectFrameSecs(digest: Digest): number[];
|
|
20
|
+
export declare function ensureVideo(run: RunCommand, transcript: Transcript, cacheDir: string, cwd: string): Promise<string>;
|
|
21
|
+
/**
|
|
22
|
+
* digest.json의 소섹션 frameSec마다 한 장씩 뽑아 frames/에 둔다.
|
|
23
|
+
* 요약이 끝나야 시각이 정해져서 ingest와 따로 돈다.
|
|
24
|
+
*/
|
|
25
|
+
export declare function extractFrames(slug: string, opts: FramesOptions): Promise<FramesResult>;
|
|
26
|
+
//# sourceMappingURL=frames.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"frames.d.ts","sourceRoot":"","sources":["../../../src/digest/frames.ts"],"names":[],"mappings":"AAIA,OAAO,EAAmB,KAAK,MAAM,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAE5E,OAAO,EAAiC,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAE5E,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,0BAA0B;IAC1B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,GAAG,CAAC,EAAE,UAAU,CAAC;CAClB;AAED,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,kEAAkE;IAClE,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,4CAA4C;AAC5C,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEjD;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAKzD;AAkBD,wBAAsB,WAAW,CAC/B,GAAG,EAAE,UAAU,EACf,UAAU,EAAE,UAAU,EACtB,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,MAAM,CAAC,CAoBjB;AAED;;;GAGG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC,YAAY,CAAC,CAuD5F"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { youtubeWatchUrl } from './schema.js';
|
|
6
|
+
import { DigestStore } from './store.js';
|
|
7
|
+
import { runCommand, timeoutFor, ytdlp } from './ytdlp.js';
|
|
8
|
+
/** 프레임 파일 이름. 렌더러가 frameSec으로 같은 이름을 찾는다 */
|
|
9
|
+
export function frameFileName(sec) {
|
|
10
|
+
return `t${String(Math.round(sec * 1000)).padStart(8, '0')}.jpg`;
|
|
11
|
+
}
|
|
12
|
+
export function collectFrameSecs(digest) {
|
|
13
|
+
const secs = digest.reports.flatMap((r) => r.sections.flatMap((s) => s.subsections.map((sub) => sub.frameSec)));
|
|
14
|
+
return [...new Set(secs)].sort((a, b) => a - b);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 프레임만 뽑으면 되니 소리 없는 낮은 화질 영상을 받는다.
|
|
18
|
+
* 403이 나면 web_embedded로 다시 받는데, 그 클라이언트는 480p 이하 포맷이 빠지는 경우가 있어 136(720p)과 18(360p)까지 넓힌다.
|
|
19
|
+
*/
|
|
20
|
+
const FORMAT = 'bv*[height<=480][ext=mp4]/b[height<=480][ext=mp4]/18';
|
|
21
|
+
const FORMAT_RETRY = '135/134/136/18';
|
|
22
|
+
// yt-dlp가 중단되면 video.mp4.part나 video.mp4.ytdl 같은 보조 파일이 남는다. 완성본 확장자만 캐시로 본다
|
|
23
|
+
const CACHED_VIDEO = /^video\.(mp4|webm|mkv)$/;
|
|
24
|
+
function findCachedVideo(cacheDir) {
|
|
25
|
+
if (!existsSync(cacheDir))
|
|
26
|
+
return null;
|
|
27
|
+
const hit = readdirSync(cacheDir).find((f) => CACHED_VIDEO.test(f));
|
|
28
|
+
return hit ? join(cacheDir, hit) : null;
|
|
29
|
+
}
|
|
30
|
+
export async function ensureVideo(run, transcript, cacheDir, cwd) {
|
|
31
|
+
const { meta } = transcript;
|
|
32
|
+
if (meta.source === 'file') {
|
|
33
|
+
if (!meta.url)
|
|
34
|
+
throw new Error('transcript에 원본 영상 경로가 없어요.');
|
|
35
|
+
return fileURLToPath(meta.url);
|
|
36
|
+
}
|
|
37
|
+
const cached = findCachedVideo(cacheDir);
|
|
38
|
+
if (cached)
|
|
39
|
+
return cached;
|
|
40
|
+
if (!meta.videoId)
|
|
41
|
+
throw new Error('transcript에 videoId가 없어요.');
|
|
42
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
43
|
+
// url 필드 대신 검증된 videoId로 주소를 다시 만들고 '--' 뒤에 둬서 옵션으로 읽히지 않게 한다
|
|
44
|
+
const out = ['-o', join(cacheDir, 'video.%(ext)s'), '--', youtubeWatchUrl(meta.videoId)];
|
|
45
|
+
await ytdlp(run, ['-f', FORMAT, ...out], ['-f', FORMAT_RETRY, ...out], {
|
|
46
|
+
cwd,
|
|
47
|
+
timeoutMs: timeoutFor(meta.durationSec, 1),
|
|
48
|
+
});
|
|
49
|
+
const got = findCachedVideo(cacheDir);
|
|
50
|
+
if (!got)
|
|
51
|
+
throw new Error('영상을 받지 못했어요.');
|
|
52
|
+
return got;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* digest.json의 소섹션 frameSec마다 한 장씩 뽑아 frames/에 둔다.
|
|
56
|
+
* 요약이 끝나야 시각이 정해져서 ingest와 따로 돈다.
|
|
57
|
+
*/
|
|
58
|
+
export async function extractFrames(slug, opts) {
|
|
59
|
+
const run = opts.run ?? runCommand;
|
|
60
|
+
const store = new DigestStore(opts.repoRoot);
|
|
61
|
+
const t = store.readTranscript(slug);
|
|
62
|
+
if (!t.ok)
|
|
63
|
+
throw new Error(t.message);
|
|
64
|
+
const transcript = t.transcript;
|
|
65
|
+
const digest = store.loadDigest(slug);
|
|
66
|
+
if (!digest) {
|
|
67
|
+
throw new Error(`digest.json이 없거나 형식이 틀렸어요. 먼저 요약을 쓰고 validate를 통과시켜 주세요: ${slug}`);
|
|
68
|
+
}
|
|
69
|
+
const secs = collectFrameSecs(digest);
|
|
70
|
+
const framesDir = store.framesDir(slug);
|
|
71
|
+
mkdirSync(framesDir, { recursive: true });
|
|
72
|
+
const todo = secs.filter((sec) => {
|
|
73
|
+
const p = join(framesDir, frameFileName(sec));
|
|
74
|
+
return opts.force || !existsSync(p) || statSync(p).size === 0;
|
|
75
|
+
});
|
|
76
|
+
if (todo.length === 0) {
|
|
77
|
+
return { framesDir, extracted: 0, skipped: secs.length, total: secs.length, missing: [] };
|
|
78
|
+
}
|
|
79
|
+
// cacheDir는 레포 안이라 cwd로 쓰면 그 레포가 커밋한 yt-dlp.conf를 읽는다
|
|
80
|
+
const cwd = mkdtempSync(join(tmpdir(), 'gestalt-digest-run-'));
|
|
81
|
+
try {
|
|
82
|
+
const video = await ensureVideo(run, transcript, store.videoCacheDir(slug), cwd);
|
|
83
|
+
// 긴 영상일수록 뒤쪽 시각을 찾는 데 오래 걸려서 상한을 길이에 비례시킨다
|
|
84
|
+
const frameTimeout = timeoutFor(transcript.meta.durationSec, 0.05, 60);
|
|
85
|
+
const missing = [];
|
|
86
|
+
for (const sec of todo) {
|
|
87
|
+
const file = join(framesDir, frameFileName(sec));
|
|
88
|
+
await run('ffmpeg', [
|
|
89
|
+
...['-v', 'error', '-y', '-ss', String(sec), '-i', video],
|
|
90
|
+
...['-frames:v', '1', '-vf', "scale='min(960,iw)':-2", '-q:v', '5'],
|
|
91
|
+
file,
|
|
92
|
+
], { cwd, timeoutMs: frameTimeout });
|
|
93
|
+
// 영상 끝을 넘긴 시각이면 ffmpeg가 파일 없이 0으로 끝나기도 한다
|
|
94
|
+
if (!existsSync(file) || statSync(file).size === 0)
|
|
95
|
+
missing.push(sec);
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
framesDir,
|
|
99
|
+
extracted: todo.length - missing.length,
|
|
100
|
+
skipped: secs.length - todo.length,
|
|
101
|
+
total: secs.length,
|
|
102
|
+
missing,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=frames.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"frames.js","sourceRoot":"","sources":["../../../src/digest/frames.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAC5F,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,eAAe,EAAgC,MAAM,aAAa,CAAC;AAC5E,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,KAAK,EAAmB,MAAM,YAAY,CAAC;AAkB5E,4CAA4C;AAC5C,MAAM,UAAU,aAAa,CAAC,GAAW;IACvC,OAAO,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,GAAG,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC;AACnE,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,MAAc;IAC7C,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CACxC,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CACpE,CAAC;IACF,OAAO,CAAC,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;AAClD,CAAC;AAED;;;GAGG;AACH,MAAM,MAAM,GAAG,sDAAsD,CAAC;AACtE,MAAM,YAAY,GAAG,gBAAgB,CAAC;AAEtC,6EAA6E;AAC7E,MAAM,YAAY,GAAG,yBAAyB,CAAC;AAE/C,SAAS,eAAe,CAAC,QAAgB;IACvC,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC;QAAE,OAAO,IAAI,CAAC;IACvC,MAAM,GAAG,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;IACpE,OAAO,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AAC1C,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,GAAe,EACf,UAAsB,EACtB,QAAgB,EAChB,GAAW;IAEX,MAAM,EAAE,IAAI,EAAE,GAAG,UAAU,CAAC;IAC5B,IAAI,IAAI,CAAC,MAAM,KAAK,MAAM,EAAE,CAAC;QAC3B,IAAI,CAAC,IAAI,CAAC,GAAG;YAAE,MAAM,IAAI,KAAK,CAAC,4BAA4B,CAAC,CAAC;QAC7D,OAAO,aAAa,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACjC,CAAC;IACD,MAAM,MAAM,GAAG,eAAe,CAAC,QAAQ,CAAC,CAAC;IACzC,IAAI,MAAM;QAAE,OAAO,MAAM,CAAC;IAE1B,IAAI,CAAC,IAAI,CAAC,OAAO;QAAE,MAAM,IAAI,KAAK,CAAC,2BAA2B,CAAC,CAAC;IAChE,SAAS,CAAC,QAAQ,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACzC,8DAA8D;IAC9D,MAAM,GAAG,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,QAAQ,EAAE,eAAe,CAAC,EAAE,IAAI,EAAE,eAAe,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC;IACzF,MAAM,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,YAAY,EAAE,GAAG,GAAG,CAAC,EAAE;QACrE,GAAG;QACH,SAAS,EAAE,UAAU,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC;KAC3C,CAAC,CAAC;IACH,MAAM,GAAG,GAAG,eAAe,CAAC,QAAQ,CAAC,CAAC;IACtC,IAAI,CAAC,GAAG;QAAE,MAAM,IAAI,KAAK,CAAC,cAAc,CAAC,CAAC;IAC1C,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,IAAY,EAAE,IAAmB;IACnE,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,UAAU,CAAC;IACnC,MAAM,KAAK,GAAG,IAAI,WAAW,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAC7C,MAAM,CAAC,GAAG,KAAK,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC;IACrC,IAAI,CAAC,CAAC,CAAC,EAAE;QAAE,MAAM,IAAI,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;IACtC,MAAM,UAAU,GAAG,CAAC,CAAC,UAAU,CAAC;IAChC,MAAM,MAAM,GAAG,KAAK,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC;IACtC,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,MAAM,IAAI,KAAK,CACb,4DAA4D,IAAI,EAAE,CACnE,CAAC;IACJ,CAAC;IAED,MAAM,IAAI,GAAG,gBAAgB,CAAC,MAAM,CAAC,CAAC;IACtC,MAAM,SAAS,GAAG,KAAK,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;IACxC,SAAS,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC1C,MAAM,IAAI,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE;QAC/B,MAAM,CAAC,GAAG,IAAI,CAAC,SAAS,EAAE,aAAa,CAAC,GAAG,CAAC,CAAC,CAAC;QAC9C,OAAO,IAAI,CAAC,KAAK,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,QAAQ,CAAC,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC;IAChE,CAAC,CAAC,CAAC;IACH,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACtB,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC;IAC5F,CAAC;IAED,sDAAsD;IACtD,MAAM,GAAG,GAAG,WAAW,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,qBAAqB,CAAC,CAAC,CAAC;IAC/D,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,MAAM,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,KAAK,CAAC,aAAa,CAAC,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC;QACjF,2CAA2C;QAC3C,MAAM,YAAY,GAAG,UAAU,CAAC,UAAU,CAAC,IAAI,CAAC,WAAW,EAAE,IAAI,EAAE,EAAE,CAAC,CAAC;QACvE,MAAM,OAAO,GAAa,EAAE,CAAC;QAC7B,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;YACvB,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,EAAE,aAAa,CAAC,GAAG,CAAC,CAAC,CAAC;YACjD,MAAM,GAAG,CACP,QAAQ,EACR;gBACE,GAAG,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC;gBACzD,GAAG,CAAC,WAAW,EAAE,GAAG,EAAE,KAAK,EAAE,wBAAwB,EAAE,MAAM,EAAE,GAAG,CAAC;gBACnE,IAAI;aACL,EACD,EAAE,GAAG,EAAE,SAAS,EAAE,YAAY,EAAE,CACjC,CAAC;YACF,0CAA0C;YAC1C,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,QAAQ,CAAC,IAAI,CAAC,CAAC,IAAI,KAAK,CAAC;gBAAE,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACxE,CAAC;QACD,OAAO;YACL,SAAS;YACT,SAAS,EAAE,IAAI,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM;YACvC,OAAO,EAAE,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM;YAClC,KAAK,EAAE,IAAI,CAAC,MAAM;YAClB,OAAO;SACR,CAAC;IACJ,CAAC;YAAS,CAAC;QACT,MAAM,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;IAChD,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export * from './schema.js';
|
|
2
|
+
export * from './lint.js';
|
|
3
|
+
export * from './store.js';
|
|
4
|
+
export * from './captions.js';
|
|
5
|
+
export * from './ingest.js';
|
|
6
|
+
export * from './frames.js';
|
|
7
|
+
export * from './format.js';
|
|
8
|
+
export * from './render-md.js';
|
|
9
|
+
export * from './render-html.js';
|
|
10
|
+
export * from './render.js';
|
|
11
|
+
export * from './server.js';
|
|
12
|
+
export { findSegmentIndex } from './viewer-logic.js';
|
|
13
|
+
export * from './prose.js';
|
|
14
|
+
export * from './reports.js';
|
|
15
|
+
export * from './merge.js';
|
|
16
|
+
export * from './slice.js';
|
|
17
|
+
export * from './translate.js';
|
|
18
|
+
export * from './ocr.js';
|
|
19
|
+
export * from './blocks.js';
|
|
20
|
+
export * from './templates.js';
|
|
21
|
+
export * from './lint-extension.js';
|
|
22
|
+
export * from './render-blocks.js';
|
|
23
|
+
export * from './comments.js';
|
|
24
|
+
export * from './svg.js';
|
|
25
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/digest/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,WAAW,CAAC;AAC1B,cAAc,YAAY,CAAC;AAC3B,cAAc,eAAe,CAAC;AAC9B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,kBAAkB,CAAC;AACjC,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AACrD,cAAc,YAAY,CAAC;AAC3B,cAAc,cAAc,CAAC;AAC7B,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,UAAU,CAAC;AACzB,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,qBAAqB,CAAC;AACpC,cAAc,oBAAoB,CAAC;AACnC,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export * from './schema.js';
|
|
2
|
+
export * from './lint.js';
|
|
3
|
+
export * from './store.js';
|
|
4
|
+
export * from './captions.js';
|
|
5
|
+
export * from './ingest.js';
|
|
6
|
+
export * from './frames.js';
|
|
7
|
+
export * from './format.js';
|
|
8
|
+
export * from './render-md.js';
|
|
9
|
+
export * from './render-html.js';
|
|
10
|
+
export * from './render.js';
|
|
11
|
+
export * from './server.js';
|
|
12
|
+
export { findSegmentIndex } from './viewer-logic.js';
|
|
13
|
+
export * from './prose.js';
|
|
14
|
+
export * from './reports.js';
|
|
15
|
+
export * from './merge.js';
|
|
16
|
+
export * from './slice.js';
|
|
17
|
+
export * from './translate.js';
|
|
18
|
+
export * from './ocr.js';
|
|
19
|
+
export * from './blocks.js';
|
|
20
|
+
export * from './templates.js';
|
|
21
|
+
export * from './lint-extension.js';
|
|
22
|
+
export * from './render-blocks.js';
|
|
23
|
+
export * from './comments.js';
|
|
24
|
+
export * from './svg.js';
|
|
25
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/digest/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,WAAW,CAAC;AAC1B,cAAc,YAAY,CAAC;AAC3B,cAAc,eAAe,CAAC;AAC9B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,kBAAkB,CAAC;AACjC,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AACrD,cAAc,YAAY,CAAC;AAC3B,cAAc,cAAc,CAAC;AAC7B,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,UAAU,CAAC;AACzB,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,qBAAqB,CAAC;AACpC,cAAc,oBAAoB,CAAC;AACnC,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type CaptionSource } from './schema.js';
|
|
2
|
+
import { type RunCommand } from './ytdlp.js';
|
|
3
|
+
export interface IngestOptions {
|
|
4
|
+
repoRoot: string;
|
|
5
|
+
/** 자막 언어 우선순위. 없으면 영상 원어를 쓴다 */
|
|
6
|
+
lang?: string;
|
|
7
|
+
slug?: string;
|
|
8
|
+
/** 자막이 있어도 whisper로 받아쓴다 */
|
|
9
|
+
whisper?: boolean;
|
|
10
|
+
whisperModel?: string;
|
|
11
|
+
run?: RunCommand;
|
|
12
|
+
}
|
|
13
|
+
export interface IngestResult {
|
|
14
|
+
slug: string;
|
|
15
|
+
transcriptPath: string;
|
|
16
|
+
title: string;
|
|
17
|
+
durationSec: number;
|
|
18
|
+
captionSource: CaptionSource;
|
|
19
|
+
/** 실제로 쓴 자막 트랙 이름(en-orig 같은). whisper면 모델 이름 */
|
|
20
|
+
captionTrack: string;
|
|
21
|
+
segmentCount: number;
|
|
22
|
+
/**
|
|
23
|
+
* 같은 slug를 다시 받아 문장이 바뀌면 기존 digest.json의 cite가 엉뚱한 문장을 가리킨다.
|
|
24
|
+
* 그래서 옮겨 두고 그 경로와 이전 문장 수를 싣는다
|
|
25
|
+
*/
|
|
26
|
+
staleDigest?: {
|
|
27
|
+
movedTo: string;
|
|
28
|
+
previousSegmentCount: number | null;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** watch?v=, youtu.be/, shorts/, embed/, live/ 꼴에서 영상 id를 꺼낸다. 유튜브 주소가 아니면 null */
|
|
32
|
+
export declare function parseYoutubeId(input: string): string | null;
|
|
33
|
+
interface YtTrack {
|
|
34
|
+
ext?: string;
|
|
35
|
+
}
|
|
36
|
+
interface YtMeta {
|
|
37
|
+
id: string;
|
|
38
|
+
title?: string;
|
|
39
|
+
channel?: string;
|
|
40
|
+
uploader?: string;
|
|
41
|
+
duration?: number;
|
|
42
|
+
upload_date?: string;
|
|
43
|
+
language?: string | null;
|
|
44
|
+
webpage_url?: string;
|
|
45
|
+
subtitles?: Record<string, YtTrack[]>;
|
|
46
|
+
automatic_captions?: Record<string, YtTrack[]>;
|
|
47
|
+
}
|
|
48
|
+
export interface CaptionPick {
|
|
49
|
+
kind: 'manual' | 'auto';
|
|
50
|
+
key: string;
|
|
51
|
+
lang: string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 수동 자막 → 자동 자막 순으로 고른다. 자동 자막은 157개 언어가 기계 번역으로 딸려 오는데
|
|
55
|
+
* 영상 원어 트랙만 `-orig`가 붙어서 그걸 먼저 잡는다. 번역 자막은 문장 시각이 어긋나 쓰지 않는다.
|
|
56
|
+
* 자동 더빙이 붙은 영상은 더빙 언어마다 `-orig`가 생겨서 원어에 맞는 것을 먼저 고른다.
|
|
57
|
+
*/
|
|
58
|
+
export declare function pickCaption(meta: YtMeta, prefLang?: string): CaptionPick | null;
|
|
59
|
+
/**
|
|
60
|
+
* 유튜브 주소나 로컬 영상 파일에서 문장 단위 스크립트를 만들어 transcript.json으로 저장한다.
|
|
61
|
+
* 자막 순서는 수동 자막 → 자동 자막(문장 병합) → faster-whisper다.
|
|
62
|
+
*/
|
|
63
|
+
export declare function ingest(input: string, opts: IngestOptions): Promise<IngestResult>;
|
|
64
|
+
export {};
|
|
65
|
+
//# sourceMappingURL=ingest.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ingest.d.ts","sourceRoot":"","sources":["../../../src/digest/ingest.ts"],"names":[],"mappings":"AAeA,OAAO,EAGL,KAAK,aAAa,EAEnB,MAAM,aAAa,CAAC;AAErB,OAAO,EAML,KAAK,UAAU,EAChB,MAAM,YAAY,CAAC;AAEpB,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,gCAAgC;IAChC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,4BAA4B;IAC5B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,GAAG,CAAC,EAAE,UAAU,CAAC;CAClB;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,cAAc,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,aAAa,CAAC;IAC7B,iDAAiD;IACjD,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,WAAW,CAAC,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;CACxE;AAMD,mFAAmF;AACnF,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAkB3D;AAED,UAAU,OAAO;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AACD,UAAU,MAAM;IACd,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC;IACtC,kBAAkB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC;CAChD;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAC;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;CACd;AAUD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAmB/E;AA2PD;;;GAGG;AACH,wBAAsB,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC,YAAY,CAAC,CA8CtF"}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { basename, dirname, extname, join, resolve } from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { log } from '../core/log.js';
|
|
7
|
+
import { captionsToSentences, cuesToWords, mergeSentences } from './captions.js';
|
|
8
|
+
import { TRANSCRIPT_SCHEMA_VERSION, youtubeWatchUrl, } from './schema.js';
|
|
9
|
+
import { DigestStore, isValidSlug, slugFromMeta } from './store.js';
|
|
10
|
+
import { CommandError, QUICK_TIMEOUT_MS, runCommand, timeoutFor, ytdlp, } from './ytdlp.js';
|
|
11
|
+
const DEFAULT_WHISPER_MODEL = 'large-v3';
|
|
12
|
+
const YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;
|
|
13
|
+
/** watch?v=, youtu.be/, shorts/, embed/, live/ 꼴에서 영상 id를 꺼낸다. 유튜브 주소가 아니면 null */
|
|
14
|
+
export function parseYoutubeId(input) {
|
|
15
|
+
let url;
|
|
16
|
+
try {
|
|
17
|
+
url = new URL(input);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
const host = url.hostname.replace(/^(www|m|music)\./, '');
|
|
23
|
+
let id = null;
|
|
24
|
+
if (host === 'youtu.be')
|
|
25
|
+
id = url.pathname.slice(1).split('/')[0] ?? null;
|
|
26
|
+
else if (host === 'youtube.com' || host === 'youtube-nocookie.com') {
|
|
27
|
+
if (url.pathname === '/watch')
|
|
28
|
+
id = url.searchParams.get('v');
|
|
29
|
+
else {
|
|
30
|
+
const m = url.pathname.match(/^\/(?:shorts|embed|live|v)\/([^/?#]+)/);
|
|
31
|
+
id = m ? m[1] : null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return id && YOUTUBE_ID.test(id) ? id : null;
|
|
35
|
+
}
|
|
36
|
+
const langOf = (key) => key.replace(/-orig$/, '');
|
|
37
|
+
const matches = (key, pref) => key === pref || key.startsWith(`${pref}-`) || langOf(key) === pref;
|
|
38
|
+
const primary = (tag) => tag.split('-')[0].toLowerCase();
|
|
39
|
+
// 메타 원어는 `en-US`인데 트랙은 `en-orig`처럼 지역 태그만 다를 때가 있어 주 언어로 한 번 더 맞춘다
|
|
40
|
+
const findPref = (keys, pref) => keys.find((k) => matches(k, pref)) ?? keys.find((k) => primary(langOf(k)) === primary(pref));
|
|
41
|
+
/**
|
|
42
|
+
* 수동 자막 → 자동 자막 순으로 고른다. 자동 자막은 157개 언어가 기계 번역으로 딸려 오는데
|
|
43
|
+
* 영상 원어 트랙만 `-orig`가 붙어서 그걸 먼저 잡는다. 번역 자막은 문장 시각이 어긋나 쓰지 않는다.
|
|
44
|
+
* 자동 더빙이 붙은 영상은 더빙 언어마다 `-orig`가 생겨서 원어에 맞는 것을 먼저 고른다.
|
|
45
|
+
*/
|
|
46
|
+
export function pickCaption(meta, prefLang) {
|
|
47
|
+
const prefs = [prefLang, meta.language ?? undefined].filter((p) => !!p);
|
|
48
|
+
const manual = Object.keys(meta.subtitles ?? {}).filter((k) => k !== 'live_chat');
|
|
49
|
+
const auto = Object.keys(meta.automatic_captions ?? {});
|
|
50
|
+
for (const p of prefs) {
|
|
51
|
+
const k = findPref(manual, p);
|
|
52
|
+
if (k)
|
|
53
|
+
return { kind: 'manual', key: k, lang: langOf(k) };
|
|
54
|
+
}
|
|
55
|
+
if (prefs.length === 0 && manual.length === 1) {
|
|
56
|
+
return { kind: 'manual', key: manual[0], lang: langOf(manual[0]) };
|
|
57
|
+
}
|
|
58
|
+
const origs = auto.filter((k) => k.endsWith('-orig'));
|
|
59
|
+
const orig = prefs.map((p) => findPref(origs, p)).find((k) => !!k) ?? origs[0];
|
|
60
|
+
if (orig)
|
|
61
|
+
return { kind: 'auto', key: orig, lang: langOf(orig) };
|
|
62
|
+
for (const p of prefs) {
|
|
63
|
+
if (auto.includes(p))
|
|
64
|
+
return { kind: 'auto', key: p, lang: p };
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const toIsoDate = (yyyymmdd) => yyyymmdd && /^\d{8}$/.test(yyyymmdd)
|
|
69
|
+
? `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`
|
|
70
|
+
: undefined;
|
|
71
|
+
const round2 = (n) => Math.round(n * 100) / 100;
|
|
72
|
+
function toSegments(cues) {
|
|
73
|
+
let prev = 0;
|
|
74
|
+
return cues.map((c, i) => {
|
|
75
|
+
// 수동 자막은 큐가 겹치기도 해서 시각이 뒤로 가는 경우만 앞 문장에 맞춘다
|
|
76
|
+
prev = Math.max(prev, round2(c.startSec));
|
|
77
|
+
return { i, startSec: prev, text: c.text };
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function findFile(dir, prefix, exts) {
|
|
81
|
+
const hit = readdirSync(dir)
|
|
82
|
+
.filter((f) => f.startsWith(prefix) && exts.includes(extname(f).toLowerCase()))
|
|
83
|
+
.sort();
|
|
84
|
+
return hit.length > 0 ? join(dir, hit[0]) : null;
|
|
85
|
+
}
|
|
86
|
+
// 모델을 받을 때 진행 로그가 stdout에 섞일 수 있어서 결과는 마지막 줄 한 줄로만 낸다
|
|
87
|
+
const WHISPER_SCRIPT = `
|
|
88
|
+
import json, sys
|
|
89
|
+
from faster_whisper import WhisperModel
|
|
90
|
+
audio, model_name, lang = sys.argv[1], sys.argv[2], sys.argv[3] or None
|
|
91
|
+
model = WhisperModel(model_name, device="auto", compute_type="auto")
|
|
92
|
+
segments, info = model.transcribe(audio, language=lang, vad_filter=True)
|
|
93
|
+
out = [{"start": s.start, "text": s.text.strip()} for s in segments if s.text.strip()]
|
|
94
|
+
sys.stdout.write("\\n" + json.dumps({"language": info.language, "segments": out}) + "\\n")
|
|
95
|
+
`;
|
|
96
|
+
const mmss = (sec) => `${Math.floor(sec / 60)}:${String(Math.floor(sec % 60)).padStart(2, '0')}`;
|
|
97
|
+
async function transcribeWithWhisper(run, cwd, audioPath, model, durationSec, lang) {
|
|
98
|
+
log(`faster-whisper ${model} 모델로 ${mmss(durationSec)} 분량을 받아써요. ` +
|
|
99
|
+
'GPU가 없으면 영상 길이보다 오래 걸릴 수 있어요.');
|
|
100
|
+
let stdout;
|
|
101
|
+
try {
|
|
102
|
+
({ stdout } = await run('python3', ['-c', WHISPER_SCRIPT, audioPath, model, lang ?? ''], {
|
|
103
|
+
cwd,
|
|
104
|
+
// CPU로 큰 모델을 돌리면 영상 길이의 몇 배가 걸린다. 멈춘 프로세스만 끊을 만큼 넉넉히 둔다
|
|
105
|
+
timeoutMs: timeoutFor(durationSec, 20, 600),
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
// faster-whisper 의존 모듈(ctranslate2, av)이 빠진 경우는 설치 안내가 틀려서 원래 오류를 그대로 올린다
|
|
110
|
+
if (e instanceof CommandError && /No module named ['"]faster_whisper/.test(e.stderr)) {
|
|
111
|
+
throw new Error('자막이 없어서 faster-whisper로 받아쓰려 했는데 설치돼 있지 않아요. ' +
|
|
112
|
+
'`python3 -m pip install faster-whisper`로 설치한 뒤 다시 돌려 주세요.', { cause: e });
|
|
113
|
+
}
|
|
114
|
+
throw e;
|
|
115
|
+
}
|
|
116
|
+
const last = stdout.trim().split('\n').pop() ?? '';
|
|
117
|
+
let parsed;
|
|
118
|
+
try {
|
|
119
|
+
parsed = JSON.parse(last);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
throw new Error(`받아쓰기 결과를 JSON으로 못 읽었어요: ${stdout.trim().slice(0, 200)}`);
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
lang: parsed.language,
|
|
126
|
+
cues: parsed.segments.map((s) => ({ startSec: s.start, text: s.text })),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
async function probeDuration(run, cwd, file) {
|
|
130
|
+
const { stdout } = await run('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file], { cwd, timeoutMs: QUICK_TIMEOUT_MS });
|
|
131
|
+
const sec = Number(stdout.trim());
|
|
132
|
+
if (!Number.isFinite(sec) || sec <= 0)
|
|
133
|
+
throw new Error(`영상 길이를 못 읽었어요: ${file}`);
|
|
134
|
+
return sec;
|
|
135
|
+
}
|
|
136
|
+
async function collectYoutube(videoId, opts, run, work) {
|
|
137
|
+
const watchUrl = youtubeWatchUrl(videoId);
|
|
138
|
+
const quick = { cwd: work, timeoutMs: QUICK_TIMEOUT_MS };
|
|
139
|
+
const { stdout } = await ytdlp(run, ['-J', '--skip-download', watchUrl], undefined, quick);
|
|
140
|
+
const meta = JSON.parse(stdout);
|
|
141
|
+
const title = meta.title?.trim() || videoId;
|
|
142
|
+
const base = {
|
|
143
|
+
source: 'youtube',
|
|
144
|
+
videoId,
|
|
145
|
+
url: watchUrl,
|
|
146
|
+
title,
|
|
147
|
+
...(meta.channel || meta.uploader ? { channel: meta.channel ?? meta.uploader } : {}),
|
|
148
|
+
durationSec: meta.duration ?? 0,
|
|
149
|
+
...(toIsoDate(meta.upload_date) ? { publishedAt: toIsoDate(meta.upload_date) } : {}),
|
|
150
|
+
};
|
|
151
|
+
const slug = opts.slug ?? slugFromMeta(videoId, title);
|
|
152
|
+
const pick = opts.whisper ? null : pickCaption(meta, opts.lang);
|
|
153
|
+
if (pick) {
|
|
154
|
+
const flag = pick.kind === 'manual' ? '--write-subs' : '--write-auto-subs';
|
|
155
|
+
const subArgs = [
|
|
156
|
+
...['--skip-download', flag, '--sub-langs', pick.key, '--sub-format', 'vtt/srt/best'],
|
|
157
|
+
...['-o', join(work, 'sub.%(ext)s'), watchUrl],
|
|
158
|
+
];
|
|
159
|
+
await ytdlp(run, subArgs, undefined, quick);
|
|
160
|
+
const file = findFile(work, 'sub.', ['.vtt', '.srt']);
|
|
161
|
+
const cues = file ? captionsToSentences(readFileSync(file, 'utf-8')) : [];
|
|
162
|
+
if (cues.length > 0) {
|
|
163
|
+
return {
|
|
164
|
+
slug,
|
|
165
|
+
meta: { ...base, lang: pick.lang, captionSource: pick.kind },
|
|
166
|
+
cues,
|
|
167
|
+
track: pick.key,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const model = opts.whisperModel ?? DEFAULT_WHISPER_MODEL;
|
|
172
|
+
await ytdlp(run, ['-f', 'bestaudio/best', '-o', join(work, 'audio.%(ext)s'), watchUrl], undefined, { cwd: work, timeoutMs: timeoutFor(base.durationSec, 1) });
|
|
173
|
+
const audio = findFile(work, 'audio.', ['.m4a', '.webm', '.mp4', '.opus', '.mp3', '.ogg']);
|
|
174
|
+
if (!audio)
|
|
175
|
+
throw new Error('오디오를 받지 못했어요.');
|
|
176
|
+
const w = await transcribeWithWhisper(run, work, audio, model, base.durationSec, opts.lang ?? meta.language ?? undefined);
|
|
177
|
+
return {
|
|
178
|
+
slug,
|
|
179
|
+
meta: { ...base, lang: w.lang, captionSource: 'whisper' },
|
|
180
|
+
cues: mergeSentences(cuesToWords(w.cues)),
|
|
181
|
+
track: model,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
async function collectFile(file, opts, run, work) {
|
|
185
|
+
const stem = basename(file, extname(file));
|
|
186
|
+
const durationSec = await probeDuration(run, work, file);
|
|
187
|
+
// 다른 폴더의 같은 이름 영상과 섞이지 않게 경로 해시를 slug 끝에 붙인다
|
|
188
|
+
const hash = createHash('sha1').update(file).digest('hex').slice(0, 8);
|
|
189
|
+
const slug = opts.slug ?? slugFromMeta(hash, stem);
|
|
190
|
+
const base = {
|
|
191
|
+
source: 'file',
|
|
192
|
+
url: pathToFileURL(file).href,
|
|
193
|
+
title: stem,
|
|
194
|
+
durationSec: round2(durationSec),
|
|
195
|
+
};
|
|
196
|
+
const sidecar = opts.whisper ? null : findFile(dirname(file), `${stem}.`, ['.vtt', '.srt']);
|
|
197
|
+
if (sidecar) {
|
|
198
|
+
const cues = captionsToSentences(readFileSync(sidecar, 'utf-8'));
|
|
199
|
+
if (cues.length > 0) {
|
|
200
|
+
return {
|
|
201
|
+
slug,
|
|
202
|
+
meta: { ...base, lang: opts.lang ?? 'und', captionSource: 'manual' },
|
|
203
|
+
cues,
|
|
204
|
+
track: basename(sidecar),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const model = opts.whisperModel ?? DEFAULT_WHISPER_MODEL;
|
|
209
|
+
const w = await transcribeWithWhisper(run, work, file, model, base.durationSec, opts.lang);
|
|
210
|
+
return {
|
|
211
|
+
slug,
|
|
212
|
+
meta: { ...base, lang: w.lang, captionSource: 'whisper' },
|
|
213
|
+
cues: mergeSentences(cuesToWords(w.cues)),
|
|
214
|
+
track: model,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const sameSegments = (a, b) => a.length === b.length &&
|
|
218
|
+
a.every((s, i) => s.startSec === b[i].startSec && s.text === b[i].text);
|
|
219
|
+
/**
|
|
220
|
+
* 문장이 바뀌었는데 digest.json을 두면 cite가 옛 번호로 엉뚱한 장면을 가리킨다.
|
|
221
|
+
* 지우지 않고 옮겨 두면 다음 validate가 DIGEST_NOT_FOUND로 요약을 새로 쓰게 이끈다
|
|
222
|
+
*/
|
|
223
|
+
function moveStaleDigest(store, next) {
|
|
224
|
+
const digestPath = store.digestPath(next.slug);
|
|
225
|
+
if (!existsSync(digestPath))
|
|
226
|
+
return undefined;
|
|
227
|
+
const prev = store.readTranscript(next.slug);
|
|
228
|
+
if (prev.ok && sameSegments(prev.transcript.segments, next.segments))
|
|
229
|
+
return undefined;
|
|
230
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
231
|
+
const movedTo = join(store.dir(next.slug), `digest.stale-${stamp}.json`);
|
|
232
|
+
renameSync(digestPath, movedTo);
|
|
233
|
+
return { movedTo, previousSegmentCount: prev.ok ? prev.transcript.segments.length : null };
|
|
234
|
+
}
|
|
235
|
+
function restoreStaleDigest(store, slug, movedTo, cause) {
|
|
236
|
+
let why;
|
|
237
|
+
try {
|
|
238
|
+
renameSync(movedTo, store.digestPath(slug));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
catch (restoreErr) {
|
|
242
|
+
why = restoreErr instanceof Error ? restoreErr.message : String(restoreErr);
|
|
243
|
+
}
|
|
244
|
+
// catch 밖에서 던져야 cause에 되돌리기 오류가 아닌 저장 오류를 둘 수 있다.
|
|
245
|
+
// CLI는 message만 찍어서 저장이 왜 실패했는지와 digest가 어디 남았는지, 왜 못 되돌렸는지를 message에 다 넣는다
|
|
246
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
247
|
+
throw new Error(`${reason} (옮겨 둔 digest.json을 되돌리지 못해 ${movedTo}에 남아 있어요: ${why})`, { cause });
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* 유튜브 주소나 로컬 영상 파일에서 문장 단위 스크립트를 만들어 transcript.json으로 저장한다.
|
|
251
|
+
* 자막 순서는 수동 자막 → 자동 자막(문장 병합) → faster-whisper다.
|
|
252
|
+
*/
|
|
253
|
+
export async function ingest(input, opts) {
|
|
254
|
+
const run = opts.run ?? runCommand;
|
|
255
|
+
if (opts.slug !== undefined && !isValidSlug(opts.slug)) {
|
|
256
|
+
throw new Error(`slug는 소문자, 숫자, 하이픈만 쓸 수 있어요: ${opts.slug}`);
|
|
257
|
+
}
|
|
258
|
+
const videoId = parseYoutubeId(input);
|
|
259
|
+
const filePath = videoId ? null : resolve(input);
|
|
260
|
+
if (!videoId && !(filePath && existsSync(filePath) && statSync(filePath).isFile())) {
|
|
261
|
+
throw new Error(`유튜브 주소도 아니고 파일도 찾지 못했어요: ${input}`);
|
|
262
|
+
}
|
|
263
|
+
const work = mkdtempSync(join(tmpdir(), 'gestalt-digest-'));
|
|
264
|
+
try {
|
|
265
|
+
const c = videoId
|
|
266
|
+
? await collectYoutube(videoId, opts, run, work)
|
|
267
|
+
: await collectFile(filePath, opts, run, work);
|
|
268
|
+
if (c.cues.length === 0)
|
|
269
|
+
throw new Error('스크립트 문장이 하나도 안 나왔어요.');
|
|
270
|
+
const transcript = {
|
|
271
|
+
schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
|
|
272
|
+
slug: c.slug,
|
|
273
|
+
meta: c.meta,
|
|
274
|
+
segments: toSegments(c.cues),
|
|
275
|
+
};
|
|
276
|
+
const store = new DigestStore(opts.repoRoot);
|
|
277
|
+
const staleDigest = moveStaleDigest(store, transcript);
|
|
278
|
+
let transcriptPath;
|
|
279
|
+
try {
|
|
280
|
+
transcriptPath = store.saveTranscript(transcript);
|
|
281
|
+
}
|
|
282
|
+
catch (e) {
|
|
283
|
+
// 저장 뒤에 옮기면 rename 실패 때 새 transcript 옆에 옛 digest가 남는다. 옮긴 걸 되돌려 옛 짝을 지킨다
|
|
284
|
+
if (staleDigest)
|
|
285
|
+
restoreStaleDigest(store, c.slug, staleDigest.movedTo, e);
|
|
286
|
+
throw e;
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
slug: c.slug,
|
|
290
|
+
transcriptPath,
|
|
291
|
+
title: c.meta.title,
|
|
292
|
+
durationSec: c.meta.durationSec,
|
|
293
|
+
captionSource: c.meta.captionSource,
|
|
294
|
+
captionTrack: c.track,
|
|
295
|
+
segmentCount: transcript.segments.length,
|
|
296
|
+
...(staleDigest ? { staleDigest } : {}),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
rmSync(work, { recursive: true, force: true });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
//# sourceMappingURL=ingest.js.map
|