@kaydash9999/banker-plugins 0.27.0 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +22 -0
- package/README.md +17 -5
- package/codex/manifest.json +3 -0
- package/package.json +1 -1
- package/skills/video-gen/SKILL.md +141 -0
- package/skills/video-gen/assets/example.json +36 -0
- package/skills/video-gen/scripts/vg-catalog.mjs +23 -0
- package/skills/video-gen/scripts/vg-deck.mjs +72 -0
- package/skills/video-gen/scripts/vg-draw.mjs +231 -0
- package/skills/video-gen/scripts/vg-engine.mjs +74 -0
- package/skills/video-gen/scripts/vg-fx.mjs +106 -0
- package/skills/video-gen/scripts/vg-model.mjs +236 -0
- package/skills/video-gen/scripts/vg-render.mjs +151 -0
- package/skills/video-gen/scripts/vg-scenes.mjs +226 -0
- package/skills/video-gen/scripts/video-gen.mjs +212 -0
- package/skills/video-gen/vendor/README.md +25 -0
- package/skills/video-gen/vendor/aimotion/LICENSE.md +21 -0
- package/skills/video-gen/vendor/canvaskit/LICENSE.md +29 -0
- package/skills/video-gen/vendor/canvaskit/THIRD-PARTY-NOTICES.md +1366 -0
- package/skills/video-gen/vendor/canvaskit/canvaskit.js +270 -0
- package/skills/video-gen/vendor/canvaskit/canvaskit.wasm +0 -0
- package/skills/video-gen/vendor/fonts/NotoSans-Bold.ttf +0 -0
- package/skills/video-gen/vendor/fonts/NotoSans-Regular.ttf +0 -0
- package/skills/video-gen/vendor/fonts/NotoSansKR-Bold.otf +0 -0
- package/skills/video-gen/vendor/fonts/NotoSansKR-Regular.otf +0 -0
- package/skills/video-gen/vendor/fonts/OFL-noto-sans-kr.md +94 -0
- package/skills/video-gen/vendor/fonts/OFL-noto-sans.md +93 -0
- package/skills/video-gen/vendor/h264-mp4-encoder/LICENSE.md +21 -0
- package/skills/video-gen/vendor/h264-mp4-encoder/MPL-1.1.md +469 -0
- package/skills/video-gen/vendor/h264-mp4-encoder/h264-mp4-encoder.node.js +2 -0
- package/skills/wbs-gen/SKILL.md +127 -0
- package/skills/wbs-gen/assets/example.json +58 -0
- package/skills/wbs-gen/scripts/wbs-gen.mjs +238 -0
- package/skills/wbs-gen/scripts/wg-dates.mjs +106 -0
- package/skills/wbs-gen/scripts/wg-holidays.mjs +89 -0
- package/skills/wbs-gen/scripts/wg-html.mjs +108 -0
- package/skills/wbs-gen/scripts/wg-plan.mjs +230 -0
- package/skills/wbs-gen/scripts/wg-xlsx.mjs +328 -0
- package/skills/wbs-gen/scripts/wg-zip.mjs +83 -0
- package/skills/wide-pptx/SKILL.md +121 -0
- package/skills/wide-pptx/assets/example.json +63 -0
- package/skills/wide-pptx/scripts/wide-pptx.mjs +185 -0
- package/skills/wide-pptx/scripts/wp-html.mjs +98 -0
- package/skills/wide-pptx/scripts/wp-model.mjs +236 -0
- package/skills/wide-pptx/scripts/wp-pptx.mjs +219 -0
- package/skills/wide-pptx/scripts/wp-svg.mjs +114 -0
- package/skills/wide-pptx/vendor/LICENSE-jszip.md +651 -0
- package/skills/wide-pptx/vendor/LICENSE-pako.md +21 -0
- package/skills/wide-pptx/vendor/LICENSE-pptxgenjs.md +21 -0
- package/skills/wide-pptx/vendor/README.md +12 -0
- package/skills/wide-pptx/vendor/THIRD-PARTY-NOTICES.md +142 -0
- package/skills/wide-pptx/vendor/pptxgen.bundle.js +96 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// vg-draw.mjs: drawing helpers on a CanvasKit canvas for video-gen. A Painter holds the objects one render reuses
|
|
2
|
+
// (paints, laid-out paragraphs, decoded images) and frees them at the end, since CanvasKit memory is not collected.
|
|
3
|
+
import { FAMILIES } from "./vg-engine.mjs";
|
|
4
|
+
|
|
5
|
+
const PARA_CACHE = 600;
|
|
6
|
+
const IMAGE_CACHE = 4;
|
|
7
|
+
const rgb = (h) => [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
|
8
|
+
|
|
9
|
+
export class Painter {
|
|
10
|
+
constructor(CK, fonts, canvas, width, height) {
|
|
11
|
+
this.CK = CK;
|
|
12
|
+
this.fonts = fonts;
|
|
13
|
+
this.c = canvas;
|
|
14
|
+
this.W = width;
|
|
15
|
+
this.H = height;
|
|
16
|
+
// One unit is a pixel of a 1280 x 720 frame, scaled to fit this frame's shorter side.
|
|
17
|
+
this.u = Math.min(width / 1280, height / 720);
|
|
18
|
+
this.paint = new CK.Paint();
|
|
19
|
+
this.paint.setAntiAlias(true);
|
|
20
|
+
// Images get a paint of their own: the shape paint's colour alpha would fade them.
|
|
21
|
+
this.imagePaint = new CK.Paint();
|
|
22
|
+
this.imagePaint.setAntiAlias(true);
|
|
23
|
+
this.paras = new Map();
|
|
24
|
+
this.images = new Map();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
color(h, a = 1) {
|
|
28
|
+
const [r, g, b] = rgb(h);
|
|
29
|
+
return this.CK.Color(r, g, b, Math.max(0, Math.min(1, a)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
clear(h) {
|
|
33
|
+
this.c.clear(this.color(h));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
rect(x, y, w, h, color, alpha = 1, radius = 0) {
|
|
37
|
+
if (w <= 0 || h <= 0 || alpha <= 0) return;
|
|
38
|
+
this.paint.setStyle(this.CK.PaintStyle.Fill);
|
|
39
|
+
this.paint.setColor(this.color(color, alpha));
|
|
40
|
+
const r = this.CK.LTRBRect(x, y, x + w, y + h);
|
|
41
|
+
if (radius > 0) this.c.drawRRect(this.CK.RRectXY(r, radius, radius), this.paint);
|
|
42
|
+
else this.c.drawRect(r, this.paint);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
circle(x, y, r, color, alpha = 1) {
|
|
46
|
+
if (r <= 0 || alpha <= 0) return;
|
|
47
|
+
this.paint.setStyle(this.CK.PaintStyle.Fill);
|
|
48
|
+
this.paint.setColor(this.color(color, alpha));
|
|
49
|
+
this.c.drawCircle(x, y, r, this.paint);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
line(points, color, width, alpha = 1) {
|
|
53
|
+
if (points.length < 2) return;
|
|
54
|
+
// CanvasKit 0.42 paths are immutable: build one, then detach it.
|
|
55
|
+
const b = new this.CK.PathBuilder();
|
|
56
|
+
b.moveTo(points[0][0], points[0][1]);
|
|
57
|
+
for (const p of points.slice(1)) b.lineTo(p[0], p[1]);
|
|
58
|
+
const path = b.detachAndDelete();
|
|
59
|
+
this.paint.setStyle(this.CK.PaintStyle.Stroke);
|
|
60
|
+
this.paint.setStrokeWidth(width);
|
|
61
|
+
this.paint.setStrokeJoin(this.CK.StrokeJoin.Round);
|
|
62
|
+
this.paint.setStrokeCap(this.CK.StrokeCap.Round);
|
|
63
|
+
this.paint.setColor(this.color(color, alpha));
|
|
64
|
+
this.c.drawPath(path, this.paint);
|
|
65
|
+
path.delete();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A laid-out paragraph: size in px, bold, color hex, width px, align left|center|right. Not cached. */
|
|
69
|
+
build(text, { size, bold = false, color = "FFFFFF", width = this.W, align = "left", lineHeight = 1.25 }) {
|
|
70
|
+
const CK = this.CK;
|
|
71
|
+
const style = new CK.ParagraphStyle({
|
|
72
|
+
textAlign: { left: CK.TextAlign.Left, center: CK.TextAlign.Center, right: CK.TextAlign.Right }[align],
|
|
73
|
+
textStyle: {
|
|
74
|
+
color: this.color(color), fontFamilies: FAMILIES, fontSize: size, heightMultiplier: lineHeight,
|
|
75
|
+
fontStyle: { weight: bold ? CK.FontWeight.Bold : CK.FontWeight.Normal },
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const b = CK.ParagraphBuilder.MakeFromFontProvider(style, this.fonts);
|
|
79
|
+
b.addText(text);
|
|
80
|
+
const p = b.build();
|
|
81
|
+
b.delete();
|
|
82
|
+
p.layout(width);
|
|
83
|
+
return p;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A cached paragraph. The cache keeps the most recently used PARA_CACHE entries and frees older ones, so a long
|
|
88
|
+
* video does not run the WASM heap out (a frame uses far fewer).
|
|
89
|
+
*/
|
|
90
|
+
para(text, opts) {
|
|
91
|
+
const key = JSON.stringify([text, opts.size, !!opts.bold, opts.color, opts.width, opts.align, opts.lineHeight]);
|
|
92
|
+
let p = this.paras.get(key);
|
|
93
|
+
if (p) {
|
|
94
|
+
this.paras.delete(key);
|
|
95
|
+
} else {
|
|
96
|
+
p = this.build(text, opts);
|
|
97
|
+
while (this.paras.size >= PARA_CACHE) {
|
|
98
|
+
const [old, q] = this.paras.entries().next().value;
|
|
99
|
+
q.delete();
|
|
100
|
+
this.paras.delete(old);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
this.paras.set(key, p);
|
|
104
|
+
return p;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Draws text at (x, y) top-left with alpha and an optional scale about its centre; returns { w, h }.
|
|
109
|
+
* transient text (a number that changes every frame) is laid out, drawn and freed, never cached.
|
|
110
|
+
*/
|
|
111
|
+
text(text, x, y, opts, { alpha = 1, scale = 1, transient = false } = {}) {
|
|
112
|
+
const p = transient ? this.build(text, opts) : this.para(text, opts);
|
|
113
|
+
const { w, h } = this.size(p, opts);
|
|
114
|
+
if (alpha > 0 && text) this.place(p, { x, y, w, h, pad: opts.size }, alpha, scale);
|
|
115
|
+
if (transient) p.delete();
|
|
116
|
+
return { w, h };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Draws a laid-out paragraph faded and scaled about its centre, in a layer bounded by its box. */
|
|
120
|
+
place(p, { x, y, w, h, pad }, alpha, scale) {
|
|
121
|
+
const cx = x + w / 2;
|
|
122
|
+
const cy = y + h / 2;
|
|
123
|
+
const box = [cx - (w / 2) * scale - pad, cy - (h / 2) * scale - pad, cx + (w / 2) * scale + pad, cy + (h / 2) * scale + pad];
|
|
124
|
+
this.layer(alpha, () => {
|
|
125
|
+
if (scale !== 1) {
|
|
126
|
+
this.c.translate(cx, cy);
|
|
127
|
+
this.c.scale(scale, scale);
|
|
128
|
+
this.c.translate(-cx, -cy);
|
|
129
|
+
}
|
|
130
|
+
this.c.drawParagraph(p, x, y);
|
|
131
|
+
}, box);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Width (the text's own for left-aligned text, else the box) and height of a laid-out paragraph. */
|
|
135
|
+
size(p, opts) {
|
|
136
|
+
const left = opts.align === "left" || !opts.align;
|
|
137
|
+
return { w: left ? p.getMaxIntrinsicWidth() : opts.width ?? this.W, h: p.getHeight() };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
measure(text, opts) {
|
|
141
|
+
const p = this.para(text, opts);
|
|
142
|
+
return { w: p.getMaxIntrinsicWidth(), h: p.getHeight() };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Runs draw() with everything it draws faded to alpha. Below 1 it draws into a layer, bounded by box
|
|
147
|
+
* [left, top, right, bottom] when given: an unbounded layer is the whole frame and costs that much per call.
|
|
148
|
+
*/
|
|
149
|
+
layer(alpha, draw, box) {
|
|
150
|
+
if (alpha >= 1) {
|
|
151
|
+
this.c.save();
|
|
152
|
+
draw();
|
|
153
|
+
this.c.restore();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const p = new this.CK.Paint();
|
|
157
|
+
p.setAlphaf(Math.max(0, alpha));
|
|
158
|
+
if (box) this.c.saveLayer(p, this.CK.LTRBRect(...box));
|
|
159
|
+
else this.c.saveLayer(p);
|
|
160
|
+
draw();
|
|
161
|
+
this.c.restore();
|
|
162
|
+
p.delete();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
clip(x, y, w, h, draw) {
|
|
166
|
+
this.c.save();
|
|
167
|
+
this.c.clipRect(this.CK.LTRBRect(x, y, x + w, y + h), this.CK.ClipOp.Intersect, true);
|
|
168
|
+
draw();
|
|
169
|
+
this.c.restore();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The image for a scene, decoded once and shrunk to at most 1.15 x the frame's longer side (sharp enough for the
|
|
174
|
+
* slow zoom, small in memory); only the most recent IMAGE_CACHE images are kept.
|
|
175
|
+
*/
|
|
176
|
+
decoded(img) {
|
|
177
|
+
let im = this.images.get(img.file);
|
|
178
|
+
if (im) {
|
|
179
|
+
this.images.delete(img.file);
|
|
180
|
+
this.images.set(img.file, im);
|
|
181
|
+
return im;
|
|
182
|
+
}
|
|
183
|
+
const full = this.CK.MakeImageFromEncoded(img.data);
|
|
184
|
+
if (!full) throw new Error(`그림을 풀지 못함: ${img.file}`);
|
|
185
|
+
im = this.shrink(full);
|
|
186
|
+
while (this.images.size >= IMAGE_CACHE) {
|
|
187
|
+
const [old, q] = this.images.entries().next().value;
|
|
188
|
+
q.delete();
|
|
189
|
+
this.images.delete(old);
|
|
190
|
+
}
|
|
191
|
+
this.images.set(img.file, im);
|
|
192
|
+
return im;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
shrink(full) {
|
|
196
|
+
// Down to the size it covers the frame at (plus room for the zoom), so a portrait or panorama is not enlarged again.
|
|
197
|
+
const k = Math.min(1, Math.max(this.W / full.width(), this.H / full.height()) * 1.15);
|
|
198
|
+
if (k >= 1) return full;
|
|
199
|
+
const w = Math.max(1, Math.round(full.width() * k));
|
|
200
|
+
const h = Math.max(1, Math.round(full.height() * k));
|
|
201
|
+
const s = this.CK.MakeSurface(w, h);
|
|
202
|
+
s.getCanvas().drawImageRectOptions(full, this.CK.LTRBRect(0, 0, full.width(), full.height()), this.CK.LTRBRect(0, 0, w, h), this.CK.FilterMode.Linear, this.CK.MipmapMode.Linear, null);
|
|
203
|
+
const small = s.makeImageSnapshot();
|
|
204
|
+
s.delete();
|
|
205
|
+
full.delete();
|
|
206
|
+
return small;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Draws an image to cover the box, zoomed by zoom about a point that drifts by pan (-1..1 of the slack). */
|
|
210
|
+
image(img, box, { zoom = 1, pan = 0, alpha = 1 } = {}) {
|
|
211
|
+
const im = this.decoded(img);
|
|
212
|
+
const k = Math.max(box.w / im.width(), box.h / im.height()) * zoom;
|
|
213
|
+
const w = im.width() * k;
|
|
214
|
+
const h = im.height() * k;
|
|
215
|
+
const x = box.x + (box.w - w) / 2 + ((w - box.w) / 2) * pan;
|
|
216
|
+
const y = box.y + (box.h - h) / 2;
|
|
217
|
+
this.clip(box.x, box.y, box.w, box.h, () => this.layer(alpha, () => {
|
|
218
|
+
const src = this.CK.LTRBRect(0, 0, im.width(), im.height());
|
|
219
|
+
this.c.drawImageRectOptions(im, src, this.CK.LTRBRect(x, y, x + w, y + h), this.CK.FilterMode.Linear, this.CK.MipmapMode.None, this.imagePaint);
|
|
220
|
+
}, [box.x, box.y, box.x + box.w, box.y + box.h]));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
free() {
|
|
224
|
+
for (const p of this.paras.values()) p.delete();
|
|
225
|
+
for (const im of this.images.values()) im.delete();
|
|
226
|
+
this.paras.clear();
|
|
227
|
+
this.images.clear();
|
|
228
|
+
this.paint.delete();
|
|
229
|
+
this.imagePaint.delete();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -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));
|