@kaydash9999/banker-plugins 0.26.1 → 0.28.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 (72) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +39 -0
  4. package/README.md +25 -10
  5. package/bin/lib/codex-hooks.mjs +7 -1
  6. package/codex/command-skills/front-qa/SKILL.md +3 -1
  7. package/codex/hooks.json +15 -5
  8. package/codex/manifest.json +4 -0
  9. package/codex/skills/ralph/SKILL.md +272 -0
  10. package/codex/skills/ralph/scripts/gate-evidence.mjs +457 -0
  11. package/codex/skills/ralph/scripts/ralph-state.mjs +622 -0
  12. package/codex/skills/ralph/scripts/run-folder.mjs +299 -0
  13. package/codex/transform-matrix.md +5 -4
  14. package/commands/front-qa.md +3 -1
  15. package/hooks/ralph-continue-codex.mjs +59 -0
  16. package/package.json +1 -1
  17. package/skills/all-in-one/SKILL.md +22 -8
  18. package/skills/ralph-qa/references/gate-evidence.mjs +3 -3
  19. package/skills/ralph-qa/references/run-folder.mjs +3 -3
  20. package/skills/ultra-chain/SKILL.md +11 -4
  21. package/skills/ultra-init/SKILL.md +9 -7
  22. package/skills/ultraqa/references/gate-evidence.mjs +3 -3
  23. package/skills/ultraqa/references/run-folder.mjs +3 -3
  24. package/skills/video-gen/SKILL.md +141 -0
  25. package/skills/video-gen/assets/example.json +36 -0
  26. package/skills/video-gen/scripts/vg-catalog.mjs +23 -0
  27. package/skills/video-gen/scripts/vg-deck.mjs +72 -0
  28. package/skills/video-gen/scripts/vg-draw.mjs +231 -0
  29. package/skills/video-gen/scripts/vg-engine.mjs +74 -0
  30. package/skills/video-gen/scripts/vg-fx.mjs +106 -0
  31. package/skills/video-gen/scripts/vg-model.mjs +236 -0
  32. package/skills/video-gen/scripts/vg-render.mjs +151 -0
  33. package/skills/video-gen/scripts/vg-scenes.mjs +226 -0
  34. package/skills/video-gen/scripts/video-gen.mjs +212 -0
  35. package/skills/video-gen/vendor/README.md +25 -0
  36. package/skills/video-gen/vendor/aimotion/LICENSE.md +21 -0
  37. package/skills/video-gen/vendor/canvaskit/LICENSE.md +29 -0
  38. package/skills/video-gen/vendor/canvaskit/THIRD-PARTY-NOTICES.md +1366 -0
  39. package/skills/video-gen/vendor/canvaskit/canvaskit.js +270 -0
  40. package/skills/video-gen/vendor/canvaskit/canvaskit.wasm +0 -0
  41. package/skills/video-gen/vendor/fonts/NotoSans-Bold.ttf +0 -0
  42. package/skills/video-gen/vendor/fonts/NotoSans-Regular.ttf +0 -0
  43. package/skills/video-gen/vendor/fonts/NotoSansKR-Bold.otf +0 -0
  44. package/skills/video-gen/vendor/fonts/NotoSansKR-Regular.otf +0 -0
  45. package/skills/video-gen/vendor/fonts/OFL-noto-sans-kr.md +94 -0
  46. package/skills/video-gen/vendor/fonts/OFL-noto-sans.md +93 -0
  47. package/skills/video-gen/vendor/h264-mp4-encoder/LICENSE.md +21 -0
  48. package/skills/video-gen/vendor/h264-mp4-encoder/MPL-1.1.md +469 -0
  49. package/skills/video-gen/vendor/h264-mp4-encoder/h264-mp4-encoder.node.js +2 -0
  50. package/skills/visual-ralph/SKILL.md +9 -4
  51. package/skills/wbs-gen/SKILL.md +127 -0
  52. package/skills/wbs-gen/assets/example.json +58 -0
  53. package/skills/wbs-gen/scripts/wbs-gen.mjs +238 -0
  54. package/skills/wbs-gen/scripts/wg-dates.mjs +106 -0
  55. package/skills/wbs-gen/scripts/wg-holidays.mjs +89 -0
  56. package/skills/wbs-gen/scripts/wg-html.mjs +108 -0
  57. package/skills/wbs-gen/scripts/wg-plan.mjs +230 -0
  58. package/skills/wbs-gen/scripts/wg-xlsx.mjs +328 -0
  59. package/skills/wbs-gen/scripts/wg-zip.mjs +83 -0
  60. package/skills/wide-pptx/SKILL.md +121 -0
  61. package/skills/wide-pptx/assets/example.json +63 -0
  62. package/skills/wide-pptx/scripts/wide-pptx.mjs +185 -0
  63. package/skills/wide-pptx/scripts/wp-html.mjs +98 -0
  64. package/skills/wide-pptx/scripts/wp-model.mjs +236 -0
  65. package/skills/wide-pptx/scripts/wp-pptx.mjs +219 -0
  66. package/skills/wide-pptx/scripts/wp-svg.mjs +114 -0
  67. package/skills/wide-pptx/vendor/LICENSE-jszip.md +651 -0
  68. package/skills/wide-pptx/vendor/LICENSE-pako.md +21 -0
  69. package/skills/wide-pptx/vendor/LICENSE-pptxgenjs.md +21 -0
  70. package/skills/wide-pptx/vendor/README.md +12 -0
  71. package/skills/wide-pptx/vendor/THIRD-PARTY-NOTICES.md +142 -0
  72. package/skills/wide-pptx/vendor/pptxgen.bundle.js +96 -0
@@ -0,0 +1,74 @@
1
+ // vg-engine.mjs: the bundled render engine. Skia CanvasKit (WASM) draws each frame on the CPU and the H.264 MP4
2
+ // encoder (WASM) writes the video: no Chrome, no ffmpeg, no native module, the same on every OS.
3
+ // Fonts are the bundled Noto Sans and Noto Sans KR; text asks for Noto Sans first, so Latin letters, digits and
4
+ // symbols use it and Hangul falls through to Noto Sans KR.
5
+ // The encoder's Emscripten glue registers process handlers for uncaught exceptions and unhandled rejections when it
6
+ // is required; the CLI handles its own rejections, so they never reach those handlers.
7
+ import { readFileSync } from "node:fs";
8
+ import { createRequire } from "node:module";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const VENDOR = join(dirname(fileURLToPath(import.meta.url)), "..", "vendor");
13
+ const require = createRequire(import.meta.url);
14
+ export const FAMILIES = ["Noto Sans", "Noto Sans KR"];
15
+ const FONTS = [
16
+ ["NotoSans-Regular.ttf", "Noto Sans"],
17
+ ["NotoSans-Bold.ttf", "Noto Sans"],
18
+ ["NotoSansKR-Regular.otf", "Noto Sans KR"],
19
+ ["NotoSansKR-Bold.otf", "Noto Sans KR"],
20
+ ];
21
+
22
+ let engine = null;
23
+
24
+ /** CanvasKit with the bundled fonts registered: { CK, fonts }. Loaded once per process. */
25
+ export async function loadEngine() {
26
+ if (engine) return engine;
27
+ const init = require(join(VENDOR, "canvaskit", "canvaskit.js"));
28
+ const wasm = readFileSync(join(VENDOR, "canvaskit", "canvaskit.wasm"));
29
+ const CK = await init({ wasmBinary: wasm, locateFile: (f) => join(VENDOR, "canvaskit", f) });
30
+ const fonts = CK.TypefaceFontProvider.Make();
31
+ for (const [file, family] of FONTS) fonts.registerFont(readFileSync(join(VENDOR, "fonts", file)), family);
32
+ engine = { CK, fonts };
33
+ return engine;
34
+ }
35
+
36
+ /**
37
+ * An H.264 MP4 writer: add(rgba) per frame, then finish() returns the MP4 bytes. Width and height must be even.
38
+ * quality is the quantizer (10 best .. 51 smallest); speed 0 (best) .. 10 (fastest).
39
+ */
40
+ export async function mp4Writer({ width, height, fps, quality = 20, speed = 5 }) {
41
+ const HME = require(join(VENDOR, "h264-mp4-encoder", "h264-mp4-encoder.node.js"));
42
+ const enc = await HME.createH264MP4Encoder();
43
+ enc.width = width;
44
+ enc.height = height;
45
+ enc.frameRate = fps;
46
+ enc.quantizationParameter = quality;
47
+ enc.speed = speed;
48
+ enc.initialize();
49
+ let count = 0;
50
+ return {
51
+ add(rgba) {
52
+ enc.addFrameRgba(rgba);
53
+ count++;
54
+ },
55
+ finish() {
56
+ enc.finalize();
57
+ const bytes = Buffer.from(enc.FS.readFile(enc.outputFilename));
58
+ enc.FS.unlink(enc.outputFilename);
59
+ enc.delete();
60
+ return { bytes, frames: count };
61
+ },
62
+ // Deleting an unfinished encoder throws inside the WASM glue; finish it first and swallow what it throws, so
63
+ // the caller's own error is the one reported.
64
+ abort() {
65
+ for (const step of [() => enc.finalize(), () => enc.FS.unlink(enc.outputFilename), () => enc.delete()]) {
66
+ try {
67
+ step();
68
+ } catch {
69
+ // already finished, or never started
70
+ }
71
+ }
72
+ },
73
+ };
74
+ }
@@ -0,0 +1,106 @@
1
+ // vg-fx.mjs: motion helpers for video-gen. Every value is a pure function of time t (seconds), so any frame can be
2
+ // drawn on its own and the same scene gives the same video.
3
+ //
4
+ // rand, clamp, lerp, smooth, ramp, ease and track are taken from aimotion (github.com/kstost/cokacmotion,
5
+ // skills/aimotion/demos/_lib/stage.js, commit c417b16), MIT License, Copyright (c) 2026 kstost; see
6
+ // vendor/aimotion/LICENSE.md. The rest is written here after techniques its demos describe (staggered entrances
7
+ // with an outBack overshoot, a stinger of staggered stripes, a counter whose digits roll).
8
+
9
+ /** mulberry32: same seed, same sequence, every run */
10
+ export function rand(seed = 1) {
11
+ let a = seed >>> 0;
12
+ return () => {
13
+ a = (a + 0x6d2b79f5) >>> 0;
14
+ let r = Math.imul(a ^ (a >>> 15), 1 | a);
15
+ r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
16
+ return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
17
+ };
18
+ }
19
+
20
+ export const clamp = (v, a = 0, b = 1) => (v < a ? a : v > b ? b : v);
21
+ export const lerp = (a, b, k) => a + (b - a) * k;
22
+ export const smooth = (v) => {
23
+ const c = clamp(v);
24
+ return c * c * (3 - 2 * c);
25
+ };
26
+ /** 0 to 1 over [a, b] with smoothstep */
27
+ export const ramp = (t, a, b) => smooth((t - a) / (b - a));
28
+ export const ease = {
29
+ inOutCubic: (p) => (p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2),
30
+ outCubic: (p) => 1 - Math.pow(1 - p, 3),
31
+ inCubic: (p) => p * p * p,
32
+ outExpo: (p) => (p >= 1 ? 1 : 1 - Math.pow(2, -10 * p)),
33
+ outBack: (p, c = 1.7) => 1 + (c + 1) * Math.pow(p - 1, 3) + c * Math.pow(p - 1, 2),
34
+ };
35
+
36
+ /**
37
+ * Keyframed values as a pure function of t: track([[t0, v0, ease?], [t1, v1, ease?], ...])(t). Each segment uses
38
+ * the ease of its end key (default inOutCubic). Values may be numbers or number arrays.
39
+ */
40
+ export function track(keys) {
41
+ return (t) => {
42
+ if (t <= keys[0][0]) return keys[0][1];
43
+ for (let i = 1; i < keys.length; i++) {
44
+ const [t1, v1, e] = keys[i];
45
+ if (t <= t1) {
46
+ const [t0, v0] = keys[i - 1];
47
+ const p = (e || ease.inOutCubic)(clamp((t - t0) / Math.max(1e-9, t1 - t0)));
48
+ return Array.isArray(v0) ? v0.map((a, j) => lerp(a, v1[j], p)) : lerp(v0, v1, p);
49
+ }
50
+ }
51
+ return keys[keys.length - 1][1];
52
+ };
53
+ }
54
+
55
+ /** Progress 0..1 of item i in a staggered entrance: starts at start + i * gap, lasts dur, eased. */
56
+ export function stagger(t, i, { start = 0, gap = 0.08, dur = 0.5, curve = ease.outCubic } = {}) {
57
+ return curve(clamp((t - start - i * gap) / dur));
58
+ }
59
+
60
+ /** Enter at the start and leave before the end of a scene of length d: 0..1..0 with the given in/out times. */
61
+ export function life(t, d, fadeIn = 0.5, fadeOut = 0.4) {
62
+ return Math.min(smooth(t / fadeIn), smooth((d - t) / fadeOut));
63
+ }
64
+
65
+ /**
66
+ * A stinger: n stripes sweep across in a stair, cover the frame around the middle of dur, then leave the other way.
67
+ * Returns each stripe's covered span [from, to] as fractions of the width (to > from while it shows).
68
+ */
69
+ export function stripes(t, dur, n = 6) {
70
+ const gap = dur * 0.06;
71
+ const half = (dur - gap * (n - 1)) / 2;
72
+ return Array.from({ length: n }, (_, i) => {
73
+ const local = t - i * gap;
74
+ const inP = ease.inOutCubic(clamp(local / half));
75
+ const outP = ease.inOutCubic(clamp((local - half) / half));
76
+ return [outP, inP];
77
+ });
78
+ }
79
+
80
+ /** Decimal places a list of numbers is written with (the most any of them has), at most 3. */
81
+ export function decimals(values) {
82
+ return values.reduce((d, v) => {
83
+ for (let k = 0; k < 3; k++) if (Math.abs(Math.round(v * 10 ** k) - v * 10 ** k) < 1e-9) return Math.max(d, k);
84
+ return 3;
85
+ }, 0);
86
+ }
87
+
88
+ /** The value a counter shows at t: from -> to over [start, start + dur], eased out, rounded to d places. */
89
+ export function counter(t, from, to, start, dur, d = 0) {
90
+ const p = ease.outExpo(clamp((t - start) / dur));
91
+ if (p >= 1) return to;
92
+ return Math.round(lerp(from, to, p) * 10 ** d) / 10 ** d;
93
+ }
94
+
95
+ /** 1234567.5 with d = 1 -> "1,234,567.5": thousands grouped, exactly d decimal places. */
96
+ export function grouped(n, d = 0) {
97
+ const [int, frac] = Math.abs(n).toFixed(d).split(".");
98
+ const sign = n < 0 && Number(Math.abs(n).toFixed(d)) !== 0 ? "-" : "";
99
+ return `${sign}${int.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}${frac ? `.${frac}` : ""}`;
100
+ }
101
+
102
+ /** Spreads n staggered starts over the time left before `end`: the gap shrinks so the last one still finishes. */
103
+ export function fitGap(n, wanted, from, dur, end) {
104
+ if (n <= 1) return wanted;
105
+ return Math.max(0, Math.min(wanted, (end - from - dur) / (n - 1)));
106
+ }
@@ -0,0 +1,236 @@
1
+ // vg-model.mjs: reads a video-gen scene file (JSON), checks it and returns one timeline the renderer draws.
2
+ // Images are read here (local regular files only). No network.
3
+ import { readFileSync, statSync } from "node:fs";
4
+ import { dirname, isAbsolute, resolve, win32 } from "node:path";
5
+
6
+ export const TYPES = ["title", "kinetic", "bullets", "chart", "counter", "lowerthird", "quote", "image", "credits"];
7
+ export const TRANSITIONS = ["cut", "fade", "wipe", "stripes"];
8
+ export const SIZES = { "1280x720": [1280, 720], "1920x1080": [1920, 1080], "1080x1920": [1080, 1920], "1080x1080": [1080, 1080] };
9
+ export const THEME = { bg: "1F3864", fg: "FFFFFF", accent: "2E86C1", muted: "D6EAF8", panel: "F4F6F7", ink: "1B2631" };
10
+ export const PALETTE = ["2E86C1", "E67E22", "27AE60", "9B59B6", "E74C3C", "16A085", "F1C40F", "34495E"];
11
+ export const LIMITS = { duration: 600, scene: 60, sceneMin: 0.5, bullets: 7, lines: 6, credits: 60, points: 24, value: 1e15, side: 16384, pixels: 50e6 };
12
+ const HEX = /^#?[0-9A-Fa-f]{6}$/;
13
+ const MAX_IMAGE = 20 * 1024 * 1024;
14
+
15
+ const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {});
16
+ export const hex = (c) => String(c).replace(/^#/, "").toUpperCase();
17
+
18
+ // Drawn text has no use for control characters; a copied soft line break (U+000B) becomes a line break.
19
+ // Text is put in NFC, so Hangul copied from macOS (jamo apart) draws as syllables.
20
+ export const cleanText = (s) => s.normalize("NFC").replace(/\u{B}/gu, "\n").replace(/[\u{0}-\u{8}\u{C}\u{E}-\u{1F}\u{FFFE}\u{FFFF}]/gu, "").replace(/[\u{D800}-\u{DFFF}]/gu, "");
21
+
22
+ function text(v, what, ctx) {
23
+ if (v === undefined || v === null) return "";
24
+ if (typeof v === "string") return cleanText(v);
25
+ if (typeof v === "number" && Number.isFinite(v)) return String(v);
26
+ ctx.errors.push(`${what}: 글이어야 함`);
27
+ return "";
28
+ }
29
+
30
+ function list(v, what, ctx) {
31
+ if (v === undefined || v === null) return [];
32
+ if (Array.isArray(v)) return v;
33
+ ctx.errors.push(`${what}: 배열이어야 함`);
34
+ return [];
35
+ }
36
+
37
+ function texts(v, what, ctx, max) {
38
+ const all = list(v, what, ctx).map((x, i) => text(x, `${what}[${i}]`, ctx));
39
+ const out = all.filter((x) => x.trim());
40
+ if (out.length < all.length) ctx.warnings.push(`${what}: 빈 항목은 뺌`);
41
+ if (out.length > max) ctx.warnings.push(`${what}: ${max}개를 넘음(화면에 다 들어가지 않을 수 있음)`);
42
+ return out;
43
+ }
44
+
45
+ const inRange = (v, min, max) => typeof v === "number" && Number.isFinite(v) && v >= min && v <= max;
46
+
47
+ function num(v, what, ctx, { min = -Infinity, max = Infinity, def } = {}) {
48
+ if (v === undefined && def !== undefined) return def;
49
+ if (!inRange(v, min, max)) {
50
+ ctx.errors.push(`${what}: ${min} 이상 ${max} 이하 숫자여야 함`);
51
+ return def ?? min;
52
+ }
53
+ return v;
54
+ }
55
+
56
+ function themeOf(doc, ctx) {
57
+ const t = { ...THEME };
58
+ for (const [k, v] of Object.entries(obj(doc.theme))) {
59
+ if (!Object.prototype.hasOwnProperty.call(THEME, k)) ctx.errors.push(`theme.${k} 는 없는 칸(${Object.keys(THEME).join(", ")})`);
60
+ else if (!HEX.test(String(v))) ctx.errors.push(`theme.${k} 색이 6자리 16진수가 아님`);
61
+ else t[k] = hex(v);
62
+ }
63
+ return t;
64
+ }
65
+
66
+ function chartOf(raw, what, ctx) {
67
+ const c = obj(raw);
68
+ const kind = c.kind === undefined ? "bar" : text(c.kind, `${what}.kind`, ctx);
69
+ if (!["bar", "line"].includes(kind)) ctx.errors.push(`${what}.kind 는 bar, line 중 하나`);
70
+ const labels = texts(c.labels, `${what}.labels`, ctx, LIMITS.points);
71
+ const series = list(c.series, `${what}.series`, ctx).map(obj).map((s, k) => {
72
+ const values = list(s.values, `${what}.series[${k}].values`, ctx);
73
+ if (values.some((v) => typeof v !== "number" || !Number.isFinite(v))) ctx.errors.push(`${what}: 계열 값은 숫자만(null 이나 글자 안 됨)`);
74
+ else if (values.some((v) => Math.abs(v) > LIMITS.value)) ctx.errors.push(`${what}: 계열 값은 절댓값 ${LIMITS.value} 이하`);
75
+ if (values.length !== labels.length) ctx.errors.push(`${what}: 계열마다 값 수가 labels 수와 같아야 함`);
76
+ return { name: text(s.name, `${what}.series[${k}].name`, ctx), values: values.map((v) => (typeof v === "number" ? v : 0)) };
77
+ });
78
+ if (!labels.length || !series.length) ctx.errors.push(`${what}: labels 와 series 가 필요함`);
79
+ return { kind, labels, series, unit: text(c.unit, `${what}.unit`, ctx) };
80
+ }
81
+
82
+ /** Pixel size and whether the file ends as its format ends, from the bytes; null when it is no PNG/JPEG/GIF/WebP. */
83
+ export function imageInfo(b) {
84
+ const at = (sig, off = 0) => sig.every((x, i) => b[off + i] === x);
85
+ if (at([0x89, 0x50, 0x4e, 0x47]) && b.length > 24) return { w: b.readUInt32BE(16), h: b.readUInt32BE(20), whole: b.includes("IEND", Math.max(0, b.length - 16)) };
86
+ if (at([0x47, 0x49, 0x46, 0x38]) && b.length > 10) return { w: b.readUInt16LE(6), h: b.readUInt16LE(8), whole: b.lastIndexOf(0x3b) >= b.length - 16 };
87
+ if (at([0xff, 0xd8, 0xff])) return jpegInfo(b);
88
+ if (at([0x52, 0x49, 0x46, 0x46]) && b.length > 30 && b.toString("latin1", 8, 12) === "WEBP") return { ...webpSize(b), whole: b.readUInt32LE(4) + 8 <= b.length };
89
+ return null;
90
+ }
91
+
92
+ // Walks the JPEG segments to the size (SOF) and the first scan (SOS). The file is whole when an end marker (FF D9)
93
+ // follows that scan: inside scan data FF is only followed by 00 or a restart marker, so the first FF D9 after it is
94
+ // the end, whatever is appended behind it (a phone's motion photo puts a whole MP4 there).
95
+ function jpegInfo(b) {
96
+ let size = { w: 0, h: 0 };
97
+ for (let i = 2; i + 3 < b.length;) {
98
+ if (b[i] !== 0xff) break;
99
+ if (b[i + 1] === 0xff) {
100
+ i++;
101
+ continue;
102
+ }
103
+ const marker = b[i + 1];
104
+ if (marker === 0xda) return { ...size, whole: b.indexOf(Buffer.from([0xff, 0xd9]), i + 2) >= 0 };
105
+ if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker) && i + 9 < b.length) size = { w: b.readUInt16BE(i + 7), h: b.readUInt16BE(i + 5) };
106
+ const len = b.readUInt16BE(i + 2);
107
+ if (len < 2) break;
108
+ i += 2 + len;
109
+ }
110
+ return { ...size, whole: false };
111
+ }
112
+
113
+ function webpSize(b) {
114
+ const kind = b.toString("latin1", 12, 16);
115
+ if (kind === "VP8X") return { w: 1 + b.readUIntLE(24, 3), h: 1 + b.readUIntLE(27, 3) };
116
+ if (kind === "VP8L") {
117
+ const v = b.readUInt32LE(21);
118
+ return { w: 1 + (v & 0x3fff), h: 1 + ((v >> 14) & 0x3fff) };
119
+ }
120
+ return { w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff };
121
+ }
122
+
123
+ function checkImage(data) {
124
+ const info = imageInfo(data);
125
+ if (!info) throw new Error("PNG, JPEG, GIF, WebP 가 아님");
126
+ if (!(info.w > 0 && info.h > 0)) throw new Error("그림 크기를 읽지 못함");
127
+ if (info.w > LIMITS.side || info.h > LIMITS.side || info.w * info.h > LIMITS.pixels) throw new Error(`너무 큼(${info.w}x${info.h}, 한 변 ${LIMITS.side}, ${LIMITS.pixels / 1e6}MP 까지)`);
128
+ if (!info.whole) throw new Error("파일이 잘림(끝 표지가 없음)");
129
+ }
130
+
131
+ function imageOf(raw, what, ctx) {
132
+ const p = text(raw, what, ctx);
133
+ const abs = isAbsolute(p) || win32.isAbsolute(p);
134
+ if (!p || (!abs && /^[a-z][a-z0-9+.-]+:/i.test(p))) {
135
+ ctx.errors.push(`${what}: 로컬 파일 경로여야 함(URL 은 받지 않음)`);
136
+ return null;
137
+ }
138
+ const file = abs ? p : resolve(ctx.base, p);
139
+ try {
140
+ const st = statSync(file);
141
+ if (!st.isFile()) throw new Error("보통 파일이 아님");
142
+ if (st.size > MAX_IMAGE) throw new Error("20MB 를 넘음");
143
+ const data = readFileSync(file);
144
+ checkImage(data);
145
+ return { file, data };
146
+ } catch (e) {
147
+ ctx.errors.push(`${what}: 이미지를 읽지 못함 (${file}: ${e.message})`);
148
+ return null;
149
+ }
150
+ }
151
+
152
+ function motionOf(v, what, ctx) {
153
+ if (v === undefined) return "zoom";
154
+ if (v === "zoom" || v === "pan") return v;
155
+ ctx.errors.push(`${what}: zoom, pan 중 하나`);
156
+ return "zoom";
157
+ }
158
+
159
+ const FIELDS = {
160
+ title: (s, w, c) => ({ title: text(s.title, `${w}.title`, c), subtitle: text(s.subtitle, `${w}.subtitle`, c) }),
161
+ kinetic: (s, w, c) => ({ lines: texts(s.lines, `${w}.lines`, c, LIMITS.lines) }),
162
+ bullets: (s, w, c) => ({ title: text(s.title, `${w}.title`, c), bullets: texts(s.bullets, `${w}.bullets`, c, LIMITS.bullets) }),
163
+ chart: (s, w, c) => ({ title: text(s.title, `${w}.title`, c), chart: chartOf(s.chart, `${w}.chart`, c) }),
164
+ counter: (s, w, c) => ({
165
+ label: text(s.label, `${w}.label`, c), unit: text(s.unit, `${w}.unit`, c),
166
+ from: num(s.from, `${w}.from`, c, { def: 0, min: -1e12, max: 1e12 }), to: num(s.to, `${w}.to`, c, { min: -1e12, max: 1e12 }),
167
+ }),
168
+ lowerthird: (s, w, c) => ({ name: text(s.name, `${w}.name`, c), role: text(s.role, `${w}.role`, c), image: s.image === undefined ? null : imageOf(s.image, `${w}.image`, c) }),
169
+ quote: (s, w, c) => ({ quote: text(s.quote, `${w}.quote`, c), by: text(s.by, `${w}.by`, c) }),
170
+ image: (s, w, c) => ({ image: imageOf(s.image, `${w}.image`, c), caption: text(s.caption, `${w}.caption`, c), motion: motionOf(s.motion, `${w}.motion`, c) }),
171
+ credits: (s, w, c) => ({ title: text(s.title, `${w}.title`, c), lines: texts(s.lines, `${w}.lines`, c, LIMITS.credits) }),
172
+ };
173
+
174
+ const NEEDS = { title: "title", kinetic: "lines", bullets: "bullets", chart: "chart", lowerthird: "name", quote: "quote", image: "image", credits: "lines" };
175
+
176
+ function sceneOf(raw, i, ctx) {
177
+ const s = obj(raw);
178
+ const what = `scenes[${i}]`;
179
+ const type = typeof s.type === "string" ? s.type : "";
180
+ if (!TYPES.includes(type)) {
181
+ ctx.errors.push(`${what}: type 은 ${TYPES.join(", ")} 중 하나 (${String(s.type)})`);
182
+ return null;
183
+ }
184
+ const duration = num(s.duration, `${what}.duration`, ctx, { def: 4, min: LIMITS.sceneMin, max: LIMITS.scene });
185
+ const transition = s.transition === undefined ? "fade" : String(s.transition);
186
+ if (!TRANSITIONS.includes(transition)) ctx.errors.push(`${what}.transition 은 ${TRANSITIONS.join(", ")} 중 하나`);
187
+ const scene = { type, duration, transition, ...FIELDS[type](s, what, ctx) };
188
+ const need = NEEDS[type];
189
+ if (need && isEmpty(scene[need])) ctx.errors.push(`${what}: ${need} 가 필요함(빈 글 안 됨)`);
190
+ return scene;
191
+ }
192
+
193
+ /** Empty: missing, blank text, or a list with no non-blank text (a chart or image object is never empty here). */
194
+ function isEmpty(v) {
195
+ if (v === null || v === undefined) return true;
196
+ if (typeof v === "string") return !v.trim();
197
+ if (Array.isArray(v)) return !v.some((x) => typeof x === "string" && x.trim());
198
+ return false;
199
+ }
200
+
201
+ /** Start times: each scene starts where the one before ends. */
202
+ function timeline(scenes) {
203
+ let at = 0;
204
+ return scenes.map((s) => {
205
+ const out = { ...s, start: at };
206
+ at += s.duration;
207
+ return out;
208
+ });
209
+ }
210
+
211
+ /** Frame size and rate: size is one of SIZES, fps a whole number 12..60. */
212
+ function frameOf(doc, ctx) {
213
+ const size = doc.size === undefined ? "1280x720" : String(doc.size);
214
+ if (!Object.prototype.hasOwnProperty.call(SIZES, size)) ctx.errors.push(`size 는 ${Object.keys(SIZES).join(", ")} 중 하나`);
215
+ const fps = num(doc.fps, "fps", ctx, { def: 30, min: 12, max: 60 });
216
+ if (!Number.isInteger(fps)) ctx.errors.push("fps 는 정수여야 함");
217
+ const [width, height] = Object.prototype.hasOwnProperty.call(SIZES, size) ? SIZES[size] : SIZES["1280x720"];
218
+ return { width, height, fps };
219
+ }
220
+
221
+ /** Checks a scene file and returns { errors, warnings, model }; model is null when there are errors. */
222
+ export function readScenes(doc, base) {
223
+ const errors = [];
224
+ const warnings = [];
225
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) return { errors: ["scene 파일은 JSON 객체여야 함"], warnings, model: null };
226
+ const ctx = { errors, warnings, base: base || process.cwd() };
227
+ const { width, height, fps } = frameOf(doc, ctx);
228
+ if (!Array.isArray(doc.scenes) || !doc.scenes.length) errors.push("scenes 가 비어 있음");
229
+ const scenes = timeline((Array.isArray(doc.scenes) ? doc.scenes : []).map((s, i) => sceneOf(s, i, ctx)).filter(Boolean));
230
+ const duration = scenes.reduce((n, s) => n + s.duration, 0);
231
+ if (duration > LIMITS.duration) errors.push(`전체 길이가 ${LIMITS.duration}초를 넘음`);
232
+ const model = { title: text(doc.title, "title", ctx), width, height, fps, theme: themeOf(doc, ctx), scenes, duration, frames: Math.round(duration * fps) };
233
+ return { errors, warnings, model: errors.length ? null : model };
234
+ }
235
+
236
+ export const sceneBase = (file) => dirname(resolve(file));
@@ -0,0 +1,151 @@
1
+ // vg-render.mjs: draws the timeline frame by frame with the bundled CanvasKit and writes the MP4 with the bundled
2
+ // H.264 encoder. A frame is a pure function of its time, so a still at any time matches the video.
3
+ import { loadEngine, mp4Writer } from "./vg-engine.mjs";
4
+ import { Painter } from "./vg-draw.mjs";
5
+ import { clamp, ease, smooth, stripes } from "./vg-fx.mjs";
6
+ import { DRAW } from "./vg-scenes.mjs";
7
+
8
+ const TRANSITION = 0.6;
9
+
10
+ /** The scene showing at global time T and its local time. */
11
+ export function sceneAt(model, T) {
12
+ const i = model.scenes.findIndex((s) => T < s.start + s.duration);
13
+ const k = i < 0 ? model.scenes.length - 1 : i;
14
+ const s = model.scenes[k];
15
+ return { k, s, t: Math.min(s.duration, Math.max(0, T - s.start)) };
16
+ }
17
+
18
+ function transitionOf(model, k, t) {
19
+ const s = model.scenes[k];
20
+ const prev = model.scenes[k - 1];
21
+ if (!prev || s.transition === "cut") return null;
22
+ const dur = Math.min(TRANSITION, prev.duration / 2, s.duration / 2);
23
+ return t < dur ? { prev, p: t / dur, dur } : null;
24
+ }
25
+
26
+ function drawTransition(P, model, tr, s, t) {
27
+ const th = model.theme;
28
+ const drawPrev = () => DRAW[tr.prev.type](P, tr.prev, th, tr.prev.duration);
29
+ const drawCur = () => DRAW[s.type](P, s, th, t);
30
+ const p = ease.inOutCubic(tr.p);
31
+ if (s.transition === "fade") {
32
+ drawPrev();
33
+ P.layer(p, drawCur);
34
+ } else if (s.transition === "wipe") {
35
+ drawPrev();
36
+ P.clip(0, 0, P.W * p, P.H, drawCur);
37
+ P.rect(P.W * p - 6 * P.u, 0, 6 * P.u, P.H, th.accent, 1 - Math.abs(2 * p - 1));
38
+ } else {
39
+ if (tr.p < 0.5) drawPrev();
40
+ else drawCur();
41
+ const band = P.H / 6;
42
+ stripes(tr.p * tr.dur, tr.dur, 6).forEach(([a, b], i) => {
43
+ if (b > a) P.rect(P.W * a, band * i, P.W * (b - a), band + 1, i % 2 ? th.ink : th.accent);
44
+ });
45
+ }
46
+ }
47
+
48
+ /** Draws the frame at global time T. */
49
+ export function drawFrame(P, model, T) {
50
+ // A clean frame every time: nothing of the frame before may show through (a still and a video frame match).
51
+ P.clear(model.theme.bg);
52
+ const { k, s, t } = sceneAt(model, T);
53
+ const tr = transitionOf(model, k, t);
54
+ if (tr) drawTransition(P, model, tr, s, t);
55
+ else DRAW[s.type](P, s, model.theme, t);
56
+ const fade = smooth((T - (model.duration - 0.5)) / 0.5);
57
+ if (fade > 0) P.rect(0, 0, P.W, P.H, model.theme.bg, clamp(fade));
58
+ }
59
+
60
+ async function withPainter(model, use) {
61
+ const { CK, fonts } = await loadEngine();
62
+ const surface = CK.MakeSurface(model.width, model.height);
63
+ if (!surface) throw new Error("그림판(surface)을 만들지 못함");
64
+ const P = new Painter(CK, fonts, surface.getCanvas(), model.width, model.height);
65
+ try {
66
+ return await use({ CK, surface, P });
67
+ } finally {
68
+ P.free();
69
+ surface.delete();
70
+ }
71
+ }
72
+
73
+ function pixels(CK, surface, w, h) {
74
+ const img = surface.makeImageSnapshot();
75
+ const px = img.readPixels(0, 0, { width: w, height: h, colorType: CK.ColorType.RGBA_8888, alphaType: CK.AlphaType.Unpremul, colorSpace: CK.ColorSpace.SRGB });
76
+ img.delete();
77
+ return px;
78
+ }
79
+
80
+ /** Renders the whole timeline; returns { bytes, frames }. onFrame(i, n) is called after each frame. */
81
+ export function renderMp4(model, { quality = 20, speed = 5, onFrame } = {}) {
82
+ return withPainter(model, async ({ CK, surface, P }) => {
83
+ const out = await mp4Writer({ width: model.width, height: model.height, fps: model.fps, quality, speed });
84
+ try {
85
+ for (let i = 0; i < model.frames; i++) {
86
+ drawFrame(P, model, i / model.fps);
87
+ surface.flush();
88
+ out.add(pixels(CK, surface, model.width, model.height));
89
+ if (onFrame) onFrame(i + 1, model.frames);
90
+ }
91
+ } catch (e) {
92
+ try {
93
+ out.abort();
94
+ } catch {
95
+ // keep the error that stopped the render
96
+ }
97
+ throw e;
98
+ }
99
+ return out.finish();
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Decodes every image of the timeline once with the engine, so a file that only looks like an image fails at
105
+ * check time rather than minutes into a render. Returns error lines (empty when all decode).
106
+ */
107
+ export async function probeImages(model) {
108
+ const { CK } = await loadEngine();
109
+ const errors = [];
110
+ model.scenes.forEach((s, i) => {
111
+ if (!s.image) return;
112
+ const im = CK.MakeImageFromEncoded(s.image.data);
113
+ if (!im || !(im.width() > 0)) errors.push(`scenes[${i}]: 그림을 풀지 못함 (${s.image.file})`);
114
+ if (im) im.delete();
115
+ });
116
+ return errors;
117
+ }
118
+
119
+ /** Text that will not fit its place, measured with the bundled fonts. Returns warning lines. */
120
+ export function layoutWarnings(model) {
121
+ return withPainter(model, ({ P }) => {
122
+ const out = [];
123
+ const m = Math.round(P.W * 0.08);
124
+ model.scenes.forEach((s, i) => {
125
+ const w = `scenes[${i}]`;
126
+ if (s.type === "kinetic") {
127
+ const size = (s.lines.length > 3 ? 56 : 76) * P.u;
128
+ if (s.lines.some((l) => P.measure(l, { size, bold: true, width: 1e6 }).w > P.W * 0.92)) out.push(`${w}: 줄이 화면 너비를 넘음(줄을 나누십시오)`);
129
+ }
130
+ const head = s.title ? P.measure(s.title, { size: 44 * P.u, bold: true, width: P.W - 2 * m }).h : 0;
131
+ if (["bullets", "chart"].includes(s.type) && head > 44 * P.u * 1.25 * 1.5) out.push(`${w}: 제목이 두 줄 이상이라 본문이 좁아짐`);
132
+ if (s.type === "bullets") {
133
+ const body = s.bullets.reduce((h, b) => h + P.measure(b, { size: 32 * P.u, width: P.W - 2 * m - 34 * P.u }).h + 16 * P.u, 0);
134
+ if (m * 0.8 + head + 40 * P.u + body > P.H - m * 0.5) out.push(`${w}: 글머리가 화면 아래로 넘침`);
135
+ }
136
+ });
137
+ return out;
138
+ });
139
+ }
140
+
141
+ /** One frame at time T as PNG bytes. */
142
+ export function renderStill(model, T) {
143
+ return withPainter(model, ({ surface, P }) => {
144
+ drawFrame(P, model, Math.max(0, Math.min(model.duration, T)));
145
+ surface.flush();
146
+ const img = surface.makeImageSnapshot();
147
+ const png = img.encodeToBytes();
148
+ img.delete();
149
+ return Buffer.from(png);
150
+ });
151
+ }