@vosjs/cli 0.13.1 → 0.15.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/dist/{chunk-YICRU4ER.js → chunk-HGLKXIXH.js} +2789 -297
- package/dist/chunk-HGLKXIXH.js.map +1 -0
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/dist/{run-PCWIQ4GI.js → run-37GVK3VS.js} +2 -2
- package/package.json +2 -2
- package/schema/actions.schema.json +76 -12
- package/schema/channel-specs.json +135 -22
- package/schema/doc.schema.json +418 -103
- package/dist/chunk-YICRU4ER.js.map +0 -1
- /package/dist/{run-PCWIQ4GI.js.map → run-37GVK3VS.js.map} +0 -0
|
@@ -6,11 +6,11 @@ import {
|
|
|
6
6
|
} from "./chunk-NHKHTIDR.js";
|
|
7
7
|
|
|
8
8
|
// src/plugin/run.ts
|
|
9
|
-
import { mkdir as
|
|
10
|
-
import { existsSync as
|
|
11
|
-
import { join as
|
|
9
|
+
import { mkdir as mkdir8, readFile as readFile12, rm as rm4 } from "fs/promises";
|
|
10
|
+
import { existsSync as existsSync12 } from "fs";
|
|
11
|
+
import { join as join15, resolve as resolve7 } from "path";
|
|
12
12
|
import { totalDuration as totalDuration2 } from "@vosjs/timeline";
|
|
13
|
-
import { migrateHostedDoc as migrateHostedDoc4, ratedSegments as
|
|
13
|
+
import { migrateHostedDoc as migrateHostedDoc4, ratedSegments as ratedSegments6 } from "@vosjs/studio-core";
|
|
14
14
|
|
|
15
15
|
// src/plugin/args.ts
|
|
16
16
|
var UsageError = class extends Error {
|
|
@@ -127,22 +127,22 @@ function validateActions(value) {
|
|
|
127
127
|
}
|
|
128
128
|
const seenIds = /* @__PURE__ */ new Set();
|
|
129
129
|
obj.steps.forEach((raw, i) => {
|
|
130
|
-
const
|
|
130
|
+
const at2 = `steps[${i}]`;
|
|
131
131
|
if (typeof raw !== "object" || raw === null) {
|
|
132
|
-
errors.push(`${
|
|
132
|
+
errors.push(`${at2}: must be an object`);
|
|
133
133
|
return;
|
|
134
134
|
}
|
|
135
135
|
const s = raw;
|
|
136
136
|
if (typeof s.do !== "string" || !VERBS.has(s.do)) {
|
|
137
|
-
errors.push(`${
|
|
137
|
+
errors.push(`${at2}: "do" must be one of ${[...VERBS].join(", ")}`);
|
|
138
138
|
return;
|
|
139
139
|
}
|
|
140
140
|
if (s.id !== void 0) {
|
|
141
141
|
if (typeof s.id !== "string" || !s.id.trim()) {
|
|
142
|
-
errors.push(`${
|
|
142
|
+
errors.push(`${at2}: id must be a non-empty string`);
|
|
143
143
|
} else if (seenIds.has(s.id)) {
|
|
144
144
|
errors.push(
|
|
145
|
-
`${
|
|
145
|
+
`${at2}: duplicate id "${s.id}" \u2014 an anchor could not tell the two steps apart`
|
|
146
146
|
);
|
|
147
147
|
} else {
|
|
148
148
|
seenIds.add(s.id);
|
|
@@ -150,26 +150,26 @@ function validateActions(value) {
|
|
|
150
150
|
}
|
|
151
151
|
const needSelector = s.do === "hover" || s.do === "click" || s.do === "type";
|
|
152
152
|
if (needSelector && typeof s.selector !== "string")
|
|
153
|
-
errors.push(`${
|
|
153
|
+
errors.push(`${at2}: ${s.do} needs a selector`);
|
|
154
154
|
if (s.do === "wait" && typeof s.ms !== "number")
|
|
155
|
-
errors.push(`${
|
|
155
|
+
errors.push(`${at2}: wait needs ms`);
|
|
156
156
|
if (s.do === "type" && typeof s.text !== "string")
|
|
157
|
-
errors.push(`${
|
|
157
|
+
errors.push(`${at2}: type needs text`);
|
|
158
158
|
if (s.do === "type" && s.focus !== void 0 && typeof s.focus !== "boolean")
|
|
159
|
-
errors.push(`${
|
|
159
|
+
errors.push(`${at2}: type focus must be true or false`);
|
|
160
160
|
if (s.do === "scroll" && typeof s.dy !== "number")
|
|
161
|
-
errors.push(`${
|
|
161
|
+
errors.push(`${at2}: scroll needs dy`);
|
|
162
162
|
if (s.do === "move" && (typeof s.x !== "number" || typeof s.y !== "number")) {
|
|
163
|
-
errors.push(`${
|
|
163
|
+
errors.push(`${at2}: move needs x and y (CSS px)`);
|
|
164
164
|
}
|
|
165
165
|
if (s.do === "drag") {
|
|
166
166
|
if (typeof s.tx !== "number" || typeof s.ty !== "number") {
|
|
167
|
-
errors.push(`${
|
|
167
|
+
errors.push(`${at2}: drag needs tx and ty (CSS px)`);
|
|
168
168
|
}
|
|
169
169
|
const hasStart = typeof s.selector === "string" || typeof s.x === "number" && typeof s.y === "number";
|
|
170
170
|
if (!hasStart)
|
|
171
171
|
errors.push(
|
|
172
|
-
`${
|
|
172
|
+
`${at2}: drag needs a selector OR x and y as the start point`
|
|
173
173
|
);
|
|
174
174
|
}
|
|
175
175
|
});
|
|
@@ -317,6 +317,33 @@ function lintDoc(docIn) {
|
|
|
317
317
|
);
|
|
318
318
|
}
|
|
319
319
|
}
|
|
320
|
+
for (const [i, seg] of (Array.isArray(doc.segments) ? doc.segments : []).entries()) {
|
|
321
|
+
const hold = seg.hold;
|
|
322
|
+
if (hold !== void 0 && (!isNum(hold) || hold < 0 || hold > 10)) {
|
|
323
|
+
problems.push(
|
|
324
|
+
`segments[${i}].hold must be 0..10 output seconds (got ${String(hold)})`
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const endCard = doc.endCard;
|
|
329
|
+
if (endCard !== void 0) {
|
|
330
|
+
if (typeof endCard !== "object" || endCard === null) {
|
|
331
|
+
problems.push("endCard must be an object: {seconds?, headline?, sub?, wordmark?}");
|
|
332
|
+
} else {
|
|
333
|
+
const ec = endCard;
|
|
334
|
+
if (ec.seconds !== void 0 && (!isNum(ec.seconds) || ec.seconds < 1 || ec.seconds > 8)) {
|
|
335
|
+
problems.push(`endCard.seconds must be 1..8 (got ${String(ec.seconds)}); absent = 2.5`);
|
|
336
|
+
}
|
|
337
|
+
for (const k of ["headline", "sub", "wordmark"]) {
|
|
338
|
+
if (ec[k] !== void 0 && typeof ec[k] !== "string") {
|
|
339
|
+
problems.push(`endCard.${k} must be a string`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (!["headline", "sub", "wordmark"].some((k) => typeof ec[k] === "string" && ec[k].trim())) {
|
|
343
|
+
warnings.push("endCard carries no words: it holds the last frame and recedes the card over nothing");
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
320
347
|
if (doc.segments !== void 0 && !Array.isArray(doc.segments)) {
|
|
321
348
|
problems.push("segments must be an array of {in, out} spans");
|
|
322
349
|
}
|
|
@@ -524,9 +551,69 @@ function lintDoc(docIn) {
|
|
|
524
551
|
"frame.borderWidth/borderColor are set but frame.border is 0 \u2014 border is the switch AND the alpha, so nothing is drawn"
|
|
525
552
|
);
|
|
526
553
|
}
|
|
554
|
+
const sc = frame.shadowContact;
|
|
555
|
+
if (sc !== void 0 && (!isNum(sc) || sc < 0 || sc > 1)) {
|
|
556
|
+
problems.push(
|
|
557
|
+
`frame.shadowContact must be 0..1 (got ${String(sc)}); absent = no contact layer`
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
const shc = frame.shadowColor;
|
|
561
|
+
if (shc !== void 0 && !/^#[0-9a-fA-F]{6}$/.test(String(shc))) {
|
|
562
|
+
problems.push(
|
|
563
|
+
`frame.shadowColor must be a #rrggbb hex (got ${String(shc)}); absent = black`
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
const ent = frame.entrance;
|
|
567
|
+
if (ent !== void 0) {
|
|
568
|
+
const kinds = ["tilt-in", "pull-out", "rise", "none"];
|
|
569
|
+
if (typeof ent !== "object" || ent === null || !kinds.includes(String(ent.kind))) {
|
|
570
|
+
problems.push(
|
|
571
|
+
`frame.entrance.kind must be one of ${kinds.join(" | ")} (got ${JSON.stringify(ent)})`
|
|
572
|
+
);
|
|
573
|
+
} else {
|
|
574
|
+
const secs = ent.seconds;
|
|
575
|
+
if (secs !== void 0 && (!isNum(secs) || secs < 0.2 || secs > 3)) {
|
|
576
|
+
problems.push(
|
|
577
|
+
`frame.entrance.seconds must be 0.2..3 (got ${String(secs)}); absent = 1.2`
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (frame.focusFollow !== void 0 && frame.focusFollow !== "camera") {
|
|
583
|
+
problems.push(
|
|
584
|
+
`frame.focusFollow must be "camera" (got ${String(frame.focusFollow)}); it reads under fit: cover only`
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
const ins = frame.inset;
|
|
588
|
+
if (ins !== void 0) {
|
|
589
|
+
if (typeof ins !== "object" || ins === null || Array.isArray(ins)) {
|
|
590
|
+
problems.push(
|
|
591
|
+
"frame.inset must be an object of {top, right, bottom, left} fractions"
|
|
592
|
+
);
|
|
593
|
+
} else {
|
|
594
|
+
const sides = ins;
|
|
595
|
+
for (const side of ["top", "right", "bottom", "left"]) {
|
|
596
|
+
const v = sides[side];
|
|
597
|
+
if (v !== void 0 && (!isNum(v) || v < -2 || v > 0.9)) {
|
|
598
|
+
problems.push(
|
|
599
|
+
`frame.inset.${side} must be a fraction of the frame in -2..0.9 (got ${String(v)}); negative bleeds the card past the edge`
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
const l = isNum(sides.left) ? sides.left : 0;
|
|
604
|
+
const r = isNum(sides.right) ? sides.right : 0;
|
|
605
|
+
const t = isNum(sides.top) ? sides.top : 0;
|
|
606
|
+
const b = isNum(sides.bottom) ? sides.bottom : 0;
|
|
607
|
+
if (l + r >= 1 || t + b >= 1) {
|
|
608
|
+
problems.push(
|
|
609
|
+
`frame.inset leaves no room for the card (left+right ${l + r}, top+bottom ${t + b}; each pair must stay under 1)`
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
527
614
|
const ebw = isNum(bw) && bw > 0 && bw <= 24 ? bw : 1.5;
|
|
528
615
|
const pad = isNum(frame.padding) ? frame.padding : 0;
|
|
529
|
-
if (frame.border && ebw > pad) {
|
|
616
|
+
if (frame.border && ebw > pad && ins === void 0) {
|
|
530
617
|
warnings.push(
|
|
531
618
|
`frame.borderWidth (${ebw}) is wider than frame.padding (${pad}) \u2014 the border grows outward from the card, so the frame edge crops it; raise the padding to at least the width to show the whole stroke`
|
|
532
619
|
);
|
|
@@ -1260,10 +1347,10 @@ function startTakeServer(rootDir, pages) {
|
|
|
1260
1347
|
res.writeHead(404).end();
|
|
1261
1348
|
});
|
|
1262
1349
|
return new Promise(
|
|
1263
|
-
(
|
|
1350
|
+
(resolve8) => server.listen(0, () => {
|
|
1264
1351
|
const addr = server.address();
|
|
1265
1352
|
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
1266
|
-
|
|
1353
|
+
resolve8({ base: `http://localhost:${port}`, close: () => server.close() });
|
|
1267
1354
|
})
|
|
1268
1355
|
);
|
|
1269
1356
|
}
|
|
@@ -1277,10 +1364,10 @@ async function waitForPageDone(page, label, onProgress, timeoutMs) {
|
|
|
1277
1364
|
if (state.error) throw new Error(`${label} failed: ${state.error}`);
|
|
1278
1365
|
if (state.done) return state.done;
|
|
1279
1366
|
if (typeof state.progress === "number") {
|
|
1280
|
-
const
|
|
1281
|
-
if (
|
|
1367
|
+
const pct2 = Math.floor(state.progress * 10) * 10;
|
|
1368
|
+
if (pct2 !== last) {
|
|
1282
1369
|
onProgress(state.progress);
|
|
1283
|
-
last =
|
|
1370
|
+
last = pct2;
|
|
1284
1371
|
}
|
|
1285
1372
|
}
|
|
1286
1373
|
if (Date.now() - start > timeoutMs) {
|
|
@@ -1500,14 +1587,14 @@ async function renderInPage(opts) {
|
|
|
1500
1587
|
w.__error = String(e instanceof Error && e.stack || e);
|
|
1501
1588
|
}
|
|
1502
1589
|
}
|
|
1503
|
-
async function renderChunk(context, serverBase,
|
|
1504
|
-
const outName = `render-chunk-${
|
|
1590
|
+
async function renderChunk(context, serverBase, chunk2, opts, onChunkProgress) {
|
|
1591
|
+
const outName = `render-chunk-${chunk2.index}.tmp`;
|
|
1505
1592
|
const page = await context.newPage();
|
|
1506
1593
|
try {
|
|
1507
1594
|
page.on("console", (m) => {
|
|
1508
1595
|
if (m.type() === "error" || process.env.VOS_RENDER_DEBUG)
|
|
1509
1596
|
process.stderr.write(
|
|
1510
|
-
` [render page ${
|
|
1597
|
+
` [render page ${chunk2.index} ${m.type()}] ${m.text()}
|
|
1511
1598
|
`
|
|
1512
1599
|
);
|
|
1513
1600
|
});
|
|
@@ -1515,7 +1602,7 @@ async function renderChunk(context, serverBase, chunk, opts, onChunkProgress) {
|
|
|
1515
1602
|
page.on(
|
|
1516
1603
|
"pageerror",
|
|
1517
1604
|
(e) => process.stderr.write(
|
|
1518
|
-
` [render pageerror ${
|
|
1605
|
+
` [render pageerror ${chunk2.index}] ${String(e)}
|
|
1519
1606
|
`
|
|
1520
1607
|
)
|
|
1521
1608
|
);
|
|
@@ -1534,8 +1621,8 @@ async function renderChunk(context, serverBase, chunk, opts, onChunkProgress) {
|
|
|
1534
1621
|
W: opts.width,
|
|
1535
1622
|
H: opts.height,
|
|
1536
1623
|
fps: opts.fps,
|
|
1537
|
-
startFrame:
|
|
1538
|
-
endFrame:
|
|
1624
|
+
startFrame: chunk2.startFrame + frameOffset,
|
|
1625
|
+
endFrame: chunk2.endFrame + frameOffset,
|
|
1539
1626
|
format: opts.format,
|
|
1540
1627
|
bitrate: opts.bitrate ?? 1e7,
|
|
1541
1628
|
mediabunnyUrl: MEDIABUNNY_URL,
|
|
@@ -1546,10 +1633,10 @@ async function renderChunk(context, serverBase, chunk, opts, onChunkProgress) {
|
|
|
1546
1633
|
audioDuration: audio?.duration
|
|
1547
1634
|
}).catch(() => {
|
|
1548
1635
|
});
|
|
1549
|
-
const timeoutMs = 12e4 +
|
|
1636
|
+
const timeoutMs = 12e4 + chunk2.frameCount * 500;
|
|
1550
1637
|
await waitForPageDone(
|
|
1551
1638
|
page,
|
|
1552
|
-
`render chunk ${
|
|
1639
|
+
`render chunk ${chunk2.index}`,
|
|
1553
1640
|
onChunkProgress,
|
|
1554
1641
|
timeoutMs
|
|
1555
1642
|
);
|
|
@@ -1581,8 +1668,8 @@ async function renderAnimation(browser, opts) {
|
|
|
1581
1668
|
};
|
|
1582
1669
|
const outNames = await Promise.all(
|
|
1583
1670
|
chunks.map(
|
|
1584
|
-
(
|
|
1585
|
-
progress[
|
|
1671
|
+
(chunk2) => renderChunk(context, server.base, chunk2, effOpts, (fraction) => {
|
|
1672
|
+
progress[chunk2.index] = fraction;
|
|
1586
1673
|
reportProgress();
|
|
1587
1674
|
})
|
|
1588
1675
|
)
|
|
@@ -1597,9 +1684,9 @@ async function renderAnimation(browser, opts) {
|
|
|
1597
1684
|
return { bytes: new Uint8Array(files[0]), totalFrames, chunks: 1 };
|
|
1598
1685
|
}
|
|
1599
1686
|
const { bytes } = await concatEncodedVideo(
|
|
1600
|
-
chunks.map((
|
|
1687
|
+
chunks.map((chunk2, i) => ({
|
|
1601
1688
|
data: new Uint8Array(files[i]),
|
|
1602
|
-
duration:
|
|
1689
|
+
duration: chunk2.duration
|
|
1603
1690
|
})),
|
|
1604
1691
|
{ format: opts.format, frameRate: opts.fps }
|
|
1605
1692
|
);
|
|
@@ -2506,7 +2593,6 @@ async function writeIndexJson(result) {
|
|
|
2506
2593
|
|
|
2507
2594
|
// src/plugin/deliver.ts
|
|
2508
2595
|
import {
|
|
2509
|
-
copyFile,
|
|
2510
2596
|
mkdir as mkdir4,
|
|
2511
2597
|
mkdtemp,
|
|
2512
2598
|
rename as rename4,
|
|
@@ -2517,12 +2603,18 @@ import {
|
|
|
2517
2603
|
import { tmpdir } from "os";
|
|
2518
2604
|
import { join as join5, relative, resolve as resolve3 } from "path";
|
|
2519
2605
|
import { totalDuration } from "@vosjs/timeline";
|
|
2606
|
+
import { existsSync as existsSync5 } from "fs";
|
|
2607
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
2608
|
+
import { parseFrontmatter } from "@vosjs/shared/frontmatter";
|
|
2520
2609
|
import {
|
|
2521
2610
|
CHANNEL_SPECS_VERIFIED,
|
|
2522
2611
|
DESTINATIONS,
|
|
2612
|
+
cardInset,
|
|
2523
2613
|
destinationsForChannel,
|
|
2524
|
-
|
|
2525
|
-
|
|
2614
|
+
houseLook,
|
|
2615
|
+
isLookKind,
|
|
2616
|
+
lookFromBrand,
|
|
2617
|
+
ratedSegments as ratedSegments5
|
|
2526
2618
|
} from "@vosjs/studio-core";
|
|
2527
2619
|
|
|
2528
2620
|
// src/plugin/renderTake.ts
|
|
@@ -2717,6 +2809,1421 @@ async function renderPosterStills(browser, config, serveDir, shots, time) {
|
|
|
2717
2809
|
}
|
|
2718
2810
|
}
|
|
2719
2811
|
|
|
2812
|
+
// src/plugin/moments.ts
|
|
2813
|
+
import { ratedSegments as ratedSegments3, spanOutputExtent as spanOutputExtent3 } from "@vosjs/studio-core";
|
|
2814
|
+
|
|
2815
|
+
// src/plugin/picture.ts
|
|
2816
|
+
import { inflateSync } from "zlib";
|
|
2817
|
+
var PNG_SIG = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
2818
|
+
function decodePng(bytes) {
|
|
2819
|
+
if (bytes.length < 33) return null;
|
|
2820
|
+
for (let i = 0; i < 8; i++) if (bytes[i] !== PNG_SIG[i]) return null;
|
|
2821
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
2822
|
+
let pos = 8;
|
|
2823
|
+
let w = 0;
|
|
2824
|
+
let h = 0;
|
|
2825
|
+
let depth = 0;
|
|
2826
|
+
let colorType = 0;
|
|
2827
|
+
let interlace = 0;
|
|
2828
|
+
const idat = [];
|
|
2829
|
+
while (pos + 8 <= bytes.length) {
|
|
2830
|
+
const len = view.getUint32(pos);
|
|
2831
|
+
const type = String.fromCharCode(
|
|
2832
|
+
bytes[pos + 4],
|
|
2833
|
+
bytes[pos + 5],
|
|
2834
|
+
bytes[pos + 6],
|
|
2835
|
+
bytes[pos + 7]
|
|
2836
|
+
);
|
|
2837
|
+
const start = pos + 8;
|
|
2838
|
+
if (type === "IHDR") {
|
|
2839
|
+
w = view.getUint32(start);
|
|
2840
|
+
h = view.getUint32(start + 4);
|
|
2841
|
+
depth = bytes[start + 8];
|
|
2842
|
+
colorType = bytes[start + 9];
|
|
2843
|
+
interlace = bytes[start + 12];
|
|
2844
|
+
} else if (type === "IDAT") {
|
|
2845
|
+
idat.push(bytes.subarray(start, start + len));
|
|
2846
|
+
} else if (type === "IEND") break;
|
|
2847
|
+
pos = start + len + 4;
|
|
2848
|
+
}
|
|
2849
|
+
if (!w || !h || depth !== 8 || interlace !== 0) return null;
|
|
2850
|
+
const channels = colorType === 6 ? 4 : colorType === 2 ? 3 : colorType === 4 ? 2 : colorType === 0 ? 1 : 0;
|
|
2851
|
+
if (!channels) return null;
|
|
2852
|
+
const total = idat.reduce((n, c) => n + c.length, 0);
|
|
2853
|
+
const joined = new Uint8Array(total);
|
|
2854
|
+
let off2 = 0;
|
|
2855
|
+
for (const c of idat) {
|
|
2856
|
+
joined.set(c, off2);
|
|
2857
|
+
off2 += c.length;
|
|
2858
|
+
}
|
|
2859
|
+
let raw;
|
|
2860
|
+
try {
|
|
2861
|
+
raw = new Uint8Array(inflateSync(joined));
|
|
2862
|
+
} catch {
|
|
2863
|
+
return null;
|
|
2864
|
+
}
|
|
2865
|
+
const stride = w * channels;
|
|
2866
|
+
if (raw.length < (stride + 1) * h) return null;
|
|
2867
|
+
const out = new Uint8Array(w * h * 4);
|
|
2868
|
+
const prev = new Uint8Array(stride);
|
|
2869
|
+
const cur = new Uint8Array(stride);
|
|
2870
|
+
for (let y = 0; y < h; y++) {
|
|
2871
|
+
const filter = raw[y * (stride + 1)];
|
|
2872
|
+
const rowStart = y * (stride + 1) + 1;
|
|
2873
|
+
for (let i = 0; i < stride; i++) {
|
|
2874
|
+
const x = raw[rowStart + i];
|
|
2875
|
+
const a = i >= channels ? cur[i - channels] : 0;
|
|
2876
|
+
const b = prev[i];
|
|
2877
|
+
const c = i >= channels ? prev[i - channels] : 0;
|
|
2878
|
+
let v;
|
|
2879
|
+
switch (filter) {
|
|
2880
|
+
case 0:
|
|
2881
|
+
v = x;
|
|
2882
|
+
break;
|
|
2883
|
+
case 1:
|
|
2884
|
+
v = x + a;
|
|
2885
|
+
break;
|
|
2886
|
+
case 2:
|
|
2887
|
+
v = x + b;
|
|
2888
|
+
break;
|
|
2889
|
+
case 3:
|
|
2890
|
+
v = x + (a + b >> 1);
|
|
2891
|
+
break;
|
|
2892
|
+
case 4: {
|
|
2893
|
+
const p = a + b - c;
|
|
2894
|
+
const pa = Math.abs(p - a);
|
|
2895
|
+
const pb = Math.abs(p - b);
|
|
2896
|
+
const pc = Math.abs(p - c);
|
|
2897
|
+
v = x + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c);
|
|
2898
|
+
break;
|
|
2899
|
+
}
|
|
2900
|
+
default:
|
|
2901
|
+
return null;
|
|
2902
|
+
}
|
|
2903
|
+
cur[i] = v & 255;
|
|
2904
|
+
}
|
|
2905
|
+
for (let px = 0; px < w; px++) {
|
|
2906
|
+
const o = (y * w + px) * 4;
|
|
2907
|
+
const s = px * channels;
|
|
2908
|
+
if (channels >= 3) {
|
|
2909
|
+
out[o] = cur[s];
|
|
2910
|
+
out[o + 1] = cur[s + 1];
|
|
2911
|
+
out[o + 2] = cur[s + 2];
|
|
2912
|
+
out[o + 3] = channels === 4 ? cur[s + 3] : 255;
|
|
2913
|
+
} else {
|
|
2914
|
+
out[o] = out[o + 1] = out[o + 2] = cur[s];
|
|
2915
|
+
out[o + 3] = channels === 2 ? cur[s + 1] : 255;
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
prev.set(cur);
|
|
2919
|
+
}
|
|
2920
|
+
return { w, h, data: out };
|
|
2921
|
+
}
|
|
2922
|
+
var at = (img, x, y) => {
|
|
2923
|
+
const o = (y * img.w + x) * 4;
|
|
2924
|
+
return [img.data[o], img.data[o + 1], img.data[o + 2]];
|
|
2925
|
+
};
|
|
2926
|
+
var median = (a) => {
|
|
2927
|
+
const s = [...a].sort((x, y) => x - y);
|
|
2928
|
+
return s[s.length >> 1] ?? 0;
|
|
2929
|
+
};
|
|
2930
|
+
function groundColour(img) {
|
|
2931
|
+
const ring = Math.max(1, Math.round(Math.min(img.w, img.h) * 0.01));
|
|
2932
|
+
const r = [];
|
|
2933
|
+
const g = [];
|
|
2934
|
+
const b = [];
|
|
2935
|
+
const step = Math.max(1, Math.round(Math.max(img.w, img.h) / 400));
|
|
2936
|
+
for (let y = 0; y < img.h; y += step) {
|
|
2937
|
+
for (let x = 0; x < img.w; x += step) {
|
|
2938
|
+
if (x < ring || y < ring || x >= img.w - ring || y >= img.h - ring) {
|
|
2939
|
+
const p = at(img, x, y);
|
|
2940
|
+
r.push(p[0]);
|
|
2941
|
+
g.push(p[1]);
|
|
2942
|
+
b.push(p[2]);
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
return [median(r), median(g), median(b)];
|
|
2947
|
+
}
|
|
2948
|
+
var hex = (c) => "#" + c.map((v) => v.toString(16).padStart(2, "0")).join("");
|
|
2949
|
+
var delta = (p, q) => Math.abs(p[0] - q[0]) + Math.abs(p[1] - q[1]) + Math.abs(p[2] - q[2]);
|
|
2950
|
+
function cardBounds(img, ground, threshold = 60) {
|
|
2951
|
+
let x0 = img.w;
|
|
2952
|
+
let y0 = img.h;
|
|
2953
|
+
let x1 = -1;
|
|
2954
|
+
let y1 = -1;
|
|
2955
|
+
const step = Math.max(1, Math.round(Math.max(img.w, img.h) / 700));
|
|
2956
|
+
for (let y = 0; y < img.h; y += step) {
|
|
2957
|
+
for (let x = 0; x < img.w; x += step) {
|
|
2958
|
+
if (delta(at(img, x, y), ground) > threshold) {
|
|
2959
|
+
if (x < x0) x0 = x;
|
|
2960
|
+
if (y < y0) y0 = y;
|
|
2961
|
+
if (x > x1) x1 = x;
|
|
2962
|
+
if (y > y1) y1 = y;
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
if (x1 < 0) return null;
|
|
2967
|
+
return { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 };
|
|
2968
|
+
}
|
|
2969
|
+
function haloReading(img, ground, card) {
|
|
2970
|
+
const band = Math.max(2, Math.round(Math.min(img.w, img.h) * 0.012));
|
|
2971
|
+
const meanRect = (x0, y0, x1, y1) => {
|
|
2972
|
+
let sum = 0;
|
|
2973
|
+
let n = 0;
|
|
2974
|
+
const sx = Math.max(1, Math.round((x1 - x0) / 60));
|
|
2975
|
+
const sy = Math.max(1, Math.round((y1 - y0) / 60));
|
|
2976
|
+
for (let y = Math.max(0, y0); y < Math.min(img.h, y1); y += sy) {
|
|
2977
|
+
for (let x = Math.max(0, x0); x < Math.min(img.w, x1); x += sx) {
|
|
2978
|
+
sum += delta(at(img, x, y), ground);
|
|
2979
|
+
n++;
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
return n ? sum / n : 0;
|
|
2983
|
+
};
|
|
2984
|
+
const rise = (bands) => {
|
|
2985
|
+
const [outer, mid] = bands;
|
|
2986
|
+
return outer >= 3 && mid - outer >= 1.5 ? mid : 0;
|
|
2987
|
+
};
|
|
2988
|
+
const readings = [];
|
|
2989
|
+
const yA = card.y + card.h * 0.2;
|
|
2990
|
+
const yB = card.y + card.h * 0.8;
|
|
2991
|
+
if (card.x >= 1 && card.w > band * 4) {
|
|
2992
|
+
const x0 = card.x;
|
|
2993
|
+
readings.push(
|
|
2994
|
+
rise([
|
|
2995
|
+
meanRect(x0, yA, x0 + band, yB),
|
|
2996
|
+
meanRect(x0 + band, yA, x0 + band * 2, yB),
|
|
2997
|
+
meanRect(x0 + band * 2, yA, x0 + band * 3, yB)
|
|
2998
|
+
])
|
|
2999
|
+
);
|
|
3000
|
+
}
|
|
3001
|
+
if (card.x + card.w <= img.w - 1 && card.w > band * 4) {
|
|
3002
|
+
const x1 = card.x + card.w;
|
|
3003
|
+
readings.push(
|
|
3004
|
+
rise([
|
|
3005
|
+
meanRect(x1 - band, yA, x1, yB),
|
|
3006
|
+
meanRect(x1 - band * 2, yA, x1 - band, yB),
|
|
3007
|
+
meanRect(x1 - band * 3, yA, x1 - band * 2, yB)
|
|
3008
|
+
])
|
|
3009
|
+
);
|
|
3010
|
+
}
|
|
3011
|
+
if (card.y + card.h <= img.h - 1 && card.h > band * 4) {
|
|
3012
|
+
const y1 = card.y + card.h;
|
|
3013
|
+
const xA = card.x + card.w * 0.2;
|
|
3014
|
+
const xB = card.x + card.w * 0.8;
|
|
3015
|
+
readings.push(
|
|
3016
|
+
rise([
|
|
3017
|
+
meanRect(xA, y1 - band, xB, y1),
|
|
3018
|
+
meanRect(xA, y1 - band * 2, xB, y1 - band),
|
|
3019
|
+
meanRect(xA, y1 - band * 3, xB, y1 - band * 2)
|
|
3020
|
+
])
|
|
3021
|
+
);
|
|
3022
|
+
}
|
|
3023
|
+
return readings.length ? Math.max(...readings) : null;
|
|
3024
|
+
}
|
|
3025
|
+
function edgeContrast(img, ground, card) {
|
|
3026
|
+
const yA = Math.max(0, Math.round(card.y + card.h * 0.2));
|
|
3027
|
+
const yB = Math.min(img.h, Math.round(card.y + card.h * 0.8));
|
|
3028
|
+
const col = (x) => {
|
|
3029
|
+
if (x < 0 || x >= img.w) return 0;
|
|
3030
|
+
const ds = [];
|
|
3031
|
+
const step = Math.max(1, Math.round((yB - yA) / 80));
|
|
3032
|
+
for (let y = yA; y < yB; y += step) ds.push(delta(at(img, x, y), ground));
|
|
3033
|
+
return median(ds);
|
|
3034
|
+
};
|
|
3035
|
+
const depth = Math.min(24, Math.floor(card.w / 8));
|
|
3036
|
+
let best = 0;
|
|
3037
|
+
for (let d = 0; d < depth; d++) {
|
|
3038
|
+
best = Math.max(best, col(card.x + d), col(card.x + card.w - 1 - d));
|
|
3039
|
+
}
|
|
3040
|
+
return best;
|
|
3041
|
+
}
|
|
3042
|
+
function inkCoverage(img, rect, threshold = 48) {
|
|
3043
|
+
const x0 = Math.max(0, Math.floor(rect.x));
|
|
3044
|
+
const y0 = Math.max(0, Math.floor(rect.y));
|
|
3045
|
+
const x1 = Math.min(img.w, Math.ceil(rect.x + rect.w));
|
|
3046
|
+
const y1 = Math.min(img.h, Math.ceil(rect.y + rect.h));
|
|
3047
|
+
if (x1 - x0 < 2 || y1 - y0 < 2) return 0;
|
|
3048
|
+
const step = Math.max(1, Math.round(Math.max(x1 - x0, y1 - y0) / 300));
|
|
3049
|
+
const r = [];
|
|
3050
|
+
const g = [];
|
|
3051
|
+
const b = [];
|
|
3052
|
+
const pts = [];
|
|
3053
|
+
for (let y = y0; y < y1; y += step) {
|
|
3054
|
+
for (let x = x0; x < x1; x += step) {
|
|
3055
|
+
const p = at(img, x, y);
|
|
3056
|
+
pts.push(p);
|
|
3057
|
+
r.push(p[0]);
|
|
3058
|
+
g.push(p[1]);
|
|
3059
|
+
b.push(p[2]);
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
const base = [median(r), median(g), median(b)];
|
|
3063
|
+
let ink = 0;
|
|
3064
|
+
for (const p of pts) if (delta(p, base) > threshold) ink++;
|
|
3065
|
+
return pts.length ? ink / pts.length : 0;
|
|
3066
|
+
}
|
|
3067
|
+
function differenceHash(img, rect) {
|
|
3068
|
+
const r = rect ?? { x: 0, y: 0, w: img.w, h: img.h };
|
|
3069
|
+
const cols = 9;
|
|
3070
|
+
const rows = 8;
|
|
3071
|
+
const cell = [];
|
|
3072
|
+
for (let cy = 0; cy < rows; cy++) {
|
|
3073
|
+
for (let cx = 0; cx < cols; cx++) {
|
|
3074
|
+
const x0 = Math.floor(r.x + cx * r.w / cols);
|
|
3075
|
+
const x1 = Math.max(x0 + 1, Math.floor(r.x + (cx + 1) * r.w / cols));
|
|
3076
|
+
const y0 = Math.floor(r.y + cy * r.h / rows);
|
|
3077
|
+
const y1 = Math.max(y0 + 1, Math.floor(r.y + (cy + 1) * r.h / rows));
|
|
3078
|
+
let sum = 0;
|
|
3079
|
+
let n = 0;
|
|
3080
|
+
const sx = Math.max(1, Math.floor((x1 - x0) / 8));
|
|
3081
|
+
const sy = Math.max(1, Math.floor((y1 - y0) / 8));
|
|
3082
|
+
for (let y = y0; y < y1 && y < img.h; y += sy) {
|
|
3083
|
+
for (let x = x0; x < x1 && x < img.w; x += sx) {
|
|
3084
|
+
const p = at(img, Math.max(0, x), Math.max(0, y));
|
|
3085
|
+
sum += 0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2];
|
|
3086
|
+
n++;
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
cell.push(n ? sum / n : 0);
|
|
3090
|
+
}
|
|
3091
|
+
}
|
|
3092
|
+
let bits = "";
|
|
3093
|
+
for (let cy = 0; cy < rows; cy++) {
|
|
3094
|
+
let byte = 0;
|
|
3095
|
+
for (let cx = 0; cx < cols - 1; cx++) {
|
|
3096
|
+
const l = cell[cy * cols + cx];
|
|
3097
|
+
const rr = cell[cy * cols + cx + 1];
|
|
3098
|
+
byte = byte << 1 | (l > rr ? 1 : 0);
|
|
3099
|
+
}
|
|
3100
|
+
bits += byte.toString(16).padStart(2, "0");
|
|
3101
|
+
}
|
|
3102
|
+
return bits;
|
|
3103
|
+
}
|
|
3104
|
+
function hammingDistance(a, b) {
|
|
3105
|
+
let d = 0;
|
|
3106
|
+
for (let i = 0; i < Math.min(a.length, b.length); i++) {
|
|
3107
|
+
let x = parseInt(a[i], 16) ^ parseInt(b[i], 16);
|
|
3108
|
+
while (x) {
|
|
3109
|
+
d += x & 1;
|
|
3110
|
+
x >>= 1;
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
return d + Math.abs(a.length - b.length) * 4;
|
|
3114
|
+
}
|
|
3115
|
+
function luminance(c) {
|
|
3116
|
+
const lin = (v) => {
|
|
3117
|
+
const s = v / 255;
|
|
3118
|
+
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
3119
|
+
};
|
|
3120
|
+
return 0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2]);
|
|
3121
|
+
}
|
|
3122
|
+
function lightness(c) {
|
|
3123
|
+
const y = luminance(c);
|
|
3124
|
+
return y > 8856e-6 ? 116 * Math.cbrt(y) - 16 : 903.3 * y;
|
|
3125
|
+
}
|
|
3126
|
+
function medianColour(img, rect) {
|
|
3127
|
+
const r = [];
|
|
3128
|
+
const g = [];
|
|
3129
|
+
const b = [];
|
|
3130
|
+
const x0 = Math.max(0, Math.floor(rect.x));
|
|
3131
|
+
const y0 = Math.max(0, Math.floor(rect.y));
|
|
3132
|
+
const x1 = Math.min(img.w, Math.ceil(rect.x + rect.w));
|
|
3133
|
+
const y1 = Math.min(img.h, Math.ceil(rect.y + rect.h));
|
|
3134
|
+
const step = Math.max(1, Math.round(Math.max(x1 - x0, y1 - y0) / 200));
|
|
3135
|
+
for (let y = y0; y < y1; y += step) {
|
|
3136
|
+
for (let x = x0; x < x1; x += step) {
|
|
3137
|
+
const p = at(img, x, y);
|
|
3138
|
+
r.push(p[0]);
|
|
3139
|
+
g.push(p[1]);
|
|
3140
|
+
b.push(p[2]);
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
return [median(r), median(g), median(b)];
|
|
3144
|
+
}
|
|
3145
|
+
function edgeEnergy(img, width) {
|
|
3146
|
+
const cols = Math.max(2, Math.min(width, img.w));
|
|
3147
|
+
const rows = Math.max(2, Math.round(cols * img.h / img.w));
|
|
3148
|
+
const lum = [];
|
|
3149
|
+
for (let cy = 0; cy < rows; cy++) {
|
|
3150
|
+
for (let cx = 0; cx < cols; cx++) {
|
|
3151
|
+
const x = Math.min(img.w - 1, Math.floor((cx + 0.5) * img.w / cols));
|
|
3152
|
+
const y = Math.min(img.h - 1, Math.floor((cy + 0.5) * img.h / rows));
|
|
3153
|
+
const p = at(img, x, y);
|
|
3154
|
+
lum.push(0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]);
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
let sum = 0;
|
|
3158
|
+
let n = 0;
|
|
3159
|
+
for (let cy = 0; cy < rows; cy++) {
|
|
3160
|
+
for (let cx = 1; cx < cols; cx++) {
|
|
3161
|
+
sum += Math.abs(lum[cy * cols + cx] - lum[cy * cols + cx - 1]);
|
|
3162
|
+
n++;
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
return n ? sum / n : 0;
|
|
3166
|
+
}
|
|
3167
|
+
function measureStill(img) {
|
|
3168
|
+
const g = groundColour(img);
|
|
3169
|
+
const strong = cardBounds(img, g, 60);
|
|
3170
|
+
const faint = cardBounds(img, g, 12) ?? strong;
|
|
3171
|
+
const card = faint;
|
|
3172
|
+
const bleed = [];
|
|
3173
|
+
let pad = null;
|
|
3174
|
+
let widthPct = null;
|
|
3175
|
+
let shadow = null;
|
|
3176
|
+
let edge = 0;
|
|
3177
|
+
let separation = null;
|
|
3178
|
+
if (card) {
|
|
3179
|
+
pad = {
|
|
3180
|
+
left: card.x / img.w,
|
|
3181
|
+
right: (img.w - card.x - card.w) / img.w,
|
|
3182
|
+
top: card.y / img.h,
|
|
3183
|
+
bottom: (img.h - card.y - card.h) / img.h
|
|
3184
|
+
};
|
|
3185
|
+
for (const side of ["left", "right", "top", "bottom"])
|
|
3186
|
+
if (pad[side] < 5e-3) bleed.push(side);
|
|
3187
|
+
widthPct = card.w / img.w;
|
|
3188
|
+
shadow = haloReading(img, g, card);
|
|
3189
|
+
edge = edgeContrast(img, g, card);
|
|
3190
|
+
const body = strong ?? card;
|
|
3191
|
+
const inner = {
|
|
3192
|
+
x: body.x + body.w * 0.1,
|
|
3193
|
+
y: body.y + body.h * 0.1,
|
|
3194
|
+
w: body.w * 0.8,
|
|
3195
|
+
h: body.h * 0.8
|
|
3196
|
+
};
|
|
3197
|
+
separation = Math.abs(lightness(medianColour(img, inner)) - lightness(g));
|
|
3198
|
+
}
|
|
3199
|
+
const inkRect = strong ?? card ?? { x: 0, y: 0, w: img.w, h: img.h };
|
|
3200
|
+
return {
|
|
3201
|
+
w: img.w,
|
|
3202
|
+
h: img.h,
|
|
3203
|
+
ground: hex(g),
|
|
3204
|
+
card,
|
|
3205
|
+
widthPct,
|
|
3206
|
+
pad,
|
|
3207
|
+
bleed,
|
|
3208
|
+
shadow,
|
|
3209
|
+
edge,
|
|
3210
|
+
ink: inkCoverage(img, inkRect),
|
|
3211
|
+
separation,
|
|
3212
|
+
hash: differenceHash(img, inkRect)
|
|
3213
|
+
};
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
// src/plugin/moments.ts
|
|
3217
|
+
var STEP_SETTLE_SECONDS = 0.4;
|
|
3218
|
+
var NOT_A_GESTURE = /* @__PURE__ */ new Set(["wait", "goto", "sleep"]);
|
|
3219
|
+
function momentCandidates(doc, duration) {
|
|
3220
|
+
const rated = ratedSegments3(doc);
|
|
3221
|
+
const out = [];
|
|
3222
|
+
const push = (c) => {
|
|
3223
|
+
if (c.time < 0 || c.time > duration) return;
|
|
3224
|
+
if (out.some((o) => Math.abs(o.time - c.time) < 0.25)) return;
|
|
3225
|
+
out.push(c);
|
|
3226
|
+
};
|
|
3227
|
+
const steps = doc.source.meta.steps ?? [];
|
|
3228
|
+
for (let i = 0; i < steps.length; i++) {
|
|
3229
|
+
const s = steps[i];
|
|
3230
|
+
if (s.skipped || NOT_A_GESTURE.has(s.do)) continue;
|
|
3231
|
+
const t = stepOutputTime(rated, s, settleAfter(steps, i));
|
|
3232
|
+
if (t === null) continue;
|
|
3233
|
+
push({ time: t, source: "step", step: s.id ?? s.step });
|
|
3234
|
+
}
|
|
3235
|
+
let dropped = 0;
|
|
3236
|
+
const apexes = [];
|
|
3237
|
+
for (const z of doc.zoom) {
|
|
3238
|
+
const ext = spanOutputExtent3(rated, z.in, z.out);
|
|
3239
|
+
if (ext) apexes.push((ext.start + ext.end) / 2);
|
|
3240
|
+
else dropped++;
|
|
3241
|
+
}
|
|
3242
|
+
for (const t of apexes.sort((a, b) => a - b))
|
|
3243
|
+
push({ time: t, source: "zoom" });
|
|
3244
|
+
if (!out.length) {
|
|
3245
|
+
for (const p of [0.1, 0.3, 0.5, 0.7, 0.9])
|
|
3246
|
+
push({ time: p * duration, source: "spread" });
|
|
3247
|
+
}
|
|
3248
|
+
return { candidates: out, dropped };
|
|
3249
|
+
}
|
|
3250
|
+
function settleAfter(steps, i) {
|
|
3251
|
+
const next = steps[i + 1];
|
|
3252
|
+
if (next && next.do === "wait" && !next.skipped)
|
|
3253
|
+
return Math.max(0, next.tEnd - steps[i].tEnd);
|
|
3254
|
+
return STEP_SETTLE_SECONDS;
|
|
3255
|
+
}
|
|
3256
|
+
function stepOutputTime(rated, step, settle) {
|
|
3257
|
+
const src = step.tEnd + settle;
|
|
3258
|
+
const ext = spanOutputExtent3(rated, src, src + 1e-3);
|
|
3259
|
+
if (!ext) return null;
|
|
3260
|
+
return ext.start;
|
|
3261
|
+
}
|
|
3262
|
+
function resolveStepTime(doc, entry) {
|
|
3263
|
+
const m = /^step:([^+-]+)([+-]\d+(?:\.\d+)?)?$/.exec(entry.trim());
|
|
3264
|
+
if (!m) return null;
|
|
3265
|
+
const key = m[1];
|
|
3266
|
+
const offset = m[2] ? Number(m[2]) : STEP_SETTLE_SECONDS;
|
|
3267
|
+
const steps = doc.source.meta.steps ?? [];
|
|
3268
|
+
const step = steps.find((s) => s.id === key) ?? (/^\d+$/.test(key) ? steps.find((s) => s.step === Number(key)) : void 0);
|
|
3269
|
+
if (!step) {
|
|
3270
|
+
const known = steps.map((s) => s.id ?? String(s.step)).filter((s) => s.length);
|
|
3271
|
+
throw new Error(
|
|
3272
|
+
`--times ${entry}: no step "${key}" in the take's step timeline${known.length ? ` (steps: ${known.join(", ")})` : " (the take carries none: record it with the current CLI)"}`
|
|
3273
|
+
);
|
|
3274
|
+
}
|
|
3275
|
+
const t = stepOutputTime(ratedSegments3(doc), step, offset);
|
|
3276
|
+
if (t === null)
|
|
3277
|
+
throw new Error(
|
|
3278
|
+
`--times ${entry}: step "${key}" (${step.tEnd.toFixed(2)}s in the recording) falls outside the cut`
|
|
3279
|
+
);
|
|
3280
|
+
return t;
|
|
3281
|
+
}
|
|
3282
|
+
var DUPLICATE_BITS = 6;
|
|
3283
|
+
var POPULATED_INK = 0.12;
|
|
3284
|
+
function pickMoments(measured, opts = {}) {
|
|
3285
|
+
const minInk = opts.minInk ?? 0.06;
|
|
3286
|
+
const relative2 = opts.relativeInk ?? 0.4;
|
|
3287
|
+
const bits = opts.duplicateBits ?? DUPLICATE_BITS;
|
|
3288
|
+
const inks = measured.map((m) => m.ink).sort((a, b) => a - b);
|
|
3289
|
+
const median2 = inks[inks.length >> 1] ?? 0;
|
|
3290
|
+
const floor = Math.max(minInk, Math.min(POPULATED_INK, median2 * relative2));
|
|
3291
|
+
const kept = [];
|
|
3292
|
+
const dropped = [];
|
|
3293
|
+
for (const m of measured) {
|
|
3294
|
+
if (m.ink < floor) {
|
|
3295
|
+
dropped.push(
|
|
3296
|
+
`blank at ${m.time.toFixed(2)}s: ${Math.round(m.ink * 100)}% ink (the floor is ${Math.round(floor * 100)}%; the take's median is ${Math.round(median2 * 100)}%)`
|
|
3297
|
+
);
|
|
3298
|
+
continue;
|
|
3299
|
+
}
|
|
3300
|
+
const twin = kept.find((k) => hammingDistance(k.hash, m.hash) <= bits);
|
|
3301
|
+
if (twin) {
|
|
3302
|
+
dropped.push(
|
|
3303
|
+
`${m.time.toFixed(2)}s is the same frame as ${twin.time.toFixed(2)}s`
|
|
3304
|
+
);
|
|
3305
|
+
continue;
|
|
3306
|
+
}
|
|
3307
|
+
kept.push(m);
|
|
3308
|
+
}
|
|
3309
|
+
if (!kept.length && measured.length) {
|
|
3310
|
+
const best = [...measured].sort((a, b) => b.ink - a.ink)[0];
|
|
3311
|
+
kept.push(best);
|
|
3312
|
+
dropped.push(
|
|
3313
|
+
`every candidate is under the blank floor; kept ${best.time.toFixed(2)}s (${Math.round(best.ink * 100)}% ink) as the least empty`
|
|
3314
|
+
);
|
|
3315
|
+
}
|
|
3316
|
+
return { times: kept.map((k) => k.time), dropped };
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
// src/plugin/shotBake.ts
|
|
3320
|
+
import { deflateSync } from "zlib";
|
|
3321
|
+
function blurPlane(src, w, h, r, passes = 3) {
|
|
3322
|
+
if (r < 1) return;
|
|
3323
|
+
const tmp = new Float32Array(src.length);
|
|
3324
|
+
for (let p = 0; p < passes; p++) {
|
|
3325
|
+
for (let y = 0; y < h; y++) {
|
|
3326
|
+
let acc = 0;
|
|
3327
|
+
const row = y * w;
|
|
3328
|
+
for (let x = -r; x <= r; x++) acc += src[row + Math.min(w - 1, Math.max(0, x))];
|
|
3329
|
+
for (let x = 0; x < w; x++) {
|
|
3330
|
+
tmp[row + x] = acc / (2 * r + 1);
|
|
3331
|
+
const add = src[row + Math.min(w - 1, x + r + 1)];
|
|
3332
|
+
const sub = src[row + Math.max(0, x - r)];
|
|
3333
|
+
acc += add - sub;
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
for (let x = 0; x < w; x++) {
|
|
3337
|
+
let acc = 0;
|
|
3338
|
+
for (let y = -r; y <= r; y++) acc += tmp[Math.min(h - 1, Math.max(0, y)) * w + x];
|
|
3339
|
+
for (let y = 0; y < h; y++) {
|
|
3340
|
+
src[y * w + x] = acc / (2 * r + 1);
|
|
3341
|
+
const add = tmp[Math.min(h - 1, y + r + 1) * w + x];
|
|
3342
|
+
const sub = tmp[Math.max(0, y - r) * w + x];
|
|
3343
|
+
acc += add - sub;
|
|
3344
|
+
}
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3348
|
+
function roundedCoverage(x, y, rect, r) {
|
|
3349
|
+
let inside = 0;
|
|
3350
|
+
for (const dy of [0.25, 0.75]) {
|
|
3351
|
+
for (const dx of [0.25, 0.75]) {
|
|
3352
|
+
const px = x + dx;
|
|
3353
|
+
const py = y + dy;
|
|
3354
|
+
if (px < rect.x || py < rect.y || px > rect.x + rect.w || py > rect.y + rect.h) continue;
|
|
3355
|
+
const cx = Math.min(Math.max(px, rect.x + r), rect.x + rect.w - r);
|
|
3356
|
+
const cy = Math.min(Math.max(py, rect.y + r), rect.y + rect.h - r);
|
|
3357
|
+
if ((px - cx) ** 2 + (py - cy) ** 2 <= r * r) inside++;
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3360
|
+
return inside / 4;
|
|
3361
|
+
}
|
|
3362
|
+
function bakeShot(shot, opts = {}) {
|
|
3363
|
+
const margin = Math.round(shot.w * (opts.margin ?? 0.06));
|
|
3364
|
+
const radius = shot.w * (opts.radius ?? 0.014);
|
|
3365
|
+
const shadowA = opts.shadow ?? 0.38;
|
|
3366
|
+
const blur = Math.round(shot.w * (opts.blur ?? 0.03));
|
|
3367
|
+
const offsetY = Math.round(shot.w * (opts.offsetY ?? 0.012));
|
|
3368
|
+
const hair = opts.hairline ?? 0;
|
|
3369
|
+
const w = shot.w + margin * 2;
|
|
3370
|
+
const h = shot.h + margin * 2;
|
|
3371
|
+
const out = new Uint8Array(w * h * 4);
|
|
3372
|
+
const rect = { x: margin, y: margin, w: shot.w, h: shot.h };
|
|
3373
|
+
if (shadowA > 0) {
|
|
3374
|
+
const mask = new Float32Array(w * h);
|
|
3375
|
+
for (let y = 0; y < h; y++)
|
|
3376
|
+
for (let x = 0; x < w; x++) {
|
|
3377
|
+
const c = roundedCoverage(x, y - offsetY, rect, radius);
|
|
3378
|
+
if (c > 0) mask[y * w + x] = c;
|
|
3379
|
+
}
|
|
3380
|
+
blurPlane(mask, w, h, Math.max(1, Math.round(blur / 2)));
|
|
3381
|
+
for (let i = 0; i < w * h; i++) {
|
|
3382
|
+
const a = mask[i] * shadowA;
|
|
3383
|
+
if (a <= 2e-3) continue;
|
|
3384
|
+
out[i * 4] = 0;
|
|
3385
|
+
out[i * 4 + 1] = 0;
|
|
3386
|
+
out[i * 4 + 2] = 0;
|
|
3387
|
+
out[i * 4 + 3] = Math.round(a * 255);
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
3390
|
+
for (let y = 0; y < h; y++) {
|
|
3391
|
+
for (let x = 0; x < w; x++) {
|
|
3392
|
+
const cov = roundedCoverage(x, y, rect, radius);
|
|
3393
|
+
if (cov <= 0) continue;
|
|
3394
|
+
const sx = Math.min(shot.w - 1, Math.max(0, x - margin));
|
|
3395
|
+
const sy = Math.min(shot.h - 1, Math.max(0, y - margin));
|
|
3396
|
+
const si = (sy * shot.w + sx) * 4;
|
|
3397
|
+
const o = (y * w + x) * 4;
|
|
3398
|
+
let r = shot.data[si];
|
|
3399
|
+
let g = shot.data[si + 1];
|
|
3400
|
+
let b = shot.data[si + 2];
|
|
3401
|
+
if (hair > 0) {
|
|
3402
|
+
const edge = Math.min(x - rect.x, rect.x + rect.w - x, y - rect.y, rect.y + rect.h - y);
|
|
3403
|
+
if (edge < 1.5) {
|
|
3404
|
+
r = Math.round(r * (1 - hair));
|
|
3405
|
+
g = Math.round(g * (1 - hair));
|
|
3406
|
+
b = Math.round(b * (1 - hair));
|
|
3407
|
+
}
|
|
3408
|
+
}
|
|
3409
|
+
const a = cov;
|
|
3410
|
+
const ba = out[o + 3] / 255;
|
|
3411
|
+
const outA = a + ba * (1 - a);
|
|
3412
|
+
const mix = (fg, bg) => outA > 0 ? Math.round((fg * a + bg * ba * (1 - a)) / outA) : 0;
|
|
3413
|
+
out[o] = mix(r, out[o]);
|
|
3414
|
+
out[o + 1] = mix(g, out[o + 1]);
|
|
3415
|
+
out[o + 2] = mix(b, out[o + 2]);
|
|
3416
|
+
out[o + 3] = Math.round(outA * 255);
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
3419
|
+
return { w, h, data: out };
|
|
3420
|
+
}
|
|
3421
|
+
var crcTable = (() => {
|
|
3422
|
+
const t = new Uint32Array(256);
|
|
3423
|
+
for (let n = 0; n < 256; n++) {
|
|
3424
|
+
let c = n;
|
|
3425
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
|
|
3426
|
+
t[n] = c >>> 0;
|
|
3427
|
+
}
|
|
3428
|
+
return t;
|
|
3429
|
+
})();
|
|
3430
|
+
function crc32(buf) {
|
|
3431
|
+
let c = 4294967295;
|
|
3432
|
+
for (const b of buf) c = crcTable[(c ^ b) & 255] ^ c >>> 8;
|
|
3433
|
+
return (c ^ 4294967295) >>> 0;
|
|
3434
|
+
}
|
|
3435
|
+
function chunk(type, body) {
|
|
3436
|
+
const out = new Uint8Array(12 + body.length);
|
|
3437
|
+
const dv = new DataView(out.buffer);
|
|
3438
|
+
dv.setUint32(0, body.length);
|
|
3439
|
+
for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
|
|
3440
|
+
out.set(body, 8);
|
|
3441
|
+
dv.setUint32(8 + body.length, crc32(out.subarray(4, 8 + body.length)));
|
|
3442
|
+
return out;
|
|
3443
|
+
}
|
|
3444
|
+
function encodePng(img) {
|
|
3445
|
+
const stride = img.w * 4;
|
|
3446
|
+
const raw = new Uint8Array((stride + 1) * img.h);
|
|
3447
|
+
for (let y = 0; y < img.h; y++) {
|
|
3448
|
+
raw[y * (stride + 1)] = 2;
|
|
3449
|
+
for (let i = 0; i < stride; i++) {
|
|
3450
|
+
const cur = img.data[y * stride + i];
|
|
3451
|
+
const up = y ? img.data[(y - 1) * stride + i] : 0;
|
|
3452
|
+
raw[y * (stride + 1) + 1 + i] = cur - up & 255;
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
const ihdr = new Uint8Array(13);
|
|
3456
|
+
const dv = new DataView(ihdr.buffer);
|
|
3457
|
+
dv.setUint32(0, img.w);
|
|
3458
|
+
dv.setUint32(4, img.h);
|
|
3459
|
+
ihdr[8] = 8;
|
|
3460
|
+
ihdr[9] = 6;
|
|
3461
|
+
const parts = [
|
|
3462
|
+
new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
|
|
3463
|
+
chunk("IHDR", ihdr),
|
|
3464
|
+
chunk("IDAT", new Uint8Array(deflateSync(raw))),
|
|
3465
|
+
chunk("IEND", new Uint8Array(0))
|
|
3466
|
+
];
|
|
3467
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
3468
|
+
let o = 0;
|
|
3469
|
+
for (const p of parts) {
|
|
3470
|
+
out.set(p, o);
|
|
3471
|
+
o += p.length;
|
|
3472
|
+
}
|
|
3473
|
+
return out;
|
|
3474
|
+
}
|
|
3475
|
+
|
|
3476
|
+
// src/plugin/template.ts
|
|
3477
|
+
function templateOf(config) {
|
|
3478
|
+
const t = config.template;
|
|
3479
|
+
if (!t || typeof t !== "object") return null;
|
|
3480
|
+
return t;
|
|
3481
|
+
}
|
|
3482
|
+
function aspectOf(size) {
|
|
3483
|
+
const r = size.w / Math.max(1, size.h);
|
|
3484
|
+
if (r > 1.15) return "landscape";
|
|
3485
|
+
if (r < 0.87) return "portrait";
|
|
3486
|
+
return "square";
|
|
3487
|
+
}
|
|
3488
|
+
function templateProblems(config) {
|
|
3489
|
+
const t = templateOf(config);
|
|
3490
|
+
if (!t) return ["no template block: a poster template declares config.template"];
|
|
3491
|
+
const out = [];
|
|
3492
|
+
const elements = Array.isArray(config.elements) ? config.elements : [];
|
|
3493
|
+
const byId = new Map(elements.filter((e) => e.id).map((e) => [e.id, e]));
|
|
3494
|
+
const params = Array.isArray(config.params) ? config.params : [];
|
|
3495
|
+
const keys = new Set(params.map((p) => p.key).filter(Boolean));
|
|
3496
|
+
if (!t.family) out.push("template.family is missing");
|
|
3497
|
+
for (const s of t.slots ?? []) {
|
|
3498
|
+
const el = byId.get(s.id);
|
|
3499
|
+
if (!el) out.push(`template.slots: no element with id "${s.id}"`);
|
|
3500
|
+
else if (el.type !== s.kind)
|
|
3501
|
+
out.push(`template.slots: "${s.id}" is a ${String(el.type)} element, the slot wants ${s.kind}`);
|
|
3502
|
+
}
|
|
3503
|
+
for (const x of t.text ?? []) {
|
|
3504
|
+
const el = byId.get(x.element);
|
|
3505
|
+
if (!el) out.push(`template.text: no element with id "${x.element}"`);
|
|
3506
|
+
else if (el.type !== "text") out.push(`template.text: "${x.element}" is not a text element`);
|
|
3507
|
+
if (!keys.has(x.param)) out.push(`template.text: param "${x.param}" is not declared in config.params`);
|
|
3508
|
+
const bound = el?.content;
|
|
3509
|
+
if (el && (!bound || typeof bound !== "object" || bound.$data !== x.param))
|
|
3510
|
+
out.push(`template.text: "${x.element}" must bind its content to {$data: "${x.param}"}`);
|
|
3511
|
+
}
|
|
3512
|
+
for (const k of t.params?.required ?? []) {
|
|
3513
|
+
if (!keys.has(k)) out.push(`template.params.required: "${k}" is not declared in config.params`);
|
|
3514
|
+
}
|
|
3515
|
+
if (!t.layouts?.landscape) out.push("template.layouts.landscape is required");
|
|
3516
|
+
for (const [name, layout] of Object.entries(t.layouts ?? {})) {
|
|
3517
|
+
for (const s of t.slots ?? []) {
|
|
3518
|
+
if (!layout?.slots?.[s.id]) out.push(`template.layouts.${name}: slot "${s.id}" is not placed`);
|
|
3519
|
+
}
|
|
3520
|
+
}
|
|
3521
|
+
return out;
|
|
3522
|
+
}
|
|
3523
|
+
var pctOf = (v) => {
|
|
3524
|
+
if (typeof v === "number") return null;
|
|
3525
|
+
if (typeof v !== "string") return null;
|
|
3526
|
+
const m = /^(-?\d+(?:\.\d+)?)%$/.exec(v.trim());
|
|
3527
|
+
return m ? Number(m[1]) / 100 : null;
|
|
3528
|
+
};
|
|
3529
|
+
function fillTemplate(config, input) {
|
|
3530
|
+
const t = templateOf(config);
|
|
3531
|
+
if (!t) throw new Error("fillTemplate: the config carries no template block");
|
|
3532
|
+
const aspect = aspectOf(input.size);
|
|
3533
|
+
const layout = t.layouts[aspect] ?? t.layouts.landscape;
|
|
3534
|
+
const out = structuredClone(config);
|
|
3535
|
+
const designH = 1080;
|
|
3536
|
+
const designW = designH * input.size.w / input.size.h;
|
|
3537
|
+
const elements = Array.isArray(out.elements) ? out.elements : [];
|
|
3538
|
+
const byId = new Map(elements.filter((e) => e.id).map((e) => [e.id, e]));
|
|
3539
|
+
const slotRects = {};
|
|
3540
|
+
for (const s of t.slots) {
|
|
3541
|
+
const el = byId.get(s.id);
|
|
3542
|
+
const place = layout.slots[s.id];
|
|
3543
|
+
const src = input.slots[s.id];
|
|
3544
|
+
if (!el || !place) continue;
|
|
3545
|
+
if (src) el.src = src.src;
|
|
3546
|
+
const pad = src?.pad ?? 0;
|
|
3547
|
+
slotRects[s.id] = {
|
|
3548
|
+
x: place.x,
|
|
3549
|
+
y: place.y,
|
|
3550
|
+
w: place.w,
|
|
3551
|
+
h: place.w * (input.size.w / input.size.h) / (src?.aspect ?? 16 / 9)
|
|
3552
|
+
};
|
|
3553
|
+
const w = place.w * designW * (1 + 2 * pad);
|
|
3554
|
+
const x = place.x - place.w * pad;
|
|
3555
|
+
const y = place.y - place.w * pad * (input.size.w / input.size.h) / (src?.aspect ?? 16 / 9);
|
|
3556
|
+
el.position = { x: `${(x * 100).toFixed(2)}%`, y: `${(y * 100).toFixed(2)}%` };
|
|
3557
|
+
el.anchor = "top-left";
|
|
3558
|
+
el.size = { ...el.size ?? {}, width: Math.round(w), height: "auto" };
|
|
3559
|
+
if (!src && s.required) el.opacity = 0;
|
|
3560
|
+
}
|
|
3561
|
+
const data = out.data && typeof out.data === "object" ? { ...out.data } : {};
|
|
3562
|
+
const params = Array.isArray(out.params) ? out.params : [];
|
|
3563
|
+
const missing = [];
|
|
3564
|
+
for (const k of [...t.params.required ?? [], ...t.params.brand ?? []]) {
|
|
3565
|
+
if (input.values[k] === void 0 || input.values[k] === null || input.values[k] === "") {
|
|
3566
|
+
if ((t.params.required ?? []).includes(k)) missing.push(k);
|
|
3567
|
+
continue;
|
|
3568
|
+
}
|
|
3569
|
+
data[k] = input.values[k];
|
|
3570
|
+
const p = params.find((q) => q.key === k);
|
|
3571
|
+
if (p) p.default = input.values[k];
|
|
3572
|
+
}
|
|
3573
|
+
for (const [k, v] of Object.entries(input.values)) {
|
|
3574
|
+
if (v !== void 0 && v !== null && v !== "") {
|
|
3575
|
+
data[k] = v;
|
|
3576
|
+
const p = params.find((q) => q.key === k);
|
|
3577
|
+
if (p) p.default = v;
|
|
3578
|
+
}
|
|
3579
|
+
}
|
|
3580
|
+
out.data = data;
|
|
3581
|
+
const boxes = [];
|
|
3582
|
+
for (const x of t.text) {
|
|
3583
|
+
const el = byId.get(x.element);
|
|
3584
|
+
if (!el) continue;
|
|
3585
|
+
const pos = layout.text?.[x.element];
|
|
3586
|
+
if (pos) el.position = { x: pos.x, y: pos.y };
|
|
3587
|
+
const size = layout.size?.[x.element];
|
|
3588
|
+
if (size) el.font = { ...el.font ?? {}, size };
|
|
3589
|
+
if (layout.hide?.includes(x.element)) {
|
|
3590
|
+
el.opacity = 0;
|
|
3591
|
+
continue;
|
|
3592
|
+
}
|
|
3593
|
+
const value = data[x.param];
|
|
3594
|
+
if (typeof value !== "string" || !value.trim()) continue;
|
|
3595
|
+
const font = el.font ?? {};
|
|
3596
|
+
const px = typeof font.size === "number" ? font.size : 40;
|
|
3597
|
+
const lines = value.split("\n");
|
|
3598
|
+
const longest = Math.max(...lines.map((l) => l.length));
|
|
3599
|
+
const lh = typeof font.lineHeight === "number" ? font.lineHeight : 1.15;
|
|
3600
|
+
const boxW = longest * px * 0.56;
|
|
3601
|
+
const boxH = lines.length * px * lh;
|
|
3602
|
+
const ex = pctOf(el.position?.x) ?? 0;
|
|
3603
|
+
const ey = pctOf(el.position?.y) ?? 0;
|
|
3604
|
+
const anchor = String(el.anchor ?? "center");
|
|
3605
|
+
let left = ex * designW;
|
|
3606
|
+
let top = ey * designH;
|
|
3607
|
+
if (anchor.includes("right")) left -= boxW;
|
|
3608
|
+
else if (!anchor.includes("left")) left -= boxW / 2;
|
|
3609
|
+
if (anchor.includes("bottom")) top -= boxH;
|
|
3610
|
+
else if (!anchor.includes("top")) top -= boxH / 2;
|
|
3611
|
+
const rawColor = font.color;
|
|
3612
|
+
const color = typeof rawColor === "string" ? rawColor : rawColor && typeof rawColor === "object" && typeof rawColor.$data === "string" ? data[rawColor.$data] : void 0;
|
|
3613
|
+
boxes.push({
|
|
3614
|
+
x: left / designW,
|
|
3615
|
+
y: top / designH,
|
|
3616
|
+
w: boxW / designW,
|
|
3617
|
+
h: boxH / designH,
|
|
3618
|
+
color: color && /^#[0-9a-f]{6}$/i.test(color) ? color : void 0,
|
|
3619
|
+
role: x.role,
|
|
3620
|
+
label: value.length > 24 ? `${value.slice(0, 24)}\u2026` : value
|
|
3621
|
+
});
|
|
3622
|
+
}
|
|
3623
|
+
for (const id of layout.hide ?? []) {
|
|
3624
|
+
const el = byId.get(id);
|
|
3625
|
+
if (el && !t.text.some((x) => x.element === id)) el.opacity = 0;
|
|
3626
|
+
}
|
|
3627
|
+
return { config: out, text: boxes, slots: slotRects, aspect, missing };
|
|
3628
|
+
}
|
|
3629
|
+
function textLimitProblems(t, values) {
|
|
3630
|
+
const out = [];
|
|
3631
|
+
for (const x of t.text) {
|
|
3632
|
+
const v = values[x.param];
|
|
3633
|
+
if (typeof v !== "string") continue;
|
|
3634
|
+
const words = v.trim().split(/\s+/).filter(Boolean).length;
|
|
3635
|
+
const lines = v.split("\n").length;
|
|
3636
|
+
if (x.maxWords && words > x.maxWords)
|
|
3637
|
+
out.push(`${x.param}: ${words} words, the template holds ${x.maxWords}`);
|
|
3638
|
+
if (x.lines && lines > x.lines)
|
|
3639
|
+
out.push(`${x.param}: ${lines} lines, the template holds ${x.lines}`);
|
|
3640
|
+
}
|
|
3641
|
+
return out;
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
// src/plugin/templates/cardOnGradient.ts
|
|
3645
|
+
var GROUND = `(ctx) => {
|
|
3646
|
+
const THREE = ctx.THREE
|
|
3647
|
+
const d = ctx.data
|
|
3648
|
+
const canvas = document.createElement('canvas')
|
|
3649
|
+
canvas.width = 1280
|
|
3650
|
+
canvas.height = 720
|
|
3651
|
+
const c = canvas.getContext('2d')
|
|
3652
|
+
c.fillStyle = d.bgA
|
|
3653
|
+
c.fillRect(0, 0, 1280, 720)
|
|
3654
|
+
const blob = (x, y, r, color) => {
|
|
3655
|
+
const g = c.createRadialGradient(x, y, 0, x, y, r)
|
|
3656
|
+
g.addColorStop(0, color)
|
|
3657
|
+
g.addColorStop(1, 'rgba(0,0,0,0)')
|
|
3658
|
+
c.fillStyle = g
|
|
3659
|
+
c.fillRect(0, 0, 1280, 720)
|
|
3660
|
+
}
|
|
3661
|
+
blob(180, 120, 760, d.blobA)
|
|
3662
|
+
blob(1120, 620, 820, d.blobB)
|
|
3663
|
+
blob(760, 60, 620, d.blobC)
|
|
3664
|
+
let seed = 7
|
|
3665
|
+
const rand = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296 }
|
|
3666
|
+
const img = c.getImageData(0, 0, 1280, 720)
|
|
3667
|
+
const px = img.data
|
|
3668
|
+
const grain = d.grain
|
|
3669
|
+
for (let i = 0; i < px.length; i += 4) {
|
|
3670
|
+
const n = (rand() - 0.5) * grain
|
|
3671
|
+
px[i] += n; px[i + 1] += n; px[i + 2] += n
|
|
3672
|
+
}
|
|
3673
|
+
c.putImageData(img, 0, 0)
|
|
3674
|
+
const tex = new THREE.CanvasTexture(canvas)
|
|
3675
|
+
tex.colorSpace = THREE.SRGBColorSpace
|
|
3676
|
+
const plane = new THREE.Mesh(
|
|
3677
|
+
new THREE.PlaneGeometry(2, 2),
|
|
3678
|
+
new THREE.MeshBasicMaterial({ map: tex, depthTest: false }),
|
|
3679
|
+
)
|
|
3680
|
+
plane.renderOrder = -10
|
|
3681
|
+
ctx.scene.add(plane)
|
|
3682
|
+
return { refs: { plane }, dispose: () => { tex.dispose(); plane.geometry.dispose(); plane.material.dispose() } }
|
|
3683
|
+
}`;
|
|
3684
|
+
var TIMELINE = `(ctx, content, duration) => {
|
|
3685
|
+
const { gsap, elements } = ctx
|
|
3686
|
+
const tl = gsap.timeline({ paused: true })
|
|
3687
|
+
const shot = elements.get('shot')
|
|
3688
|
+
if (shot) tl.fromTo(shot.props, { opacity: 0, translateY: 30, scale: 0.97 }, { opacity: 1, translateY: 0, scale: 1, duration: 1.0, ease: 'power3.out' }, 0.2)
|
|
3689
|
+
return tl
|
|
3690
|
+
}`;
|
|
3691
|
+
function cardOnGradient() {
|
|
3692
|
+
return {
|
|
3693
|
+
version: 2,
|
|
3694
|
+
duration: 4,
|
|
3695
|
+
camera: { preset: "fullscreen" },
|
|
3696
|
+
template: {
|
|
3697
|
+
family: "card-on-gradient",
|
|
3698
|
+
slots: [{ id: "shot", kind: "image", required: true }],
|
|
3699
|
+
params: { required: [], brand: ["bgA", "blobA", "blobB", "blobC", "grain"] },
|
|
3700
|
+
text: [],
|
|
3701
|
+
layouts: {
|
|
3702
|
+
landscape: { slots: { shot: { x: 0.09, y: 0.12, w: 0.82 } } },
|
|
3703
|
+
square: { slots: { shot: { x: 0.08, y: 0.27, w: 0.84 } } },
|
|
3704
|
+
portrait: { slots: { shot: { x: 0.06, y: 0.3, w: 0.88 } } }
|
|
3705
|
+
}
|
|
3706
|
+
},
|
|
3707
|
+
params: [
|
|
3708
|
+
{ key: "bgA", type: "color", label: "Ground", default: "#f3efe8" },
|
|
3709
|
+
{ key: "blobA", type: "color", label: "Blob A", default: "rgba(255,183,146,0.55)" },
|
|
3710
|
+
{ key: "blobB", type: "color", label: "Blob B", default: "rgba(170,196,255,0.5)" },
|
|
3711
|
+
{ key: "blobC", type: "color", label: "Blob C", default: "rgba(255,236,170,0.45)" },
|
|
3712
|
+
{ key: "grain", type: "number", label: "Grain", default: 14, min: 0, max: 40, step: 1 }
|
|
3713
|
+
],
|
|
3714
|
+
data: {
|
|
3715
|
+
bgA: "#f3efe8",
|
|
3716
|
+
blobA: "rgba(255,183,146,0.55)",
|
|
3717
|
+
blobB: "rgba(170,196,255,0.5)",
|
|
3718
|
+
blobC: "rgba(255,236,170,0.45)",
|
|
3719
|
+
grain: 14
|
|
3720
|
+
},
|
|
3721
|
+
elements: [
|
|
3722
|
+
{
|
|
3723
|
+
type: "image",
|
|
3724
|
+
id: "shot",
|
|
3725
|
+
src: "",
|
|
3726
|
+
position: { x: "9%", y: "12%" },
|
|
3727
|
+
anchor: "top-left",
|
|
3728
|
+
size: { width: 1574, height: "auto", fit: "contain" },
|
|
3729
|
+
zIndex: 60,
|
|
3730
|
+
opacity: 0
|
|
3731
|
+
}
|
|
3732
|
+
],
|
|
3733
|
+
createContent: GROUND,
|
|
3734
|
+
createTimeline: TIMELINE
|
|
3735
|
+
};
|
|
3736
|
+
}
|
|
3737
|
+
|
|
3738
|
+
// src/plugin/templates/splitCover.ts
|
|
3739
|
+
var GROUND2 = `(ctx) => {
|
|
3740
|
+
const THREE = ctx.THREE
|
|
3741
|
+
const d = ctx.data
|
|
3742
|
+
const canvas = document.createElement('canvas')
|
|
3743
|
+
canvas.width = 1280
|
|
3744
|
+
canvas.height = 720
|
|
3745
|
+
const c = canvas.getContext('2d')
|
|
3746
|
+
const g = c.createLinearGradient(0, 0, 1280, 720)
|
|
3747
|
+
g.addColorStop(0, d.bgA)
|
|
3748
|
+
g.addColorStop(0.55, d.bgB)
|
|
3749
|
+
g.addColorStop(1, d.bgC)
|
|
3750
|
+
c.fillStyle = g
|
|
3751
|
+
c.fillRect(0, 0, 1280, 720)
|
|
3752
|
+
const rg = c.createRadialGradient(980, 120, 40, 980, 120, 820)
|
|
3753
|
+
rg.addColorStop(0, d.streak)
|
|
3754
|
+
rg.addColorStop(1, 'rgba(0,0,0,0)')
|
|
3755
|
+
c.fillStyle = rg
|
|
3756
|
+
c.fillRect(0, 0, 1280, 720)
|
|
3757
|
+
let seed = 41
|
|
3758
|
+
const rand = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296 }
|
|
3759
|
+
const img = c.getImageData(0, 0, 1280, 720)
|
|
3760
|
+
const px = img.data
|
|
3761
|
+
const grain = d.grain
|
|
3762
|
+
for (let i = 0; i < px.length; i += 4) {
|
|
3763
|
+
const n = (rand() - 0.5) * grain
|
|
3764
|
+
px[i] += n; px[i + 1] += n; px[i + 2] += n
|
|
3765
|
+
}
|
|
3766
|
+
c.putImageData(img, 0, 0)
|
|
3767
|
+
const tex = new THREE.CanvasTexture(canvas)
|
|
3768
|
+
tex.colorSpace = THREE.SRGBColorSpace
|
|
3769
|
+
const plane = new THREE.Mesh(
|
|
3770
|
+
new THREE.PlaneGeometry(2, 2),
|
|
3771
|
+
new THREE.MeshBasicMaterial({ map: tex, depthTest: false }),
|
|
3772
|
+
)
|
|
3773
|
+
plane.renderOrder = -10
|
|
3774
|
+
ctx.scene.add(plane)
|
|
3775
|
+
return { refs: { plane }, dispose: () => { tex.dispose(); plane.geometry.dispose(); plane.material.dispose() } }
|
|
3776
|
+
}`;
|
|
3777
|
+
var TIMELINE2 = `(ctx, content, duration) => {
|
|
3778
|
+
const { gsap, elements } = ctx
|
|
3779
|
+
const tl = gsap.timeline({ paused: true })
|
|
3780
|
+
const kicker = elements.get('kicker')
|
|
3781
|
+
const title = elements.get('title')
|
|
3782
|
+
const brand = elements.get('brand')
|
|
3783
|
+
const shot = elements.get('shot')
|
|
3784
|
+
if (kicker) tl.fromTo(kicker.props, { opacity: 0, translateY: 14 }, { opacity: 1, translateY: 0, duration: 0.7, ease: 'power2.out' }, 0.25)
|
|
3785
|
+
if (title) tl.fromTo(title.props, { opacity: 0, translateY: 26 }, { opacity: 1, translateY: 0, duration: 0.9, ease: 'power3.out' }, 0.45)
|
|
3786
|
+
if (brand) tl.fromTo(brand.props, { opacity: 0 }, { opacity: 1, duration: 0.8, ease: 'power2.out' }, 0.9)
|
|
3787
|
+
if (shot) tl.fromTo(shot.props, { opacity: 0, translateX: 70 }, { opacity: 1, translateX: 0, duration: 1.1, ease: 'power3.out' }, 0.55)
|
|
3788
|
+
return tl
|
|
3789
|
+
}`;
|
|
3790
|
+
function splitCover() {
|
|
3791
|
+
return {
|
|
3792
|
+
version: 2,
|
|
3793
|
+
duration: 6,
|
|
3794
|
+
camera: { preset: "fullscreen" },
|
|
3795
|
+
fonts: [
|
|
3796
|
+
{ family: "Fraunces", url: "https://assets.vos.so/fonts/fraunces/600.woff2", weight: 600 },
|
|
3797
|
+
{ family: "Lexend", url: "https://assets.vos.so/fonts/lexend/700.woff2", weight: 700 },
|
|
3798
|
+
{ family: "JetBrains Mono", url: "https://assets.vos.so/fonts/jetbrains-mono/400.woff2", weight: 400 }
|
|
3799
|
+
],
|
|
3800
|
+
template: {
|
|
3801
|
+
family: "split-cover",
|
|
3802
|
+
slots: [{ id: "shot", kind: "image", required: true }],
|
|
3803
|
+
params: {
|
|
3804
|
+
required: ["headline", "brand"],
|
|
3805
|
+
brand: ["bgA", "bgB", "bgC", "ink", "inkSoft", "accent", "streak", "fontDisplay", "fontBody", "grain"]
|
|
3806
|
+
},
|
|
3807
|
+
text: [
|
|
3808
|
+
{ element: "title", param: "headline", role: "headline", maxWords: 8, lines: 3 },
|
|
3809
|
+
{ element: "kicker", param: "kicker", role: "body" },
|
|
3810
|
+
{ element: "brand", param: "brand", role: "body" }
|
|
3811
|
+
],
|
|
3812
|
+
layouts: {
|
|
3813
|
+
landscape: {
|
|
3814
|
+
slots: { shot: { x: 0.46, y: 0.28, w: 1.02 } },
|
|
3815
|
+
text: { kicker: { x: "7%", y: "24%" }, title: { x: "7%", y: "44%" }, brand: { x: "7%", y: "86%" } },
|
|
3816
|
+
size: { title: 84, kicker: 19, brand: 25 }
|
|
3817
|
+
},
|
|
3818
|
+
square: {
|
|
3819
|
+
slots: { shot: { x: 0.1, y: 0.5, w: 1 } },
|
|
3820
|
+
text: { kicker: { x: "8%", y: "12%" }, title: { x: "8%", y: "27%" }, brand: { x: "8%", y: "93%" } },
|
|
3821
|
+
size: { title: 72, kicker: 18, brand: 23 }
|
|
3822
|
+
},
|
|
3823
|
+
portrait: {
|
|
3824
|
+
slots: { shot: { x: 0.08, y: 0.44, w: 1.06 } },
|
|
3825
|
+
text: { kicker: { x: "8%", y: "12%" }, title: { x: "8%", y: "26%" }, brand: { x: "8%", y: "94%" } },
|
|
3826
|
+
size: { title: 64, kicker: 17, brand: 22 }
|
|
3827
|
+
}
|
|
3828
|
+
}
|
|
3829
|
+
},
|
|
3830
|
+
params: [
|
|
3831
|
+
{ key: "kicker", type: "text", label: "Kicker", default: "RELEASE" },
|
|
3832
|
+
{ key: "headline", type: "text", label: "Headline", default: "What shipped,\nin three lines" },
|
|
3833
|
+
{ key: "brand", type: "text", label: "Wordmark", default: "brand" },
|
|
3834
|
+
{ key: "bgA", type: "color", label: "Ground A", default: "#8a3d2a" },
|
|
3835
|
+
{ key: "bgB", type: "color", label: "Ground B", default: "#c0632f" },
|
|
3836
|
+
{ key: "bgC", type: "color", label: "Ground C", default: "#5c5a2e" },
|
|
3837
|
+
{ key: "ink", type: "color", label: "Ink", default: "#fff6ec" },
|
|
3838
|
+
{ key: "inkSoft", type: "color", label: "Ink, softened", default: "#e9d9c8" },
|
|
3839
|
+
{ key: "streak", type: "color", label: "Light streak", default: "rgba(255,220,170,0.18)" },
|
|
3840
|
+
{ key: "fontDisplay", type: "text", label: "Headline face", default: "Fraunces, serif" },
|
|
3841
|
+
{ key: "fontBody", type: "text", label: "Body face", default: "Lexend, sans-serif" },
|
|
3842
|
+
{ key: "grain", type: "number", label: "Grain", default: 22, min: 0, max: 40, step: 1 }
|
|
3843
|
+
],
|
|
3844
|
+
data: {
|
|
3845
|
+
kicker: "RELEASE",
|
|
3846
|
+
headline: "What shipped,\nin three lines",
|
|
3847
|
+
brand: "brand",
|
|
3848
|
+
bgA: "#8a3d2a",
|
|
3849
|
+
bgB: "#c0632f",
|
|
3850
|
+
bgC: "#5c5a2e",
|
|
3851
|
+
ink: "#fff6ec",
|
|
3852
|
+
inkSoft: "#e9d9c8",
|
|
3853
|
+
streak: "rgba(255,220,170,0.18)",
|
|
3854
|
+
fontDisplay: "Fraunces, serif",
|
|
3855
|
+
fontBody: "Lexend, sans-serif",
|
|
3856
|
+
grain: 22
|
|
3857
|
+
},
|
|
3858
|
+
elements: [
|
|
3859
|
+
{
|
|
3860
|
+
type: "text",
|
|
3861
|
+
id: "kicker",
|
|
3862
|
+
content: { $data: "kicker" },
|
|
3863
|
+
position: { x: "7%", y: "24%" },
|
|
3864
|
+
anchor: "center-left",
|
|
3865
|
+
font: { family: "JetBrains Mono, monospace", size: 19, weight: 400, color: { $data: "inkSoft" }, letterSpacing: 6, align: "left" },
|
|
3866
|
+
opacity: 0
|
|
3867
|
+
},
|
|
3868
|
+
{
|
|
3869
|
+
type: "text",
|
|
3870
|
+
id: "title",
|
|
3871
|
+
content: { $data: "headline" },
|
|
3872
|
+
position: { x: "7%", y: "44%" },
|
|
3873
|
+
anchor: "center-left",
|
|
3874
|
+
font: { family: { $data: "fontDisplay" }, size: 84, weight: 600, color: { $data: "ink" }, lineHeight: 1.08, align: "left", letterSpacing: 0 },
|
|
3875
|
+
opacity: 0
|
|
3876
|
+
},
|
|
3877
|
+
{
|
|
3878
|
+
type: "text",
|
|
3879
|
+
id: "brand",
|
|
3880
|
+
content: { $data: "brand" },
|
|
3881
|
+
position: { x: "7%", y: "86%" },
|
|
3882
|
+
anchor: "center-left",
|
|
3883
|
+
font: { family: { $data: "fontBody" }, size: 25, weight: 700, color: { $data: "ink" }, letterSpacing: 1, align: "left" },
|
|
3884
|
+
opacity: 0
|
|
3885
|
+
},
|
|
3886
|
+
{
|
|
3887
|
+
type: "image",
|
|
3888
|
+
id: "shot",
|
|
3889
|
+
src: "",
|
|
3890
|
+
position: { x: "46%", y: "28%" },
|
|
3891
|
+
anchor: "top-left",
|
|
3892
|
+
size: { width: 1400, height: "auto", fit: "contain" },
|
|
3893
|
+
zIndex: 60,
|
|
3894
|
+
opacity: 0
|
|
3895
|
+
}
|
|
3896
|
+
],
|
|
3897
|
+
createContent: GROUND2,
|
|
3898
|
+
createTimeline: TIMELINE2
|
|
3899
|
+
};
|
|
3900
|
+
}
|
|
3901
|
+
|
|
3902
|
+
// src/plugin/templates/index.ts
|
|
3903
|
+
var TEMPLATES = {
|
|
3904
|
+
"split-cover": splitCover,
|
|
3905
|
+
"card-on-gradient": cardOnGradient
|
|
3906
|
+
};
|
|
3907
|
+
var TEMPLATE_NAMES = Object.keys(TEMPLATES);
|
|
3908
|
+
function templateByName(name) {
|
|
3909
|
+
const make = TEMPLATES[name];
|
|
3910
|
+
return make ? make() : null;
|
|
3911
|
+
}
|
|
3912
|
+
|
|
3913
|
+
// src/plugin/posterValues.ts
|
|
3914
|
+
import {
|
|
3915
|
+
findFontFamily as findFontFamily2,
|
|
3916
|
+
fontFaceUrl,
|
|
3917
|
+
fontStack,
|
|
3918
|
+
nearestFontWeight
|
|
3919
|
+
} from "@vosjs/shared";
|
|
3920
|
+
var HEX = /^#([0-9a-f]{6})$/i;
|
|
3921
|
+
var rgb = (hex2) => {
|
|
3922
|
+
const m = HEX.exec(hex2.trim());
|
|
3923
|
+
if (!m) return null;
|
|
3924
|
+
const n = parseInt(m[1], 16);
|
|
3925
|
+
return [n >> 16 & 255, n >> 8 & 255, n & 255];
|
|
3926
|
+
};
|
|
3927
|
+
var toHex = (c) => "#" + c.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0")).join("");
|
|
3928
|
+
function mixHex(a, b, t) {
|
|
3929
|
+
const pa = rgb(a);
|
|
3930
|
+
const pb = rgb(b);
|
|
3931
|
+
if (!pa || !pb) return a;
|
|
3932
|
+
return toHex([pa[0] + (pb[0] - pa[0]) * t, pa[1] + (pb[1] - pa[1]) * t, pa[2] + (pb[2] - pa[2]) * t]);
|
|
3933
|
+
}
|
|
3934
|
+
function rgba(hex2, alpha) {
|
|
3935
|
+
const c = rgb(hex2) ?? [255, 255, 255];
|
|
3936
|
+
return `rgba(${c[0]},${c[1]},${c[2]},${alpha})`;
|
|
3937
|
+
}
|
|
3938
|
+
function isLightHex(hex2) {
|
|
3939
|
+
const c = rgb(hex2);
|
|
3940
|
+
if (!c) return true;
|
|
3941
|
+
return (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) / 255 >= 0.6;
|
|
3942
|
+
}
|
|
3943
|
+
function resolveFace(family, weights) {
|
|
3944
|
+
if (!family) return null;
|
|
3945
|
+
const first = family.split(",")[0].replace(/['"]/g, "").replace(/\s+variable$/i, "").trim();
|
|
3946
|
+
const entry = findFontFamily2(first);
|
|
3947
|
+
if (!entry) return null;
|
|
3948
|
+
const fonts = [...new Set(weights.map((w) => nearestFontWeight(entry, w)))].map((w) => ({
|
|
3949
|
+
family: entry.family,
|
|
3950
|
+
url: fontFaceUrl(entry.slug, w),
|
|
3951
|
+
weight: w
|
|
3952
|
+
}));
|
|
3953
|
+
return { stack: fontStack(entry), category: entry.category, fonts };
|
|
3954
|
+
}
|
|
3955
|
+
var HOUSE_SERIF = "Fraunces";
|
|
3956
|
+
function posterValues(brand, words) {
|
|
3957
|
+
const b = brand ?? {};
|
|
3958
|
+
const bgA = b.bgA && HEX.test(b.bgA) ? b.bgA : null;
|
|
3959
|
+
const bgB = b.bgB && HEX.test(b.bgB) ? b.bgB : bgA;
|
|
3960
|
+
const bgC = b.bgC && HEX.test(b.bgC) ? b.bgC : bgB;
|
|
3961
|
+
const ink = b.ink && HEX.test(b.ink) ? b.ink : null;
|
|
3962
|
+
const accent = b.accent && HEX.test(b.accent) ? b.accent : null;
|
|
3963
|
+
const wordmark = (words.brand ?? b.wordmark ?? b.name ?? "").trim();
|
|
3964
|
+
const release = (words.release ?? "").trim();
|
|
3965
|
+
const lightGround = bgA ? isLightHex(bgA) : false;
|
|
3966
|
+
const values = {};
|
|
3967
|
+
if (bgA) values.bgA = bgA;
|
|
3968
|
+
if (bgB) values.bgB = bgB;
|
|
3969
|
+
if (bgC) values.bgC = bgC;
|
|
3970
|
+
if (ink) values.ink = ink;
|
|
3971
|
+
if (accent) values.accent = accent;
|
|
3972
|
+
if (ink && bgA) values.inkSoft = mixHex(ink, bgA, 0.28);
|
|
3973
|
+
if (accent) values.streak = rgba(accent, lightGround ? 0.12 : 0.18);
|
|
3974
|
+
if (bgA) values.grain = lightGround ? 10 : 22;
|
|
3975
|
+
if (accent) values.blobA = rgba(accent, lightGround ? 0.28 : 0.4);
|
|
3976
|
+
if (bgC) values.blobB = rgba(bgC, 0.75);
|
|
3977
|
+
if (accent) values.blobC = rgba(mixHex(accent, "#ffffff", 0.55), lightGround ? 0.35 : 0.25);
|
|
3978
|
+
if (wordmark) values.brand = wordmark;
|
|
3979
|
+
const headline = (words.headline ?? "").trim();
|
|
3980
|
+
if (headline) values.headline = headline;
|
|
3981
|
+
const kicker = (words.kicker ?? "").trim();
|
|
3982
|
+
values.kicker = kicker ? kicker : [wordmark, release].filter(Boolean).join(" ").toUpperCase();
|
|
3983
|
+
const fonts = [];
|
|
3984
|
+
const display = resolveFace(b.fontDisplay, [600, 700]);
|
|
3985
|
+
const serif = display && display.category === "serif" ? display : resolveFace(HOUSE_SERIF, [600, 700]);
|
|
3986
|
+
if (serif) {
|
|
3987
|
+
values.fontDisplay = serif.stack;
|
|
3988
|
+
fonts.push(...serif.fonts);
|
|
3989
|
+
}
|
|
3990
|
+
const body = resolveFace(b.fontBody, [700]);
|
|
3991
|
+
if (body) {
|
|
3992
|
+
values.fontBody = body.stack;
|
|
3993
|
+
fonts.push(...body.fonts);
|
|
3994
|
+
}
|
|
3995
|
+
return { values, fonts, lightGround };
|
|
3996
|
+
}
|
|
3997
|
+
|
|
3998
|
+
// src/plugin/stages.ts
|
|
3999
|
+
var HOUSE_SERIF2 = "Fraunces";
|
|
4000
|
+
var str = (v, fallback) => typeof v === "string" && v.trim() ? v.trim() : fallback;
|
|
4001
|
+
var firstFamily = (stack) => stack.split(",")[0].replace(/['"]/g, "").trim();
|
|
4002
|
+
function stageSplitCover(input) {
|
|
4003
|
+
const R = input.size.w / Math.max(1, input.size.h);
|
|
4004
|
+
const portrait = R < 0.87;
|
|
4005
|
+
const square = !portrait && R <= 1.15;
|
|
4006
|
+
const v = input.values;
|
|
4007
|
+
const ground = `linear-gradient(135deg, ${str(v.bgA, "#8a3d2a")}, ${str(v.bgC, str(v.bgB, "#5c5a2e"))})`;
|
|
4008
|
+
const ink = str(v.ink, "#fff6ec");
|
|
4009
|
+
const inkSoft = str(v.inkSoft, ink);
|
|
4010
|
+
const serif = firstFamily(str(v.fontDisplay, HOUSE_SERIF2));
|
|
4011
|
+
const body = firstFamily(str(v.fontBody, "Lexend"));
|
|
4012
|
+
const headline = str(v.headline, "");
|
|
4013
|
+
const kicker = str(v.kicker, "");
|
|
4014
|
+
const brand = str(v.brand, "");
|
|
4015
|
+
const inset = portrait ? { left: 0.08, right: -0.08, top: 0.44, bottom: -0.1 } : square ? { left: 0.1, right: -0.1, top: 0.5, bottom: -0.12 } : { left: 0.44, right: -0.06, top: 0.3, bottom: -0.12 };
|
|
4016
|
+
const shot = {
|
|
4017
|
+
x: inset.left,
|
|
4018
|
+
y: inset.top,
|
|
4019
|
+
w: 1 - inset.left - inset.right,
|
|
4020
|
+
h: 1 - inset.top - inset.bottom
|
|
4021
|
+
};
|
|
4022
|
+
const set = [
|
|
4023
|
+
`frame.background=${JSON.stringify(ground)}`,
|
|
4024
|
+
"frame.backgroundMedia=null",
|
|
4025
|
+
"frame.fit=cover",
|
|
4026
|
+
`frame.inset=${JSON.stringify(inset)}`,
|
|
4027
|
+
'frame.focus={"cx":0,"cy":0}',
|
|
4028
|
+
"frame.radius=16",
|
|
4029
|
+
"frame.shadow=0.45",
|
|
4030
|
+
"frame.shadowContact=0.25",
|
|
4031
|
+
"frame.border=0",
|
|
4032
|
+
"zoom=[]",
|
|
4033
|
+
`tilt=[{"id":"stage","in":0,"out":${input.sourceSeconds.toFixed(3)},"rx":3,"ry":${portrait || square ? 0 : 10}}]`,
|
|
4034
|
+
"cursor.visible=false",
|
|
4035
|
+
"cursor.clickFx.style=none"
|
|
4036
|
+
];
|
|
4037
|
+
const designW = 1080 * input.size.w / input.size.h;
|
|
4038
|
+
const boxes = [];
|
|
4039
|
+
const clips = [];
|
|
4040
|
+
const column = portrait || square ? 0.84 : 0.36;
|
|
4041
|
+
const left = portrait || square ? 0.08 : 0.07;
|
|
4042
|
+
const size = portrait ? 60 : square ? 68 : 84;
|
|
4043
|
+
const word = (id, text, preset, family, px, y, color, extra, role) => {
|
|
4044
|
+
if (!text) return;
|
|
4045
|
+
const lines = text.split("\n");
|
|
4046
|
+
const longest = Math.max(...lines.map((l) => l.length));
|
|
4047
|
+
const widest = Math.min(column, longest * px * (preset === "title" ? 0.5 : 0.62) / designW);
|
|
4048
|
+
const lh = preset === "title" ? 1.05 : 1.2;
|
|
4049
|
+
const h = lines.length * px * lh / 1080;
|
|
4050
|
+
clips.push({
|
|
4051
|
+
id,
|
|
4052
|
+
kind: "text",
|
|
4053
|
+
text,
|
|
4054
|
+
preset,
|
|
4055
|
+
family,
|
|
4056
|
+
size: px,
|
|
4057
|
+
color,
|
|
4058
|
+
align: "left",
|
|
4059
|
+
maxWidth: column,
|
|
4060
|
+
lineHeight: lh,
|
|
4061
|
+
transform: { x: left + widest / 2, y, scale: 1, rotation: 0 },
|
|
4062
|
+
shadow: "none",
|
|
4063
|
+
start: 0,
|
|
4064
|
+
duration: +input.outputSeconds.toFixed(3),
|
|
4065
|
+
enter: "none",
|
|
4066
|
+
exit: "none",
|
|
4067
|
+
...extra
|
|
4068
|
+
});
|
|
4069
|
+
boxes.push({ x: left, y: y - h / 2, w: widest, h, color, role, label: text.length > 24 ? `${text.slice(0, 24)}\u2026` : text });
|
|
4070
|
+
};
|
|
4071
|
+
const kickerY = portrait ? 0.1 : square ? 0.12 : 0.24;
|
|
4072
|
+
const titleY = portrait ? 0.24 : square ? 0.28 : 0.46;
|
|
4073
|
+
const brandY = portrait ? 0.94 : square ? 0.94 : 0.86;
|
|
4074
|
+
word("stage-kicker", kicker, "label", "JetBrains Mono", 19, kickerY, inkSoft, { letterSpacing: 6, weight: 400 }, "body");
|
|
4075
|
+
word("stage-title", headline, "title", serif, size, titleY, ink, { weight: 600 }, "headline");
|
|
4076
|
+
word("stage-brand", brand, "label", body, 25, brandY, ink, { weight: 700, letterSpacing: 1 }, "body");
|
|
4077
|
+
set.push(`overlays=${JSON.stringify(clips)}`);
|
|
4078
|
+
return { set, text: boxes, shot };
|
|
4079
|
+
}
|
|
4080
|
+
|
|
4081
|
+
// src/plugin/motionPlan.ts
|
|
4082
|
+
import { ratedSegments as ratedSegments4, spanOutputExtent as spanOutputExtent4 } from "@vosjs/studio-core";
|
|
4083
|
+
var SOUND_DESTINATIONS = /* @__PURE__ */ new Set([
|
|
4084
|
+
"x-feed-cut",
|
|
4085
|
+
"youtube-main-demo",
|
|
4086
|
+
"shorts-linkedin-vertical-cut"
|
|
4087
|
+
]);
|
|
4088
|
+
var LOOP_DESTINATIONS = /* @__PURE__ */ new Set(["github-readme-loop"]);
|
|
4089
|
+
var off = (v) => v !== void 0 && /^(none|off|false|no)$/i.test(v.trim());
|
|
4090
|
+
function pickTrack(catalog, ask) {
|
|
4091
|
+
if (!catalog || !ask || off(ask)) return null;
|
|
4092
|
+
const want = ask.trim().toLowerCase();
|
|
4093
|
+
return catalog.tracks.find((t) => t.slug.toLowerCase() === want) ?? catalog.tracks.find((t) => (t.mood ?? "").toLowerCase() === want) ?? null;
|
|
4094
|
+
}
|
|
4095
|
+
function clickTimes(doc, range) {
|
|
4096
|
+
const rated = ratedSegments4(doc);
|
|
4097
|
+
const out = [];
|
|
4098
|
+
for (const e of doc.source.cursor) {
|
|
4099
|
+
if (e.type !== "down") continue;
|
|
4100
|
+
const src = e.t / 1e3;
|
|
4101
|
+
const ext = spanOutputExtent4(rated, src, src + 1e-3);
|
|
4102
|
+
if (!ext) continue;
|
|
4103
|
+
const t = ext.start;
|
|
4104
|
+
if (t < range[0] || t > range[1]) continue;
|
|
4105
|
+
if (out.length && t - out[out.length - 1] < 0.12) continue;
|
|
4106
|
+
out.push(+(t - range[0]).toFixed(3));
|
|
4107
|
+
}
|
|
4108
|
+
return out;
|
|
4109
|
+
}
|
|
4110
|
+
function planMotion(input) {
|
|
4111
|
+
const { destination: d, doc, range, words, launch, catalog } = input;
|
|
4112
|
+
const set = [];
|
|
4113
|
+
const notes = [];
|
|
4114
|
+
const skipped = [];
|
|
4115
|
+
if (d.kind !== "video") return { set, notes, skipped };
|
|
4116
|
+
const loop = LOOP_DESTINATIONS.has(d.id);
|
|
4117
|
+
const sound = SOUND_DESTINATIONS.has(d.id);
|
|
4118
|
+
const length = range[1] - range[0];
|
|
4119
|
+
const portrait = d.px.w / d.px.h < 0.9;
|
|
4120
|
+
const entrance = launch.entrance;
|
|
4121
|
+
if (!loop && !off(entrance)) {
|
|
4122
|
+
const kind = entrance && /^(tilt-in|pull-out|rise)$/.test(entrance.trim()) ? entrance.trim() : "tilt-in";
|
|
4123
|
+
set.push(`frame.entrance={"kind":"${kind}"}`);
|
|
4124
|
+
notes.push(`entrance ${kind}`);
|
|
4125
|
+
}
|
|
4126
|
+
if (!loop && !off(launch.endCard)) {
|
|
4127
|
+
const headline = (words.headline ?? "").trim();
|
|
4128
|
+
const brand = (words.brand ?? "").trim();
|
|
4129
|
+
const sub = [brand, (words.release ?? "").trim()].filter(Boolean).join(" ");
|
|
4130
|
+
if (headline || brand) {
|
|
4131
|
+
const card = { seconds: 2.5 };
|
|
4132
|
+
if (input.ink) card.ink = input.ink;
|
|
4133
|
+
if (headline) card.headline = headline;
|
|
4134
|
+
if (sub && sub !== headline) card.sub = sub;
|
|
4135
|
+
if (brand) card.wordmark = brand;
|
|
4136
|
+
set.push(`endCard=${JSON.stringify(card)}`);
|
|
4137
|
+
notes.push("end card");
|
|
4138
|
+
} else {
|
|
4139
|
+
skipped.push(`${d.id}: no end card (no headline or wordmark in LAUNCH.md, BRAND.md or the flags)`);
|
|
4140
|
+
}
|
|
4141
|
+
}
|
|
4142
|
+
if (!loop && d.text !== "none" && input.captions.length && !off(launch.captions)) {
|
|
4143
|
+
const rated = ratedSegments4(doc);
|
|
4144
|
+
const steps = doc.source.meta.steps ?? [];
|
|
4145
|
+
const clips = [];
|
|
4146
|
+
for (const c of input.captions) {
|
|
4147
|
+
const step = steps.find((s) => c.id !== void 0 && s.id === c.id || s.step === c.step);
|
|
4148
|
+
if (!step || step.skipped) continue;
|
|
4149
|
+
const t = stepOutputTime(rated, step, 0.2);
|
|
4150
|
+
if (t === null || t < range[0] || t > range[1] - 1) continue;
|
|
4151
|
+
const start = +(t - range[0]).toFixed(3);
|
|
4152
|
+
clips.push({
|
|
4153
|
+
id: `caption-${c.step}`,
|
|
4154
|
+
kind: "text",
|
|
4155
|
+
text: c.caption,
|
|
4156
|
+
preset: "caption",
|
|
4157
|
+
start,
|
|
4158
|
+
duration: Math.min(3.5, Math.max(2.5, range[1] - t - 0.2)),
|
|
4159
|
+
transform: { x: 0.5, y: portrait ? 0.8 : 0.86, scale: 1, rotation: 0 },
|
|
4160
|
+
enter: "rise",
|
|
4161
|
+
exit: "fade",
|
|
4162
|
+
align: "center",
|
|
4163
|
+
box: { color: "rgba(17,17,17,0.72)" }
|
|
4164
|
+
});
|
|
4165
|
+
}
|
|
4166
|
+
if (clips.length) {
|
|
4167
|
+
const existing = Array.isArray(doc.overlays) ? doc.overlays : [];
|
|
4168
|
+
set.push(`overlays=${JSON.stringify([...existing, ...clips])}`);
|
|
4169
|
+
notes.push(`${clips.length} caption(s)`);
|
|
4170
|
+
}
|
|
4171
|
+
}
|
|
4172
|
+
if (sound && !loop) {
|
|
4173
|
+
const track = pickTrack(catalog, launch.music);
|
|
4174
|
+
const clips = Array.isArray(doc.audio) ? [...doc.audio] : [];
|
|
4175
|
+
if (track) {
|
|
4176
|
+
const hasMic = !!doc.source.micKey;
|
|
4177
|
+
const fadeOut = Math.min(2.5, length * 0.15);
|
|
4178
|
+
clips.push({
|
|
4179
|
+
id: "bed",
|
|
4180
|
+
key: track.url,
|
|
4181
|
+
name: track.title,
|
|
4182
|
+
start: 0,
|
|
4183
|
+
in: 0,
|
|
4184
|
+
out: Math.min(track.duration, length),
|
|
4185
|
+
duration: track.duration,
|
|
4186
|
+
gain: hasMic ? 0.35 : 0.5,
|
|
4187
|
+
fadeIn: 0.6,
|
|
4188
|
+
fadeOut,
|
|
4189
|
+
loop: track.duration < length,
|
|
4190
|
+
loopLen: track.duration < length ? length : void 0,
|
|
4191
|
+
duck: hasMic
|
|
4192
|
+
});
|
|
4193
|
+
notes.push(`bed ${track.slug}`);
|
|
4194
|
+
} else if (launch.music && !off(launch.music)) {
|
|
4195
|
+
skipped.push(`${d.id}: music "${launch.music}" is not a catalog track or mood${catalog ? "" : " (the catalog could not be read)"}`);
|
|
4196
|
+
}
|
|
4197
|
+
const click = catalog?.sfx.find((s) => s.slug === "sfx-click");
|
|
4198
|
+
if (click && !doc.source.micKey && !off(launch.clicks)) {
|
|
4199
|
+
const times = clickTimes(doc, range);
|
|
4200
|
+
for (const [i, t] of times.entries()) {
|
|
4201
|
+
clips.push({
|
|
4202
|
+
id: `click-${i}`,
|
|
4203
|
+
key: click.url,
|
|
4204
|
+
name: click.title,
|
|
4205
|
+
start: t,
|
|
4206
|
+
in: 0,
|
|
4207
|
+
out: click.duration,
|
|
4208
|
+
duration: click.duration,
|
|
4209
|
+
gain: 0.4,
|
|
4210
|
+
fadeIn: 0,
|
|
4211
|
+
fadeOut: 0
|
|
4212
|
+
});
|
|
4213
|
+
}
|
|
4214
|
+
if (times.length) notes.push(`${times.length} click sound(s)`);
|
|
4215
|
+
}
|
|
4216
|
+
if (clips.length) set.push(`audio=${JSON.stringify(clips)}`);
|
|
4217
|
+
}
|
|
4218
|
+
if (portrait) {
|
|
4219
|
+
set.push("frame.fit=cover");
|
|
4220
|
+
set.push('frame.inset={"left":0.06,"right":0.06,"top":0.17,"bottom":0.17}');
|
|
4221
|
+
set.push("frame.focusFollow=camera");
|
|
4222
|
+
notes.push("vertical reframe follows the camera");
|
|
4223
|
+
}
|
|
4224
|
+
return { set, notes, skipped };
|
|
4225
|
+
}
|
|
4226
|
+
|
|
2720
4227
|
// src/plugin/deliver.ts
|
|
2721
4228
|
var CHANNEL_ALIASES = {
|
|
2722
4229
|
ph: "producthunt",
|
|
@@ -2744,20 +4251,154 @@ function resolveChannels(raw) {
|
|
|
2744
4251
|
}
|
|
2745
4252
|
return out;
|
|
2746
4253
|
}
|
|
2747
|
-
function
|
|
2748
|
-
const
|
|
2749
|
-
const
|
|
2750
|
-
|
|
2751
|
-
const
|
|
2752
|
-
|
|
4254
|
+
async function readBrandBesideTake(dir, explicit) {
|
|
4255
|
+
const candidates = explicit ? [explicit] : [join5(dir, "BRAND.md"), join5(dir, "..", "BRAND.md")];
|
|
4256
|
+
for (const file of candidates) {
|
|
4257
|
+
if (!existsSync5(file)) continue;
|
|
4258
|
+
const roles = parseFrontmatter(await readFile5(file, "utf8"));
|
|
4259
|
+
return { file, roles };
|
|
4260
|
+
}
|
|
4261
|
+
return null;
|
|
4262
|
+
}
|
|
4263
|
+
async function readLaunchBesideTake(dir, explicit) {
|
|
4264
|
+
const candidates = explicit ? [explicit] : [join5(dir, "LAUNCH.md"), join5(dir, "..", "LAUNCH.md")];
|
|
4265
|
+
for (const file of candidates) {
|
|
4266
|
+
if (!existsSync5(file)) continue;
|
|
4267
|
+
return { file, roles: parseFrontmatter(await readFile5(file, "utf8")) };
|
|
4268
|
+
}
|
|
4269
|
+
return null;
|
|
4270
|
+
}
|
|
4271
|
+
async function resolveLook(dir, opts) {
|
|
4272
|
+
const brand = await readBrandBesideTake(dir, opts.brand);
|
|
4273
|
+
const roles = brand?.roles ?? null;
|
|
4274
|
+
if (opts.look === "none") return { look: null, from: "--look none", roles };
|
|
4275
|
+
if (opts.look !== void 0) {
|
|
4276
|
+
if (!isLookKind(opts.look))
|
|
4277
|
+
throw new Error(
|
|
4278
|
+
`--look "${opts.look}" \u2014 one of plate | gradient | dark | none`
|
|
4279
|
+
);
|
|
4280
|
+
return { look: houseLook(opts.look), from: `--look ${opts.look}`, roles };
|
|
4281
|
+
}
|
|
4282
|
+
if (brand) {
|
|
4283
|
+
const look = lookFromBrand(brand.roles);
|
|
4284
|
+
return { look, from: `${brand.file} (${look.kind})`, roles };
|
|
2753
4285
|
}
|
|
2754
|
-
const dropped = doc.zoom.length - apexes.length;
|
|
2755
|
-
if (apexes.length) return { times: apexes.sort((a, b) => a - b), dropped };
|
|
2756
4286
|
return {
|
|
2757
|
-
|
|
2758
|
-
|
|
4287
|
+
look: houseLook("gradient"),
|
|
4288
|
+
from: "the house gradient (no BRAND.md beside the take)",
|
|
4289
|
+
roles
|
|
2759
4290
|
};
|
|
2760
4291
|
}
|
|
4292
|
+
function templateForCard(d, opts) {
|
|
4293
|
+
if (opts.poster === null) return null;
|
|
4294
|
+
if (opts.poster) return { config: opts.poster.config, from: opts.poster.from };
|
|
4295
|
+
if (!d.template) return null;
|
|
4296
|
+
let name = d.template;
|
|
4297
|
+
let note;
|
|
4298
|
+
const wants = templateOf(templateByName(name) ?? {});
|
|
4299
|
+
const needsHeadline = wants?.text.some((t) => t.role === "headline");
|
|
4300
|
+
if (needsHeadline && !opts.words?.headline?.trim()) {
|
|
4301
|
+
name = "card-on-gradient";
|
|
4302
|
+
note = `${d.id}: no headline (LAUNCH.md headline: or --headline), so the ${d.template} template stands down for card-on-gradient`;
|
|
4303
|
+
}
|
|
4304
|
+
const config = templateByName(name);
|
|
4305
|
+
if (!config) return null;
|
|
4306
|
+
if (name === "split-cover") return { config, from: "stage split-cover", note, stage: true };
|
|
4307
|
+
return { config, from: `template ${name}`, note };
|
|
4308
|
+
}
|
|
4309
|
+
function lookOverrides(look, placement, size, video, opts) {
|
|
4310
|
+
const inset = cardInset(look, size, video, placement);
|
|
4311
|
+
const set = [
|
|
4312
|
+
"frame.fit=contain",
|
|
4313
|
+
`frame.background=${JSON.stringify(look.ground)}`,
|
|
4314
|
+
`frame.inset=${JSON.stringify(inset)}`,
|
|
4315
|
+
`frame.radius=${look.radius}`,
|
|
4316
|
+
`frame.shadow=${look.shadow}`,
|
|
4317
|
+
`frame.shadowContact=${look.shadowContact}`,
|
|
4318
|
+
`frame.border=${look.border}`
|
|
4319
|
+
];
|
|
4320
|
+
if (look.shadowColor) set.push(`frame.shadowColor=${look.shadowColor}`);
|
|
4321
|
+
if (look.border > 0) {
|
|
4322
|
+
set.push("frame.borderWidth=1");
|
|
4323
|
+
set.push(`frame.borderColor=${look.borderColor ?? "#000000"}`);
|
|
4324
|
+
}
|
|
4325
|
+
if (!opts.keepMedia) set.push("frame.backgroundMedia=null");
|
|
4326
|
+
if (opts.still) {
|
|
4327
|
+
set.push("zoom=[]", "tilt=[]", "cursor.visible=false", "cursor.clickFx.style=none");
|
|
4328
|
+
}
|
|
4329
|
+
return set;
|
|
4330
|
+
}
|
|
4331
|
+
function endCardInk(look, brand) {
|
|
4332
|
+
if (!look) return null;
|
|
4333
|
+
const ground = look.ground;
|
|
4334
|
+
const m = /#([0-9a-f]{6})/i.exec(ground);
|
|
4335
|
+
const hex2 = m ? m[0] : null;
|
|
4336
|
+
const light = look.kind === "plate" || (hex2 ? isLightHexGround(hex2) : look.kind === "gradient");
|
|
4337
|
+
if (!light) return "#ffffff";
|
|
4338
|
+
const ink = brand?.ink;
|
|
4339
|
+
return ink && /^#[0-9a-f]{6}$/i.test(ink) ? ink : "#111111";
|
|
4340
|
+
}
|
|
4341
|
+
function isLightHexGround(hex2) {
|
|
4342
|
+
const n = parseInt(hex2.slice(1), 16);
|
|
4343
|
+
const r = n >> 16 & 255;
|
|
4344
|
+
const g = n >> 8 & 255;
|
|
4345
|
+
const b = n & 255;
|
|
4346
|
+
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 >= 0.6;
|
|
4347
|
+
}
|
|
4348
|
+
var PROBE_WIDTH = 640;
|
|
4349
|
+
async function pickStillTimes(browser, dir, doc, duration, opts) {
|
|
4350
|
+
const { candidates, dropped } = momentCandidates(doc, duration);
|
|
4351
|
+
const notes = [];
|
|
4352
|
+
if (dropped > 0)
|
|
4353
|
+
notes.push(
|
|
4354
|
+
`${dropped} of ${doc.zoom.length} zoom apex(es) fall outside the cut`
|
|
4355
|
+
);
|
|
4356
|
+
if (!candidates.length) return { times: [], moments: [], notes };
|
|
4357
|
+
const meta = doc.source.meta;
|
|
4358
|
+
const vw = meta.captureWidth ?? meta.width;
|
|
4359
|
+
const vh = meta.captureHeight ?? meta.height;
|
|
4360
|
+
const probeDir = await mkdtemp(join5(tmpdir(), "vos-moments-"));
|
|
4361
|
+
try {
|
|
4362
|
+
const probe = await framesTake(browser, dir, {
|
|
4363
|
+
times: candidates.map((c) => c.time),
|
|
4364
|
+
width: PROBE_WIDTH,
|
|
4365
|
+
height: Math.max(2, Math.round(PROBE_WIDTH * vh / vw / 2) * 2),
|
|
4366
|
+
outDir: probeDir,
|
|
4367
|
+
overrides: {
|
|
4368
|
+
...opts.overrides,
|
|
4369
|
+
set: [...SCREENSHOT_DEFAULTS, ...opts.overrides?.set ?? []]
|
|
4370
|
+
}
|
|
4371
|
+
});
|
|
4372
|
+
const byTime = /* @__PURE__ */ new Map();
|
|
4373
|
+
for (const frame of probe.frames) {
|
|
4374
|
+
const img = decodePng(new Uint8Array(await readFile5(frame.file)));
|
|
4375
|
+
if (!img) continue;
|
|
4376
|
+
const whole = { x: 0, y: 0, w: img.w, h: img.h };
|
|
4377
|
+
byTime.set(+frame.time.toFixed(3), {
|
|
4378
|
+
ink: inkCoverage(img, whole),
|
|
4379
|
+
hash: differenceHash(img, whole)
|
|
4380
|
+
});
|
|
4381
|
+
}
|
|
4382
|
+
const measured = [];
|
|
4383
|
+
for (const c of candidates) {
|
|
4384
|
+
const m = byTime.get(+c.time.toFixed(3));
|
|
4385
|
+
if (m) measured.push({ time: c.time, ...m });
|
|
4386
|
+
}
|
|
4387
|
+
const pick = pickMoments(measured);
|
|
4388
|
+
const kept = candidates.filter((c) => pick.times.includes(c.time));
|
|
4389
|
+
const bySource = (s) => candidates.filter((c) => c.source === s).length;
|
|
4390
|
+
notes.push(
|
|
4391
|
+
`${candidates.length} candidate(s): ${bySource("step")} from steps, ${bySource("zoom")} from zoom apexes, ${bySource("spread")} from the spread; ${kept.length} kept`
|
|
4392
|
+
);
|
|
4393
|
+
return {
|
|
4394
|
+
times: pick.times,
|
|
4395
|
+
moments: kept,
|
|
4396
|
+
notes: [...notes, ...pick.dropped.map((d) => `moment: ${d}`)]
|
|
4397
|
+
};
|
|
4398
|
+
} finally {
|
|
4399
|
+
await rm3(probeDir, { recursive: true, force: true });
|
|
4400
|
+
}
|
|
4401
|
+
}
|
|
2761
4402
|
var overCeiling = (bytes, maxBytes) => `${Math.ceil(bytes / 1024)} KB exceeds the ${Math.floor(maxBytes / 1024)} KB ceiling`;
|
|
2762
4403
|
var specWords = (d) => {
|
|
2763
4404
|
const parts = [`${d.px.w}x${d.px.h}`];
|
|
@@ -2777,12 +4418,22 @@ var FULL_BLEED = [
|
|
|
2777
4418
|
"cursor.clickFx.style=none"
|
|
2778
4419
|
];
|
|
2779
4420
|
var SCREENSHOT_DEFAULTS = [...FULL_BLEED, "zoom=[]", "tilt=[]"];
|
|
2780
|
-
function stillOverridesFor(d, opts) {
|
|
4421
|
+
function stillOverridesFor(d, opts, video) {
|
|
2781
4422
|
const set = [];
|
|
2782
|
-
if (d.
|
|
2783
|
-
|
|
4423
|
+
if (d.genre === "screenshot" && !opts.composed) {
|
|
4424
|
+
if (d.fit === "cover") set.push("frame.fit=cover");
|
|
2784
4425
|
set.push(...SCREENSHOT_DEFAULTS);
|
|
2785
|
-
else if (d.genre === "card" && !opts.composed
|
|
4426
|
+
} else if (d.genre === "card" && !opts.composed && opts.look && video && d.px) {
|
|
4427
|
+
set.push(
|
|
4428
|
+
...lookOverrides(opts.look, "card", d.px, video, {
|
|
4429
|
+
still: true,
|
|
4430
|
+
keepMedia: opts.overrides?.background !== void 0
|
|
4431
|
+
})
|
|
4432
|
+
);
|
|
4433
|
+
} else {
|
|
4434
|
+
if (d.fit === "cover") set.push("frame.fit=cover");
|
|
4435
|
+
if (d.genre === "card" && !opts.composed) set.push(...FULL_BLEED);
|
|
4436
|
+
}
|
|
2786
4437
|
set.push(...opts.overrides?.set ?? []);
|
|
2787
4438
|
if (!set.length) return opts.overrides;
|
|
2788
4439
|
return { ...opts.overrides, set };
|
|
@@ -2791,34 +4442,69 @@ async function deliverTake(browser, dir, opts) {
|
|
|
2791
4442
|
const take = await loadTake(dir);
|
|
2792
4443
|
if (!take.doc) throw new Error(`${dir} has no doc.json \u2014 run plan first`);
|
|
2793
4444
|
const doc = take.doc;
|
|
2794
|
-
const duration = totalDuration(
|
|
4445
|
+
const duration = totalDuration(ratedSegments5(doc));
|
|
2795
4446
|
const videoSeconds = opts.range ? Math.min(opts.range[1], duration) - Math.min(opts.range[0], duration) : duration;
|
|
4447
|
+
const outDir = resolve3(opts.outDir ?? join5(dir, "kit"));
|
|
4448
|
+
await mkdir4(outDir, { recursive: true });
|
|
4449
|
+
const destinations = opts.channels.flatMap((c) => destinationsForChannel(c));
|
|
4450
|
+
const assets = [];
|
|
4451
|
+
const skipped = [];
|
|
2796
4452
|
let stillTimes;
|
|
4453
|
+
let moments;
|
|
2797
4454
|
if (opts.times?.length) {
|
|
2798
4455
|
stillTimes = opts.times;
|
|
4456
|
+
moments = opts.times.map((time) => ({ time, source: "times" }));
|
|
2799
4457
|
} else {
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
4458
|
+
opts.onPhase?.("moments (the step timeline, the zoom apexes, the spread)");
|
|
4459
|
+
const picked = await pickStillTimes(browser, dir, doc, duration, opts);
|
|
4460
|
+
stillTimes = picked.times;
|
|
4461
|
+
moments = picked.moments;
|
|
4462
|
+
for (const n of picked.notes) {
|
|
4463
|
+
if (n.startsWith("moment: ")) skipped.push(n);
|
|
4464
|
+
else opts.onPhase?.(`note: ${n}`);
|
|
4465
|
+
}
|
|
4466
|
+
}
|
|
4467
|
+
const meta0 = doc.source.meta;
|
|
4468
|
+
const video = {
|
|
4469
|
+
w: meta0.captureWidth ?? meta0.width,
|
|
4470
|
+
h: meta0.captureHeight ?? meta0.height
|
|
4471
|
+
};
|
|
4472
|
+
const videoOverrides = (d, range) => {
|
|
4473
|
+
const set = [];
|
|
4474
|
+
if (opts.look) {
|
|
4475
|
+
set.push(
|
|
4476
|
+
...lookOverrides(opts.look, "hero", d.px, video, {
|
|
4477
|
+
still: false,
|
|
4478
|
+
keepMedia: opts.overrides?.background !== void 0
|
|
4479
|
+
})
|
|
2805
4480
|
);
|
|
2806
4481
|
}
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
4482
|
+
const plan = planMotion({
|
|
4483
|
+
destination: d,
|
|
4484
|
+
doc,
|
|
4485
|
+
range: range ?? [0, duration],
|
|
4486
|
+
words: opts.words ?? {},
|
|
4487
|
+
launch: opts.launchRoles ?? {},
|
|
4488
|
+
captions: opts.captions ?? [],
|
|
4489
|
+
catalog: opts.catalog ?? null,
|
|
4490
|
+
ink: endCardInk(opts.look, opts.brandRoles)
|
|
4491
|
+
});
|
|
4492
|
+
set.push(...plan.set);
|
|
4493
|
+
if (plan.notes.length) opts.onPhase?.(`${d.id}: ${plan.notes.join(", ")}`);
|
|
4494
|
+
for (const s of plan.skipped) skipped.push(`note: ${s}`);
|
|
4495
|
+
set.push(...opts.overrides?.set ?? []);
|
|
4496
|
+
if (!set.length) return opts.overrides;
|
|
4497
|
+
return { ...opts.overrides, set };
|
|
4498
|
+
};
|
|
4499
|
+
const cardPlans = destinations.filter((d) => d.kind !== "video" && d.genre === "card" && !NOT_FROM_FOOTAGE[d.id]).map((d) => ({ d, plan: templateForCard(d, opts) })).filter((p) => p.plan !== null);
|
|
4500
|
+
const posterCardIds = new Set(cardPlans.map((p) => p.d.id));
|
|
4501
|
+
for (const p of cardPlans) if (p.plan.note) skipped.push(`note: ${p.plan.note}`);
|
|
4502
|
+
if (cardPlans.length) {
|
|
2818
4503
|
const meta = doc.source.meta;
|
|
2819
4504
|
const heroTime = opts.shotTime ?? (stillTimes.length ? stillTimes[0] : duration / 2);
|
|
4505
|
+
const fill = posterValues(opts.brandRoles, opts.words ?? {});
|
|
2820
4506
|
opts.onPhase?.(
|
|
2821
|
-
`poster shot (full bleed at ${heroTime.toFixed(2)}s)
|
|
4507
|
+
`poster shot (full bleed at ${heroTime.toFixed(2)}s), baked as an object`
|
|
2822
4508
|
);
|
|
2823
4509
|
const serveDir = await mkdtemp(join5(tmpdir(), "vos-poster-"));
|
|
2824
4510
|
try {
|
|
@@ -2836,71 +4522,135 @@ async function deliverTake(browser, dir, opts) {
|
|
|
2836
4522
|
"frame.shadow=0",
|
|
2837
4523
|
"frame.border=0",
|
|
2838
4524
|
"frame.browserBar.kind=none",
|
|
4525
|
+
"cursor.visible=false",
|
|
4526
|
+
"cursor.clickFx.style=none",
|
|
2839
4527
|
...opts.overrides?.set ?? []
|
|
2840
4528
|
]
|
|
2841
4529
|
}
|
|
2842
4530
|
});
|
|
2843
|
-
await
|
|
2844
|
-
|
|
2845
|
-
const
|
|
2846
|
-
const
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
);
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
)
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
4531
|
+
const raw = decodePng(new Uint8Array(await readFile5(shot.frames[0].file)));
|
|
4532
|
+
if (!raw) throw new Error("the poster shot could not be decoded");
|
|
4533
|
+
const PAD = 0.06;
|
|
4534
|
+
const baked = bakeShot(raw, {
|
|
4535
|
+
margin: PAD,
|
|
4536
|
+
hairline: fill.lightGround ? 0.14 : 0,
|
|
4537
|
+
shadow: fill.lightGround ? 0.32 : 0.45
|
|
4538
|
+
});
|
|
4539
|
+
await writeFile6(join5(serveDir, "shot.png"), encodePng(baked));
|
|
4540
|
+
const shotAspect = raw.w / raw.h;
|
|
4541
|
+
for (const { d, plan } of cardPlans) {
|
|
4542
|
+
if (plan.stage) {
|
|
4543
|
+
const staged = stageSplitCover({
|
|
4544
|
+
size: d.px,
|
|
4545
|
+
values: fill.values,
|
|
4546
|
+
sourceSeconds: meta.durationMs / 1e3,
|
|
4547
|
+
outputSeconds: duration
|
|
4548
|
+
});
|
|
4549
|
+
opts.onPhase?.(`${d.channel} ${d.asset} (${specWords(d)}) from ${plan.from}`);
|
|
4550
|
+
const shotDir = await mkdtemp(join5(tmpdir(), "vos-stage-"));
|
|
4551
|
+
try {
|
|
4552
|
+
const captured = await framesTake(browser, dir, {
|
|
4553
|
+
times: [heroTime],
|
|
4554
|
+
width: d.px.w,
|
|
4555
|
+
height: d.px.h,
|
|
4556
|
+
outDir: shotDir,
|
|
4557
|
+
overrides: {
|
|
4558
|
+
...opts.overrides,
|
|
4559
|
+
set: [...staged.set, ...opts.overrides?.set ?? []]
|
|
4560
|
+
}
|
|
4561
|
+
});
|
|
4562
|
+
const to2 = join5(outDir, `${d.id}.png`);
|
|
4563
|
+
await rename4(captured.frames[0].file, to2);
|
|
4564
|
+
const bytes2 = (await stat(to2)).size;
|
|
4565
|
+
if (d.maxBytes !== void 0 && bytes2 > d.maxBytes) {
|
|
4566
|
+
skipped.push(`${d.channel} ${d.asset}: ${overCeiling(bytes2, d.maxBytes)} (kept at ${to2})`);
|
|
4567
|
+
continue;
|
|
4568
|
+
}
|
|
4569
|
+
assets.push({
|
|
4570
|
+
channel: d.channel,
|
|
4571
|
+
asset: d.asset,
|
|
4572
|
+
destination: d.id,
|
|
4573
|
+
path: relative(outDir, to2),
|
|
4574
|
+
w: d.px.w,
|
|
4575
|
+
h: d.px.h,
|
|
4576
|
+
bytes: bytes2,
|
|
4577
|
+
seconds: null,
|
|
4578
|
+
frameTime: heroTime,
|
|
4579
|
+
source: "stage",
|
|
4580
|
+
template: "split-cover-stage",
|
|
4581
|
+
text: staged.text,
|
|
4582
|
+
shot: staged.shot
|
|
4583
|
+
});
|
|
4584
|
+
} finally {
|
|
4585
|
+
await rm3(shotDir, { recursive: true, force: true });
|
|
4586
|
+
}
|
|
4587
|
+
continue;
|
|
4588
|
+
}
|
|
4589
|
+
const problems = templateProblems(plan.config);
|
|
4590
|
+
if (problems.length) {
|
|
4591
|
+
skipped.push(
|
|
4592
|
+
`${d.channel} ${d.asset}: ${plan.from} is not a valid template (${problems[0]}) \u2014 kept from the take`
|
|
4593
|
+
);
|
|
4594
|
+
posterCardIds.delete(d.id);
|
|
4595
|
+
continue;
|
|
4596
|
+
}
|
|
4597
|
+
const filled = fillTemplate(plan.config, {
|
|
4598
|
+
size: d.px,
|
|
4599
|
+
slots: { shot: { src: "/shot.png", aspect: shotAspect, pad: PAD } },
|
|
4600
|
+
values: fill.values
|
|
4601
|
+
});
|
|
4602
|
+
const limits = textLimitProblems(templateOf(plan.config), fill.values);
|
|
4603
|
+
for (const l of limits) skipped.push(`note: ${d.id}: ${l}`);
|
|
4604
|
+
if (filled.missing.length) {
|
|
4605
|
+
skipped.push(
|
|
4606
|
+
`${d.channel} ${d.asset}: ${plan.from} needs ${filled.missing.join(", ")} \u2014 kept from the take`
|
|
4607
|
+
);
|
|
4608
|
+
posterCardIds.delete(d.id);
|
|
4609
|
+
continue;
|
|
4610
|
+
}
|
|
4611
|
+
const config = filled.config;
|
|
4612
|
+
if (fill.fonts.length) {
|
|
4613
|
+
const declared = Array.isArray(config.fonts) ? config.fonts : [];
|
|
4614
|
+
config.fonts = [...declared, ...fill.fonts];
|
|
4615
|
+
}
|
|
2861
4616
|
const posterDuration = typeof config.duration === "number" ? config.duration : 6;
|
|
2862
4617
|
const time = Math.min(
|
|
2863
4618
|
opts.posterTime ?? posterDuration * 0.9,
|
|
2864
4619
|
Math.max(0, posterDuration - 0.05)
|
|
2865
4620
|
);
|
|
2866
|
-
opts.onPhase?.(
|
|
2867
|
-
`poster cards (${posterCards.map((d) => d.id).join(", ")}) at ${time.toFixed(2)}s`
|
|
2868
|
-
);
|
|
4621
|
+
opts.onPhase?.(`${d.channel} ${d.asset} (${specWords(d)}) from ${plan.from}, ${filled.aspect}`);
|
|
2869
4622
|
await renderPosterStills(
|
|
2870
4623
|
browser,
|
|
2871
4624
|
config,
|
|
2872
4625
|
serveDir,
|
|
2873
|
-
|
|
2874
|
-
name: `${d.id}.png`,
|
|
2875
|
-
width: d.px.w,
|
|
2876
|
-
height: d.px.h
|
|
2877
|
-
})),
|
|
4626
|
+
[{ name: `${d.id}.png`, width: d.px.w, height: d.px.h }],
|
|
2878
4627
|
time
|
|
2879
4628
|
);
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
continue;
|
|
2890
|
-
}
|
|
2891
|
-
assets.push({
|
|
2892
|
-
channel: d.channel,
|
|
2893
|
-
asset: d.asset,
|
|
2894
|
-
destination: d.id,
|
|
2895
|
-
path: relative(outDir, to),
|
|
2896
|
-
w: d.px.w,
|
|
2897
|
-
h: d.px.h,
|
|
2898
|
-
bytes,
|
|
2899
|
-
seconds: null,
|
|
2900
|
-
frameTime: null,
|
|
2901
|
-
source: "poster"
|
|
2902
|
-
});
|
|
4629
|
+
const from = join5(serveDir, `${d.id}.png`);
|
|
4630
|
+
const to = join5(outDir, `${d.id}.png`);
|
|
4631
|
+
await rename4(from, to);
|
|
4632
|
+
const bytes = (await stat(to)).size;
|
|
4633
|
+
if (d.maxBytes !== void 0 && bytes > d.maxBytes) {
|
|
4634
|
+
skipped.push(
|
|
4635
|
+
`${d.channel} ${d.asset}: ${overCeiling(bytes, d.maxBytes)} (kept at ${to})`
|
|
4636
|
+
);
|
|
4637
|
+
continue;
|
|
2903
4638
|
}
|
|
4639
|
+
assets.push({
|
|
4640
|
+
channel: d.channel,
|
|
4641
|
+
asset: d.asset,
|
|
4642
|
+
destination: d.id,
|
|
4643
|
+
path: relative(outDir, to),
|
|
4644
|
+
w: d.px.w,
|
|
4645
|
+
h: d.px.h,
|
|
4646
|
+
bytes,
|
|
4647
|
+
seconds: null,
|
|
4648
|
+
frameTime: heroTime,
|
|
4649
|
+
source: "poster",
|
|
4650
|
+
template: templateOf(plan.config)?.family ?? plan.from,
|
|
4651
|
+
text: filled.text,
|
|
4652
|
+
shot: filled.slots.shot
|
|
4653
|
+
});
|
|
2904
4654
|
}
|
|
2905
4655
|
} finally {
|
|
2906
4656
|
await rm3(serveDir, { recursive: true, force: true });
|
|
@@ -2921,26 +4671,36 @@ async function deliverTake(browser, dir, opts) {
|
|
|
2921
4671
|
);
|
|
2922
4672
|
continue;
|
|
2923
4673
|
}
|
|
4674
|
+
let range = opts.range;
|
|
4675
|
+
let seconds = videoSeconds;
|
|
2924
4676
|
if (d.maxSeconds !== void 0 && videoSeconds > d.maxSeconds) {
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
4677
|
+
if (LOOP_DESTINATIONS.has(d.id) && !opts.range) {
|
|
4678
|
+
range = [0, d.maxSeconds];
|
|
4679
|
+
seconds = d.maxSeconds;
|
|
4680
|
+
opts.onPhase?.(
|
|
4681
|
+
`note: ${label} takes the first ${d.maxSeconds}s of the ${videoSeconds.toFixed(0)}s take (a loop's cap)`
|
|
4682
|
+
);
|
|
4683
|
+
} else {
|
|
4684
|
+
skipped.push(
|
|
4685
|
+
`${label}: spec caps at ${d.maxSeconds}s, the take is ${videoSeconds.toFixed(0)}s \u2014 cut it (--range, or trim segments in doc.json)`
|
|
4686
|
+
);
|
|
4687
|
+
continue;
|
|
4688
|
+
}
|
|
2929
4689
|
}
|
|
2930
4690
|
opts.onPhase?.(`${label} (${specWords(d)})`);
|
|
2931
4691
|
const outFile = join5(outDir, `${d.id}.${d.format}`);
|
|
2932
4692
|
const bitrate = d.maxBytes !== void 0 ? Math.min(
|
|
2933
4693
|
1e7,
|
|
2934
|
-
Math.floor(d.maxBytes * 8 /
|
|
4694
|
+
Math.floor(d.maxBytes * 8 / seconds * 0.85)
|
|
2935
4695
|
) : void 0;
|
|
2936
4696
|
const result = await renderTake(browser, dir, outFile, {
|
|
2937
4697
|
width: d.px.w,
|
|
2938
4698
|
height: d.px.h,
|
|
2939
4699
|
format: "mp4",
|
|
2940
4700
|
parallel: opts.parallel,
|
|
2941
|
-
range
|
|
4701
|
+
range,
|
|
2942
4702
|
bitrate,
|
|
2943
|
-
overrides:
|
|
4703
|
+
overrides: videoOverrides(d, range),
|
|
2944
4704
|
onProgress: opts.onProgress
|
|
2945
4705
|
});
|
|
2946
4706
|
if (d.maxBytes !== void 0 && result.bytes > d.maxBytes) {
|
|
@@ -2964,7 +4724,7 @@ async function deliverTake(browser, dir, opts) {
|
|
|
2964
4724
|
}
|
|
2965
4725
|
const wanted = d.kind === "still-set" ? stillTimes.slice(0, d.count?.max ?? stillTimes.length) : stillTimes.slice(0, 1);
|
|
2966
4726
|
opts.onPhase?.(`${label} (${specWords(d)}, ${wanted.length} still(s))`);
|
|
2967
|
-
const overrides = stillOverridesFor(d, opts);
|
|
4727
|
+
const overrides = stillOverridesFor(d, opts, video);
|
|
2968
4728
|
const captured = await framesTake(browser, dir, {
|
|
2969
4729
|
times: wanted,
|
|
2970
4730
|
width: d.px.w,
|
|
@@ -2993,7 +4753,8 @@ async function deliverTake(browser, dir, opts) {
|
|
|
2993
4753
|
h: captured.height,
|
|
2994
4754
|
bytes,
|
|
2995
4755
|
seconds: null,
|
|
2996
|
-
frameTime: frame.time
|
|
4756
|
+
frameTime: frame.time,
|
|
4757
|
+
...d.genre === "screenshot" && opts.composed ? { composed: true } : {}
|
|
2997
4758
|
});
|
|
2998
4759
|
}
|
|
2999
4760
|
if (d.count && captured.frames.length < d.count.min) {
|
|
@@ -3004,9 +4765,11 @@ async function deliverTake(browser, dir, opts) {
|
|
|
3004
4765
|
}
|
|
3005
4766
|
const kit = {
|
|
3006
4767
|
release: opts.release ?? null,
|
|
4768
|
+
look: opts.look?.kind ?? null,
|
|
3007
4769
|
take: dir,
|
|
3008
4770
|
produced: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3009
4771
|
specsVerified: CHANNEL_SPECS_VERIFIED,
|
|
4772
|
+
moments,
|
|
3010
4773
|
skipped,
|
|
3011
4774
|
assets
|
|
3012
4775
|
};
|
|
@@ -3015,19 +4778,567 @@ async function deliverTake(browser, dir, opts) {
|
|
|
3015
4778
|
return { kit, kitFile, outDir };
|
|
3016
4779
|
}
|
|
3017
4780
|
|
|
3018
|
-
// src/plugin/
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
4781
|
+
// src/plugin/music.ts
|
|
4782
|
+
async function fetchMusicCatalog(origin) {
|
|
4783
|
+
const base = origin.replace(/\/+$/, "");
|
|
4784
|
+
const res = await fetch(`${base}/api/music`);
|
|
4785
|
+
if (!res.ok) throw new Error(`GET ${base}/api/music answered ${res.status}`);
|
|
4786
|
+
const body = await res.json();
|
|
4787
|
+
return {
|
|
4788
|
+
tracks: Array.isArray(body.tracks) ? body.tracks : [],
|
|
4789
|
+
sfx: Array.isArray(body.sfx) ? body.sfx : []
|
|
4790
|
+
};
|
|
4791
|
+
}
|
|
4792
|
+
|
|
4793
|
+
// src/plugin/judge.ts
|
|
4794
|
+
import { existsSync as existsSync6 } from "fs";
|
|
4795
|
+
import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile7 } from "fs/promises";
|
|
4796
|
+
import { dirname, isAbsolute, join as join6, resolve as resolve4 } from "path";
|
|
3022
4797
|
import { DESTINATIONS as DESTINATIONS2 } from "@vosjs/studio-core";
|
|
3023
|
-
var
|
|
4798
|
+
var RUBRIC = [
|
|
4799
|
+
"A plate, and room on it: the subject sits at 74 to 89% of the width with headroom; nobody bleeds a window on all four sides.",
|
|
4800
|
+
"The window is an object: rounded corners and a shadow with weight (a soft wide one on a light plate, a tight glow on a dark one).",
|
|
4801
|
+
"It enters: the first frame is not a static, centred, flat window (a tilt-in, a pull-out, a rise).",
|
|
4802
|
+
"The camera zooms to the affordance, holds through the response, and pulls out to show the consequence.",
|
|
4803
|
+
"Beats are cut at stillness; no wipes.",
|
|
4804
|
+
"Content is staged: real data, a populated state, never an empty canvas or a wallpaper.",
|
|
4805
|
+
"The cursor is a character: visible, smoothed, a press on click, never drifting through dead space.",
|
|
4806
|
+
"The last frame is a poster: a resolved state you could ship as the still.",
|
|
4807
|
+
"The message has its own column or its own frame; the headline is two to six words over up to three lines at about 40% of the height; the wordmark is anchored.",
|
|
4808
|
+
"The poster test: would you post this frame as a still, alone?",
|
|
4809
|
+
"The thumbnail test: does it read at half size?",
|
|
4810
|
+
"Name three ways this asset acknowledges THIS product (its data, its state, its brand), not a generic window."
|
|
4811
|
+
];
|
|
4812
|
+
function rolesFor(asset) {
|
|
4813
|
+
if ((asset.source === "poster" || asset.source === "stage") && asset.template)
|
|
4814
|
+
return [asset.template.replace(/-stage$/, "")];
|
|
4815
|
+
const spec = DESTINATIONS2.find((d) => d.id === asset.destination);
|
|
4816
|
+
if (spec?.kind === "video") {
|
|
4817
|
+
const portrait = spec.px.w < spec.px.h;
|
|
4818
|
+
return portrait ? ["feature-clip", "site-walkthrough"] : ["feature-clip", "site-walkthrough", "feature-clip-dark"];
|
|
4819
|
+
}
|
|
4820
|
+
if (spec?.genre === "card") return ["window-in-scene", "card-on-gradient", "framed-screenshot"];
|
|
4821
|
+
if (spec?.genre === "screenshot") return ["framed-screenshot", "app-session"];
|
|
4822
|
+
return [];
|
|
4823
|
+
}
|
|
4824
|
+
function resample(img, w, h) {
|
|
4825
|
+
const out = new Uint8Array(w * h * 4);
|
|
4826
|
+
const sx = img.w / w;
|
|
4827
|
+
const sy = img.h / h;
|
|
4828
|
+
for (let y = 0; y < h; y++) {
|
|
4829
|
+
const y0 = Math.floor(y * sy);
|
|
4830
|
+
const y1 = Math.max(y0 + 1, Math.floor((y + 1) * sy));
|
|
4831
|
+
for (let x = 0; x < w; x++) {
|
|
4832
|
+
const x0 = Math.floor(x * sx);
|
|
4833
|
+
const x1 = Math.max(x0 + 1, Math.floor((x + 1) * sx));
|
|
4834
|
+
let r = 0;
|
|
4835
|
+
let g = 0;
|
|
4836
|
+
let b = 0;
|
|
4837
|
+
let a = 0;
|
|
4838
|
+
let n = 0;
|
|
4839
|
+
for (let yy = y0; yy < y1 && yy < img.h; yy++) {
|
|
4840
|
+
for (let xx = x0; xx < x1 && xx < img.w; xx++) {
|
|
4841
|
+
const o2 = (yy * img.w + xx) * 4;
|
|
4842
|
+
r += img.data[o2];
|
|
4843
|
+
g += img.data[o2 + 1];
|
|
4844
|
+
b += img.data[o2 + 2];
|
|
4845
|
+
a += img.data[o2 + 3];
|
|
4846
|
+
n++;
|
|
4847
|
+
}
|
|
4848
|
+
}
|
|
4849
|
+
const o = (y * w + x) * 4;
|
|
4850
|
+
out[o] = n ? r / n : 0;
|
|
4851
|
+
out[o + 1] = n ? g / n : 0;
|
|
4852
|
+
out[o + 2] = n ? b / n : 0;
|
|
4853
|
+
out[o + 3] = n ? a / n : 0;
|
|
4854
|
+
}
|
|
4855
|
+
}
|
|
4856
|
+
return { w, h, data: out };
|
|
4857
|
+
}
|
|
4858
|
+
function composeSheet(left, right, height = 540, gutter = 32) {
|
|
4859
|
+
const lw = Math.max(1, Math.round(left.w * height / left.h));
|
|
4860
|
+
const rw = Math.max(1, Math.round(right.w * height / right.h));
|
|
4861
|
+
const L = resample(left, lw, height);
|
|
4862
|
+
const R = resample(right, rw, height);
|
|
4863
|
+
const margin = 24;
|
|
4864
|
+
const band = 8;
|
|
4865
|
+
const w = margin * 2 + lw + gutter + rw;
|
|
4866
|
+
const h = margin * 2 + height + band + 6;
|
|
4867
|
+
const out = new Uint8Array(w * h * 4);
|
|
4868
|
+
for (let i = 0; i < w * h; i++) {
|
|
4869
|
+
out[i * 4] = 240;
|
|
4870
|
+
out[i * 4 + 1] = 242;
|
|
4871
|
+
out[i * 4 + 2] = 244;
|
|
4872
|
+
out[i * 4 + 3] = 255;
|
|
4873
|
+
}
|
|
4874
|
+
const blit = (src, x0, y0) => {
|
|
4875
|
+
for (let y = 0; y < src.h; y++) {
|
|
4876
|
+
for (let x = 0; x < src.w; x++) {
|
|
4877
|
+
const si = (y * src.w + x) * 4;
|
|
4878
|
+
const a = src.data[si + 3] / 255;
|
|
4879
|
+
const o = ((y0 + y) * w + x0 + x) * 4;
|
|
4880
|
+
for (let c = 0; c < 3; c++) out[o + c] = Math.round(src.data[si + c] * a + out[o + c] * (1 - a));
|
|
4881
|
+
}
|
|
4882
|
+
}
|
|
4883
|
+
};
|
|
4884
|
+
blit(L, margin, margin);
|
|
4885
|
+
blit(R, margin + lw + gutter, margin);
|
|
4886
|
+
const fill = (x0, x1, y0, y1, v) => {
|
|
4887
|
+
for (let y = y0; y < y1; y++)
|
|
4888
|
+
for (let x = x0; x < x1; x++) {
|
|
4889
|
+
const o = (y * w + x) * 4;
|
|
4890
|
+
out[o] = v;
|
|
4891
|
+
out[o + 1] = v;
|
|
4892
|
+
out[o + 2] = v;
|
|
4893
|
+
}
|
|
4894
|
+
};
|
|
4895
|
+
fill(margin, margin + lw, margin + height + 6, margin + height + 6 + band, 40);
|
|
4896
|
+
fill(margin + lw + gutter, margin + lw + gutter + rw, margin + height + 6, margin + height + 6 + band, 200);
|
|
4897
|
+
return { w, h, data: out };
|
|
4898
|
+
}
|
|
4899
|
+
async function judgeKit(kitPath, manifestPath, outDir) {
|
|
4900
|
+
const kit = JSON.parse(await readFile6(kitPath, "utf8"));
|
|
4901
|
+
const manifest = JSON.parse(await readFile6(manifestPath, "utf8"));
|
|
4902
|
+
const manifestDir = dirname(resolve4(manifestPath));
|
|
4903
|
+
const kitDir = dirname(resolve4(kitPath));
|
|
4904
|
+
const out = resolve4(outDir ?? join6(kitDir, "judge"));
|
|
4905
|
+
await mkdir5(out, { recursive: true });
|
|
4906
|
+
const sheets = [];
|
|
4907
|
+
const skipped = [];
|
|
4908
|
+
const refCache = /* @__PURE__ */ new Map();
|
|
4909
|
+
const loadRef = async (file) => {
|
|
4910
|
+
if (refCache.has(file)) return refCache.get(file) ?? null;
|
|
4911
|
+
const p = join6(manifestDir, file);
|
|
4912
|
+
const img = existsSync6(p) ? decodePng(new Uint8Array(await readFile6(p))) : null;
|
|
4913
|
+
refCache.set(file, img);
|
|
4914
|
+
return img;
|
|
4915
|
+
};
|
|
4916
|
+
for (const a of kit.assets) {
|
|
4917
|
+
if (!/\.png$/i.test(a.path)) continue;
|
|
4918
|
+
const file = isAbsolute(a.path) && existsSync6(a.path) ? a.path : join6(kitDir, a.path.split("/").pop() ?? a.path);
|
|
4919
|
+
const img = existsSync6(file) ? decodePng(new Uint8Array(await readFile6(file))) : null;
|
|
4920
|
+
if (!img) {
|
|
4921
|
+
skipped.push(`${a.destination ?? a.path}: unreadable`);
|
|
4922
|
+
continue;
|
|
4923
|
+
}
|
|
4924
|
+
const roles = rolesFor({ destination: a.destination, source: a.source, template: a.template, path: a.path });
|
|
4925
|
+
const ref = manifest.assets.find((r) => roles.includes(r.role) && r.genre !== "context");
|
|
4926
|
+
if (!ref) {
|
|
4927
|
+
skipped.push(`${a.destination ?? a.path}: no reference of role ${roles.join("|") || "(none)"}`);
|
|
4928
|
+
continue;
|
|
4929
|
+
}
|
|
4930
|
+
const refImg = await loadRef(ref.file);
|
|
4931
|
+
if (!refImg) {
|
|
4932
|
+
skipped.push(`${a.destination ?? a.path}: reference ${ref.file} unreadable`);
|
|
4933
|
+
continue;
|
|
4934
|
+
}
|
|
4935
|
+
const stem = (a.path.split("/").pop() ?? a.path).replace(/\.png$/i, "");
|
|
4936
|
+
const id = stem.replace(/[^A-Za-z0-9_-]+/g, "-") || a.destination || "asset";
|
|
4937
|
+
const nameA = `${id}--A.png`;
|
|
4938
|
+
const nameB = `${id}--B.png`;
|
|
4939
|
+
await writeFile7(join6(out, nameA), encodePng(composeSheet(img, refImg)));
|
|
4940
|
+
await writeFile7(join6(out, nameB), encodePng(composeSheet(refImg, img)));
|
|
4941
|
+
const rubric = [
|
|
4942
|
+
`# ${id} against ${ref.id} (${ref.file})`,
|
|
4943
|
+
"",
|
|
4944
|
+
`Sheet A: the kit asset LEFT (dark band), the reference RIGHT (light band). Sheet B: the reverse.`,
|
|
4945
|
+
`Judge each sheet on its own, both orders, and write the verdict where the two agree; a disagreement is a tie.`,
|
|
4946
|
+
"",
|
|
4947
|
+
`## The reference`,
|
|
4948
|
+
ref.layout,
|
|
4949
|
+
...ref.facts ? ["", "```json", JSON.stringify(ref.facts, null, 2), "```"] : [],
|
|
4950
|
+
"",
|
|
4951
|
+
`Rule: ${ref.rule}`,
|
|
4952
|
+
"",
|
|
4953
|
+
"## The rubric",
|
|
4954
|
+
...RUBRIC.map((r, i) => `${i + 1}. ${r}`),
|
|
4955
|
+
"",
|
|
4956
|
+
"## Verdict",
|
|
4957
|
+
"In judge.json: `win` true when the kit asset is at least as good as the reference on the rules that apply to its genre, false when it is worse, null for a tie; `reasons` names the rules by number.",
|
|
4958
|
+
""
|
|
4959
|
+
].join("\n");
|
|
4960
|
+
await writeFile7(join6(out, `${id}.md`), rubric);
|
|
4961
|
+
sheets.push({ asset: id, reference: ref.id, sheetA: nameA, sheetB: nameB, rubric: `${id}.md` });
|
|
4962
|
+
}
|
|
4963
|
+
const verdictFile = join6(out, "judge.json");
|
|
4964
|
+
if (!existsSync6(verdictFile)) {
|
|
4965
|
+
await writeFile7(
|
|
4966
|
+
verdictFile,
|
|
4967
|
+
JSON.stringify(
|
|
4968
|
+
{
|
|
4969
|
+
kit: kitPath,
|
|
4970
|
+
manifest: manifestPath,
|
|
4971
|
+
verdicts: sheets.map((s) => ({ asset: s.asset, reference: s.reference, A: null, B: null, win: null, reasons: [] }))
|
|
4972
|
+
},
|
|
4973
|
+
null,
|
|
4974
|
+
2
|
|
4975
|
+
)
|
|
4976
|
+
);
|
|
4977
|
+
}
|
|
4978
|
+
return { sheets, skipped, outDir: out, verdictFile };
|
|
4979
|
+
}
|
|
4980
|
+
function winRate(verdicts) {
|
|
4981
|
+
const judgedList = verdicts.filter((v) => v.win !== null || (v.reasons?.length ?? 0) > 0);
|
|
4982
|
+
const wins = judgedList.filter((v) => v.win === true).length;
|
|
4983
|
+
const ties = judgedList.filter((v) => v.win === null).length;
|
|
4984
|
+
const judged = judgedList.length;
|
|
4985
|
+
return { wins, ties, judged, rate: judged ? (wins + ties / 2) / judged : null };
|
|
4986
|
+
}
|
|
4987
|
+
|
|
4988
|
+
// src/plugin/kitPicture.ts
|
|
4989
|
+
import { execFile } from "child_process";
|
|
4990
|
+
import { promisify } from "util";
|
|
4991
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
4992
|
+
var execFileP = promisify(execFile);
|
|
4993
|
+
var SUBJECT_BAND = { min: 0.6, max: 0.92 };
|
|
4994
|
+
var BLANK_INK = 0.12;
|
|
4995
|
+
var SEPARATION_L = 8;
|
|
4996
|
+
var SHADOW_PRESENT = 6;
|
|
4997
|
+
var EDGE_PRESENT = 40;
|
|
4998
|
+
var DUPLICATE_BITS2 = 6;
|
|
4999
|
+
var HALFSIZE_KEEP = 0.45;
|
|
5000
|
+
function apcaContrast(text, bg) {
|
|
5001
|
+
const lum = (c) => {
|
|
5002
|
+
const ch = (v) => Math.pow(v / 255, 2.4);
|
|
5003
|
+
let y = 0.2126729 * ch(c[0]) + 0.7151522 * ch(c[1]) + 0.072175 * ch(c[2]);
|
|
5004
|
+
if (y < 0.022) y += Math.pow(0.022 - y, 1.414);
|
|
5005
|
+
return y;
|
|
5006
|
+
};
|
|
5007
|
+
const yt = lum(text);
|
|
5008
|
+
const yb = lum(bg);
|
|
5009
|
+
if (Math.abs(yb - yt) < 5e-4) return 0;
|
|
5010
|
+
let sapc;
|
|
5011
|
+
if (yb > yt) {
|
|
5012
|
+
sapc = (Math.pow(yb, 0.56) - Math.pow(yt, 0.57)) * 1.14;
|
|
5013
|
+
return sapc < 0.1 ? 0 : (sapc - 0.027) * 100;
|
|
5014
|
+
}
|
|
5015
|
+
sapc = (Math.pow(yb, 0.65) - Math.pow(yt, 0.62)) * 1.14;
|
|
5016
|
+
return sapc > -0.1 ? 0 : -(sapc + 0.027) * 100;
|
|
5017
|
+
}
|
|
5018
|
+
var pct = (v) => `${Math.round(v * 100)}%`;
|
|
5019
|
+
var hexToRgb = (h) => {
|
|
5020
|
+
const m = /^#([0-9a-f]{6})$/i.exec(h.trim());
|
|
5021
|
+
if (!m) return null;
|
|
5022
|
+
const n = parseInt(m[1], 16);
|
|
5023
|
+
return [n >> 16 & 255, n >> 8 & 255, n & 255];
|
|
5024
|
+
};
|
|
5025
|
+
function stillFindings(a, img, m) {
|
|
5026
|
+
const out = [];
|
|
5027
|
+
const genre = a.spec?.genre;
|
|
5028
|
+
const bledAll = m.bleed.length === 4;
|
|
5029
|
+
if (a.shot) {
|
|
5030
|
+
const sx = Math.max(0, a.shot.x) * img.w;
|
|
5031
|
+
const sy = Math.max(0, a.shot.y) * img.h;
|
|
5032
|
+
const sw = Math.min(1, a.shot.x + a.shot.w) * img.w - sx;
|
|
5033
|
+
const sh = Math.min(1, a.shot.y + a.shot.h) * img.h - sy;
|
|
5034
|
+
const rect = { x: sx, y: sy, w: Math.max(1, sw), h: Math.max(1, sh) };
|
|
5035
|
+
const ink = inkCoverage(img, rect);
|
|
5036
|
+
if (ink < BLANK_INK) {
|
|
5037
|
+
out.push({
|
|
5038
|
+
code: "blank",
|
|
5039
|
+
severity: "error",
|
|
5040
|
+
asset: a.destination,
|
|
5041
|
+
message: `${pct(ink)} ink inside the shot: the moment shows a wallpaper, an empty canvas or a flat panel`,
|
|
5042
|
+
fixHint: "pick a moment after a gesture landed (--shot-time, or --times step:<id>) and stage the set before recording",
|
|
5043
|
+
bbox: rect
|
|
5044
|
+
});
|
|
5045
|
+
}
|
|
5046
|
+
if (a.shot.w < 0.5 || a.shot.w > 1.2) {
|
|
5047
|
+
out.push({
|
|
5048
|
+
code: "subject",
|
|
5049
|
+
severity: "error",
|
|
5050
|
+
asset: a.destination,
|
|
5051
|
+
message: `the shot is ${pct(a.shot.w)} of the width; a card sits at 60 to 92%, a bleed a little past the edge`,
|
|
5052
|
+
fixHint: "the template layout places the slot; fix its place for this aspect",
|
|
5053
|
+
bbox: rect
|
|
5054
|
+
});
|
|
5055
|
+
}
|
|
5056
|
+
for (const f of textFindings(a, img)) out.push(f);
|
|
5057
|
+
return out;
|
|
5058
|
+
}
|
|
5059
|
+
const subject = m.card ?? { x: 0, y: 0, w: img.w, h: img.h };
|
|
5060
|
+
if (m.ink < BLANK_INK) {
|
|
5061
|
+
out.push({
|
|
5062
|
+
code: "blank",
|
|
5063
|
+
severity: "error",
|
|
5064
|
+
asset: a.destination,
|
|
5065
|
+
message: `${pct(m.ink)} ink inside the ${m.card ? "card" : "frame"}: a wallpaper, an empty canvas or a flat panel, not the product doing something`,
|
|
5066
|
+
fixHint: "pick a moment after a gesture landed (--times step:<id>) and stage the set before recording: real data, a populated state",
|
|
5067
|
+
bbox: subject
|
|
5068
|
+
});
|
|
5069
|
+
}
|
|
5070
|
+
if (genre === "card") {
|
|
5071
|
+
if (bledAll || m.widthPct === null) {
|
|
5072
|
+
out.push({
|
|
5073
|
+
code: "subject",
|
|
5074
|
+
severity: "error",
|
|
5075
|
+
asset: a.destination,
|
|
5076
|
+
message: bledAll ? "the picture fills the frame on all four sides: a crop, not a card on a ground" : "no card found: the frame is one flat tone",
|
|
5077
|
+
fixHint: "present the card in a look (vos deliver reads BRAND.md or --look) or render this destination from a poster template"
|
|
5078
|
+
});
|
|
5079
|
+
} else if (m.widthPct < SUBJECT_BAND.min || m.widthPct > SUBJECT_BAND.max) {
|
|
5080
|
+
out.push({
|
|
5081
|
+
code: "subject",
|
|
5082
|
+
severity: "error",
|
|
5083
|
+
asset: a.destination,
|
|
5084
|
+
message: `the card is ${pct(m.widthPct)} of the width; the references sit at ${pct(SUBJECT_BAND.min)} to ${pct(SUBJECT_BAND.max)}`,
|
|
5085
|
+
fixHint: "the look places the card at 84% (frame.inset); a poster template sets its own placement",
|
|
5086
|
+
bbox: m.card ?? void 0
|
|
5087
|
+
});
|
|
5088
|
+
}
|
|
5089
|
+
if (m.card && !bledAll) {
|
|
5090
|
+
const sep = m.separation ?? 0;
|
|
5091
|
+
const shadow = m.shadow ?? 0;
|
|
5092
|
+
if (sep < SEPARATION_L && shadow < SHADOW_PRESENT && m.edge < EDGE_PRESENT) {
|
|
5093
|
+
out.push({
|
|
5094
|
+
code: "separation",
|
|
5095
|
+
severity: "error",
|
|
5096
|
+
asset: a.destination,
|
|
5097
|
+
message: `the card and the ground are ${sep.toFixed(1)} L* apart with no shadow halo (${shadow.toFixed(1)}) and no drawn edge (${m.edge.toFixed(0)}): the card dissolves into the plate`,
|
|
5098
|
+
fixHint: "a contact shadow (frame.shadowContact) plus a hairline (frame.border with borderColor) makes a light card sit on a light ground; the plate look sets both",
|
|
5099
|
+
bbox: m.card
|
|
5100
|
+
});
|
|
5101
|
+
}
|
|
5102
|
+
}
|
|
5103
|
+
}
|
|
5104
|
+
if (genre === "screenshot" && a.composed && m.card && (m.widthPct ?? 0) < 0.98) {
|
|
5105
|
+
out.push({
|
|
5106
|
+
code: "subject",
|
|
5107
|
+
severity: "error",
|
|
5108
|
+
asset: a.destination,
|
|
5109
|
+
message: `a store screenshot must be the real page full bleed; this one shows a ${pct(m.widthPct ?? 0)} card on a ground`,
|
|
5110
|
+
fixHint: "drop --composed: the screenshot genre renders the page with no chrome and no padding",
|
|
5111
|
+
bbox: m.card
|
|
5112
|
+
});
|
|
5113
|
+
}
|
|
5114
|
+
if (img.w <= 500 && (a.spec?.text === "none" || genre === "card")) {
|
|
5115
|
+
const full = edgeEnergy(img, Math.min(img.w, 400));
|
|
5116
|
+
const half = edgeEnergy(img, Math.min(img.w, 400) >> 1);
|
|
5117
|
+
const keep = full > 0 ? half / full : 1;
|
|
5118
|
+
if (keep < HALFSIZE_KEEP) {
|
|
5119
|
+
out.push({
|
|
5120
|
+
code: "halfsize",
|
|
5121
|
+
severity: "warning",
|
|
5122
|
+
asset: a.destination,
|
|
5123
|
+
message: `at half size the tile keeps ${pct(keep)} of its edges: fine text and thin lines vanish where the store shows it small`,
|
|
5124
|
+
fixHint: "a tile is the subject large and saturated with no text (the store rule); render it from the card-on-gradient template"
|
|
5125
|
+
});
|
|
5126
|
+
}
|
|
5127
|
+
}
|
|
5128
|
+
for (const f of textFindings(a, img)) out.push(f);
|
|
5129
|
+
return out;
|
|
5130
|
+
}
|
|
5131
|
+
function textFindings(a, img) {
|
|
5132
|
+
const out = [];
|
|
5133
|
+
for (const t of a.text ?? []) {
|
|
5134
|
+
const box = { x: t.x * img.w, y: t.y * img.h, w: t.w * img.w, h: t.h * img.h };
|
|
5135
|
+
const name = t.label ? `"${t.label}"` : "a text box";
|
|
5136
|
+
if (box.x < 0 || box.y < 0 || box.x + box.w > img.w + 0.5 || box.y + box.h > img.h + 0.5) {
|
|
5137
|
+
out.push({
|
|
5138
|
+
code: "sliced",
|
|
5139
|
+
severity: "error",
|
|
5140
|
+
asset: a.destination,
|
|
5141
|
+
message: `${name} crosses the frame edge: cut mid-word`,
|
|
5142
|
+
fixHint: "shorten the line, or let the template recompose (headline lines at 12 to 14% of the height, inside the safe rect)",
|
|
5143
|
+
bbox: box
|
|
5144
|
+
});
|
|
5145
|
+
}
|
|
5146
|
+
const s = a.spec?.safe;
|
|
5147
|
+
if (s) {
|
|
5148
|
+
const sx = s.x * img.w;
|
|
5149
|
+
const sy = s.y * img.h;
|
|
5150
|
+
const sw = s.w * img.w;
|
|
5151
|
+
const sh = s.h * img.h;
|
|
5152
|
+
if (box.x < sx - 0.5 || box.y < sy - 0.5 || box.x + box.w > sx + sw + 0.5 || box.y + box.h > sy + sh + 0.5) {
|
|
5153
|
+
out.push({
|
|
5154
|
+
code: "safe",
|
|
5155
|
+
severity: "warning",
|
|
5156
|
+
asset: a.destination,
|
|
5157
|
+
message: `${name} sits outside the destination's safe rect (${pct(s.w)}x${pct(s.h)} at ${pct(s.x)},${pct(s.y)}): the platform's chrome or crop covers it`,
|
|
5158
|
+
fixHint: "move the text inside the safe rect; the template reads channel-specs safe",
|
|
5159
|
+
bbox: box
|
|
5160
|
+
});
|
|
5161
|
+
}
|
|
5162
|
+
}
|
|
5163
|
+
const rgb2 = t.color ? hexToRgb(t.color) : null;
|
|
5164
|
+
if (rgb2) {
|
|
5165
|
+
const ground = medianColour(img, box);
|
|
5166
|
+
const lc = apcaContrast(rgb2, ground);
|
|
5167
|
+
const floor = t.role === "body" ? 75 : 60;
|
|
5168
|
+
if (lc < floor) {
|
|
5169
|
+
out.push({
|
|
5170
|
+
code: "contrast",
|
|
5171
|
+
severity: "warning",
|
|
5172
|
+
asset: a.destination,
|
|
5173
|
+
message: `${name} reads APCA Lc ${lc.toFixed(0)} on its ground; ${t.role === "body" ? "body" : "a headline"} wants ${floor}`,
|
|
5174
|
+
fixHint: "darken the ink or lighten the ground under the text (BRAND.md ink on bgA/bgB, never on the shot)",
|
|
5175
|
+
bbox: box
|
|
5176
|
+
});
|
|
5177
|
+
}
|
|
5178
|
+
}
|
|
5179
|
+
}
|
|
5180
|
+
if (a.spec?.text === "none" && (a.text?.length ?? 0) > 0) {
|
|
5181
|
+
out.push({
|
|
5182
|
+
code: "safe",
|
|
5183
|
+
severity: "warning",
|
|
5184
|
+
asset: a.destination,
|
|
5185
|
+
message: "this destination wants no text (the picture carries it alone) and the kit put words on it",
|
|
5186
|
+
fixHint: "render the tile from the card-on-gradient template with no headline"
|
|
5187
|
+
});
|
|
5188
|
+
}
|
|
5189
|
+
return out;
|
|
5190
|
+
}
|
|
5191
|
+
function duplicateFindings(stills) {
|
|
5192
|
+
const groups = [];
|
|
5193
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5194
|
+
const comparable = (a, b) => a.genre === "screenshot" || b.genre === "screenshot" ? a.destination === b.destination : true;
|
|
5195
|
+
for (let i = 0; i < stills.length; i++) {
|
|
5196
|
+
if (seen.has(i)) continue;
|
|
5197
|
+
const g = [stills[i]];
|
|
5198
|
+
for (let j = i + 1; j < stills.length; j++) {
|
|
5199
|
+
if (seen.has(j)) continue;
|
|
5200
|
+
if (!comparable(stills[i], stills[j])) continue;
|
|
5201
|
+
if (hammingDistance(stills[i].hash, stills[j].hash) <= DUPLICATE_BITS2) {
|
|
5202
|
+
g.push(stills[j]);
|
|
5203
|
+
seen.add(j);
|
|
5204
|
+
}
|
|
5205
|
+
}
|
|
5206
|
+
if (g.length > 1) groups.push(g);
|
|
5207
|
+
}
|
|
5208
|
+
return groups.map((g) => {
|
|
5209
|
+
const composed = g.every((s) => stills.find((x) => x.destination === s.destination)?.composed);
|
|
5210
|
+
return {
|
|
5211
|
+
code: "duplicate",
|
|
5212
|
+
severity: composed ? "info" : g.length >= 3 ? "error" : "warning",
|
|
5213
|
+
asset: g.map((s) => s.destination).join(", "),
|
|
5214
|
+
message: composed ? `${g.length} channels share one cover: ${g.map((s) => s.destination).join(", ")}` : `${g.length} assets share one frame${g[0].time !== null ? ` (${g[0].time.toFixed(2)}s)` : ""}: ${g.map((s) => s.destination).join(", ")}`,
|
|
5215
|
+
fixHint: "a kit is many moments: let deliver pick from the step timeline, or pass --times with one step per still; a poster template composes each card differently from one shot"
|
|
5216
|
+
};
|
|
5217
|
+
});
|
|
5218
|
+
}
|
|
5219
|
+
async function videoFrame(file, at2, ffmpeg) {
|
|
5220
|
+
try {
|
|
5221
|
+
const { stdout } = await execFileP(
|
|
5222
|
+
ffmpeg,
|
|
5223
|
+
["-v", "error", "-ss", at2.toFixed(3), "-i", file, "-frames:v", "1", "-f", "image2pipe", "-vcodec", "png", "-"],
|
|
5224
|
+
{ encoding: "buffer", maxBuffer: 64 * 1024 * 1024 }
|
|
5225
|
+
);
|
|
5226
|
+
return decodePng(new Uint8Array(stdout));
|
|
5227
|
+
} catch {
|
|
5228
|
+
return null;
|
|
5229
|
+
}
|
|
5230
|
+
}
|
|
5231
|
+
async function onPath(bin) {
|
|
5232
|
+
try {
|
|
5233
|
+
await execFileP(bin, ["-version"]);
|
|
5234
|
+
return true;
|
|
5235
|
+
} catch {
|
|
5236
|
+
return false;
|
|
5237
|
+
}
|
|
5238
|
+
}
|
|
5239
|
+
async function pictureChecks(assets) {
|
|
5240
|
+
const findings = [];
|
|
5241
|
+
const measured = [];
|
|
5242
|
+
const stills = [];
|
|
5243
|
+
let ffmpeg = null;
|
|
5244
|
+
for (const a of assets) {
|
|
5245
|
+
if (/\.png$/i.test(a.file)) {
|
|
5246
|
+
const img = decodePng(new Uint8Array(await readFile7(a.file)));
|
|
5247
|
+
if (!img) {
|
|
5248
|
+
findings.push({
|
|
5249
|
+
code: "unreadable",
|
|
5250
|
+
severity: "info",
|
|
5251
|
+
asset: a.destination,
|
|
5252
|
+
message: `${a.path} is not an 8-bit non-interlaced PNG; the picture checks cannot read it`,
|
|
5253
|
+
fixHint: "render stills through vos deliver or vos frames, which write readable PNGs"
|
|
5254
|
+
});
|
|
5255
|
+
measured.push({ destination: a.destination, measure: null });
|
|
5256
|
+
continue;
|
|
5257
|
+
}
|
|
5258
|
+
const m = measureStill(img);
|
|
5259
|
+
measured.push({ destination: a.destination, measure: m });
|
|
5260
|
+
findings.push(...stillFindings(a, img, m));
|
|
5261
|
+
if (a.spec?.kind !== "video")
|
|
5262
|
+
stills.push({
|
|
5263
|
+
destination: a.destination,
|
|
5264
|
+
hash: m.hash,
|
|
5265
|
+
time: null,
|
|
5266
|
+
genre: a.spec?.genre,
|
|
5267
|
+
composed: !!a.shot
|
|
5268
|
+
});
|
|
5269
|
+
continue;
|
|
5270
|
+
}
|
|
5271
|
+
if (/\.(mp4|webm|mov)$/i.test(a.file)) {
|
|
5272
|
+
if (ffmpeg === null) ffmpeg = await onPath("ffmpeg");
|
|
5273
|
+
if (!ffmpeg) {
|
|
5274
|
+
findings.push({
|
|
5275
|
+
code: "firstlast",
|
|
5276
|
+
severity: "info",
|
|
5277
|
+
asset: a.destination,
|
|
5278
|
+
message: "ffmpeg is not on PATH, so the first and last frames were not read",
|
|
5279
|
+
fixHint: "install ffmpeg to have the video checks run"
|
|
5280
|
+
});
|
|
5281
|
+
measured.push({ destination: a.destination, measure: null });
|
|
5282
|
+
continue;
|
|
5283
|
+
}
|
|
5284
|
+
const seconds = a.seconds ?? 0;
|
|
5285
|
+
const first = await videoFrame(a.file, 0, "ffmpeg");
|
|
5286
|
+
const last = await videoFrame(a.file, Math.max(0, seconds - 0.1), "ffmpeg");
|
|
5287
|
+
for (const [name, img] of [
|
|
5288
|
+
["first", first],
|
|
5289
|
+
["last", last]
|
|
5290
|
+
]) {
|
|
5291
|
+
if (!img) continue;
|
|
5292
|
+
const m = measureStill(img);
|
|
5293
|
+
if (name === "first") measured.push({ destination: a.destination, measure: m });
|
|
5294
|
+
const subject = m.card ?? { x: 0, y: 0, w: img.w, h: img.h };
|
|
5295
|
+
const ink = inkCoverage(img, subject);
|
|
5296
|
+
if (ink < BLANK_INK) {
|
|
5297
|
+
findings.push({
|
|
5298
|
+
code: "firstlast",
|
|
5299
|
+
severity: "error",
|
|
5300
|
+
asset: a.destination,
|
|
5301
|
+
message: `the ${name} frame is ${pct(ink)} ink: a ${name === "first" ? "cold open on nothing" : "clip that ends on nothing"}`,
|
|
5302
|
+
fixHint: name === "first" ? "open on the product (trim the head, or an entrance over a populated frame)" : "end on a resolved state or an end card (the last frame is the poster)"
|
|
5303
|
+
});
|
|
5304
|
+
}
|
|
5305
|
+
if (m.bleed.length === 4 && a.spec?.genre !== "screenshot") {
|
|
5306
|
+
findings.push({
|
|
5307
|
+
code: "firstlast",
|
|
5308
|
+
severity: "warning",
|
|
5309
|
+
asset: a.destination,
|
|
5310
|
+
message: `the ${name} frame fills the frame on all four sides: no ground, no room`,
|
|
5311
|
+
fixHint: "present the cut in a look (vos deliver reads BRAND.md or --look)"
|
|
5312
|
+
});
|
|
5313
|
+
}
|
|
5314
|
+
}
|
|
5315
|
+
continue;
|
|
5316
|
+
}
|
|
5317
|
+
}
|
|
5318
|
+
findings.push(...duplicateFindings(stills));
|
|
5319
|
+
const order = { error: 0, warning: 1, info: 2 };
|
|
5320
|
+
findings.sort((a, b) => order[a.severity] - order[b.severity]);
|
|
5321
|
+
return { findings, measured };
|
|
5322
|
+
}
|
|
5323
|
+
function formatFinding(f) {
|
|
5324
|
+
const where = f.bbox ? ` @ ${Math.round(f.bbox.x)},${Math.round(f.bbox.y)} ${Math.round(f.bbox.w)}x${Math.round(f.bbox.h)}` : "";
|
|
5325
|
+
return `${f.severity} ${f.code} ${f.asset}${where}: ${f.message}
|
|
5326
|
+
fix: ${f.fixHint}`;
|
|
5327
|
+
}
|
|
5328
|
+
|
|
5329
|
+
// src/plugin/validateKit.ts
|
|
5330
|
+
import { existsSync as existsSync7 } from "fs";
|
|
5331
|
+
import { readFile as readFile8, stat as stat2 } from "fs/promises";
|
|
5332
|
+
import { dirname as dirname2, isAbsolute as isAbsolute2, join as join7 } from "path";
|
|
5333
|
+
import { DESTINATIONS as DESTINATIONS3 } from "@vosjs/studio-core";
|
|
5334
|
+
var PNG_SIG2 = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
3024
5335
|
function pngDimensions(bytes) {
|
|
3025
|
-
if (bytes.length < 24 || !bytes.subarray(0, 8).equals(
|
|
5336
|
+
if (bytes.length < 24 || !bytes.subarray(0, 8).equals(PNG_SIG2)) return null;
|
|
3026
5337
|
if (bytes.toString("ascii", 12, 16) !== "IHDR") return null;
|
|
3027
5338
|
return { w: bytes.readUInt32BE(16), h: bytes.readUInt32BE(20) };
|
|
3028
5339
|
}
|
|
3029
5340
|
function sniffImage(bytes) {
|
|
3030
|
-
if (bytes.subarray(0, 8).equals(
|
|
5341
|
+
if (bytes.subarray(0, 8).equals(PNG_SIG2)) return "png";
|
|
3031
5342
|
if (bytes.length >= 12 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP")
|
|
3032
5343
|
return "webp";
|
|
3033
5344
|
if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216) return "jpeg";
|
|
@@ -3037,7 +5348,7 @@ async function probeVideo(path) {
|
|
|
3037
5348
|
const MB = await import("mediabunny");
|
|
3038
5349
|
const input = new MB.Input({
|
|
3039
5350
|
formats: MB.ALL_FORMATS,
|
|
3040
|
-
source: new MB.BufferSource(new Uint8Array(await
|
|
5351
|
+
source: new MB.BufferSource(new Uint8Array(await readFile8(path)))
|
|
3041
5352
|
});
|
|
3042
5353
|
try {
|
|
3043
5354
|
const track = await input.getPrimaryVideoTrack();
|
|
@@ -3049,16 +5360,16 @@ async function probeVideo(path) {
|
|
|
3049
5360
|
}
|
|
3050
5361
|
}
|
|
3051
5362
|
var specById = new Map(
|
|
3052
|
-
|
|
5363
|
+
DESTINATIONS3.map((d) => [d.id, d])
|
|
3053
5364
|
);
|
|
3054
5365
|
var near = (a, b, tol) => Math.abs(a - b) <= tol;
|
|
3055
|
-
async function validateKit(kitPath) {
|
|
5366
|
+
async function validateKit(kitPath, opts = {}) {
|
|
3056
5367
|
const problems = [];
|
|
3057
5368
|
const warnings = [];
|
|
3058
5369
|
const measured = [];
|
|
3059
5370
|
let kit;
|
|
3060
5371
|
try {
|
|
3061
|
-
kit = JSON.parse(await
|
|
5372
|
+
kit = JSON.parse(await readFile8(kitPath, "utf8"));
|
|
3062
5373
|
} catch (e) {
|
|
3063
5374
|
return {
|
|
3064
5375
|
valid: false,
|
|
@@ -3075,13 +5386,14 @@ async function validateKit(kitPath) {
|
|
|
3075
5386
|
measured
|
|
3076
5387
|
};
|
|
3077
5388
|
}
|
|
3078
|
-
const base =
|
|
5389
|
+
const base = dirname2(kitPath);
|
|
3079
5390
|
const resolvePath = (p) => {
|
|
3080
|
-
if (
|
|
3081
|
-
const beside =
|
|
5391
|
+
if (isAbsolute2(p) && existsSync7(p)) return p;
|
|
5392
|
+
const beside = join7(base, p.split("/").pop() ?? p);
|
|
3082
5393
|
return beside;
|
|
3083
5394
|
};
|
|
3084
5395
|
const perDestination = /* @__PURE__ */ new Map();
|
|
5396
|
+
const pictureAssets = [];
|
|
3085
5397
|
for (const a of kit.assets) {
|
|
3086
5398
|
const id = a.destination ?? `${a.channel}-${a.asset}`;
|
|
3087
5399
|
const label = `${a.channel} ${a.asset}`;
|
|
@@ -3103,7 +5415,7 @@ async function validateKit(kitPath) {
|
|
|
3103
5415
|
let h = null;
|
|
3104
5416
|
let seconds = null;
|
|
3105
5417
|
if (/\.(png|jpe?g|webp)$/i.test(file)) {
|
|
3106
|
-
const head = Buffer.from(await
|
|
5418
|
+
const head = Buffer.from(await readFile8(file));
|
|
3107
5419
|
const kind = sniffImage(head);
|
|
3108
5420
|
if (file.toLowerCase().endsWith(".png") && kind !== "png")
|
|
3109
5421
|
problems.push(
|
|
@@ -3148,6 +5460,16 @@ async function validateKit(kitPath) {
|
|
|
3148
5460
|
);
|
|
3149
5461
|
}
|
|
3150
5462
|
measured.push({ destination: id, path: a.path, w, h, bytes, seconds });
|
|
5463
|
+
pictureAssets.push({
|
|
5464
|
+
destination: id,
|
|
5465
|
+
path: a.path,
|
|
5466
|
+
file,
|
|
5467
|
+
spec,
|
|
5468
|
+
text: a.text,
|
|
5469
|
+
seconds,
|
|
5470
|
+
composed: a.composed,
|
|
5471
|
+
shot: a.source === "poster" || a.source === "stage" ? a.shot : void 0
|
|
5472
|
+
});
|
|
3151
5473
|
if (!spec) {
|
|
3152
5474
|
if (a.channel !== "demo")
|
|
3153
5475
|
warnings.push(
|
|
@@ -3185,13 +5507,25 @@ async function validateKit(kitPath) {
|
|
|
3185
5507
|
`${spec.channel} ${spec.asset}: spec wants ${spec.count.min}-${spec.count.max}, the kit has ${n}`
|
|
3186
5508
|
);
|
|
3187
5509
|
}
|
|
3188
|
-
|
|
5510
|
+
if (!opts.picture) {
|
|
5511
|
+
return { valid: problems.length === 0, problems, warnings, measured };
|
|
5512
|
+
}
|
|
5513
|
+
const picture = await pictureChecks(pictureAssets);
|
|
5514
|
+
const pictureErrors = picture.findings.filter((f) => f.severity === "error");
|
|
5515
|
+
return {
|
|
5516
|
+
valid: problems.length === 0 && pictureErrors.length === 0,
|
|
5517
|
+
problems,
|
|
5518
|
+
warnings,
|
|
5519
|
+
measured,
|
|
5520
|
+
picture: picture.findings,
|
|
5521
|
+
pictureMeasured: picture.measured
|
|
5522
|
+
};
|
|
3189
5523
|
}
|
|
3190
5524
|
|
|
3191
5525
|
// src/plugin/platform.ts
|
|
3192
5526
|
import { mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
3193
5527
|
import { homedir } from "os";
|
|
3194
|
-
import { join as
|
|
5528
|
+
import { join as join8 } from "path";
|
|
3195
5529
|
function platformOrigin(flags = {}) {
|
|
3196
5530
|
const legacyEnv = process.env.VOS_API_BASE?.trim();
|
|
3197
5531
|
const raw = flags.origin ?? flags.api ?? process.env.VOS_ORIGIN?.trim() ?? legacyEnv ?? "https://vos.so";
|
|
@@ -3224,7 +5558,7 @@ function parseVosId(input) {
|
|
|
3224
5558
|
function deriveSlug(title) {
|
|
3225
5559
|
return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50).replace(/-+$/, "") || "remix";
|
|
3226
5560
|
}
|
|
3227
|
-
var CREDENTIALS_PATH =
|
|
5561
|
+
var CREDENTIALS_PATH = join8(homedir(), ".config", "vos", "credentials");
|
|
3228
5562
|
function resolveCredential(explicit) {
|
|
3229
5563
|
const flag = explicit?.trim();
|
|
3230
5564
|
if (flag) return flag;
|
|
@@ -3247,7 +5581,7 @@ function requireCredential(explicit) {
|
|
|
3247
5581
|
return key;
|
|
3248
5582
|
}
|
|
3249
5583
|
function writeCredential(key) {
|
|
3250
|
-
mkdirSync(
|
|
5584
|
+
mkdirSync(join8(homedir(), ".config", "vos"), { recursive: true, mode: 448 });
|
|
3251
5585
|
writeFileSync(CREDENTIALS_PATH, `${key.trim()}
|
|
3252
5586
|
`, { mode: 384 });
|
|
3253
5587
|
return CREDENTIALS_PATH;
|
|
@@ -3291,7 +5625,7 @@ function readJsonFile(file) {
|
|
|
3291
5625
|
}
|
|
3292
5626
|
}
|
|
3293
5627
|
function readSyncState(dir) {
|
|
3294
|
-
const own = readJsonFile(
|
|
5628
|
+
const own = readJsonFile(join8(dir, SYNC_STATE_NAME));
|
|
3295
5629
|
if (own && typeof own.vosId === "string") {
|
|
3296
5630
|
return {
|
|
3297
5631
|
vosId: own.vosId,
|
|
@@ -3302,7 +5636,7 @@ function readSyncState(dir) {
|
|
|
3302
5636
|
...typeof own.remixOfId === "string" ? { remixOfId: own.remixOfId } : {}
|
|
3303
5637
|
};
|
|
3304
5638
|
}
|
|
3305
|
-
const push = readJsonFile(
|
|
5639
|
+
const push = readJsonFile(join8(dir, "push.json"));
|
|
3306
5640
|
if (push && typeof push.vosId === "string") {
|
|
3307
5641
|
return {
|
|
3308
5642
|
vosId: push.vosId,
|
|
@@ -3310,7 +5644,7 @@ function readSyncState(dir) {
|
|
|
3310
5644
|
...typeof push.pushedAt === "string" ? { pushedAt: push.pushedAt } : {}
|
|
3311
5645
|
};
|
|
3312
5646
|
}
|
|
3313
|
-
const meta = readJsonFile(
|
|
5647
|
+
const meta = readJsonFile(join8(dir, "meta.json"));
|
|
3314
5648
|
if (meta && typeof meta.id === "string") {
|
|
3315
5649
|
return {
|
|
3316
5650
|
vosId: meta.id,
|
|
@@ -3328,7 +5662,7 @@ function writeSyncState(dir, patch) {
|
|
|
3328
5662
|
...patch
|
|
3329
5663
|
};
|
|
3330
5664
|
writeFileSync(
|
|
3331
|
-
|
|
5665
|
+
join8(dir, SYNC_STATE_NAME),
|
|
3332
5666
|
`${JSON.stringify(merged, null, 2)}
|
|
3333
5667
|
`
|
|
3334
5668
|
);
|
|
@@ -3349,7 +5683,7 @@ function formatChanges(changes) {
|
|
|
3349
5683
|
|
|
3350
5684
|
// src/plugin/recorder.ts
|
|
3351
5685
|
import { readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
3352
|
-
import { join as
|
|
5686
|
+
import { join as join9 } from "path";
|
|
3353
5687
|
|
|
3354
5688
|
// src/plugin/recordingCap.ts
|
|
3355
5689
|
var HOSTED_RECORDING_CAP_SECONDS = 30 * 60;
|
|
@@ -3437,7 +5771,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
|
|
|
3437
5771
|
cdp.on("Page.screencastFrame", (ev) => {
|
|
3438
5772
|
const tsMs = ev.metadata.timestamp ? ev.metadata.timestamp * 1e3 : Date.now();
|
|
3439
5773
|
const file = `frame-${String(frameIdx++).padStart(5, "0")}.jpg`;
|
|
3440
|
-
writeFileSync2(
|
|
5774
|
+
writeFileSync2(join9(paths.framesDir, file), Buffer.from(ev.data, "base64"));
|
|
3441
5775
|
frames.push({ file, tMs: Math.max(0, Math.round(tsMs - t0)) });
|
|
3442
5776
|
cdp.send("Page.screencastFrameAck", { sessionId: ev.sessionId }).catch(() => {
|
|
3443
5777
|
});
|
|
@@ -3660,7 +5994,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
|
|
|
3660
5994
|
await sleep(200);
|
|
3661
5995
|
const pageTitle = await page.title().catch(() => "");
|
|
3662
5996
|
await context.close();
|
|
3663
|
-
const firstFrame = frames[0] ? jpegDims(readFileSync3(
|
|
5997
|
+
const firstFrame = frames[0] ? jpegDims(readFileSync3(join9(paths.framesDir, frames[0].file))) : null;
|
|
3664
5998
|
const meta = {
|
|
3665
5999
|
dpr: 1,
|
|
3666
6000
|
zoom: 1,
|
|
@@ -3768,9 +6102,9 @@ async function encodeRecording(browser, takeDir, onProgress) {
|
|
|
3768
6102
|
}
|
|
3769
6103
|
|
|
3770
6104
|
// src/plugin/plan.ts
|
|
3771
|
-
import { existsSync as
|
|
3772
|
-
import { readFile as
|
|
3773
|
-
import { join as
|
|
6105
|
+
import { existsSync as existsSync8 } from "fs";
|
|
6106
|
+
import { readFile as readFile9 } from "fs/promises";
|
|
6107
|
+
import { join as join10 } from "path";
|
|
3774
6108
|
import {
|
|
3775
6109
|
DEFAULT_FRAME_STYLE,
|
|
3776
6110
|
STYLE_FIELDS,
|
|
@@ -3963,10 +6297,10 @@ function retimeCut(prev, newSteps, newDurationMs) {
|
|
|
3963
6297
|
// src/plugin/plan.ts
|
|
3964
6298
|
var overlaps = (a, b) => a.in < b.out && b.in < a.out;
|
|
3965
6299
|
async function readDigestActivity(dir) {
|
|
3966
|
-
const file =
|
|
3967
|
-
if (!
|
|
6300
|
+
const file = join10(dir, "digest", "digest.json");
|
|
6301
|
+
if (!existsSync8(file)) return null;
|
|
3968
6302
|
try {
|
|
3969
|
-
const d = JSON.parse(await
|
|
6303
|
+
const d = JSON.parse(await readFile9(file, "utf8"));
|
|
3970
6304
|
return Array.isArray(d.activity) && d.activity.every((v) => typeof v === "number") ? d.activity : null;
|
|
3971
6305
|
} catch {
|
|
3972
6306
|
return null;
|
|
@@ -4079,16 +6413,16 @@ async function planTake(dir, opts = {}) {
|
|
|
4079
6413
|
|
|
4080
6414
|
// src/plugin/sync.ts
|
|
4081
6415
|
import { createHash } from "crypto";
|
|
4082
|
-
import { existsSync as
|
|
4083
|
-
import { readFile as
|
|
6416
|
+
import { existsSync as existsSync10 } from "fs";
|
|
6417
|
+
import { readFile as readFile10 } from "fs/promises";
|
|
4084
6418
|
import { createInterface } from "readline/promises";
|
|
4085
|
-
import { basename, join as
|
|
6419
|
+
import { basename, join as join13 } from "path";
|
|
4086
6420
|
import { lowerToComposition as lowerToComposition3, migrateHostedDoc as migrateHostedDoc2 } from "@vosjs/studio-core";
|
|
4087
6421
|
|
|
4088
6422
|
// src/plugin/media.ts
|
|
4089
|
-
import { createWriteStream, existsSync as
|
|
4090
|
-
import { writeFile as
|
|
4091
|
-
import { join as
|
|
6423
|
+
import { createWriteStream, existsSync as existsSync9 } from "fs";
|
|
6424
|
+
import { writeFile as writeFile8 } from "fs/promises";
|
|
6425
|
+
import { join as join11 } from "path";
|
|
4092
6426
|
import { Readable } from "stream";
|
|
4093
6427
|
import { pipeline } from "stream/promises";
|
|
4094
6428
|
var MIC_NAME = "mic.webm";
|
|
@@ -4109,8 +6443,8 @@ async function pullMedia(ctx, dir, doc, log) {
|
|
|
4109
6443
|
const url = doc.source[key];
|
|
4110
6444
|
const assetId = assetIdOf(url);
|
|
4111
6445
|
if (!assetId) continue;
|
|
4112
|
-
const target =
|
|
4113
|
-
if (
|
|
6446
|
+
const target = join11(dir, file);
|
|
6447
|
+
if (existsSync9(target)) {
|
|
4114
6448
|
result.kept.push(file);
|
|
4115
6449
|
} else {
|
|
4116
6450
|
const abs = /^https?:/.test(url) ? url : `${ctx.origin}${url}`;
|
|
@@ -4132,17 +6466,17 @@ async function pullMedia(ctx, dir, doc, log) {
|
|
|
4132
6466
|
}
|
|
4133
6467
|
doc.source[key] = file;
|
|
4134
6468
|
}
|
|
4135
|
-
const metaPath =
|
|
4136
|
-
if (!
|
|
4137
|
-
const cursorPath =
|
|
4138
|
-
if (!
|
|
4139
|
-
await
|
|
6469
|
+
const metaPath = join11(dir, "meta.json");
|
|
6470
|
+
if (!existsSync9(metaPath)) await writeJson(metaPath, doc.source.meta, true);
|
|
6471
|
+
const cursorPath = join11(dir, "cursor.json");
|
|
6472
|
+
if (!existsSync9(cursorPath)) await writeJson(cursorPath, doc.source.cursor);
|
|
6473
|
+
await writeFile8(join11(dir, "doc.json"), JSON.stringify(doc, null, 2));
|
|
4140
6474
|
return result;
|
|
4141
6475
|
}
|
|
4142
6476
|
|
|
4143
6477
|
// src/plugin/folder.ts
|
|
4144
|
-
import { mkdir as
|
|
4145
|
-
import { join as
|
|
6478
|
+
import { mkdir as mkdir6, writeFile as writeFile9 } from "fs/promises";
|
|
6479
|
+
import { join as join12 } from "path";
|
|
4146
6480
|
import { recipeHints } from "@vosjs/shared/frontmatter";
|
|
4147
6481
|
import { migrateHostedDoc } from "@vosjs/studio-core";
|
|
4148
6482
|
var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["json", "help", "media"]);
|
|
@@ -4318,25 +6652,25 @@ async function cmdPull(argv) {
|
|
|
4318
6652
|
throw new Error(apiError(`pull folder ${folder.slug}`, res));
|
|
4319
6653
|
const payload = res.body;
|
|
4320
6654
|
const out = strFlag(flags, "out") ?? folder.slug;
|
|
4321
|
-
await
|
|
6655
|
+
await mkdir6(join12(out, "recipes"), { recursive: true });
|
|
4322
6656
|
const lines = [];
|
|
4323
6657
|
const recipes = [];
|
|
4324
6658
|
for (const rec of payload.recipes) {
|
|
4325
|
-
const path =
|
|
4326
|
-
await
|
|
6659
|
+
const path = join12(out, "recipes", safeName(rec.filename));
|
|
6660
|
+
await writeFile9(path, rec.body ?? "");
|
|
4327
6661
|
recipes.push({ path, line: recipeLine(rec.filename, rec.body ?? "") });
|
|
4328
6662
|
}
|
|
4329
6663
|
for (const rec of payload.inheritedRecipes) {
|
|
4330
6664
|
const from = safeName(rec.folderSlug ?? "inherited");
|
|
4331
|
-
await
|
|
4332
|
-
const path =
|
|
6665
|
+
await mkdir6(join12(out, "recipes", "_inherited", from), { recursive: true });
|
|
6666
|
+
const path = join12(
|
|
4333
6667
|
out,
|
|
4334
6668
|
"recipes",
|
|
4335
6669
|
"_inherited",
|
|
4336
6670
|
from,
|
|
4337
6671
|
safeName(rec.filename)
|
|
4338
6672
|
);
|
|
4339
|
-
await
|
|
6673
|
+
await writeFile9(path, rec.body ?? "");
|
|
4340
6674
|
recipes.push({
|
|
4341
6675
|
path,
|
|
4342
6676
|
line: `${recipeLine(rec.filename, rec.body ?? "")} [inherited from ${rec.folderName ?? from}]`
|
|
@@ -4344,15 +6678,15 @@ async function cmdPull(argv) {
|
|
|
4344
6678
|
}
|
|
4345
6679
|
const members = [];
|
|
4346
6680
|
for (const v of payload.voses) {
|
|
4347
|
-
const dir =
|
|
4348
|
-
await
|
|
6681
|
+
const dir = join12(out, "members", safeName(v.slug || v.title || v.id));
|
|
6682
|
+
await mkdir6(dir, { recursive: true });
|
|
4349
6683
|
const cfg = await apiJson(origin, `/api/vos/${v.id}/config`, { key });
|
|
4350
6684
|
if (cfg.status !== 200) {
|
|
4351
6685
|
lines.push(`skipped ${v.title}: ${apiError("fetch config", cfg)}`);
|
|
4352
6686
|
continue;
|
|
4353
6687
|
}
|
|
4354
|
-
await
|
|
4355
|
-
|
|
6688
|
+
await writeFile9(
|
|
6689
|
+
join12(dir, "config.json"),
|
|
4356
6690
|
JSON.stringify(cfg.body.config, null, 2)
|
|
4357
6691
|
);
|
|
4358
6692
|
let take = false;
|
|
@@ -4363,8 +6697,8 @@ async function cmdPull(argv) {
|
|
|
4363
6697
|
{ key }
|
|
4364
6698
|
);
|
|
4365
6699
|
if (doc.status === 200) {
|
|
4366
|
-
await
|
|
4367
|
-
|
|
6700
|
+
await writeFile9(
|
|
6701
|
+
join12(dir, "doc.json"),
|
|
4368
6702
|
JSON.stringify(doc.body, null, 2)
|
|
4369
6703
|
);
|
|
4370
6704
|
take = true;
|
|
@@ -4481,7 +6815,7 @@ async function pushTake(dir, flags, r) {
|
|
|
4481
6815
|
throw new Error(`doc.json fails lint:
|
|
4482
6816
|
${lint.problems.join("\n ")}`);
|
|
4483
6817
|
}
|
|
4484
|
-
if (!
|
|
6818
|
+
if (!existsSync10(take.paths.recording)) {
|
|
4485
6819
|
throw new Error("no recording.webm in this take");
|
|
4486
6820
|
}
|
|
4487
6821
|
const ctx = apiContext(flags);
|
|
@@ -4513,7 +6847,7 @@ async function pushTake(dir, flags, r) {
|
|
|
4513
6847
|
throw new Error("push cancelled");
|
|
4514
6848
|
}
|
|
4515
6849
|
}
|
|
4516
|
-
const bytes = await
|
|
6850
|
+
const bytes = await readFile10(take.paths.recording);
|
|
4517
6851
|
const hash = createHash("sha256").update(bytes).digest("hex");
|
|
4518
6852
|
r.log(`uploading recording (${Math.round(bytes.length / 1024)} kB)\u2026`);
|
|
4519
6853
|
const upload = await api(ctx, "/assets/recording", {
|
|
@@ -4664,9 +6998,9 @@ async function pullTake(dir, flags, r) {
|
|
|
4664
6998
|
};
|
|
4665
6999
|
}
|
|
4666
7000
|
let media2;
|
|
4667
|
-
if (flags.media &&
|
|
7001
|
+
if (flags.media && existsSync10(join13(dir, "doc.json"))) {
|
|
4668
7002
|
const local = JSON.parse(
|
|
4669
|
-
await
|
|
7003
|
+
await readFile10(join13(dir, "doc.json"), "utf8")
|
|
4670
7004
|
);
|
|
4671
7005
|
media2 = await pullMedia(ctx, dir, local, r.log);
|
|
4672
7006
|
}
|
|
@@ -4726,10 +7060,10 @@ async function pullTake(dir, flags, r) {
|
|
|
4726
7060
|
}
|
|
4727
7061
|
const hostedDoc = migrateHostedDoc2(docRes.json);
|
|
4728
7062
|
const doc = hostedDoc;
|
|
4729
|
-
if (
|
|
7063
|
+
if (existsSync10(join13(dir, RECORDING_NAME))) {
|
|
4730
7064
|
doc.source.videoKey = RECORDING_NAME;
|
|
4731
7065
|
}
|
|
4732
|
-
await writeJson(
|
|
7066
|
+
await writeJson(join13(dir, "doc.json"), doc, true);
|
|
4733
7067
|
const media = flags.media ? await pullMedia(ctx, dir, doc, r.log) : void 0;
|
|
4734
7068
|
const versions = await api(ctx, `/vos/${vosId}/versions`);
|
|
4735
7069
|
const headRow = (versions.json.versions ?? []).find((v) => v.id === head);
|
|
@@ -4748,10 +7082,10 @@ async function pullTake(dir, flags, r) {
|
|
|
4748
7082
|
}
|
|
4749
7083
|
|
|
4750
7084
|
// src/plugin/program.ts
|
|
4751
|
-
import { existsSync as
|
|
4752
|
-
import { mkdir as
|
|
7085
|
+
import { existsSync as existsSync11 } from "fs";
|
|
7086
|
+
import { mkdir as mkdir7, readFile as readFile11, writeFile as writeFile10 } from "fs/promises";
|
|
4753
7087
|
import { createInterface as createInterface2 } from "readline/promises";
|
|
4754
|
-
import { basename as basename2, dirname as
|
|
7088
|
+
import { basename as basename2, dirname as dirname3, join as join14 } from "path";
|
|
4755
7089
|
import {
|
|
4756
7090
|
CURRENT_CONFIG_VERSION,
|
|
4757
7091
|
migrateConfig,
|
|
@@ -4921,8 +7255,8 @@ function preflightConfig(parsed) {
|
|
|
4921
7255
|
}
|
|
4922
7256
|
function resolveConfigPath(target) {
|
|
4923
7257
|
if (target.endsWith(".json")) return target;
|
|
4924
|
-
const inDir =
|
|
4925
|
-
if (
|
|
7258
|
+
const inDir = join14(target, "config.json");
|
|
7259
|
+
if (existsSync11(inDir)) return inDir;
|
|
4926
7260
|
throw new UsageError(
|
|
4927
7261
|
`${target} has no config.json (and is not a take \u2014 no doc.json)`
|
|
4928
7262
|
);
|
|
@@ -4948,9 +7282,9 @@ async function cmdFetch(argv) {
|
|
|
4948
7282
|
throw new Error(apiError(`fetch config for ${vosId}`, cfg));
|
|
4949
7283
|
const slug = typeof vosMeta.slug === "string" && vosMeta.slug ? vosMeta.slug : vosId;
|
|
4950
7284
|
const out = strFlag(flags, "out") ?? slug;
|
|
4951
|
-
await
|
|
4952
|
-
await
|
|
4953
|
-
|
|
7285
|
+
await mkdir7(out, { recursive: true });
|
|
7286
|
+
await writeFile10(
|
|
7287
|
+
join14(out, "config.json"),
|
|
4954
7288
|
JSON.stringify(cfg.body.config, null, 2)
|
|
4955
7289
|
);
|
|
4956
7290
|
const title = typeof vosMeta.title === "string" ? vosMeta.title : "";
|
|
@@ -4975,12 +7309,12 @@ async function cmdFetch(argv) {
|
|
|
4975
7309
|
const hosted = migrateHostedDoc3(doc.body);
|
|
4976
7310
|
const { config: own } = await writeProgramDoc(out, hosted);
|
|
4977
7311
|
if (own) {
|
|
4978
|
-
await
|
|
7312
|
+
await writeFile10(join14(out, "config.json"), JSON.stringify(own, null, 2));
|
|
4979
7313
|
}
|
|
4980
7314
|
} else if (doc.status === 200) {
|
|
4981
7315
|
take = true;
|
|
4982
7316
|
const hosted = migrateHostedDoc3(doc.body);
|
|
4983
|
-
await
|
|
7317
|
+
await writeFile10(join14(out, "doc.json"), JSON.stringify(hosted, null, 2));
|
|
4984
7318
|
if (flags.media === true) {
|
|
4985
7319
|
const media = await pullMedia({ origin, key }, out, hosted, r.log);
|
|
4986
7320
|
mediaLine = media.downloaded.length ? ` + ${media.downloaded.map((m) => m.file).join(", ")}` : "";
|
|
@@ -5091,8 +7425,8 @@ async function cmdPushProgram(argv) {
|
|
|
5091
7425
|
origin: strFlag(flags, "origin"),
|
|
5092
7426
|
api: strFlag(flags, "api")
|
|
5093
7427
|
});
|
|
5094
|
-
const dir =
|
|
5095
|
-
const parsed = JSON.parse(await
|
|
7428
|
+
const dir = dirname3(source);
|
|
7429
|
+
const parsed = JSON.parse(await readFile11(source, "utf8"));
|
|
5096
7430
|
const pre = preflightConfig(parsed);
|
|
5097
7431
|
if (!pre.ok || !pre.config) {
|
|
5098
7432
|
for (const issue of pre.issues) r.log(`error ${issue}`);
|
|
@@ -5261,7 +7595,7 @@ Iterate with: vos push ${source} --vos ${created.id}`
|
|
|
5261
7595
|
async function cmdPullProgram(argv) {
|
|
5262
7596
|
const { positionals, flags } = parseArgs(argv, BOOLEAN_FLAGS2);
|
|
5263
7597
|
const target = positionals[0] ?? ".";
|
|
5264
|
-
const dir = target.endsWith(".json") ?
|
|
7598
|
+
const dir = target.endsWith(".json") ? dirname3(target) : target;
|
|
5265
7599
|
const r = createReporter(flags.json === true);
|
|
5266
7600
|
const origin = platformOrigin({
|
|
5267
7601
|
origin: strFlag(flags, "origin"),
|
|
@@ -5325,10 +7659,10 @@ async function cmdPullProgram(argv) {
|
|
|
5325
7659
|
const cfg = await apiJson(origin, `/api/vos/${vosId}/config`, { key });
|
|
5326
7660
|
if (cfg.status !== 200)
|
|
5327
7661
|
throw new Error(apiError(`fetch head config for ${vosId}`, cfg));
|
|
5328
|
-
const configPath =
|
|
7662
|
+
const configPath = join14(dir, "config.json");
|
|
5329
7663
|
let backedUp = false;
|
|
5330
|
-
if (
|
|
5331
|
-
await
|
|
7664
|
+
if (existsSync11(configPath)) {
|
|
7665
|
+
await writeFile10(join14(dir, "config.backup.json"), await readFile11(configPath));
|
|
5332
7666
|
backedUp = true;
|
|
5333
7667
|
}
|
|
5334
7668
|
let headConfig = cfg.body.config;
|
|
@@ -5347,7 +7681,7 @@ async function cmdPullProgram(argv) {
|
|
|
5347
7681
|
if (own) headConfig = own;
|
|
5348
7682
|
}
|
|
5349
7683
|
}
|
|
5350
|
-
await
|
|
7684
|
+
await writeFile10(configPath, JSON.stringify(headConfig, null, 2));
|
|
5351
7685
|
writeSyncState(dir, {
|
|
5352
7686
|
vosId,
|
|
5353
7687
|
versionId: typeof head.id === "string" ? head.id : since
|
|
@@ -5361,7 +7695,7 @@ async function cmdPullProgram(argv) {
|
|
|
5361
7695
|
protected: protectedIds,
|
|
5362
7696
|
changes,
|
|
5363
7697
|
out: configPath,
|
|
5364
|
-
backup: backedUp ?
|
|
7698
|
+
backup: backedUp ? join14(dir, "config.backup.json") : null
|
|
5365
7699
|
},
|
|
5366
7700
|
`Pulled ${changes.length} version${changes.length === 1 ? "" : "s"} \u2192 ${configPath}` + (backedUp ? ` (previous copy: config.backup.json)` : "") + `
|
|
5367
7701
|
Re-apply your edit on the new head, then: vos push ${configPath} --vos ${vosId}`
|
|
@@ -5418,9 +7752,9 @@ function isTakeDir(target) {
|
|
|
5418
7752
|
return directoryKind(target) === "take";
|
|
5419
7753
|
}
|
|
5420
7754
|
async function readProgramDoc(dir, config) {
|
|
5421
|
-
const docPath =
|
|
5422
|
-
if (!
|
|
5423
|
-
const parsed = JSON.parse(await
|
|
7755
|
+
const docPath = join14(dir, "doc.json");
|
|
7756
|
+
if (!existsSync11(docPath)) return null;
|
|
7757
|
+
const parsed = JSON.parse(await readFile11(docPath, "utf8"));
|
|
5424
7758
|
if (typeof parsed !== "object" || parsed === null || "source" in parsed)
|
|
5425
7759
|
return null;
|
|
5426
7760
|
const raw = parsed;
|
|
@@ -5431,7 +7765,7 @@ async function writeProgramDoc(dir, hosted) {
|
|
|
5431
7765
|
const program = hosted.program && typeof hosted.program === "object" ? hosted.program : {};
|
|
5432
7766
|
const { config, ...rest } = program;
|
|
5433
7767
|
const onDisk = { ...hosted, program: rest };
|
|
5434
|
-
await
|
|
7768
|
+
await writeFile10(join14(dir, "doc.json"), JSON.stringify(onDisk, null, 2));
|
|
5435
7769
|
return {
|
|
5436
7770
|
config: config && typeof config === "object" ? config : null
|
|
5437
7771
|
};
|
|
@@ -5638,9 +7972,10 @@ async function cmdRecipe(argv) {
|
|
|
5638
7972
|
}
|
|
5639
7973
|
|
|
5640
7974
|
// src/plugin/brand.ts
|
|
5641
|
-
import { writeFile as
|
|
5642
|
-
import { resolve as
|
|
5643
|
-
import { parseFrontmatter } from "@vosjs/shared/frontmatter";
|
|
7975
|
+
import { writeFile as writeFile11 } from "fs/promises";
|
|
7976
|
+
import { resolve as resolve5 } from "path";
|
|
7977
|
+
import { parseFrontmatter as parseFrontmatter2 } from "@vosjs/shared/frontmatter";
|
|
7978
|
+
import { lookKindForGround } from "@vosjs/studio-core";
|
|
5644
7979
|
var HEX_RE = /#(?:[0-9a-f]{6}|[0-9a-f]{3})\b/gi;
|
|
5645
7980
|
function extractHexes(text) {
|
|
5646
7981
|
const out = [];
|
|
@@ -5661,13 +7996,13 @@ function rgbToHex(raw) {
|
|
|
5661
7996
|
if (!m) return normalizeHex(raw);
|
|
5662
7997
|
const alpha = m[4];
|
|
5663
7998
|
if (alpha !== void 0 && Number(alpha) === 0) return null;
|
|
5664
|
-
const
|
|
5665
|
-
return `#${
|
|
7999
|
+
const hex2 = (n) => Number(n).toString(16).padStart(2, "0");
|
|
8000
|
+
return `#${hex2(m[1])}${hex2(m[2])}${hex2(m[3])}`;
|
|
5666
8001
|
}
|
|
5667
|
-
function hsl(
|
|
5668
|
-
const r = parseInt(
|
|
5669
|
-
const g = parseInt(
|
|
5670
|
-
const b = parseInt(
|
|
8002
|
+
function hsl(hex2) {
|
|
8003
|
+
const r = parseInt(hex2.slice(1, 3), 16) / 255;
|
|
8004
|
+
const g = parseInt(hex2.slice(3, 5), 16) / 255;
|
|
8005
|
+
const b = parseInt(hex2.slice(5, 7), 16) / 255;
|
|
5671
8006
|
const max = Math.max(r, g, b);
|
|
5672
8007
|
const min = Math.min(r, g, b);
|
|
5673
8008
|
const l = (max + min) / 2;
|
|
@@ -5682,12 +8017,12 @@ function hsl(hex) {
|
|
|
5682
8017
|
}
|
|
5683
8018
|
return { h, s, l };
|
|
5684
8019
|
}
|
|
5685
|
-
function isSaturated(
|
|
5686
|
-
const { s, l } = hsl(
|
|
8020
|
+
function isSaturated(hex2) {
|
|
8021
|
+
const { s, l } = hsl(hex2);
|
|
5687
8022
|
return s >= 0.25 && l > 0.12 && l < 0.88;
|
|
5688
8023
|
}
|
|
5689
|
-
var isLight = (
|
|
5690
|
-
function
|
|
8024
|
+
var isLight = (hex2) => hsl(hex2).l >= 0.5;
|
|
8025
|
+
function mixHex2(a, b, t) {
|
|
5691
8026
|
const ch = (i) => Math.round(
|
|
5692
8027
|
parseInt(a.slice(i, i + 2), 16) * (1 - t) + parseInt(b.slice(i, i + 2), 16) * t
|
|
5693
8028
|
).toString(16).padStart(2, "0");
|
|
@@ -5705,7 +8040,7 @@ function mostCommon(values) {
|
|
|
5705
8040
|
}
|
|
5706
8041
|
return best;
|
|
5707
8042
|
}
|
|
5708
|
-
function
|
|
8043
|
+
function firstFamily2(fontFamily) {
|
|
5709
8044
|
const first = fontFamily.split(",")[0]?.trim() ?? "";
|
|
5710
8045
|
return first.replace(/^["']|["']$/g, "");
|
|
5711
8046
|
}
|
|
@@ -5713,7 +8048,7 @@ var FONT_HINT_RE = /\b(?:[Uu]se|[Uu]ses|[Ss]et in)\s+([A-Z][A-Za-z0-9]+(?:\s+[A-
|
|
|
5713
8048
|
var FONT_FAMILY_RE = /font-family\s*:\s*["']?([^;"',\n]+)/gi;
|
|
5714
8049
|
var LOGO_URL_RE = /https?:\/\/[^\s)"'<>]+?(?:logo|wordmark|mark|brand|icon)[^\s)"'<>]*?\.(?:svg|png|webp)/gi;
|
|
5715
8050
|
function parseDesignMd(text) {
|
|
5716
|
-
const fm =
|
|
8051
|
+
const fm = parseFrontmatter2(text);
|
|
5717
8052
|
const fonts = [];
|
|
5718
8053
|
for (const m of text.matchAll(FONT_HINT_RE)) {
|
|
5719
8054
|
const f = m[1].trim();
|
|
@@ -5824,7 +8159,7 @@ function composeBrand(input) {
|
|
|
5824
8159
|
p.bgA = `the body's background`;
|
|
5825
8160
|
const surfaceHexes = w.surfaces.map(rgbToHex).filter((h) => h !== null && h !== bgA);
|
|
5826
8161
|
const neutralSurface = mostCommon(surfaceHexes.filter((h) => !isSaturated(h)));
|
|
5827
|
-
const bgB = neutralSurface ??
|
|
8162
|
+
const bgB = neutralSurface ?? mixHex2(bgA, isLight(bgA) ? "#000000" : "#ffffff", 0.04);
|
|
5828
8163
|
p.bgB = neutralSurface ? `the most common section / card ground` : `no distinct surface found; bgA stepped 4% toward ${isLight(bgA) ? "black" : "white"}`;
|
|
5829
8164
|
const accentHexes = w.accents.map(rgbToHex).filter((h) => h !== null && isSaturated(h));
|
|
5830
8165
|
const themeHex = w.themeColor ? rgbToHex(w.themeColor) : null;
|
|
@@ -5834,13 +8169,13 @@ function composeBrand(input) {
|
|
|
5834
8169
|
const inkHex = rgbToHex(w.h1?.color ?? w.body.color) ?? (isLight(bgA) ? "#111111" : "#f5f5f5");
|
|
5835
8170
|
if (!accent) accent = inkHex;
|
|
5836
8171
|
p.accent = accentHexes.length ? `the saturated colour buttons and links agree on` : themeHex && isSaturated(themeHex) ? `the theme-color meta` : design?.hexes.find(isSaturated) ? `a hex quoted in design.md` : `no saturated colour on the page; the ink stands in`;
|
|
5837
|
-
const bgC =
|
|
8172
|
+
const bgC = mixHex2(bgA, accent, 0.14);
|
|
5838
8173
|
p.bgC = `bgA tinted 14% toward the accent (a highlight ground)`;
|
|
5839
8174
|
p.ink = w.h1 ? `the h1's colour` : `the body's colour`;
|
|
5840
|
-
const fontDisplay = design?.fonts[0] ?? (w.h1 ?
|
|
8175
|
+
const fontDisplay = design?.fonts[0] ?? (w.h1 ? firstFamily2(w.h1.fontFamily) : firstFamily2(w.body.fontFamily));
|
|
5841
8176
|
p.fontDisplay = design?.fonts[0] ? `named in design.md` : w.h1 ? `the h1's computed face` : `the body's computed face (no h1)`;
|
|
5842
8177
|
const designBody = design?.fonts.slice(1).find((f) => !/\bmono\b/i.test(f));
|
|
5843
|
-
const fontBody = designBody ??
|
|
8178
|
+
const fontBody = designBody ?? firstFamily2(w.body.fontFamily);
|
|
5844
8179
|
p.fontBody = designBody ? `named in design.md` : `the body's computed face`;
|
|
5845
8180
|
const logoUrl = design?.logos.find((u) => /wordmark|logo/i.test(u)) ?? null;
|
|
5846
8181
|
const iconUrl = w.icons.find((u) => /apple-touch/i.test(u)) ?? (w.icons.length ? w.icons[0] : null);
|
|
@@ -5865,8 +8200,10 @@ function composeBrand(input) {
|
|
|
5865
8200
|
ogImage: w.ogImage,
|
|
5866
8201
|
wordmark,
|
|
5867
8202
|
designMd: input.designUrl,
|
|
5868
|
-
llmsTxt: input.llmsUrl
|
|
8203
|
+
llmsTxt: input.llmsUrl,
|
|
8204
|
+
look: lookKindForGround(bgA)
|
|
5869
8205
|
};
|
|
8206
|
+
p.look = `from the body's ground (${bgA}): a paper site is a plate, a dark site is dark, else the gradient`;
|
|
5870
8207
|
return { kit, provenance: p, avoid: design?.avoid ?? [] };
|
|
5871
8208
|
}
|
|
5872
8209
|
function renderBrandMd(c, claim) {
|
|
@@ -5893,6 +8230,7 @@ function renderBrandMd(c, claim) {
|
|
|
5893
8230
|
`wordmark: ${q(k.wordmark)}`,
|
|
5894
8231
|
`designMd: ${q(k.designMd)}`,
|
|
5895
8232
|
`llmsTxt: ${q(k.llmsTxt)}`,
|
|
8233
|
+
`look: ${q(k.look)}`,
|
|
5896
8234
|
"---"
|
|
5897
8235
|
].join("\n");
|
|
5898
8236
|
const lines = [
|
|
@@ -5943,7 +8281,7 @@ async function cmdBrand(argv) {
|
|
|
5943
8281
|
throw new UsageError(`not a URL: ${raw}`);
|
|
5944
8282
|
}
|
|
5945
8283
|
const r = createReporter(flags.json === true);
|
|
5946
|
-
const out =
|
|
8284
|
+
const out = resolve5(strFlag(flags, "out") ?? "BRAND.md");
|
|
5947
8285
|
r.log(`reading ${origin}/design.md and /llms.txt\u2026`);
|
|
5948
8286
|
const [designText, llmsText] = await Promise.all([
|
|
5949
8287
|
fetchText(`${origin}/design.md`),
|
|
@@ -5977,7 +8315,7 @@ async function cmdBrand(argv) {
|
|
|
5977
8315
|
composition,
|
|
5978
8316
|
llms?.claim ?? design?.description ?? null
|
|
5979
8317
|
);
|
|
5980
|
-
await
|
|
8318
|
+
await writeFile11(out, md);
|
|
5981
8319
|
const k = composition.kit;
|
|
5982
8320
|
r.done(
|
|
5983
8321
|
{
|
|
@@ -5995,8 +8333,8 @@ async function cmdBrand(argv) {
|
|
|
5995
8333
|
}
|
|
5996
8334
|
|
|
5997
8335
|
// src/plugin/agentBrowser.ts
|
|
5998
|
-
import { writeFile as
|
|
5999
|
-
import { resolve as
|
|
8336
|
+
import { writeFile as writeFile12 } from "fs/promises";
|
|
8337
|
+
import { resolve as resolve6 } from "path";
|
|
6000
8338
|
function parseAgentBrowserLog(text) {
|
|
6001
8339
|
const records = [];
|
|
6002
8340
|
const problems = [];
|
|
@@ -6466,7 +8804,7 @@ async function cmdActions(argv) {
|
|
|
6466
8804
|
);
|
|
6467
8805
|
}
|
|
6468
8806
|
const r = createReporter(flags.json === true);
|
|
6469
|
-
const out =
|
|
8807
|
+
const out = resolve6(strFlag(flags, "out") ?? "actions.json");
|
|
6470
8808
|
const viewportFlag = strFlag(flags, "viewport");
|
|
6471
8809
|
let viewport;
|
|
6472
8810
|
if (viewportFlag) {
|
|
@@ -6474,8 +8812,8 @@ async function cmdActions(argv) {
|
|
|
6474
8812
|
if (!m) throw new UsageError("--viewport expects WxH, e.g. 1280x720");
|
|
6475
8813
|
viewport = { width: Number(m[1]), height: Number(m[2]) };
|
|
6476
8814
|
}
|
|
6477
|
-
const { readFile:
|
|
6478
|
-
const text = await
|
|
8815
|
+
const { readFile: readFile13 } = await import("fs/promises");
|
|
8816
|
+
const text = await readFile13(resolve6(input), "utf8");
|
|
6479
8817
|
const { records, problems } = parseAgentBrowserLog(text);
|
|
6480
8818
|
for (const p of problems) r.log(` ${p}`);
|
|
6481
8819
|
const result = convertAgentBrowser(records, {
|
|
@@ -6492,7 +8830,7 @@ async function cmdActions(argv) {
|
|
|
6492
8830
|
);
|
|
6493
8831
|
return EXIT_ERROR;
|
|
6494
8832
|
}
|
|
6495
|
-
await
|
|
8833
|
+
await writeFile12(out, `${JSON.stringify(result.actions, null, 2)}
|
|
6496
8834
|
`);
|
|
6497
8835
|
r.done(
|
|
6498
8836
|
{ ok: true, out, url: result.actions.url ?? null, ...summary(result) },
|
|
@@ -6515,6 +8853,7 @@ function summary(result) {
|
|
|
6515
8853
|
var BOOLEAN_FLAGS5 = /* @__PURE__ */ new Set([
|
|
6516
8854
|
"json",
|
|
6517
8855
|
"help",
|
|
8856
|
+
"picture",
|
|
6518
8857
|
"fresh",
|
|
6519
8858
|
"reuse",
|
|
6520
8859
|
"strict",
|
|
@@ -6537,11 +8876,12 @@ Take pipeline
|
|
|
6537
8876
|
vos plan <take> [--fresh] [--reuse [--from <doc.json>]] [--style <doc.json|vosId>] [--background <slug|url|none>] [--json]
|
|
6538
8877
|
vos render <take> [out.webm] [--width] [--height] [--fps] [--format webm|mp4] [--parallel N] [--range a..b] [--draft] [--frame <kind>] [--background <url|slug>] [--set <path=value>]... [--json]
|
|
6539
8878
|
vos frames <take> [--times 0,25%,50%,75%,100%] [--frame <t>] [--at-zooms] [--at-moments] [--size WxH] [--out dir] [--background <url|slug>] [--set <path=value>]... [--json]
|
|
6540
|
-
vos deliver <take> --to cws,producthunt,x,linkedin,og,github,youtube (or all) [--poster <config.json|vosId>] [--shot-time <t>] [--poster-time <t>] [--composed] [--set path=value] [--release v2.1] [--out dir] [--times a,b] [--range a..b] [--parallel N] [--json]
|
|
8879
|
+
vos deliver <take> --to cws,producthunt,x,linkedin,og,github,youtube (or all) [--headline "\u2026"] [--kicker "\u2026"] [--launch LAUNCH.md] [--music <slug|mood|none>] [--entrance tilt-in|pull-out|rise|none] [--end-card none] [--captions none] [--clicks none] [--look plate|gradient|dark|none] [--brand BRAND.md] [--poster <split-cover|card-on-gradient|config.json|vosId|none>] [--shot-time <t>] [--poster-time <t>] [--composed] [--set path=value] [--release v2.1] [--out dir] [--times a,b] [--range a..b] [--parallel N] [--json]
|
|
6541
8880
|
vos digest <take> [--out dir] [--full 960] [--crop 640] [--no-frames] [--transcript <file.json>] [--style <doc.json|vosId>] [--json]
|
|
6542
8881
|
vos brand <url> [--out BRAND.md] [--json]
|
|
6543
8882
|
vos open <take> [--studio <url>] [--print]
|
|
6544
|
-
vos validate <actions.json|take> [--json]
|
|
8883
|
+
vos validate <actions.json|take|kit.json> [--picture] [--json]
|
|
8884
|
+
vos judge <kit.json> --against <MANIFEST.json> [--out dir] [--json]
|
|
6545
8885
|
vos actions from-agent-browser <steps.jsonl> [--out actions.json] [--url <url>] [--viewport WxH] [--json]
|
|
6546
8886
|
|
|
6547
8887
|
Platform (vos.so) \u2014 fetch, edit, push, pull, repeat
|
|
@@ -6629,13 +8969,48 @@ channel specs (schema/channel-specs.json, verified sizes for the Chrome
|
|
|
6629
8969
|
Web Store, Product Hunt, X, LinkedIn, OG, GitHub, YouTube) drive stills at
|
|
6630
8970
|
exact pixels and video cuts, every artifact is VERIFIED against its spec
|
|
6631
8971
|
(px, bytes, duration; misses land in skipped[] with the reason), and
|
|
6632
|
-
kit.json beside the assets is the manifest. Still times
|
|
6633
|
-
|
|
6634
|
-
|
|
6635
|
-
|
|
6636
|
-
|
|
6637
|
-
|
|
6638
|
-
|
|
8972
|
+
kit.json beside the assets is the manifest. Still times come from the
|
|
8973
|
+
STORY: every step's end plus a 0.4 s settle (the response, not the
|
|
8974
|
+
travel), then the zoom apexes, then an even spread; each candidate is
|
|
8975
|
+
read once as the real page, blank ones (a wallpaper, an empty canvas) are
|
|
8976
|
+
dropped and two of one frame collapse to one, with every drop said in
|
|
8977
|
+
skipped[]. --times overrides with seconds, percents or step:<id>[+offset]
|
|
8978
|
+
(the id from actions.json); --range cuts every video destination.
|
|
8979
|
+
The LOOK presents the card: card-genre stills with no poster and every
|
|
8980
|
+
video cut sit on a ground (a cream plate, the house gradient, a dark plate
|
|
8981
|
+
with a light streak) at ~84% of the width with headroom, a soft ambient
|
|
8982
|
+
shadow plus a tight contact shadow, and a hairline when card and ground
|
|
8983
|
+
are both light; a wide frame runs the card off the bottom. --look picks a
|
|
8984
|
+
house look (or none for the pre-look crops); with no flag the BRAND.md
|
|
8985
|
+
beside the take (or --brand <file>) decides from its look role or its own
|
|
8986
|
+
ground (a paper site is a plate, a dark site is dark), and with no brand
|
|
8987
|
+
the house gradient. Screenshot-genre stills never take a look.
|
|
8988
|
+
Card-genre destinations (OG, LinkedIn, X, YouTube thumbnail, the CWS
|
|
8989
|
+
tile + marquee, GitHub social preview) COMPOSE by default: each renders
|
|
8990
|
+
from its destination's poster TEMPLATE (split-cover is a STAGE, the
|
|
8991
|
+
take's own card leaning in perspective with its chrome and shadow beside
|
|
8992
|
+
a serif headline column, on the brand's ground; card-on-gradient is the
|
|
8993
|
+
shot alone on a mesh, the store's tile rule), filled with BRAND.md's colours and faces and the
|
|
8994
|
+
release's words, the shot baked as an object (padded, rounded, shadowed,
|
|
8995
|
+
a hairline on a light ground), PNG at exact pixels; kit.json records the
|
|
8996
|
+
template and the text boxes. The headline is LAUNCH.md's headline role
|
|
8997
|
+
beside the take or --headline (with none, the headline templates stand
|
|
8998
|
+
down for card-on-gradient, said); --kicker overrides the wordmark plus
|
|
8999
|
+
--release line. --poster names a bundled template for every card, your
|
|
9000
|
+
own template (a config.json or a hosted vos id carrying a template
|
|
9001
|
+
block), or none to keep the take path.
|
|
9002
|
+
Every video cut but the README loop opens on an ENTRANCE (tilt-in by
|
|
9003
|
+
default: the card swings in from a perspective pose and settles) and
|
|
9004
|
+
closes on an END CARD (the last frame holds 2.5 s while the card recedes
|
|
9005
|
+
and the headline, the release line and the wordmark rise); LAUNCH.md's
|
|
9006
|
+
entrance and endCard roles, or --entrance and --end-card, change or
|
|
9007
|
+
switch them off. A step's caption in actions.json lands as a lower-third
|
|
9008
|
+
at the step's moment on cuts that take words (--captions none to skip).
|
|
9009
|
+
Destinations that play sound (the X cut, the YouTube demo, the vertical
|
|
9010
|
+
cut) take a music bed from LAUNCH.md's music role (a catalog slug or a
|
|
9011
|
+
mood; --music overrides) and a click sound on every press when the take
|
|
9012
|
+
has no mic (--clicks none). The 9:16 cut is a reframe, not a letterbox:
|
|
9013
|
+
the crop follows the camera. --shot-time <t> picks the take moment (OUTPUT seconds;
|
|
6639
9014
|
default the first still time \u2014 pick a zoom apex, the cut's camera makes
|
|
6640
9015
|
the shot the feature, not the whole page); --poster-time <t> is the instant
|
|
6641
9016
|
inside the poster's OWN timeline (default 90% through it). Screenshot-genre
|
|
@@ -6645,7 +9020,26 @@ policy); --composed keeps the cut's camera and chrome instead. --set
|
|
|
6645
9020
|
path=value overrides the doc in memory for every render here (the user's
|
|
6646
9021
|
sets apply last). Store uploads stay manual: hand the human the kit
|
|
6647
9022
|
directory, then vos validate <kit.json> re-measures every asset from its
|
|
6648
|
-
bytes against the channel specs
|
|
9023
|
+
bytes against the channel specs; --picture adds what each asset LOOKS
|
|
9024
|
+
like, read from its pixels: blank (a wallpaper or an empty canvas where
|
|
9025
|
+
the product should be), duplicate (two stills of one frame), subject (the
|
|
9026
|
+
card off the 60 to 92% band, or a crop where a card was asked for),
|
|
9027
|
+
separation (a light card on a light ground with no shadow), halfsize (a
|
|
9028
|
+
tile that loses its edges when the store shrinks it), and, where the kit
|
|
9029
|
+
records its text boxes, sliced, safe and contrast (APCA Lc 60/75); a
|
|
9030
|
+
video's first and last frames are read through ffmpeg. Every finding
|
|
9031
|
+
carries a code, a severity, a fix hint and a box; an error fails the
|
|
9032
|
+
verdict beside the spec problems.
|
|
9033
|
+
judge puts the kit beside its REFERENCES: for every still that has a
|
|
9034
|
+
reference of its role in the manifest (a template family, a card genre),
|
|
9035
|
+
two sheets at a common height (the asset left, then right, so order
|
|
9036
|
+
cannot bias the call) and the rubric in words, plus judge.json, a slot
|
|
9037
|
+
per pair the judge fills (win true, false or null for a tie, with the
|
|
9038
|
+
rule numbers). No model runs inside the verb: the skill judges the
|
|
9039
|
+
sheets pairwise, both orders, and the verb reports the win rate beside
|
|
9040
|
+
the spec and picture counts. The reference set is the maker's own
|
|
9041
|
+
fixture folder with a MANIFEST.json (id, file, role, layout, facts,
|
|
9042
|
+
rule per asset).
|
|
6649
9043
|
brand writes the product's BRAND.md, witnessed: it reads /design.md when
|
|
6650
9044
|
the site publishes one (the convention beside /llms.txt: fonts, logo assets,
|
|
6651
9045
|
an avoid list), /llms.txt for the name and the claim, then the page itself
|
|
@@ -6707,7 +9101,7 @@ lint-gated, so a bad override fails like a bad doc.json):
|
|
|
6707
9101
|
first ready loop (the house backdrop the studio opens on), or a flat ground offline
|
|
6708
9102
|
`;
|
|
6709
9103
|
async function loadActions(file) {
|
|
6710
|
-
const raw = JSON.parse(await
|
|
9104
|
+
const raw = JSON.parse(await readFile12(file, "utf8"));
|
|
6711
9105
|
const errors = validateActions(raw);
|
|
6712
9106
|
if (errors.length)
|
|
6713
9107
|
throw new UsageError(`invalid actions file:
|
|
@@ -6763,16 +9157,16 @@ async function cmdRecord(argv) {
|
|
|
6763
9157
|
const url = strFlag(flags, "url") ?? actions.url;
|
|
6764
9158
|
if (!url)
|
|
6765
9159
|
throw new UsageError('no URL \u2014 set "url" in the actions file or pass --url');
|
|
6766
|
-
const outDir =
|
|
9160
|
+
const outDir = resolve7(strFlag(flags, "out") ?? "take");
|
|
6767
9161
|
const backdrop = await takeBackdrop(flags, r);
|
|
6768
9162
|
const maxDurationSeconds = await maxDuration(flags, r);
|
|
6769
|
-
if (
|
|
9163
|
+
if (existsSync12(join15(outDir, "meta.json"))) {
|
|
6770
9164
|
const { prevDoc, kept } = await prepareReRecord(outDir);
|
|
6771
9165
|
r.log(
|
|
6772
9166
|
`note: re-recording ${outDir} \u2014 kept ${kept.length ? kept.join(", ") : "nothing"}` + (prevDoc ? "; apply the previous cut to the new footage with: vos plan " + outDir + " --reuse" : "")
|
|
6773
9167
|
);
|
|
6774
9168
|
}
|
|
6775
|
-
await
|
|
9169
|
+
await mkdir8(outDir, { recursive: true });
|
|
6776
9170
|
const paths = await ensureTakeDir(outDir);
|
|
6777
9171
|
const browser = await launchBrowser();
|
|
6778
9172
|
try {
|
|
@@ -6813,7 +9207,7 @@ async function cmdRecord(argv) {
|
|
|
6813
9207
|
},
|
|
6814
9208
|
strictFail ? `STRICT: take recorded but incomplete \u2014 ${strictReason(rec)}; fix the flow and re-record.${skippedNote}` : `Take ready: ${outDir}
|
|
6815
9209
|
${(rec.meta.durationMs / 1e3).toFixed(1)}s \xB7 ${rec.frames.length} frames \xB7 ${rec.events.length} cursor events \xB7 ${clicks} clicks \xB7 ${plan.doc.zoom.length} zoom spans planned \xB7 ${rec.freezePct}% frozen${rec.freezePct >= 25 ? " \u26A0 keep motion in frame or trim" : ""}${skippedNote}
|
|
6816
|
-
Next: edit ${
|
|
9210
|
+
Next: edit ${join15(outDir, "doc.json")} (optional), then: vos render ${outDir}`
|
|
6817
9211
|
);
|
|
6818
9212
|
return strictFail ? EXIT_USAGE : EXIT_OK;
|
|
6819
9213
|
} finally {
|
|
@@ -6837,25 +9231,25 @@ async function cmdCreate2(argv) {
|
|
|
6837
9231
|
const url = strFlag(flags, "url") ?? actions.url;
|
|
6838
9232
|
if (!url)
|
|
6839
9233
|
throw new UsageError('no URL \u2014 set "url" in the actions file or pass --url');
|
|
6840
|
-
const outDir =
|
|
9234
|
+
const outDir = resolve7(strFlag(flags, "out") ?? "take");
|
|
6841
9235
|
const fmtRaw = strFlag(flags, "format") ?? "webm";
|
|
6842
9236
|
if (fmtRaw !== "webm" && fmtRaw !== "mp4")
|
|
6843
9237
|
throw new UsageError("--format must be webm or mp4");
|
|
6844
9238
|
const format = fmtRaw;
|
|
6845
|
-
const out = positionals[0] ??
|
|
9239
|
+
const out = positionals[0] ?? join15(outDir, `out.${format}`);
|
|
6846
9240
|
const parallel = numFlag(flags, "parallel", 1);
|
|
6847
9241
|
if (!Number.isInteger(parallel) || parallel < 1 || parallel > 16) {
|
|
6848
9242
|
throw new UsageError("--parallel expects an integer between 1 and 16");
|
|
6849
9243
|
}
|
|
6850
9244
|
const backdrop = await takeBackdrop(flags, r);
|
|
6851
9245
|
const maxDurationSeconds = await maxDuration(flags, r);
|
|
6852
|
-
if (
|
|
9246
|
+
if (existsSync12(join15(outDir, "meta.json"))) {
|
|
6853
9247
|
const { prevDoc, kept } = await prepareReRecord(outDir);
|
|
6854
9248
|
r.log(
|
|
6855
9249
|
`note: re-recording ${outDir} \u2014 kept ${kept.length ? kept.join(", ") : "nothing"}` + (prevDoc ? "; apply the previous cut to the new footage with: vos plan " + outDir + " --reuse" : "")
|
|
6856
9250
|
);
|
|
6857
9251
|
}
|
|
6858
|
-
await
|
|
9252
|
+
await mkdir8(outDir, { recursive: true });
|
|
6859
9253
|
const paths = await ensureTakeDir(outDir);
|
|
6860
9254
|
const browser = await launchBrowser();
|
|
6861
9255
|
try {
|
|
@@ -6936,24 +9330,24 @@ async function cmdPlan(argv) {
|
|
|
6936
9330
|
);
|
|
6937
9331
|
const r = createReporter(flags.json === true);
|
|
6938
9332
|
if (flags.fresh === true) {
|
|
6939
|
-
await rm4(
|
|
9333
|
+
await rm4(join15(dir, "doc.json"), { force: true });
|
|
6940
9334
|
r.log("note: --fresh discarded the existing doc.json");
|
|
6941
9335
|
}
|
|
6942
9336
|
let reuse;
|
|
6943
9337
|
if (flags.reuse === true) {
|
|
6944
|
-
const from =
|
|
6945
|
-
if (!
|
|
9338
|
+
const from = resolve7(strFlag(flags, "from") ?? join15(dir, PREV_DOC_NAME));
|
|
9339
|
+
if (!existsSync12(from)) {
|
|
6946
9340
|
throw new UsageError(
|
|
6947
9341
|
`--reuse: ${from} does not exist \u2014 a re-record into this take writes doc.prev.json, or pass --from <doc.json>`
|
|
6948
9342
|
);
|
|
6949
9343
|
}
|
|
6950
9344
|
reuse = {
|
|
6951
9345
|
from,
|
|
6952
|
-
doc: JSON.parse(await
|
|
9346
|
+
doc: JSON.parse(await readFile12(from, "utf8"))
|
|
6953
9347
|
};
|
|
6954
9348
|
}
|
|
6955
9349
|
const style = await resolveStyleRef(flags);
|
|
6956
|
-
const backdrop =
|
|
9350
|
+
const backdrop = existsSync12(join15(dir, "doc.json")) ? null : await takeBackdrop(flags, r);
|
|
6957
9351
|
const s = await planTake(dir, {
|
|
6958
9352
|
...style ? { style } : {},
|
|
6959
9353
|
...reuse ? { reuse } : {},
|
|
@@ -6965,7 +9359,7 @@ async function cmdPlan(argv) {
|
|
|
6965
9359
|
${s.reuse.flagged.join("\n ")}` : "") : "";
|
|
6966
9360
|
r.done(
|
|
6967
9361
|
{
|
|
6968
|
-
take:
|
|
9362
|
+
take: resolve7(dir),
|
|
6969
9363
|
fresh: s.fresh,
|
|
6970
9364
|
cursorKept: s.cursorKept,
|
|
6971
9365
|
zoomAuto: s.zoomAuto,
|
|
@@ -6975,7 +9369,7 @@ async function cmdPlan(argv) {
|
|
|
6975
9369
|
...s.styleFrom ? { styleFrom: s.styleFrom, styleFields: s.styleFields } : {},
|
|
6976
9370
|
...s.reuse ? { reuse: s.reuse } : {}
|
|
6977
9371
|
},
|
|
6978
|
-
`${s.reuse ? "Reused" : s.fresh ? "Planned" : "Refreshed"} ${
|
|
9372
|
+
`${s.reuse ? "Reused" : s.fresh ? "Planned" : "Refreshed"} ${join15(dir, "doc.json")}: ${s.zoomAuto} auto + ${s.zoomManual} manual zoom spans${s.cursorKept ? "" : " (cursor track dropped)"}${reuseLines}`
|
|
6979
9373
|
);
|
|
6980
9374
|
return EXIT_OK;
|
|
6981
9375
|
}
|
|
@@ -6995,7 +9389,7 @@ async function cmdRender(argv) {
|
|
|
6995
9389
|
if (fmtRaw !== "webm" && fmtRaw !== "mp4")
|
|
6996
9390
|
throw new UsageError("--format must be webm or mp4");
|
|
6997
9391
|
const format = fmtRaw;
|
|
6998
|
-
const out = positionals[1] ??
|
|
9392
|
+
const out = positionals[1] ?? join15(dir, `out.${format}`);
|
|
6999
9393
|
const parallel = numFlag(flags, "parallel", 1);
|
|
7000
9394
|
if (!Number.isInteger(parallel) || parallel < 1 || parallel > 16) {
|
|
7001
9395
|
throw new UsageError("--parallel expects an integer between 1 and 16");
|
|
@@ -7071,7 +9465,7 @@ async function cmdFrames(argv) {
|
|
|
7071
9465
|
if (!m) throw new UsageError("--size expects WxH (e.g. --size 1280x800)");
|
|
7072
9466
|
size = { width: Number(m[1]), height: Number(m[2]) };
|
|
7073
9467
|
}
|
|
7074
|
-
const duration = totalDuration2(
|
|
9468
|
+
const duration = totalDuration2(ratedSegments6(take.doc));
|
|
7075
9469
|
const frameRaw = strFlag(flags, "frame");
|
|
7076
9470
|
const timesRaw = strFlag(flags, "times");
|
|
7077
9471
|
const atZooms = flags["at-zooms"] === true;
|
|
@@ -7158,12 +9552,13 @@ async function cmdDeliver(argv) {
|
|
|
7158
9552
|
}
|
|
7159
9553
|
const take = await loadTake(dir);
|
|
7160
9554
|
if (!take.doc) throw new UsageError(`${dir} has no doc.json \u2014 run plan first`);
|
|
7161
|
-
const duration = totalDuration2(
|
|
9555
|
+
const duration = totalDuration2(ratedSegments6(take.doc));
|
|
7162
9556
|
const timesRaw = strFlag(flags, "times");
|
|
7163
9557
|
let times;
|
|
7164
9558
|
if (timesRaw !== void 0) {
|
|
7165
9559
|
try {
|
|
7166
|
-
|
|
9560
|
+
const doc = take.doc;
|
|
9561
|
+
times = timesRaw.split(",").map((s) => s.trim()).filter(Boolean).map((s) => resolveStepTime(doc, s) ?? parseTimes(s, duration)[0]);
|
|
7167
9562
|
} catch (e) {
|
|
7168
9563
|
throw new UsageError(e instanceof Error ? e.message : String(e));
|
|
7169
9564
|
}
|
|
@@ -7174,11 +9569,15 @@ async function cmdDeliver(argv) {
|
|
|
7174
9569
|
}
|
|
7175
9570
|
let poster;
|
|
7176
9571
|
const posterRef = strFlag(flags, "poster");
|
|
7177
|
-
if (posterRef
|
|
7178
|
-
|
|
9572
|
+
if (posterRef === "none") {
|
|
9573
|
+
poster = null;
|
|
9574
|
+
} else if (posterRef !== void 0 && templateByName(posterRef)) {
|
|
9575
|
+
poster = { from: `template ${posterRef}`, config: templateByName(posterRef) };
|
|
9576
|
+
} else if (posterRef !== void 0) {
|
|
9577
|
+
if (existsSync12(posterRef)) {
|
|
7179
9578
|
poster = {
|
|
7180
|
-
from:
|
|
7181
|
-
config: JSON.parse(await
|
|
9579
|
+
from: resolve7(posterRef),
|
|
9580
|
+
config: JSON.parse(await readFile12(posterRef, "utf8"))
|
|
7182
9581
|
};
|
|
7183
9582
|
} else {
|
|
7184
9583
|
const origin = platformOrigin({
|
|
@@ -7196,9 +9595,65 @@ async function cmdDeliver(argv) {
|
|
|
7196
9595
|
poster = { from: posterRef, config: res.config };
|
|
7197
9596
|
}
|
|
7198
9597
|
}
|
|
9598
|
+
let lookPick;
|
|
9599
|
+
try {
|
|
9600
|
+
lookPick = await resolveLook(dir, {
|
|
9601
|
+
look: strFlag(flags, "look"),
|
|
9602
|
+
brand: strFlag(flags, "brand")
|
|
9603
|
+
});
|
|
9604
|
+
} catch (e) {
|
|
9605
|
+
throw new UsageError(e instanceof Error ? e.message : String(e));
|
|
9606
|
+
}
|
|
9607
|
+
r.log(`look: ${lookPick.from}`);
|
|
9608
|
+
const launch = await readLaunchBesideTake(dir, strFlag(flags, "launch"));
|
|
9609
|
+
const lines = (v) => v === null || v === void 0 ? null : v.replace(/\\n/g, "\n");
|
|
9610
|
+
const words = {
|
|
9611
|
+
headline: lines(strFlag(flags, "headline") ?? launch?.roles.headline),
|
|
9612
|
+
kicker: lines(strFlag(flags, "kicker") ?? launch?.roles.kicker),
|
|
9613
|
+
brand: strFlag(flags, "brand-name") ?? lookPick.roles?.wordmark ?? null,
|
|
9614
|
+
release: strFlag(flags, "release") ?? null
|
|
9615
|
+
};
|
|
9616
|
+
if (launch) r.log(`words: ${launch.file}`);
|
|
9617
|
+
const launchRoles = { ...launch?.roles ?? {} };
|
|
9618
|
+
for (const [flag, role] of [
|
|
9619
|
+
["music", "music"],
|
|
9620
|
+
["entrance", "entrance"],
|
|
9621
|
+
["end-card", "endCard"],
|
|
9622
|
+
["captions", "captions"],
|
|
9623
|
+
["clicks", "clicks"]
|
|
9624
|
+
]) {
|
|
9625
|
+
const v = strFlag(flags, flag);
|
|
9626
|
+
if (v !== void 0) launchRoles[role] = v;
|
|
9627
|
+
}
|
|
9628
|
+
const soundWanted = channels.some(
|
|
9629
|
+
(c) => ["x", "youtube", "shorts-linkedin"].includes(c)
|
|
9630
|
+
);
|
|
9631
|
+
let catalog = null;
|
|
9632
|
+
if (soundWanted && !/^(none|off|no|false)$/i.test(launchRoles.music ?? "") && !/^(none|off|no|false)$/i.test(launchRoles.clicks ?? "")) {
|
|
9633
|
+
try {
|
|
9634
|
+
catalog = await fetchMusicCatalog(
|
|
9635
|
+
platformOrigin({
|
|
9636
|
+
origin: strFlag(flags, "origin"),
|
|
9637
|
+
api: strFlag(flags, "api")
|
|
9638
|
+
})
|
|
9639
|
+
);
|
|
9640
|
+
} catch (e) {
|
|
9641
|
+
r.log(`music catalog: ${e instanceof Error ? e.message : String(e)} \u2014 the cuts stay silent`);
|
|
9642
|
+
}
|
|
9643
|
+
}
|
|
9644
|
+
const captions = (take.actions?.steps ?? []).flatMap((s, i) => {
|
|
9645
|
+
const step = s;
|
|
9646
|
+
return typeof step.caption === "string" && step.caption.trim() ? [{ step: i, id: step.id, caption: step.caption.trim() }] : [];
|
|
9647
|
+
});
|
|
7199
9648
|
const browser = await launchBrowser();
|
|
7200
9649
|
try {
|
|
7201
9650
|
const result = await deliverTake(browser, dir, {
|
|
9651
|
+
look: lookPick.look,
|
|
9652
|
+
brandRoles: lookPick.roles,
|
|
9653
|
+
launchRoles,
|
|
9654
|
+
catalog,
|
|
9655
|
+
captions,
|
|
9656
|
+
words,
|
|
7202
9657
|
channels,
|
|
7203
9658
|
outDir: strFlag(flags, "out"),
|
|
7204
9659
|
release: strFlag(flags, "release"),
|
|
@@ -7248,14 +9703,14 @@ Store uploads stay manual: hand the human this directory and the manifest.`
|
|
|
7248
9703
|
async function resolveStyleRef(flags) {
|
|
7249
9704
|
const styleRef = strFlag(flags, "style");
|
|
7250
9705
|
if (!styleRef) return null;
|
|
7251
|
-
const file =
|
|
9706
|
+
const file = existsSync12(styleRef) ? resolve7(
|
|
7252
9707
|
styleRef,
|
|
7253
|
-
|
|
9708
|
+
existsSync12(join15(styleRef, "doc.json")) ? "doc.json" : ""
|
|
7254
9709
|
) : null;
|
|
7255
|
-
if (file &&
|
|
9710
|
+
if (file && existsSync12(file)) {
|
|
7256
9711
|
return {
|
|
7257
9712
|
from: file,
|
|
7258
|
-
doc: JSON.parse(await
|
|
9713
|
+
doc: JSON.parse(await readFile12(file, "utf8"))
|
|
7259
9714
|
};
|
|
7260
9715
|
}
|
|
7261
9716
|
const origin = platformOrigin({
|
|
@@ -7292,7 +9747,7 @@ async function cmdDigest(argv) {
|
|
|
7292
9747
|
const take = await loadTake(dir);
|
|
7293
9748
|
if (!take.doc) throw new UsageError(`${dir} has no doc.json \u2014 run plan first`);
|
|
7294
9749
|
const transcriptPath = strFlag(flags, "transcript");
|
|
7295
|
-
const transcript = transcriptPath ? parseTranscript(JSON.parse(await
|
|
9750
|
+
const transcript = transcriptPath ? parseTranscript(JSON.parse(await readFile12(transcriptPath, "utf8"))) : null;
|
|
7296
9751
|
const style = await resolveStyleRef(flags);
|
|
7297
9752
|
const noFrames = flags["no-frames"] === true;
|
|
7298
9753
|
let browser = null;
|
|
@@ -7323,7 +9778,7 @@ async function cmdDigest(argv) {
|
|
|
7323
9778
|
kinds,
|
|
7324
9779
|
frames: d.images.full,
|
|
7325
9780
|
crops: d.images.crop,
|
|
7326
|
-
sheet: d.images.sheet ?
|
|
9781
|
+
sheet: d.images.sheet ? join15(result.outDir, d.images.sheet) : null,
|
|
7327
9782
|
scenes: kinds.scene ?? 0,
|
|
7328
9783
|
sourceDuration: d.take.sourceDuration,
|
|
7329
9784
|
outputDuration: d.take.outputDuration,
|
|
@@ -7347,14 +9802,14 @@ async function cmdOpen(argv) {
|
|
|
7347
9802
|
if (!dir) throw new UsageError("vos open <take> [--studio <url>]");
|
|
7348
9803
|
const r = createReporter(flags.json === true);
|
|
7349
9804
|
const take = await loadTake(dir);
|
|
7350
|
-
if (!
|
|
9805
|
+
if (!existsSync12(take.paths.recording)) {
|
|
7351
9806
|
throw new UsageError(`${dir} has no recording.webm \u2014 re-run record`);
|
|
7352
9807
|
}
|
|
7353
9808
|
const studio = (strFlag(flags, "studio") ?? "http://localhost:6060").replace(
|
|
7354
9809
|
/\/+$/,
|
|
7355
9810
|
""
|
|
7356
9811
|
);
|
|
7357
|
-
const server = await startTakeServer(
|
|
9812
|
+
const server = await startTakeServer(resolve7(dir), {});
|
|
7358
9813
|
const url = `${studio}/studio?take=${encodeURIComponent(server.base)}`;
|
|
7359
9814
|
r.event({ event: "open", server: server.base, url });
|
|
7360
9815
|
r.log(`take served at ${server.base}`);
|
|
@@ -7379,22 +9834,57 @@ async function cmdOpen(argv) {
|
|
|
7379
9834
|
});
|
|
7380
9835
|
return EXIT_OK;
|
|
7381
9836
|
}
|
|
9837
|
+
async function cmdJudge(argv) {
|
|
9838
|
+
const { positionals, flags } = parseArgs(argv, BOOLEAN_FLAGS5);
|
|
9839
|
+
const target = positionals[0];
|
|
9840
|
+
const against = strFlag(flags, "against");
|
|
9841
|
+
if (!target || !against)
|
|
9842
|
+
throw new UsageError(
|
|
9843
|
+
"vos judge <kit.json> --against <MANIFEST.json> [--out dir] [--json]\n(the manifest names the reference set: id, file, role, layout, facts, rule per asset)"
|
|
9844
|
+
);
|
|
9845
|
+
const r = createReporter(flags.json === true);
|
|
9846
|
+
const kitFile = target.endsWith("kit.json") ? target : join15(target, "kit.json");
|
|
9847
|
+
if (!existsSync12(kitFile)) throw new UsageError(`${kitFile}: no kit manifest`);
|
|
9848
|
+
if (!existsSync12(against)) throw new UsageError(`${against}: no reference manifest`);
|
|
9849
|
+
const result = await judgeKit(kitFile, against, strFlag(flags, "out"));
|
|
9850
|
+
const verdicts = JSON.parse(await readFile12(result.verdictFile, "utf8")).verdicts;
|
|
9851
|
+
const rate = winRate(verdicts);
|
|
9852
|
+
const lines = result.sheets.map(
|
|
9853
|
+
(s) => `${s.asset} vs ${s.reference}: ${s.sheetA}, ${s.sheetB}, ${s.rubric}`
|
|
9854
|
+
);
|
|
9855
|
+
r.done(
|
|
9856
|
+
{ ...result, winRate: rate },
|
|
9857
|
+
`Wrote ${result.sheets.length} sheet pair(s) to ${result.outDir}` + (lines.length ? `
|
|
9858
|
+
${lines.join("\n ")}` : "") + (result.skipped.length ? `
|
|
9859
|
+
skipped: ${result.skipped.join("\n skipped: ")}` : "") + `
|
|
9860
|
+
Judge each pair both ways (the rubric is beside it) and fill ${result.verdictFile}` + (rate.judged ? `
|
|
9861
|
+
Win rate so far: ${rate.wins} win(s), ${rate.ties} tie(s) of ${rate.judged} judged (${(rate.rate * 100).toFixed(0)}%; a tie counts a half); parity with the references is 50%, the marketability bar is 40%` : "")
|
|
9862
|
+
);
|
|
9863
|
+
return EXIT_OK;
|
|
9864
|
+
}
|
|
7382
9865
|
async function cmdValidate(argv) {
|
|
7383
9866
|
const { positionals, flags } = parseArgs(argv, BOOLEAN_FLAGS5);
|
|
7384
9867
|
const target = positionals[0];
|
|
7385
9868
|
if (!target) throw new UsageError("vos validate <actions.json|take>");
|
|
7386
9869
|
const r = createReporter(flags.json === true);
|
|
7387
|
-
const kitTarget = target.endsWith("kit.json") ? target :
|
|
9870
|
+
const kitTarget = target.endsWith("kit.json") ? target : existsSync12(join15(target, "kit.json")) && !isTakeDir(target) ? join15(target, "kit.json") : null;
|
|
7388
9871
|
if (kitTarget) {
|
|
7389
|
-
const
|
|
7390
|
-
const
|
|
9872
|
+
const picture = flags.picture === true;
|
|
9873
|
+
const verdict = await validateKit(kitTarget, { picture });
|
|
9874
|
+
const pictureLines = (verdict.picture ?? []).map(formatFinding);
|
|
9875
|
+
const pictureErrors = (verdict.picture ?? []).filter(
|
|
9876
|
+
(f) => f.severity === "error"
|
|
9877
|
+
).length;
|
|
9878
|
+
const tail = (verdict.warnings.length ? `
|
|
7391
9879
|
warnings:
|
|
7392
|
-
${verdict.warnings.join("\n ")}` : ""
|
|
9880
|
+
${verdict.warnings.join("\n ")}` : "") + (picture ? `
|
|
9881
|
+
picture: ${pictureErrors} problem(s), ${(verdict.picture ?? []).length - pictureErrors} note(s)` + (pictureLines.length ? `
|
|
9882
|
+
${pictureLines.join("\n ")}` : "") : "");
|
|
7393
9883
|
if (!verdict.valid) {
|
|
7394
9884
|
r.done(
|
|
7395
9885
|
{ ...verdict, target: kitTarget },
|
|
7396
|
-
`${kitTarget}
|
|
7397
|
-
${verdict.problems.join("\n ")}${tail}`
|
|
9886
|
+
`${kitTarget}:${verdict.problems.length ? `
|
|
9887
|
+
${verdict.problems.join("\n ")}` : ""}${tail}`
|
|
7398
9888
|
);
|
|
7399
9889
|
return EXIT_ERROR;
|
|
7400
9890
|
}
|
|
@@ -7409,9 +9899,9 @@ async function cmdValidate(argv) {
|
|
|
7409
9899
|
r.done({ valid: true, target }, `${target}: valid actions file`);
|
|
7410
9900
|
return EXIT_OK;
|
|
7411
9901
|
}
|
|
7412
|
-
if (!isTakeDir(target) &&
|
|
9902
|
+
if (!isTakeDir(target) && existsSync12(join15(target, "config.json"))) {
|
|
7413
9903
|
const parsed = JSON.parse(
|
|
7414
|
-
await
|
|
9904
|
+
await readFile12(join15(target, "config.json"), "utf8")
|
|
7415
9905
|
);
|
|
7416
9906
|
const pre = preflightConfig(parsed);
|
|
7417
9907
|
const problems2 = pre.ok ? [] : pre.issues.map((i) => `config.json: ${i}`);
|
|
@@ -7452,7 +9942,7 @@ async function cmdValidate(argv) {
|
|
|
7452
9942
|
const take = await loadTake(target);
|
|
7453
9943
|
const problems = [];
|
|
7454
9944
|
const warnings = [];
|
|
7455
|
-
if (!
|
|
9945
|
+
if (!existsSync12(take.paths.recording))
|
|
7456
9946
|
problems.push("missing recording.webm (re-run record)");
|
|
7457
9947
|
if (!take.doc) problems.push("missing doc.json (run plan)");
|
|
7458
9948
|
if (take.doc) {
|
|
@@ -7497,7 +9987,7 @@ async function cmdPush3(argv) {
|
|
|
7497
9987
|
const r = createReporter(flags.json === true);
|
|
7498
9988
|
const overrides = multi.override;
|
|
7499
9989
|
const result = await pushTake(
|
|
7500
|
-
|
|
9990
|
+
resolve7(dir),
|
|
7501
9991
|
{
|
|
7502
9992
|
key: strFlag(flags, "key"),
|
|
7503
9993
|
api: strFlag(flags, "api"),
|
|
@@ -7524,7 +10014,7 @@ async function cmdPull2(argv) {
|
|
|
7524
10014
|
const dir = positionals[0] ?? ".";
|
|
7525
10015
|
const r = createReporter(flags.json === true);
|
|
7526
10016
|
const result = await pullTake(
|
|
7527
|
-
|
|
10017
|
+
resolve7(dir),
|
|
7528
10018
|
{
|
|
7529
10019
|
key: strFlag(flags, "key"),
|
|
7530
10020
|
api: strFlag(flags, "api"),
|
|
@@ -7576,6 +10066,8 @@ async function run(argv) {
|
|
|
7576
10066
|
return await cmdOpen(rest);
|
|
7577
10067
|
case "validate":
|
|
7578
10068
|
return await cmdValidate(rest);
|
|
10069
|
+
case "judge":
|
|
10070
|
+
return await cmdJudge(rest);
|
|
7579
10071
|
case "fetch":
|
|
7580
10072
|
return await cmdFetch(rest);
|
|
7581
10073
|
case "duplicate":
|
|
@@ -7657,4 +10149,4 @@ export {
|
|
|
7657
10149
|
convertAgentBrowser,
|
|
7658
10150
|
run
|
|
7659
10151
|
};
|
|
7660
|
-
//# sourceMappingURL=chunk-
|
|
10152
|
+
//# sourceMappingURL=chunk-HGLKXIXH.js.map
|