@vosjs/cli 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +171 -90
- package/dist/{chunk-5BFGODBI.js → chunk-EFI3WUQB.js} +5 -1
- package/dist/chunk-EFI3WUQB.js.map +1 -0
- package/dist/{chunk-X2BKHZ56.js → chunk-GCWE75OA.js} +1 -105
- package/dist/chunk-GCWE75OA.js.map +1 -0
- package/dist/{chunk-6TFKMNSG.js → chunk-MMKDYE57.js} +2717 -320
- package/dist/chunk-MMKDYE57.js.map +1 -0
- package/dist/chunk-NHKHTIDR.js +216 -0
- package/dist/chunk-NHKHTIDR.js.map +1 -0
- package/dist/cli.js +32 -21
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +7 -7
- package/dist/manifest-UCS6OVWZ.js +8 -0
- package/dist/run-R5P44XNR.js +9 -0
- package/package.json +9 -9
- package/schema/actions.schema.json +76 -12
- package/schema/channel-specs.json +135 -22
- package/schema/doc.schema.json +418 -103
- package/dist/chunk-5BFGODBI.js.map +0 -1
- package/dist/chunk-6TFKMNSG.js.map +0 -1
- package/dist/chunk-A2VSDK3C.js +0 -42
- package/dist/chunk-A2VSDK3C.js.map +0 -1
- package/dist/chunk-X2BKHZ56.js.map +0 -1
- package/dist/manifest-3LIL5M3F.js +0 -8
- package/dist/run-VU6B3CXC.js +0 -9
- /package/dist/{manifest-3LIL5M3F.js.map → manifest-UCS6OVWZ.js.map} +0 -0
- /package/dist/{run-VU6B3CXC.js.map → run-R5P44XNR.js.map} +0 -0
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
BrowserUnavailableError,
|
|
4
|
+
directoryKind,
|
|
4
5
|
launchBrowser
|
|
5
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-NHKHTIDR.js";
|
|
6
7
|
|
|
7
8
|
// src/plugin/run.ts
|
|
8
|
-
import { mkdir as
|
|
9
|
-
import { existsSync as
|
|
10
|
-
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";
|
|
11
12
|
import { totalDuration as totalDuration2 } from "@vosjs/timeline";
|
|
12
|
-
import { migrateHostedDoc as migrateHostedDoc4, ratedSegments as
|
|
13
|
+
import { migrateHostedDoc as migrateHostedDoc4, ratedSegments as ratedSegments6 } from "@vosjs/studio-core";
|
|
13
14
|
|
|
14
15
|
// src/plugin/args.ts
|
|
15
16
|
var UsageError = class extends Error {
|
|
@@ -126,22 +127,22 @@ function validateActions(value) {
|
|
|
126
127
|
}
|
|
127
128
|
const seenIds = /* @__PURE__ */ new Set();
|
|
128
129
|
obj.steps.forEach((raw, i) => {
|
|
129
|
-
const
|
|
130
|
+
const at2 = `steps[${i}]`;
|
|
130
131
|
if (typeof raw !== "object" || raw === null) {
|
|
131
|
-
errors.push(`${
|
|
132
|
+
errors.push(`${at2}: must be an object`);
|
|
132
133
|
return;
|
|
133
134
|
}
|
|
134
135
|
const s = raw;
|
|
135
136
|
if (typeof s.do !== "string" || !VERBS.has(s.do)) {
|
|
136
|
-
errors.push(`${
|
|
137
|
+
errors.push(`${at2}: "do" must be one of ${[...VERBS].join(", ")}`);
|
|
137
138
|
return;
|
|
138
139
|
}
|
|
139
140
|
if (s.id !== void 0) {
|
|
140
141
|
if (typeof s.id !== "string" || !s.id.trim()) {
|
|
141
|
-
errors.push(`${
|
|
142
|
+
errors.push(`${at2}: id must be a non-empty string`);
|
|
142
143
|
} else if (seenIds.has(s.id)) {
|
|
143
144
|
errors.push(
|
|
144
|
-
`${
|
|
145
|
+
`${at2}: duplicate id "${s.id}" \u2014 an anchor could not tell the two steps apart`
|
|
145
146
|
);
|
|
146
147
|
} else {
|
|
147
148
|
seenIds.add(s.id);
|
|
@@ -149,26 +150,26 @@ function validateActions(value) {
|
|
|
149
150
|
}
|
|
150
151
|
const needSelector = s.do === "hover" || s.do === "click" || s.do === "type";
|
|
151
152
|
if (needSelector && typeof s.selector !== "string")
|
|
152
|
-
errors.push(`${
|
|
153
|
+
errors.push(`${at2}: ${s.do} needs a selector`);
|
|
153
154
|
if (s.do === "wait" && typeof s.ms !== "number")
|
|
154
|
-
errors.push(`${
|
|
155
|
+
errors.push(`${at2}: wait needs ms`);
|
|
155
156
|
if (s.do === "type" && typeof s.text !== "string")
|
|
156
|
-
errors.push(`${
|
|
157
|
+
errors.push(`${at2}: type needs text`);
|
|
157
158
|
if (s.do === "type" && s.focus !== void 0 && typeof s.focus !== "boolean")
|
|
158
|
-
errors.push(`${
|
|
159
|
+
errors.push(`${at2}: type focus must be true or false`);
|
|
159
160
|
if (s.do === "scroll" && typeof s.dy !== "number")
|
|
160
|
-
errors.push(`${
|
|
161
|
+
errors.push(`${at2}: scroll needs dy`);
|
|
161
162
|
if (s.do === "move" && (typeof s.x !== "number" || typeof s.y !== "number")) {
|
|
162
|
-
errors.push(`${
|
|
163
|
+
errors.push(`${at2}: move needs x and y (CSS px)`);
|
|
163
164
|
}
|
|
164
165
|
if (s.do === "drag") {
|
|
165
166
|
if (typeof s.tx !== "number" || typeof s.ty !== "number") {
|
|
166
|
-
errors.push(`${
|
|
167
|
+
errors.push(`${at2}: drag needs tx and ty (CSS px)`);
|
|
167
168
|
}
|
|
168
169
|
const hasStart = typeof s.selector === "string" || typeof s.x === "number" && typeof s.y === "number";
|
|
169
170
|
if (!hasStart)
|
|
170
171
|
errors.push(
|
|
171
|
-
`${
|
|
172
|
+
`${at2}: drag needs a selector OR x and y as the start point`
|
|
172
173
|
);
|
|
173
174
|
}
|
|
174
175
|
});
|
|
@@ -316,6 +317,33 @@ function lintDoc(docIn) {
|
|
|
316
317
|
);
|
|
317
318
|
}
|
|
318
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
|
+
}
|
|
319
347
|
if (doc.segments !== void 0 && !Array.isArray(doc.segments)) {
|
|
320
348
|
problems.push("segments must be an array of {in, out} spans");
|
|
321
349
|
}
|
|
@@ -523,9 +551,69 @@ function lintDoc(docIn) {
|
|
|
523
551
|
"frame.borderWidth/borderColor are set but frame.border is 0 \u2014 border is the switch AND the alpha, so nothing is drawn"
|
|
524
552
|
);
|
|
525
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
|
+
}
|
|
526
614
|
const ebw = isNum(bw) && bw > 0 && bw <= 24 ? bw : 1.5;
|
|
527
615
|
const pad = isNum(frame.padding) ? frame.padding : 0;
|
|
528
|
-
if (frame.border && ebw > pad) {
|
|
616
|
+
if (frame.border && ebw > pad && ins === void 0) {
|
|
529
617
|
warnings.push(
|
|
530
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`
|
|
531
619
|
);
|
|
@@ -1259,10 +1347,10 @@ function startTakeServer(rootDir, pages) {
|
|
|
1259
1347
|
res.writeHead(404).end();
|
|
1260
1348
|
});
|
|
1261
1349
|
return new Promise(
|
|
1262
|
-
(
|
|
1350
|
+
(resolve8) => server.listen(0, () => {
|
|
1263
1351
|
const addr = server.address();
|
|
1264
1352
|
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
1265
|
-
|
|
1353
|
+
resolve8({ base: `http://localhost:${port}`, close: () => server.close() });
|
|
1266
1354
|
})
|
|
1267
1355
|
);
|
|
1268
1356
|
}
|
|
@@ -1276,10 +1364,10 @@ async function waitForPageDone(page, label, onProgress, timeoutMs) {
|
|
|
1276
1364
|
if (state.error) throw new Error(`${label} failed: ${state.error}`);
|
|
1277
1365
|
if (state.done) return state.done;
|
|
1278
1366
|
if (typeof state.progress === "number") {
|
|
1279
|
-
const
|
|
1280
|
-
if (
|
|
1367
|
+
const pct2 = Math.floor(state.progress * 10) * 10;
|
|
1368
|
+
if (pct2 !== last) {
|
|
1281
1369
|
onProgress(state.progress);
|
|
1282
|
-
last =
|
|
1370
|
+
last = pct2;
|
|
1283
1371
|
}
|
|
1284
1372
|
}
|
|
1285
1373
|
if (Date.now() - start > timeoutMs) {
|
|
@@ -1499,14 +1587,14 @@ async function renderInPage(opts) {
|
|
|
1499
1587
|
w.__error = String(e instanceof Error && e.stack || e);
|
|
1500
1588
|
}
|
|
1501
1589
|
}
|
|
1502
|
-
async function renderChunk(context, serverBase,
|
|
1503
|
-
const outName = `render-chunk-${
|
|
1590
|
+
async function renderChunk(context, serverBase, chunk2, opts, onChunkProgress) {
|
|
1591
|
+
const outName = `render-chunk-${chunk2.index}.tmp`;
|
|
1504
1592
|
const page = await context.newPage();
|
|
1505
1593
|
try {
|
|
1506
1594
|
page.on("console", (m) => {
|
|
1507
1595
|
if (m.type() === "error" || process.env.VOS_RENDER_DEBUG)
|
|
1508
1596
|
process.stderr.write(
|
|
1509
|
-
` [render page ${
|
|
1597
|
+
` [render page ${chunk2.index} ${m.type()}] ${m.text()}
|
|
1510
1598
|
`
|
|
1511
1599
|
);
|
|
1512
1600
|
});
|
|
@@ -1514,7 +1602,7 @@ async function renderChunk(context, serverBase, chunk, opts, onChunkProgress) {
|
|
|
1514
1602
|
page.on(
|
|
1515
1603
|
"pageerror",
|
|
1516
1604
|
(e) => process.stderr.write(
|
|
1517
|
-
` [render pageerror ${
|
|
1605
|
+
` [render pageerror ${chunk2.index}] ${String(e)}
|
|
1518
1606
|
`
|
|
1519
1607
|
)
|
|
1520
1608
|
);
|
|
@@ -1533,8 +1621,8 @@ async function renderChunk(context, serverBase, chunk, opts, onChunkProgress) {
|
|
|
1533
1621
|
W: opts.width,
|
|
1534
1622
|
H: opts.height,
|
|
1535
1623
|
fps: opts.fps,
|
|
1536
|
-
startFrame:
|
|
1537
|
-
endFrame:
|
|
1624
|
+
startFrame: chunk2.startFrame + frameOffset,
|
|
1625
|
+
endFrame: chunk2.endFrame + frameOffset,
|
|
1538
1626
|
format: opts.format,
|
|
1539
1627
|
bitrate: opts.bitrate ?? 1e7,
|
|
1540
1628
|
mediabunnyUrl: MEDIABUNNY_URL,
|
|
@@ -1545,10 +1633,10 @@ async function renderChunk(context, serverBase, chunk, opts, onChunkProgress) {
|
|
|
1545
1633
|
audioDuration: audio?.duration
|
|
1546
1634
|
}).catch(() => {
|
|
1547
1635
|
});
|
|
1548
|
-
const timeoutMs = 12e4 +
|
|
1636
|
+
const timeoutMs = 12e4 + chunk2.frameCount * 500;
|
|
1549
1637
|
await waitForPageDone(
|
|
1550
1638
|
page,
|
|
1551
|
-
`render chunk ${
|
|
1639
|
+
`render chunk ${chunk2.index}`,
|
|
1552
1640
|
onChunkProgress,
|
|
1553
1641
|
timeoutMs
|
|
1554
1642
|
);
|
|
@@ -1580,8 +1668,8 @@ async function renderAnimation(browser, opts) {
|
|
|
1580
1668
|
};
|
|
1581
1669
|
const outNames = await Promise.all(
|
|
1582
1670
|
chunks.map(
|
|
1583
|
-
(
|
|
1584
|
-
progress[
|
|
1671
|
+
(chunk2) => renderChunk(context, server.base, chunk2, effOpts, (fraction) => {
|
|
1672
|
+
progress[chunk2.index] = fraction;
|
|
1585
1673
|
reportProgress();
|
|
1586
1674
|
})
|
|
1587
1675
|
)
|
|
@@ -1596,9 +1684,9 @@ async function renderAnimation(browser, opts) {
|
|
|
1596
1684
|
return { bytes: new Uint8Array(files[0]), totalFrames, chunks: 1 };
|
|
1597
1685
|
}
|
|
1598
1686
|
const { bytes } = await concatEncodedVideo(
|
|
1599
|
-
chunks.map((
|
|
1687
|
+
chunks.map((chunk2, i) => ({
|
|
1600
1688
|
data: new Uint8Array(files[i]),
|
|
1601
|
-
duration:
|
|
1689
|
+
duration: chunk2.duration
|
|
1602
1690
|
})),
|
|
1603
1691
|
{ format: opts.format, frameRate: opts.fps }
|
|
1604
1692
|
);
|
|
@@ -2505,7 +2593,6 @@ async function writeIndexJson(result) {
|
|
|
2505
2593
|
|
|
2506
2594
|
// src/plugin/deliver.ts
|
|
2507
2595
|
import {
|
|
2508
|
-
copyFile,
|
|
2509
2596
|
mkdir as mkdir4,
|
|
2510
2597
|
mkdtemp,
|
|
2511
2598
|
rename as rename4,
|
|
@@ -2516,12 +2603,18 @@ import {
|
|
|
2516
2603
|
import { tmpdir } from "os";
|
|
2517
2604
|
import { join as join5, relative, resolve as resolve3 } from "path";
|
|
2518
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";
|
|
2519
2609
|
import {
|
|
2520
2610
|
CHANNEL_SPECS_VERIFIED,
|
|
2521
2611
|
DESTINATIONS,
|
|
2612
|
+
cardInset,
|
|
2522
2613
|
destinationsForChannel,
|
|
2523
|
-
|
|
2524
|
-
|
|
2614
|
+
houseLook,
|
|
2615
|
+
isLookKind,
|
|
2616
|
+
lookFromBrand,
|
|
2617
|
+
ratedSegments as ratedSegments5
|
|
2525
2618
|
} from "@vosjs/studio-core";
|
|
2526
2619
|
|
|
2527
2620
|
// src/plugin/renderTake.ts
|
|
@@ -2716,6 +2809,1336 @@ async function renderPosterStills(browser, config, serveDir, shots, time) {
|
|
|
2716
2809
|
}
|
|
2717
2810
|
}
|
|
2718
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), fonts };
|
|
3954
|
+
}
|
|
3955
|
+
function posterValues(brand, words) {
|
|
3956
|
+
const b = brand ?? {};
|
|
3957
|
+
const bgA = b.bgA && HEX.test(b.bgA) ? b.bgA : null;
|
|
3958
|
+
const bgB = b.bgB && HEX.test(b.bgB) ? b.bgB : bgA;
|
|
3959
|
+
const bgC = b.bgC && HEX.test(b.bgC) ? b.bgC : bgB;
|
|
3960
|
+
const ink = b.ink && HEX.test(b.ink) ? b.ink : null;
|
|
3961
|
+
const accent = b.accent && HEX.test(b.accent) ? b.accent : null;
|
|
3962
|
+
const wordmark = (words.brand ?? b.wordmark ?? b.name ?? "").trim();
|
|
3963
|
+
const release = (words.release ?? "").trim();
|
|
3964
|
+
const lightGround = bgA ? isLightHex(bgA) : false;
|
|
3965
|
+
const values = {};
|
|
3966
|
+
if (bgA) values.bgA = bgA;
|
|
3967
|
+
if (bgB) values.bgB = bgB;
|
|
3968
|
+
if (bgC) values.bgC = bgC;
|
|
3969
|
+
if (ink) values.ink = ink;
|
|
3970
|
+
if (accent) values.accent = accent;
|
|
3971
|
+
if (ink && bgA) values.inkSoft = mixHex(ink, bgA, 0.28);
|
|
3972
|
+
if (accent) values.streak = rgba(accent, lightGround ? 0.12 : 0.18);
|
|
3973
|
+
if (bgA) values.grain = lightGround ? 10 : 22;
|
|
3974
|
+
if (accent) values.blobA = rgba(accent, lightGround ? 0.28 : 0.4);
|
|
3975
|
+
if (bgC) values.blobB = rgba(bgC, 0.75);
|
|
3976
|
+
if (accent) values.blobC = rgba(mixHex(accent, "#ffffff", 0.55), lightGround ? 0.35 : 0.25);
|
|
3977
|
+
if (wordmark) values.brand = wordmark;
|
|
3978
|
+
const headline = (words.headline ?? "").trim();
|
|
3979
|
+
if (headline) values.headline = headline;
|
|
3980
|
+
const kicker = (words.kicker ?? "").trim();
|
|
3981
|
+
values.kicker = kicker ? kicker : [wordmark, release].filter(Boolean).join(" ").toUpperCase();
|
|
3982
|
+
const fonts = [];
|
|
3983
|
+
const display = resolveFace(b.fontDisplay, [600, 700]);
|
|
3984
|
+
if (display) {
|
|
3985
|
+
values.fontDisplay = display.stack;
|
|
3986
|
+
fonts.push(...display.fonts);
|
|
3987
|
+
}
|
|
3988
|
+
const body = resolveFace(b.fontBody, [700]);
|
|
3989
|
+
if (body) {
|
|
3990
|
+
values.fontBody = body.stack;
|
|
3991
|
+
fonts.push(...body.fonts);
|
|
3992
|
+
}
|
|
3993
|
+
return { values, fonts, lightGround };
|
|
3994
|
+
}
|
|
3995
|
+
|
|
3996
|
+
// src/plugin/motionPlan.ts
|
|
3997
|
+
import { ratedSegments as ratedSegments4, spanOutputExtent as spanOutputExtent4 } from "@vosjs/studio-core";
|
|
3998
|
+
var SOUND_DESTINATIONS = /* @__PURE__ */ new Set([
|
|
3999
|
+
"x-feed-cut",
|
|
4000
|
+
"youtube-main-demo",
|
|
4001
|
+
"shorts-linkedin-vertical-cut"
|
|
4002
|
+
]);
|
|
4003
|
+
var LOOP_DESTINATIONS = /* @__PURE__ */ new Set(["github-readme-loop"]);
|
|
4004
|
+
var off = (v) => v !== void 0 && /^(none|off|false|no)$/i.test(v.trim());
|
|
4005
|
+
function pickTrack(catalog, ask) {
|
|
4006
|
+
if (!catalog || !ask || off(ask)) return null;
|
|
4007
|
+
const want = ask.trim().toLowerCase();
|
|
4008
|
+
return catalog.tracks.find((t) => t.slug.toLowerCase() === want) ?? catalog.tracks.find((t) => (t.mood ?? "").toLowerCase() === want) ?? null;
|
|
4009
|
+
}
|
|
4010
|
+
function clickTimes(doc, range) {
|
|
4011
|
+
const rated = ratedSegments4(doc);
|
|
4012
|
+
const out = [];
|
|
4013
|
+
for (const e of doc.source.cursor) {
|
|
4014
|
+
if (e.type !== "down") continue;
|
|
4015
|
+
const src = e.t / 1e3;
|
|
4016
|
+
const ext = spanOutputExtent4(rated, src, src + 1e-3);
|
|
4017
|
+
if (!ext) continue;
|
|
4018
|
+
const t = ext.start;
|
|
4019
|
+
if (t < range[0] || t > range[1]) continue;
|
|
4020
|
+
if (out.length && t - out[out.length - 1] < 0.12) continue;
|
|
4021
|
+
out.push(+(t - range[0]).toFixed(3));
|
|
4022
|
+
}
|
|
4023
|
+
return out;
|
|
4024
|
+
}
|
|
4025
|
+
function planMotion(input) {
|
|
4026
|
+
const { destination: d, doc, range, words, launch, catalog } = input;
|
|
4027
|
+
const set = [];
|
|
4028
|
+
const notes = [];
|
|
4029
|
+
const skipped = [];
|
|
4030
|
+
if (d.kind !== "video") return { set, notes, skipped };
|
|
4031
|
+
const loop = LOOP_DESTINATIONS.has(d.id);
|
|
4032
|
+
const sound = SOUND_DESTINATIONS.has(d.id);
|
|
4033
|
+
const length = range[1] - range[0];
|
|
4034
|
+
const portrait = d.px.w / d.px.h < 0.9;
|
|
4035
|
+
const entrance = launch.entrance;
|
|
4036
|
+
if (!loop && !off(entrance)) {
|
|
4037
|
+
const kind = entrance && /^(tilt-in|pull-out|rise)$/.test(entrance.trim()) ? entrance.trim() : "tilt-in";
|
|
4038
|
+
set.push(`frame.entrance={"kind":"${kind}"}`);
|
|
4039
|
+
notes.push(`entrance ${kind}`);
|
|
4040
|
+
}
|
|
4041
|
+
if (!loop && !off(launch.endCard)) {
|
|
4042
|
+
const headline = (words.headline ?? "").trim();
|
|
4043
|
+
const brand = (words.brand ?? "").trim();
|
|
4044
|
+
const sub = [brand, (words.release ?? "").trim()].filter(Boolean).join(" ");
|
|
4045
|
+
if (headline || brand) {
|
|
4046
|
+
const card = { seconds: 2.5 };
|
|
4047
|
+
if (input.ink) card.ink = input.ink;
|
|
4048
|
+
if (headline) card.headline = headline;
|
|
4049
|
+
if (sub && sub !== headline) card.sub = sub;
|
|
4050
|
+
if (brand) card.wordmark = brand;
|
|
4051
|
+
set.push(`endCard=${JSON.stringify(card)}`);
|
|
4052
|
+
notes.push("end card");
|
|
4053
|
+
} else {
|
|
4054
|
+
skipped.push(`${d.id}: no end card (no headline or wordmark in LAUNCH.md, BRAND.md or the flags)`);
|
|
4055
|
+
}
|
|
4056
|
+
}
|
|
4057
|
+
if (!loop && d.text !== "none" && input.captions.length && !off(launch.captions)) {
|
|
4058
|
+
const rated = ratedSegments4(doc);
|
|
4059
|
+
const steps = doc.source.meta.steps ?? [];
|
|
4060
|
+
const clips = [];
|
|
4061
|
+
for (const c of input.captions) {
|
|
4062
|
+
const step = steps.find((s) => c.id !== void 0 && s.id === c.id || s.step === c.step);
|
|
4063
|
+
if (!step || step.skipped) continue;
|
|
4064
|
+
const t = stepOutputTime(rated, step, 0.2);
|
|
4065
|
+
if (t === null || t < range[0] || t > range[1] - 1) continue;
|
|
4066
|
+
const start = +(t - range[0]).toFixed(3);
|
|
4067
|
+
clips.push({
|
|
4068
|
+
id: `caption-${c.step}`,
|
|
4069
|
+
kind: "text",
|
|
4070
|
+
text: c.caption,
|
|
4071
|
+
preset: "caption",
|
|
4072
|
+
start,
|
|
4073
|
+
duration: Math.min(3.5, Math.max(2.5, range[1] - t - 0.2)),
|
|
4074
|
+
transform: { x: 0.5, y: portrait ? 0.8 : 0.86, scale: 1, rotation: 0 },
|
|
4075
|
+
enter: "rise",
|
|
4076
|
+
exit: "fade",
|
|
4077
|
+
align: "center",
|
|
4078
|
+
box: { color: "rgba(17,17,17,0.72)" }
|
|
4079
|
+
});
|
|
4080
|
+
}
|
|
4081
|
+
if (clips.length) {
|
|
4082
|
+
const existing = Array.isArray(doc.overlays) ? doc.overlays : [];
|
|
4083
|
+
set.push(`overlays=${JSON.stringify([...existing, ...clips])}`);
|
|
4084
|
+
notes.push(`${clips.length} caption(s)`);
|
|
4085
|
+
}
|
|
4086
|
+
}
|
|
4087
|
+
if (sound && !loop) {
|
|
4088
|
+
const track = pickTrack(catalog, launch.music);
|
|
4089
|
+
const clips = Array.isArray(doc.audio) ? [...doc.audio] : [];
|
|
4090
|
+
if (track) {
|
|
4091
|
+
const hasMic = !!doc.source.micKey;
|
|
4092
|
+
const fadeOut = Math.min(2.5, length * 0.15);
|
|
4093
|
+
clips.push({
|
|
4094
|
+
id: "bed",
|
|
4095
|
+
key: track.url,
|
|
4096
|
+
name: track.title,
|
|
4097
|
+
start: 0,
|
|
4098
|
+
in: 0,
|
|
4099
|
+
out: Math.min(track.duration, length),
|
|
4100
|
+
duration: track.duration,
|
|
4101
|
+
gain: hasMic ? 0.35 : 0.5,
|
|
4102
|
+
fadeIn: 0.6,
|
|
4103
|
+
fadeOut,
|
|
4104
|
+
loop: track.duration < length,
|
|
4105
|
+
loopLen: track.duration < length ? length : void 0,
|
|
4106
|
+
duck: hasMic
|
|
4107
|
+
});
|
|
4108
|
+
notes.push(`bed ${track.slug}`);
|
|
4109
|
+
} else if (launch.music && !off(launch.music)) {
|
|
4110
|
+
skipped.push(`${d.id}: music "${launch.music}" is not a catalog track or mood${catalog ? "" : " (the catalog could not be read)"}`);
|
|
4111
|
+
}
|
|
4112
|
+
const click = catalog?.sfx.find((s) => s.slug === "sfx-click");
|
|
4113
|
+
if (click && !doc.source.micKey && !off(launch.clicks)) {
|
|
4114
|
+
const times = clickTimes(doc, range);
|
|
4115
|
+
for (const [i, t] of times.entries()) {
|
|
4116
|
+
clips.push({
|
|
4117
|
+
id: `click-${i}`,
|
|
4118
|
+
key: click.url,
|
|
4119
|
+
name: click.title,
|
|
4120
|
+
start: t,
|
|
4121
|
+
in: 0,
|
|
4122
|
+
out: click.duration,
|
|
4123
|
+
duration: click.duration,
|
|
4124
|
+
gain: 0.4,
|
|
4125
|
+
fadeIn: 0,
|
|
4126
|
+
fadeOut: 0
|
|
4127
|
+
});
|
|
4128
|
+
}
|
|
4129
|
+
if (times.length) notes.push(`${times.length} click sound(s)`);
|
|
4130
|
+
}
|
|
4131
|
+
if (clips.length) set.push(`audio=${JSON.stringify(clips)}`);
|
|
4132
|
+
}
|
|
4133
|
+
if (portrait) {
|
|
4134
|
+
set.push("frame.fit=cover");
|
|
4135
|
+
set.push('frame.inset={"left":0.06,"right":0.06,"top":0.17,"bottom":0.17}');
|
|
4136
|
+
set.push("frame.focusFollow=camera");
|
|
4137
|
+
notes.push("vertical reframe follows the camera");
|
|
4138
|
+
}
|
|
4139
|
+
return { set, notes, skipped };
|
|
4140
|
+
}
|
|
4141
|
+
|
|
2719
4142
|
// src/plugin/deliver.ts
|
|
2720
4143
|
var CHANNEL_ALIASES = {
|
|
2721
4144
|
ph: "producthunt",
|
|
@@ -2743,20 +4166,153 @@ function resolveChannels(raw) {
|
|
|
2743
4166
|
}
|
|
2744
4167
|
return out;
|
|
2745
4168
|
}
|
|
2746
|
-
function
|
|
2747
|
-
const
|
|
2748
|
-
const
|
|
2749
|
-
|
|
2750
|
-
const
|
|
2751
|
-
|
|
4169
|
+
async function readBrandBesideTake(dir, explicit) {
|
|
4170
|
+
const candidates = explicit ? [explicit] : [join5(dir, "BRAND.md"), join5(dir, "..", "BRAND.md")];
|
|
4171
|
+
for (const file of candidates) {
|
|
4172
|
+
if (!existsSync5(file)) continue;
|
|
4173
|
+
const roles = parseFrontmatter(await readFile5(file, "utf8"));
|
|
4174
|
+
return { file, roles };
|
|
4175
|
+
}
|
|
4176
|
+
return null;
|
|
4177
|
+
}
|
|
4178
|
+
async function readLaunchBesideTake(dir, explicit) {
|
|
4179
|
+
const candidates = explicit ? [explicit] : [join5(dir, "LAUNCH.md"), join5(dir, "..", "LAUNCH.md")];
|
|
4180
|
+
for (const file of candidates) {
|
|
4181
|
+
if (!existsSync5(file)) continue;
|
|
4182
|
+
return { file, roles: parseFrontmatter(await readFile5(file, "utf8")) };
|
|
4183
|
+
}
|
|
4184
|
+
return null;
|
|
4185
|
+
}
|
|
4186
|
+
async function resolveLook(dir, opts) {
|
|
4187
|
+
const brand = await readBrandBesideTake(dir, opts.brand);
|
|
4188
|
+
const roles = brand?.roles ?? null;
|
|
4189
|
+
if (opts.look === "none") return { look: null, from: "--look none", roles };
|
|
4190
|
+
if (opts.look !== void 0) {
|
|
4191
|
+
if (!isLookKind(opts.look))
|
|
4192
|
+
throw new Error(
|
|
4193
|
+
`--look "${opts.look}" \u2014 one of plate | gradient | dark | none`
|
|
4194
|
+
);
|
|
4195
|
+
return { look: houseLook(opts.look), from: `--look ${opts.look}`, roles };
|
|
4196
|
+
}
|
|
4197
|
+
if (brand) {
|
|
4198
|
+
const look = lookFromBrand(brand.roles);
|
|
4199
|
+
return { look, from: `${brand.file} (${look.kind})`, roles };
|
|
2752
4200
|
}
|
|
2753
|
-
const dropped = doc.zoom.length - apexes.length;
|
|
2754
|
-
if (apexes.length) return { times: apexes.sort((a, b) => a - b), dropped };
|
|
2755
4201
|
return {
|
|
2756
|
-
|
|
2757
|
-
|
|
4202
|
+
look: houseLook("gradient"),
|
|
4203
|
+
from: "the house gradient (no BRAND.md beside the take)",
|
|
4204
|
+
roles
|
|
2758
4205
|
};
|
|
2759
4206
|
}
|
|
4207
|
+
function templateForCard(d, opts) {
|
|
4208
|
+
if (opts.poster === null) return null;
|
|
4209
|
+
if (opts.poster) return { config: opts.poster.config, from: opts.poster.from };
|
|
4210
|
+
if (!d.template) return null;
|
|
4211
|
+
let name = d.template;
|
|
4212
|
+
let note;
|
|
4213
|
+
const wants = templateOf(templateByName(name) ?? {});
|
|
4214
|
+
const needsHeadline = wants?.text.some((t) => t.role === "headline");
|
|
4215
|
+
if (needsHeadline && !opts.words?.headline?.trim()) {
|
|
4216
|
+
name = "card-on-gradient";
|
|
4217
|
+
note = `${d.id}: no headline (LAUNCH.md headline: or --headline), so the ${d.template} template stands down for card-on-gradient`;
|
|
4218
|
+
}
|
|
4219
|
+
const config = templateByName(name);
|
|
4220
|
+
if (!config) return null;
|
|
4221
|
+
return { config, from: `template ${name}`, note };
|
|
4222
|
+
}
|
|
4223
|
+
function lookOverrides(look, placement, size, video, opts) {
|
|
4224
|
+
const inset = cardInset(look, size, video, placement);
|
|
4225
|
+
const set = [
|
|
4226
|
+
"frame.fit=contain",
|
|
4227
|
+
`frame.background=${JSON.stringify(look.ground)}`,
|
|
4228
|
+
`frame.inset=${JSON.stringify(inset)}`,
|
|
4229
|
+
`frame.radius=${look.radius}`,
|
|
4230
|
+
`frame.shadow=${look.shadow}`,
|
|
4231
|
+
`frame.shadowContact=${look.shadowContact}`,
|
|
4232
|
+
`frame.border=${look.border}`
|
|
4233
|
+
];
|
|
4234
|
+
if (look.shadowColor) set.push(`frame.shadowColor=${look.shadowColor}`);
|
|
4235
|
+
if (look.border > 0) {
|
|
4236
|
+
set.push("frame.borderWidth=1");
|
|
4237
|
+
set.push(`frame.borderColor=${look.borderColor ?? "#000000"}`);
|
|
4238
|
+
}
|
|
4239
|
+
if (!opts.keepMedia) set.push("frame.backgroundMedia=null");
|
|
4240
|
+
if (opts.still) {
|
|
4241
|
+
set.push("zoom=[]", "tilt=[]", "cursor.visible=false", "cursor.clickFx.style=none");
|
|
4242
|
+
}
|
|
4243
|
+
return set;
|
|
4244
|
+
}
|
|
4245
|
+
function endCardInk(look, brand) {
|
|
4246
|
+
if (!look) return null;
|
|
4247
|
+
const ground = look.ground;
|
|
4248
|
+
const m = /#([0-9a-f]{6})/i.exec(ground);
|
|
4249
|
+
const hex2 = m ? m[0] : null;
|
|
4250
|
+
const light = look.kind === "plate" || (hex2 ? isLightHexGround(hex2) : look.kind === "gradient");
|
|
4251
|
+
if (!light) return "#ffffff";
|
|
4252
|
+
const ink = brand?.ink;
|
|
4253
|
+
return ink && /^#[0-9a-f]{6}$/i.test(ink) ? ink : "#111111";
|
|
4254
|
+
}
|
|
4255
|
+
function isLightHexGround(hex2) {
|
|
4256
|
+
const n = parseInt(hex2.slice(1), 16);
|
|
4257
|
+
const r = n >> 16 & 255;
|
|
4258
|
+
const g = n >> 8 & 255;
|
|
4259
|
+
const b = n & 255;
|
|
4260
|
+
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 >= 0.6;
|
|
4261
|
+
}
|
|
4262
|
+
var PROBE_WIDTH = 640;
|
|
4263
|
+
async function pickStillTimes(browser, dir, doc, duration, opts) {
|
|
4264
|
+
const { candidates, dropped } = momentCandidates(doc, duration);
|
|
4265
|
+
const notes = [];
|
|
4266
|
+
if (dropped > 0)
|
|
4267
|
+
notes.push(
|
|
4268
|
+
`${dropped} of ${doc.zoom.length} zoom apex(es) fall outside the cut`
|
|
4269
|
+
);
|
|
4270
|
+
if (!candidates.length) return { times: [], moments: [], notes };
|
|
4271
|
+
const meta = doc.source.meta;
|
|
4272
|
+
const vw = meta.captureWidth ?? meta.width;
|
|
4273
|
+
const vh = meta.captureHeight ?? meta.height;
|
|
4274
|
+
const probeDir = await mkdtemp(join5(tmpdir(), "vos-moments-"));
|
|
4275
|
+
try {
|
|
4276
|
+
const probe = await framesTake(browser, dir, {
|
|
4277
|
+
times: candidates.map((c) => c.time),
|
|
4278
|
+
width: PROBE_WIDTH,
|
|
4279
|
+
height: Math.max(2, Math.round(PROBE_WIDTH * vh / vw / 2) * 2),
|
|
4280
|
+
outDir: probeDir,
|
|
4281
|
+
overrides: {
|
|
4282
|
+
...opts.overrides,
|
|
4283
|
+
set: [...SCREENSHOT_DEFAULTS, ...opts.overrides?.set ?? []]
|
|
4284
|
+
}
|
|
4285
|
+
});
|
|
4286
|
+
const byTime = /* @__PURE__ */ new Map();
|
|
4287
|
+
for (const frame of probe.frames) {
|
|
4288
|
+
const img = decodePng(new Uint8Array(await readFile5(frame.file)));
|
|
4289
|
+
if (!img) continue;
|
|
4290
|
+
const whole = { x: 0, y: 0, w: img.w, h: img.h };
|
|
4291
|
+
byTime.set(+frame.time.toFixed(3), {
|
|
4292
|
+
ink: inkCoverage(img, whole),
|
|
4293
|
+
hash: differenceHash(img, whole)
|
|
4294
|
+
});
|
|
4295
|
+
}
|
|
4296
|
+
const measured = [];
|
|
4297
|
+
for (const c of candidates) {
|
|
4298
|
+
const m = byTime.get(+c.time.toFixed(3));
|
|
4299
|
+
if (m) measured.push({ time: c.time, ...m });
|
|
4300
|
+
}
|
|
4301
|
+
const pick = pickMoments(measured);
|
|
4302
|
+
const kept = candidates.filter((c) => pick.times.includes(c.time));
|
|
4303
|
+
const bySource = (s) => candidates.filter((c) => c.source === s).length;
|
|
4304
|
+
notes.push(
|
|
4305
|
+
`${candidates.length} candidate(s): ${bySource("step")} from steps, ${bySource("zoom")} from zoom apexes, ${bySource("spread")} from the spread; ${kept.length} kept`
|
|
4306
|
+
);
|
|
4307
|
+
return {
|
|
4308
|
+
times: pick.times,
|
|
4309
|
+
moments: kept,
|
|
4310
|
+
notes: [...notes, ...pick.dropped.map((d) => `moment: ${d}`)]
|
|
4311
|
+
};
|
|
4312
|
+
} finally {
|
|
4313
|
+
await rm3(probeDir, { recursive: true, force: true });
|
|
4314
|
+
}
|
|
4315
|
+
}
|
|
2760
4316
|
var overCeiling = (bytes, maxBytes) => `${Math.ceil(bytes / 1024)} KB exceeds the ${Math.floor(maxBytes / 1024)} KB ceiling`;
|
|
2761
4317
|
var specWords = (d) => {
|
|
2762
4318
|
const parts = [`${d.px.w}x${d.px.h}`];
|
|
@@ -2776,12 +4332,22 @@ var FULL_BLEED = [
|
|
|
2776
4332
|
"cursor.clickFx.style=none"
|
|
2777
4333
|
];
|
|
2778
4334
|
var SCREENSHOT_DEFAULTS = [...FULL_BLEED, "zoom=[]", "tilt=[]"];
|
|
2779
|
-
function stillOverridesFor(d, opts) {
|
|
4335
|
+
function stillOverridesFor(d, opts, video) {
|
|
2780
4336
|
const set = [];
|
|
2781
|
-
if (d.
|
|
2782
|
-
|
|
4337
|
+
if (d.genre === "screenshot" && !opts.composed) {
|
|
4338
|
+
if (d.fit === "cover") set.push("frame.fit=cover");
|
|
2783
4339
|
set.push(...SCREENSHOT_DEFAULTS);
|
|
2784
|
-
else if (d.genre === "card" && !opts.composed
|
|
4340
|
+
} else if (d.genre === "card" && !opts.composed && opts.look && video && d.px) {
|
|
4341
|
+
set.push(
|
|
4342
|
+
...lookOverrides(opts.look, "card", d.px, video, {
|
|
4343
|
+
still: true,
|
|
4344
|
+
keepMedia: opts.overrides?.background !== void 0
|
|
4345
|
+
})
|
|
4346
|
+
);
|
|
4347
|
+
} else {
|
|
4348
|
+
if (d.fit === "cover") set.push("frame.fit=cover");
|
|
4349
|
+
if (d.genre === "card" && !opts.composed) set.push(...FULL_BLEED);
|
|
4350
|
+
}
|
|
2785
4351
|
set.push(...opts.overrides?.set ?? []);
|
|
2786
4352
|
if (!set.length) return opts.overrides;
|
|
2787
4353
|
return { ...opts.overrides, set };
|
|
@@ -2790,34 +4356,69 @@ async function deliverTake(browser, dir, opts) {
|
|
|
2790
4356
|
const take = await loadTake(dir);
|
|
2791
4357
|
if (!take.doc) throw new Error(`${dir} has no doc.json \u2014 run plan first`);
|
|
2792
4358
|
const doc = take.doc;
|
|
2793
|
-
const duration = totalDuration(
|
|
4359
|
+
const duration = totalDuration(ratedSegments5(doc));
|
|
2794
4360
|
const videoSeconds = opts.range ? Math.min(opts.range[1], duration) - Math.min(opts.range[0], duration) : duration;
|
|
4361
|
+
const outDir = resolve3(opts.outDir ?? join5(dir, "kit"));
|
|
4362
|
+
await mkdir4(outDir, { recursive: true });
|
|
4363
|
+
const destinations = opts.channels.flatMap((c) => destinationsForChannel(c));
|
|
4364
|
+
const assets = [];
|
|
4365
|
+
const skipped = [];
|
|
2795
4366
|
let stillTimes;
|
|
4367
|
+
let moments;
|
|
2796
4368
|
if (opts.times?.length) {
|
|
2797
4369
|
stillTimes = opts.times;
|
|
4370
|
+
moments = opts.times.map((time) => ({ time, source: "times" }));
|
|
2798
4371
|
} else {
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
4372
|
+
opts.onPhase?.("moments (the step timeline, the zoom apexes, the spread)");
|
|
4373
|
+
const picked = await pickStillTimes(browser, dir, doc, duration, opts);
|
|
4374
|
+
stillTimes = picked.times;
|
|
4375
|
+
moments = picked.moments;
|
|
4376
|
+
for (const n of picked.notes) {
|
|
4377
|
+
if (n.startsWith("moment: ")) skipped.push(n);
|
|
4378
|
+
else opts.onPhase?.(`note: ${n}`);
|
|
4379
|
+
}
|
|
4380
|
+
}
|
|
4381
|
+
const meta0 = doc.source.meta;
|
|
4382
|
+
const video = {
|
|
4383
|
+
w: meta0.captureWidth ?? meta0.width,
|
|
4384
|
+
h: meta0.captureHeight ?? meta0.height
|
|
4385
|
+
};
|
|
4386
|
+
const videoOverrides = (d, range) => {
|
|
4387
|
+
const set = [];
|
|
4388
|
+
if (opts.look) {
|
|
4389
|
+
set.push(
|
|
4390
|
+
...lookOverrides(opts.look, "hero", d.px, video, {
|
|
4391
|
+
still: false,
|
|
4392
|
+
keepMedia: opts.overrides?.background !== void 0
|
|
4393
|
+
})
|
|
2804
4394
|
);
|
|
2805
4395
|
}
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
4396
|
+
const plan = planMotion({
|
|
4397
|
+
destination: d,
|
|
4398
|
+
doc,
|
|
4399
|
+
range: range ?? [0, duration],
|
|
4400
|
+
words: opts.words ?? {},
|
|
4401
|
+
launch: opts.launchRoles ?? {},
|
|
4402
|
+
captions: opts.captions ?? [],
|
|
4403
|
+
catalog: opts.catalog ?? null,
|
|
4404
|
+
ink: endCardInk(opts.look, opts.brandRoles)
|
|
4405
|
+
});
|
|
4406
|
+
set.push(...plan.set);
|
|
4407
|
+
if (plan.notes.length) opts.onPhase?.(`${d.id}: ${plan.notes.join(", ")}`);
|
|
4408
|
+
for (const s of plan.skipped) skipped.push(`note: ${s}`);
|
|
4409
|
+
set.push(...opts.overrides?.set ?? []);
|
|
4410
|
+
if (!set.length) return opts.overrides;
|
|
4411
|
+
return { ...opts.overrides, set };
|
|
4412
|
+
};
|
|
4413
|
+
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);
|
|
4414
|
+
const posterCardIds = new Set(cardPlans.map((p) => p.d.id));
|
|
4415
|
+
for (const p of cardPlans) if (p.plan.note) skipped.push(`note: ${p.plan.note}`);
|
|
4416
|
+
if (cardPlans.length) {
|
|
2817
4417
|
const meta = doc.source.meta;
|
|
2818
4418
|
const heroTime = opts.shotTime ?? (stillTimes.length ? stillTimes[0] : duration / 2);
|
|
4419
|
+
const fill = posterValues(opts.brandRoles, opts.words ?? {});
|
|
2819
4420
|
opts.onPhase?.(
|
|
2820
|
-
`poster shot (full bleed at ${heroTime.toFixed(2)}s)
|
|
4421
|
+
`poster shot (full bleed at ${heroTime.toFixed(2)}s), baked as an object`
|
|
2821
4422
|
);
|
|
2822
4423
|
const serveDir = await mkdtemp(join5(tmpdir(), "vos-poster-"));
|
|
2823
4424
|
try {
|
|
@@ -2835,71 +4436,88 @@ async function deliverTake(browser, dir, opts) {
|
|
|
2835
4436
|
"frame.shadow=0",
|
|
2836
4437
|
"frame.border=0",
|
|
2837
4438
|
"frame.browserBar.kind=none",
|
|
4439
|
+
"cursor.visible=false",
|
|
4440
|
+
"cursor.clickFx.style=none",
|
|
2838
4441
|
...opts.overrides?.set ?? []
|
|
2839
4442
|
]
|
|
2840
4443
|
}
|
|
2841
4444
|
});
|
|
2842
|
-
await
|
|
2843
|
-
|
|
2844
|
-
const
|
|
2845
|
-
const
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
);
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
);
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
4445
|
+
const raw = decodePng(new Uint8Array(await readFile5(shot.frames[0].file)));
|
|
4446
|
+
if (!raw) throw new Error("the poster shot could not be decoded");
|
|
4447
|
+
const PAD = 0.06;
|
|
4448
|
+
const baked = bakeShot(raw, {
|
|
4449
|
+
margin: PAD,
|
|
4450
|
+
hairline: fill.lightGround ? 0.14 : 0,
|
|
4451
|
+
shadow: fill.lightGround ? 0.32 : 0.45
|
|
4452
|
+
});
|
|
4453
|
+
await writeFile6(join5(serveDir, "shot.png"), encodePng(baked));
|
|
4454
|
+
const shotAspect = raw.w / raw.h;
|
|
4455
|
+
for (const { d, plan } of cardPlans) {
|
|
4456
|
+
const problems = templateProblems(plan.config);
|
|
4457
|
+
if (problems.length) {
|
|
4458
|
+
skipped.push(
|
|
4459
|
+
`${d.channel} ${d.asset}: ${plan.from} is not a valid template (${problems[0]}) \u2014 kept from the take`
|
|
4460
|
+
);
|
|
4461
|
+
posterCardIds.delete(d.id);
|
|
4462
|
+
continue;
|
|
4463
|
+
}
|
|
4464
|
+
const filled = fillTemplate(plan.config, {
|
|
4465
|
+
size: d.px,
|
|
4466
|
+
slots: { shot: { src: "/shot.png", aspect: shotAspect, pad: PAD } },
|
|
4467
|
+
values: fill.values
|
|
4468
|
+
});
|
|
4469
|
+
const limits = textLimitProblems(templateOf(plan.config), fill.values);
|
|
4470
|
+
for (const l of limits) skipped.push(`note: ${d.id}: ${l}`);
|
|
4471
|
+
if (filled.missing.length) {
|
|
4472
|
+
skipped.push(
|
|
4473
|
+
`${d.channel} ${d.asset}: ${plan.from} needs ${filled.missing.join(", ")} \u2014 kept from the take`
|
|
4474
|
+
);
|
|
4475
|
+
posterCardIds.delete(d.id);
|
|
4476
|
+
continue;
|
|
4477
|
+
}
|
|
4478
|
+
const config = filled.config;
|
|
4479
|
+
if (fill.fonts.length) {
|
|
4480
|
+
const declared = Array.isArray(config.fonts) ? config.fonts : [];
|
|
4481
|
+
config.fonts = [...declared, ...fill.fonts];
|
|
4482
|
+
}
|
|
2860
4483
|
const posterDuration = typeof config.duration === "number" ? config.duration : 6;
|
|
2861
4484
|
const time = Math.min(
|
|
2862
4485
|
opts.posterTime ?? posterDuration * 0.9,
|
|
2863
4486
|
Math.max(0, posterDuration - 0.05)
|
|
2864
4487
|
);
|
|
2865
|
-
opts.onPhase?.(
|
|
2866
|
-
`poster cards (${posterCards.map((d) => d.id).join(", ")}) at ${time.toFixed(2)}s`
|
|
2867
|
-
);
|
|
4488
|
+
opts.onPhase?.(`${d.channel} ${d.asset} (${specWords(d)}) from ${plan.from}, ${filled.aspect}`);
|
|
2868
4489
|
await renderPosterStills(
|
|
2869
4490
|
browser,
|
|
2870
4491
|
config,
|
|
2871
4492
|
serveDir,
|
|
2872
|
-
|
|
2873
|
-
name: `${d.id}.png`,
|
|
2874
|
-
width: d.px.w,
|
|
2875
|
-
height: d.px.h
|
|
2876
|
-
})),
|
|
4493
|
+
[{ name: `${d.id}.png`, width: d.px.w, height: d.px.h }],
|
|
2877
4494
|
time
|
|
2878
4495
|
);
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
continue;
|
|
2889
|
-
}
|
|
2890
|
-
assets.push({
|
|
2891
|
-
channel: d.channel,
|
|
2892
|
-
asset: d.asset,
|
|
2893
|
-
destination: d.id,
|
|
2894
|
-
path: relative(outDir, to),
|
|
2895
|
-
w: d.px.w,
|
|
2896
|
-
h: d.px.h,
|
|
2897
|
-
bytes,
|
|
2898
|
-
seconds: null,
|
|
2899
|
-
frameTime: null,
|
|
2900
|
-
source: "poster"
|
|
2901
|
-
});
|
|
4496
|
+
const from = join5(serveDir, `${d.id}.png`);
|
|
4497
|
+
const to = join5(outDir, `${d.id}.png`);
|
|
4498
|
+
await rename4(from, to);
|
|
4499
|
+
const bytes = (await stat(to)).size;
|
|
4500
|
+
if (d.maxBytes !== void 0 && bytes > d.maxBytes) {
|
|
4501
|
+
skipped.push(
|
|
4502
|
+
`${d.channel} ${d.asset}: ${overCeiling(bytes, d.maxBytes)} (kept at ${to})`
|
|
4503
|
+
);
|
|
4504
|
+
continue;
|
|
2902
4505
|
}
|
|
4506
|
+
assets.push({
|
|
4507
|
+
channel: d.channel,
|
|
4508
|
+
asset: d.asset,
|
|
4509
|
+
destination: d.id,
|
|
4510
|
+
path: relative(outDir, to),
|
|
4511
|
+
w: d.px.w,
|
|
4512
|
+
h: d.px.h,
|
|
4513
|
+
bytes,
|
|
4514
|
+
seconds: null,
|
|
4515
|
+
frameTime: heroTime,
|
|
4516
|
+
source: "poster",
|
|
4517
|
+
template: templateOf(plan.config)?.family ?? plan.from,
|
|
4518
|
+
text: filled.text,
|
|
4519
|
+
shot: filled.slots.shot
|
|
4520
|
+
});
|
|
2903
4521
|
}
|
|
2904
4522
|
} finally {
|
|
2905
4523
|
await rm3(serveDir, { recursive: true, force: true });
|
|
@@ -2920,26 +4538,36 @@ async function deliverTake(browser, dir, opts) {
|
|
|
2920
4538
|
);
|
|
2921
4539
|
continue;
|
|
2922
4540
|
}
|
|
4541
|
+
let range = opts.range;
|
|
4542
|
+
let seconds = videoSeconds;
|
|
2923
4543
|
if (d.maxSeconds !== void 0 && videoSeconds > d.maxSeconds) {
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
4544
|
+
if (LOOP_DESTINATIONS.has(d.id) && !opts.range) {
|
|
4545
|
+
range = [0, d.maxSeconds];
|
|
4546
|
+
seconds = d.maxSeconds;
|
|
4547
|
+
opts.onPhase?.(
|
|
4548
|
+
`note: ${label} takes the first ${d.maxSeconds}s of the ${videoSeconds.toFixed(0)}s take (a loop's cap)`
|
|
4549
|
+
);
|
|
4550
|
+
} else {
|
|
4551
|
+
skipped.push(
|
|
4552
|
+
`${label}: spec caps at ${d.maxSeconds}s, the take is ${videoSeconds.toFixed(0)}s \u2014 cut it (--range, or trim segments in doc.json)`
|
|
4553
|
+
);
|
|
4554
|
+
continue;
|
|
4555
|
+
}
|
|
2928
4556
|
}
|
|
2929
4557
|
opts.onPhase?.(`${label} (${specWords(d)})`);
|
|
2930
4558
|
const outFile = join5(outDir, `${d.id}.${d.format}`);
|
|
2931
4559
|
const bitrate = d.maxBytes !== void 0 ? Math.min(
|
|
2932
4560
|
1e7,
|
|
2933
|
-
Math.floor(d.maxBytes * 8 /
|
|
4561
|
+
Math.floor(d.maxBytes * 8 / seconds * 0.85)
|
|
2934
4562
|
) : void 0;
|
|
2935
4563
|
const result = await renderTake(browser, dir, outFile, {
|
|
2936
4564
|
width: d.px.w,
|
|
2937
4565
|
height: d.px.h,
|
|
2938
4566
|
format: "mp4",
|
|
2939
4567
|
parallel: opts.parallel,
|
|
2940
|
-
range
|
|
4568
|
+
range,
|
|
2941
4569
|
bitrate,
|
|
2942
|
-
overrides:
|
|
4570
|
+
overrides: videoOverrides(d, range),
|
|
2943
4571
|
onProgress: opts.onProgress
|
|
2944
4572
|
});
|
|
2945
4573
|
if (d.maxBytes !== void 0 && result.bytes > d.maxBytes) {
|
|
@@ -2963,7 +4591,7 @@ async function deliverTake(browser, dir, opts) {
|
|
|
2963
4591
|
}
|
|
2964
4592
|
const wanted = d.kind === "still-set" ? stillTimes.slice(0, d.count?.max ?? stillTimes.length) : stillTimes.slice(0, 1);
|
|
2965
4593
|
opts.onPhase?.(`${label} (${specWords(d)}, ${wanted.length} still(s))`);
|
|
2966
|
-
const overrides = stillOverridesFor(d, opts);
|
|
4594
|
+
const overrides = stillOverridesFor(d, opts, video);
|
|
2967
4595
|
const captured = await framesTake(browser, dir, {
|
|
2968
4596
|
times: wanted,
|
|
2969
4597
|
width: d.px.w,
|
|
@@ -2992,7 +4620,8 @@ async function deliverTake(browser, dir, opts) {
|
|
|
2992
4620
|
h: captured.height,
|
|
2993
4621
|
bytes,
|
|
2994
4622
|
seconds: null,
|
|
2995
|
-
frameTime: frame.time
|
|
4623
|
+
frameTime: frame.time,
|
|
4624
|
+
...d.genre === "screenshot" && opts.composed ? { composed: true } : {}
|
|
2996
4625
|
});
|
|
2997
4626
|
}
|
|
2998
4627
|
if (d.count && captured.frames.length < d.count.min) {
|
|
@@ -3003,9 +4632,11 @@ async function deliverTake(browser, dir, opts) {
|
|
|
3003
4632
|
}
|
|
3004
4633
|
const kit = {
|
|
3005
4634
|
release: opts.release ?? null,
|
|
4635
|
+
look: opts.look?.kind ?? null,
|
|
3006
4636
|
take: dir,
|
|
3007
4637
|
produced: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3008
4638
|
specsVerified: CHANNEL_SPECS_VERIFIED,
|
|
4639
|
+
moments,
|
|
3009
4640
|
skipped,
|
|
3010
4641
|
assets
|
|
3011
4642
|
};
|
|
@@ -3014,19 +4645,560 @@ async function deliverTake(browser, dir, opts) {
|
|
|
3014
4645
|
return { kit, kitFile, outDir };
|
|
3015
4646
|
}
|
|
3016
4647
|
|
|
3017
|
-
// src/plugin/
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
4648
|
+
// src/plugin/music.ts
|
|
4649
|
+
async function fetchMusicCatalog(origin) {
|
|
4650
|
+
const base = origin.replace(/\/+$/, "");
|
|
4651
|
+
const res = await fetch(`${base}/api/music`);
|
|
4652
|
+
if (!res.ok) throw new Error(`GET ${base}/api/music answered ${res.status}`);
|
|
4653
|
+
const body = await res.json();
|
|
4654
|
+
return {
|
|
4655
|
+
tracks: Array.isArray(body.tracks) ? body.tracks : [],
|
|
4656
|
+
sfx: Array.isArray(body.sfx) ? body.sfx : []
|
|
4657
|
+
};
|
|
4658
|
+
}
|
|
4659
|
+
|
|
4660
|
+
// src/plugin/judge.ts
|
|
4661
|
+
import { existsSync as existsSync6 } from "fs";
|
|
4662
|
+
import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile7 } from "fs/promises";
|
|
4663
|
+
import { dirname, isAbsolute, join as join6, resolve as resolve4 } from "path";
|
|
3021
4664
|
import { DESTINATIONS as DESTINATIONS2 } from "@vosjs/studio-core";
|
|
3022
|
-
var
|
|
4665
|
+
var RUBRIC = [
|
|
4666
|
+
"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.",
|
|
4667
|
+
"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).",
|
|
4668
|
+
"It enters: the first frame is not a static, centred, flat window (a tilt-in, a pull-out, a rise).",
|
|
4669
|
+
"The camera zooms to the affordance, holds through the response, and pulls out to show the consequence.",
|
|
4670
|
+
"Beats are cut at stillness; no wipes.",
|
|
4671
|
+
"Content is staged: real data, a populated state, never an empty canvas or a wallpaper.",
|
|
4672
|
+
"The cursor is a character: visible, smoothed, a press on click, never drifting through dead space.",
|
|
4673
|
+
"The last frame is a poster: a resolved state you could ship as the still.",
|
|
4674
|
+
"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.",
|
|
4675
|
+
"The poster test: would you post this frame as a still, alone?",
|
|
4676
|
+
"The thumbnail test: does it read at half size?",
|
|
4677
|
+
"Name three ways this asset acknowledges THIS product (its data, its state, its brand), not a generic window."
|
|
4678
|
+
];
|
|
4679
|
+
function rolesFor(asset) {
|
|
4680
|
+
if (asset.source === "poster" && asset.template) return [asset.template];
|
|
4681
|
+
const spec = DESTINATIONS2.find((d) => d.id === asset.destination);
|
|
4682
|
+
if (spec?.kind === "video") {
|
|
4683
|
+
const portrait = spec.px.w < spec.px.h;
|
|
4684
|
+
return portrait ? ["feature-clip", "site-walkthrough"] : ["feature-clip", "site-walkthrough", "feature-clip-dark"];
|
|
4685
|
+
}
|
|
4686
|
+
if (spec?.genre === "card") return ["window-in-scene", "card-on-gradient", "framed-screenshot"];
|
|
4687
|
+
if (spec?.genre === "screenshot") return ["framed-screenshot", "app-session"];
|
|
4688
|
+
return [];
|
|
4689
|
+
}
|
|
4690
|
+
function resample(img, w, h) {
|
|
4691
|
+
const out = new Uint8Array(w * h * 4);
|
|
4692
|
+
const sx = img.w / w;
|
|
4693
|
+
const sy = img.h / h;
|
|
4694
|
+
for (let y = 0; y < h; y++) {
|
|
4695
|
+
const y0 = Math.floor(y * sy);
|
|
4696
|
+
const y1 = Math.max(y0 + 1, Math.floor((y + 1) * sy));
|
|
4697
|
+
for (let x = 0; x < w; x++) {
|
|
4698
|
+
const x0 = Math.floor(x * sx);
|
|
4699
|
+
const x1 = Math.max(x0 + 1, Math.floor((x + 1) * sx));
|
|
4700
|
+
let r = 0;
|
|
4701
|
+
let g = 0;
|
|
4702
|
+
let b = 0;
|
|
4703
|
+
let a = 0;
|
|
4704
|
+
let n = 0;
|
|
4705
|
+
for (let yy = y0; yy < y1 && yy < img.h; yy++) {
|
|
4706
|
+
for (let xx = x0; xx < x1 && xx < img.w; xx++) {
|
|
4707
|
+
const o2 = (yy * img.w + xx) * 4;
|
|
4708
|
+
r += img.data[o2];
|
|
4709
|
+
g += img.data[o2 + 1];
|
|
4710
|
+
b += img.data[o2 + 2];
|
|
4711
|
+
a += img.data[o2 + 3];
|
|
4712
|
+
n++;
|
|
4713
|
+
}
|
|
4714
|
+
}
|
|
4715
|
+
const o = (y * w + x) * 4;
|
|
4716
|
+
out[o] = n ? r / n : 0;
|
|
4717
|
+
out[o + 1] = n ? g / n : 0;
|
|
4718
|
+
out[o + 2] = n ? b / n : 0;
|
|
4719
|
+
out[o + 3] = n ? a / n : 0;
|
|
4720
|
+
}
|
|
4721
|
+
}
|
|
4722
|
+
return { w, h, data: out };
|
|
4723
|
+
}
|
|
4724
|
+
function composeSheet(left, right, height = 540, gutter = 32) {
|
|
4725
|
+
const lw = Math.max(1, Math.round(left.w * height / left.h));
|
|
4726
|
+
const rw = Math.max(1, Math.round(right.w * height / right.h));
|
|
4727
|
+
const L = resample(left, lw, height);
|
|
4728
|
+
const R = resample(right, rw, height);
|
|
4729
|
+
const margin = 24;
|
|
4730
|
+
const band = 8;
|
|
4731
|
+
const w = margin * 2 + lw + gutter + rw;
|
|
4732
|
+
const h = margin * 2 + height + band + 6;
|
|
4733
|
+
const out = new Uint8Array(w * h * 4);
|
|
4734
|
+
for (let i = 0; i < w * h; i++) {
|
|
4735
|
+
out[i * 4] = 240;
|
|
4736
|
+
out[i * 4 + 1] = 242;
|
|
4737
|
+
out[i * 4 + 2] = 244;
|
|
4738
|
+
out[i * 4 + 3] = 255;
|
|
4739
|
+
}
|
|
4740
|
+
const blit = (src, x0, y0) => {
|
|
4741
|
+
for (let y = 0; y < src.h; y++) {
|
|
4742
|
+
for (let x = 0; x < src.w; x++) {
|
|
4743
|
+
const si = (y * src.w + x) * 4;
|
|
4744
|
+
const a = src.data[si + 3] / 255;
|
|
4745
|
+
const o = ((y0 + y) * w + x0 + x) * 4;
|
|
4746
|
+
for (let c = 0; c < 3; c++) out[o + c] = Math.round(src.data[si + c] * a + out[o + c] * (1 - a));
|
|
4747
|
+
}
|
|
4748
|
+
}
|
|
4749
|
+
};
|
|
4750
|
+
blit(L, margin, margin);
|
|
4751
|
+
blit(R, margin + lw + gutter, margin);
|
|
4752
|
+
const fill = (x0, x1, y0, y1, v) => {
|
|
4753
|
+
for (let y = y0; y < y1; y++)
|
|
4754
|
+
for (let x = x0; x < x1; x++) {
|
|
4755
|
+
const o = (y * w + x) * 4;
|
|
4756
|
+
out[o] = v;
|
|
4757
|
+
out[o + 1] = v;
|
|
4758
|
+
out[o + 2] = v;
|
|
4759
|
+
}
|
|
4760
|
+
};
|
|
4761
|
+
fill(margin, margin + lw, margin + height + 6, margin + height + 6 + band, 40);
|
|
4762
|
+
fill(margin + lw + gutter, margin + lw + gutter + rw, margin + height + 6, margin + height + 6 + band, 200);
|
|
4763
|
+
return { w, h, data: out };
|
|
4764
|
+
}
|
|
4765
|
+
async function judgeKit(kitPath, manifestPath, outDir) {
|
|
4766
|
+
const kit = JSON.parse(await readFile6(kitPath, "utf8"));
|
|
4767
|
+
const manifest = JSON.parse(await readFile6(manifestPath, "utf8"));
|
|
4768
|
+
const manifestDir = dirname(resolve4(manifestPath));
|
|
4769
|
+
const kitDir = dirname(resolve4(kitPath));
|
|
4770
|
+
const out = resolve4(outDir ?? join6(kitDir, "judge"));
|
|
4771
|
+
await mkdir5(out, { recursive: true });
|
|
4772
|
+
const sheets = [];
|
|
4773
|
+
const skipped = [];
|
|
4774
|
+
const refCache = /* @__PURE__ */ new Map();
|
|
4775
|
+
const loadRef = async (file) => {
|
|
4776
|
+
if (refCache.has(file)) return refCache.get(file) ?? null;
|
|
4777
|
+
const p = join6(manifestDir, file);
|
|
4778
|
+
const img = existsSync6(p) ? decodePng(new Uint8Array(await readFile6(p))) : null;
|
|
4779
|
+
refCache.set(file, img);
|
|
4780
|
+
return img;
|
|
4781
|
+
};
|
|
4782
|
+
for (const a of kit.assets) {
|
|
4783
|
+
if (!/\.png$/i.test(a.path)) continue;
|
|
4784
|
+
const file = isAbsolute(a.path) && existsSync6(a.path) ? a.path : join6(kitDir, a.path.split("/").pop() ?? a.path);
|
|
4785
|
+
const img = existsSync6(file) ? decodePng(new Uint8Array(await readFile6(file))) : null;
|
|
4786
|
+
if (!img) {
|
|
4787
|
+
skipped.push(`${a.destination ?? a.path}: unreadable`);
|
|
4788
|
+
continue;
|
|
4789
|
+
}
|
|
4790
|
+
const roles = rolesFor({ destination: a.destination, source: a.source, template: a.template, path: a.path });
|
|
4791
|
+
const ref = manifest.assets.find((r) => roles.includes(r.role) && r.genre !== "context");
|
|
4792
|
+
if (!ref) {
|
|
4793
|
+
skipped.push(`${a.destination ?? a.path}: no reference of role ${roles.join("|") || "(none)"}`);
|
|
4794
|
+
continue;
|
|
4795
|
+
}
|
|
4796
|
+
const refImg = await loadRef(ref.file);
|
|
4797
|
+
if (!refImg) {
|
|
4798
|
+
skipped.push(`${a.destination ?? a.path}: reference ${ref.file} unreadable`);
|
|
4799
|
+
continue;
|
|
4800
|
+
}
|
|
4801
|
+
const stem = (a.path.split("/").pop() ?? a.path).replace(/\.png$/i, "");
|
|
4802
|
+
const id = stem.replace(/[^A-Za-z0-9_-]+/g, "-") || a.destination || "asset";
|
|
4803
|
+
const nameA = `${id}--A.png`;
|
|
4804
|
+
const nameB = `${id}--B.png`;
|
|
4805
|
+
await writeFile7(join6(out, nameA), encodePng(composeSheet(img, refImg)));
|
|
4806
|
+
await writeFile7(join6(out, nameB), encodePng(composeSheet(refImg, img)));
|
|
4807
|
+
const rubric = [
|
|
4808
|
+
`# ${id} against ${ref.id} (${ref.file})`,
|
|
4809
|
+
"",
|
|
4810
|
+
`Sheet A: the kit asset LEFT (dark band), the reference RIGHT (light band). Sheet B: the reverse.`,
|
|
4811
|
+
`Judge each sheet on its own, both orders, and write the verdict where the two agree; a disagreement is a tie.`,
|
|
4812
|
+
"",
|
|
4813
|
+
`## The reference`,
|
|
4814
|
+
ref.layout,
|
|
4815
|
+
...ref.facts ? ["", "```json", JSON.stringify(ref.facts, null, 2), "```"] : [],
|
|
4816
|
+
"",
|
|
4817
|
+
`Rule: ${ref.rule}`,
|
|
4818
|
+
"",
|
|
4819
|
+
"## The rubric",
|
|
4820
|
+
...RUBRIC.map((r, i) => `${i + 1}. ${r}`),
|
|
4821
|
+
"",
|
|
4822
|
+
"## Verdict",
|
|
4823
|
+
"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.",
|
|
4824
|
+
""
|
|
4825
|
+
].join("\n");
|
|
4826
|
+
await writeFile7(join6(out, `${id}.md`), rubric);
|
|
4827
|
+
sheets.push({ asset: id, reference: ref.id, sheetA: nameA, sheetB: nameB, rubric: `${id}.md` });
|
|
4828
|
+
}
|
|
4829
|
+
const verdictFile = join6(out, "judge.json");
|
|
4830
|
+
if (!existsSync6(verdictFile)) {
|
|
4831
|
+
await writeFile7(
|
|
4832
|
+
verdictFile,
|
|
4833
|
+
JSON.stringify(
|
|
4834
|
+
{
|
|
4835
|
+
kit: kitPath,
|
|
4836
|
+
manifest: manifestPath,
|
|
4837
|
+
verdicts: sheets.map((s) => ({ asset: s.asset, reference: s.reference, A: null, B: null, win: null, reasons: [] }))
|
|
4838
|
+
},
|
|
4839
|
+
null,
|
|
4840
|
+
2
|
|
4841
|
+
)
|
|
4842
|
+
);
|
|
4843
|
+
}
|
|
4844
|
+
return { sheets, skipped, outDir: out, verdictFile };
|
|
4845
|
+
}
|
|
4846
|
+
function winRate(verdicts) {
|
|
4847
|
+
const judged = verdicts.filter((v) => v.win !== null).length;
|
|
4848
|
+
const wins = verdicts.filter((v) => v.win === true).length;
|
|
4849
|
+
return { wins, judged, rate: judged ? wins / judged : null };
|
|
4850
|
+
}
|
|
4851
|
+
|
|
4852
|
+
// src/plugin/kitPicture.ts
|
|
4853
|
+
import { execFile } from "child_process";
|
|
4854
|
+
import { promisify } from "util";
|
|
4855
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
4856
|
+
var execFileP = promisify(execFile);
|
|
4857
|
+
var SUBJECT_BAND = { min: 0.6, max: 0.92 };
|
|
4858
|
+
var BLANK_INK = 0.12;
|
|
4859
|
+
var SEPARATION_L = 8;
|
|
4860
|
+
var SHADOW_PRESENT = 6;
|
|
4861
|
+
var EDGE_PRESENT = 40;
|
|
4862
|
+
var DUPLICATE_BITS2 = 6;
|
|
4863
|
+
var HALFSIZE_KEEP = 0.45;
|
|
4864
|
+
function apcaContrast(text, bg) {
|
|
4865
|
+
const lum = (c) => {
|
|
4866
|
+
const ch = (v) => Math.pow(v / 255, 2.4);
|
|
4867
|
+
let y = 0.2126729 * ch(c[0]) + 0.7151522 * ch(c[1]) + 0.072175 * ch(c[2]);
|
|
4868
|
+
if (y < 0.022) y += Math.pow(0.022 - y, 1.414);
|
|
4869
|
+
return y;
|
|
4870
|
+
};
|
|
4871
|
+
const yt = lum(text);
|
|
4872
|
+
const yb = lum(bg);
|
|
4873
|
+
if (Math.abs(yb - yt) < 5e-4) return 0;
|
|
4874
|
+
let sapc;
|
|
4875
|
+
if (yb > yt) {
|
|
4876
|
+
sapc = (Math.pow(yb, 0.56) - Math.pow(yt, 0.57)) * 1.14;
|
|
4877
|
+
return sapc < 0.1 ? 0 : (sapc - 0.027) * 100;
|
|
4878
|
+
}
|
|
4879
|
+
sapc = (Math.pow(yb, 0.65) - Math.pow(yt, 0.62)) * 1.14;
|
|
4880
|
+
return sapc > -0.1 ? 0 : -(sapc + 0.027) * 100;
|
|
4881
|
+
}
|
|
4882
|
+
var pct = (v) => `${Math.round(v * 100)}%`;
|
|
4883
|
+
var hexToRgb = (h) => {
|
|
4884
|
+
const m = /^#([0-9a-f]{6})$/i.exec(h.trim());
|
|
4885
|
+
if (!m) return null;
|
|
4886
|
+
const n = parseInt(m[1], 16);
|
|
4887
|
+
return [n >> 16 & 255, n >> 8 & 255, n & 255];
|
|
4888
|
+
};
|
|
4889
|
+
function stillFindings(a, img, m) {
|
|
4890
|
+
const out = [];
|
|
4891
|
+
const genre = a.spec?.genre;
|
|
4892
|
+
const bledAll = m.bleed.length === 4;
|
|
4893
|
+
if (a.shot) {
|
|
4894
|
+
const sx = Math.max(0, a.shot.x) * img.w;
|
|
4895
|
+
const sy = Math.max(0, a.shot.y) * img.h;
|
|
4896
|
+
const sw = Math.min(1, a.shot.x + a.shot.w) * img.w - sx;
|
|
4897
|
+
const sh = Math.min(1, a.shot.y + a.shot.h) * img.h - sy;
|
|
4898
|
+
const rect = { x: sx, y: sy, w: Math.max(1, sw), h: Math.max(1, sh) };
|
|
4899
|
+
const ink = inkCoverage(img, rect);
|
|
4900
|
+
if (ink < BLANK_INK) {
|
|
4901
|
+
out.push({
|
|
4902
|
+
code: "blank",
|
|
4903
|
+
severity: "error",
|
|
4904
|
+
asset: a.destination,
|
|
4905
|
+
message: `${pct(ink)} ink inside the shot: the moment shows a wallpaper, an empty canvas or a flat panel`,
|
|
4906
|
+
fixHint: "pick a moment after a gesture landed (--shot-time, or --times step:<id>) and stage the set before recording",
|
|
4907
|
+
bbox: rect
|
|
4908
|
+
});
|
|
4909
|
+
}
|
|
4910
|
+
if (a.shot.w < 0.5 || a.shot.w > 1.2) {
|
|
4911
|
+
out.push({
|
|
4912
|
+
code: "subject",
|
|
4913
|
+
severity: "error",
|
|
4914
|
+
asset: a.destination,
|
|
4915
|
+
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`,
|
|
4916
|
+
fixHint: "the template layout places the slot; fix its place for this aspect",
|
|
4917
|
+
bbox: rect
|
|
4918
|
+
});
|
|
4919
|
+
}
|
|
4920
|
+
for (const f of textFindings(a, img)) out.push(f);
|
|
4921
|
+
return out;
|
|
4922
|
+
}
|
|
4923
|
+
const subject = m.card ?? { x: 0, y: 0, w: img.w, h: img.h };
|
|
4924
|
+
if (m.ink < BLANK_INK) {
|
|
4925
|
+
out.push({
|
|
4926
|
+
code: "blank",
|
|
4927
|
+
severity: "error",
|
|
4928
|
+
asset: a.destination,
|
|
4929
|
+
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`,
|
|
4930
|
+
fixHint: "pick a moment after a gesture landed (--times step:<id>) and stage the set before recording: real data, a populated state",
|
|
4931
|
+
bbox: subject
|
|
4932
|
+
});
|
|
4933
|
+
}
|
|
4934
|
+
if (genre === "card") {
|
|
4935
|
+
if (bledAll || m.widthPct === null) {
|
|
4936
|
+
out.push({
|
|
4937
|
+
code: "subject",
|
|
4938
|
+
severity: "error",
|
|
4939
|
+
asset: a.destination,
|
|
4940
|
+
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",
|
|
4941
|
+
fixHint: "present the card in a look (vos deliver reads BRAND.md or --look) or render this destination from a poster template"
|
|
4942
|
+
});
|
|
4943
|
+
} else if (m.widthPct < SUBJECT_BAND.min || m.widthPct > SUBJECT_BAND.max) {
|
|
4944
|
+
out.push({
|
|
4945
|
+
code: "subject",
|
|
4946
|
+
severity: "error",
|
|
4947
|
+
asset: a.destination,
|
|
4948
|
+
message: `the card is ${pct(m.widthPct)} of the width; the references sit at ${pct(SUBJECT_BAND.min)} to ${pct(SUBJECT_BAND.max)}`,
|
|
4949
|
+
fixHint: "the look places the card at 84% (frame.inset); a poster template sets its own placement",
|
|
4950
|
+
bbox: m.card ?? void 0
|
|
4951
|
+
});
|
|
4952
|
+
}
|
|
4953
|
+
if (m.card && !bledAll) {
|
|
4954
|
+
const sep = m.separation ?? 0;
|
|
4955
|
+
const shadow = m.shadow ?? 0;
|
|
4956
|
+
if (sep < SEPARATION_L && shadow < SHADOW_PRESENT && m.edge < EDGE_PRESENT) {
|
|
4957
|
+
out.push({
|
|
4958
|
+
code: "separation",
|
|
4959
|
+
severity: "error",
|
|
4960
|
+
asset: a.destination,
|
|
4961
|
+
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`,
|
|
4962
|
+
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",
|
|
4963
|
+
bbox: m.card
|
|
4964
|
+
});
|
|
4965
|
+
}
|
|
4966
|
+
}
|
|
4967
|
+
}
|
|
4968
|
+
if (genre === "screenshot" && a.composed && m.card && (m.widthPct ?? 0) < 0.98) {
|
|
4969
|
+
out.push({
|
|
4970
|
+
code: "subject",
|
|
4971
|
+
severity: "error",
|
|
4972
|
+
asset: a.destination,
|
|
4973
|
+
message: `a store screenshot must be the real page full bleed; this one shows a ${pct(m.widthPct ?? 0)} card on a ground`,
|
|
4974
|
+
fixHint: "drop --composed: the screenshot genre renders the page with no chrome and no padding",
|
|
4975
|
+
bbox: m.card
|
|
4976
|
+
});
|
|
4977
|
+
}
|
|
4978
|
+
if (img.w <= 500 && (a.spec?.text === "none" || genre === "card")) {
|
|
4979
|
+
const full = edgeEnergy(img, Math.min(img.w, 400));
|
|
4980
|
+
const half = edgeEnergy(img, Math.min(img.w, 400) >> 1);
|
|
4981
|
+
const keep = full > 0 ? half / full : 1;
|
|
4982
|
+
if (keep < HALFSIZE_KEEP) {
|
|
4983
|
+
out.push({
|
|
4984
|
+
code: "halfsize",
|
|
4985
|
+
severity: "warning",
|
|
4986
|
+
asset: a.destination,
|
|
4987
|
+
message: `at half size the tile keeps ${pct(keep)} of its edges: fine text and thin lines vanish where the store shows it small`,
|
|
4988
|
+
fixHint: "a tile is the subject large and saturated with no text (the store rule); render it from the card-on-gradient template"
|
|
4989
|
+
});
|
|
4990
|
+
}
|
|
4991
|
+
}
|
|
4992
|
+
for (const f of textFindings(a, img)) out.push(f);
|
|
4993
|
+
return out;
|
|
4994
|
+
}
|
|
4995
|
+
function textFindings(a, img) {
|
|
4996
|
+
const out = [];
|
|
4997
|
+
for (const t of a.text ?? []) {
|
|
4998
|
+
const box = { x: t.x * img.w, y: t.y * img.h, w: t.w * img.w, h: t.h * img.h };
|
|
4999
|
+
const name = t.label ? `"${t.label}"` : "a text box";
|
|
5000
|
+
if (box.x < 0 || box.y < 0 || box.x + box.w > img.w + 0.5 || box.y + box.h > img.h + 0.5) {
|
|
5001
|
+
out.push({
|
|
5002
|
+
code: "sliced",
|
|
5003
|
+
severity: "error",
|
|
5004
|
+
asset: a.destination,
|
|
5005
|
+
message: `${name} crosses the frame edge: cut mid-word`,
|
|
5006
|
+
fixHint: "shorten the line, or let the template recompose (headline lines at 12 to 14% of the height, inside the safe rect)",
|
|
5007
|
+
bbox: box
|
|
5008
|
+
});
|
|
5009
|
+
}
|
|
5010
|
+
const s = a.spec?.safe;
|
|
5011
|
+
if (s) {
|
|
5012
|
+
const sx = s.x * img.w;
|
|
5013
|
+
const sy = s.y * img.h;
|
|
5014
|
+
const sw = s.w * img.w;
|
|
5015
|
+
const sh = s.h * img.h;
|
|
5016
|
+
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) {
|
|
5017
|
+
out.push({
|
|
5018
|
+
code: "safe",
|
|
5019
|
+
severity: "warning",
|
|
5020
|
+
asset: a.destination,
|
|
5021
|
+
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`,
|
|
5022
|
+
fixHint: "move the text inside the safe rect; the template reads channel-specs safe",
|
|
5023
|
+
bbox: box
|
|
5024
|
+
});
|
|
5025
|
+
}
|
|
5026
|
+
}
|
|
5027
|
+
const rgb2 = t.color ? hexToRgb(t.color) : null;
|
|
5028
|
+
if (rgb2) {
|
|
5029
|
+
const ground = medianColour(img, box);
|
|
5030
|
+
const lc = apcaContrast(rgb2, ground);
|
|
5031
|
+
const floor = t.role === "body" ? 75 : 60;
|
|
5032
|
+
if (lc < floor) {
|
|
5033
|
+
out.push({
|
|
5034
|
+
code: "contrast",
|
|
5035
|
+
severity: "warning",
|
|
5036
|
+
asset: a.destination,
|
|
5037
|
+
message: `${name} reads APCA Lc ${lc.toFixed(0)} on its ground; ${t.role === "body" ? "body" : "a headline"} wants ${floor}`,
|
|
5038
|
+
fixHint: "darken the ink or lighten the ground under the text (BRAND.md ink on bgA/bgB, never on the shot)",
|
|
5039
|
+
bbox: box
|
|
5040
|
+
});
|
|
5041
|
+
}
|
|
5042
|
+
}
|
|
5043
|
+
}
|
|
5044
|
+
if (a.spec?.text === "none" && (a.text?.length ?? 0) > 0) {
|
|
5045
|
+
out.push({
|
|
5046
|
+
code: "safe",
|
|
5047
|
+
severity: "warning",
|
|
5048
|
+
asset: a.destination,
|
|
5049
|
+
message: "this destination wants no text (the picture carries it alone) and the kit put words on it",
|
|
5050
|
+
fixHint: "render the tile from the card-on-gradient template with no headline"
|
|
5051
|
+
});
|
|
5052
|
+
}
|
|
5053
|
+
return out;
|
|
5054
|
+
}
|
|
5055
|
+
function duplicateFindings(stills) {
|
|
5056
|
+
const groups = [];
|
|
5057
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5058
|
+
const comparable = (a, b) => a.genre === "screenshot" || b.genre === "screenshot" ? a.destination === b.destination : true;
|
|
5059
|
+
for (let i = 0; i < stills.length; i++) {
|
|
5060
|
+
if (seen.has(i)) continue;
|
|
5061
|
+
const g = [stills[i]];
|
|
5062
|
+
for (let j = i + 1; j < stills.length; j++) {
|
|
5063
|
+
if (seen.has(j)) continue;
|
|
5064
|
+
if (!comparable(stills[i], stills[j])) continue;
|
|
5065
|
+
if (hammingDistance(stills[i].hash, stills[j].hash) <= DUPLICATE_BITS2) {
|
|
5066
|
+
g.push(stills[j]);
|
|
5067
|
+
seen.add(j);
|
|
5068
|
+
}
|
|
5069
|
+
}
|
|
5070
|
+
if (g.length > 1) groups.push(g);
|
|
5071
|
+
}
|
|
5072
|
+
return groups.map((g) => ({
|
|
5073
|
+
code: "duplicate",
|
|
5074
|
+
severity: g.length >= 3 ? "error" : "warning",
|
|
5075
|
+
asset: g.map((s) => s.destination).join(", "),
|
|
5076
|
+
message: `${g.length} assets share one frame${g[0].time !== null ? ` (${g[0].time.toFixed(2)}s)` : ""}: ${g.map((s) => s.destination).join(", ")}`,
|
|
5077
|
+
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"
|
|
5078
|
+
}));
|
|
5079
|
+
}
|
|
5080
|
+
async function videoFrame(file, at2, ffmpeg) {
|
|
5081
|
+
try {
|
|
5082
|
+
const { stdout } = await execFileP(
|
|
5083
|
+
ffmpeg,
|
|
5084
|
+
["-v", "error", "-ss", at2.toFixed(3), "-i", file, "-frames:v", "1", "-f", "image2pipe", "-vcodec", "png", "-"],
|
|
5085
|
+
{ encoding: "buffer", maxBuffer: 64 * 1024 * 1024 }
|
|
5086
|
+
);
|
|
5087
|
+
return decodePng(new Uint8Array(stdout));
|
|
5088
|
+
} catch {
|
|
5089
|
+
return null;
|
|
5090
|
+
}
|
|
5091
|
+
}
|
|
5092
|
+
async function onPath(bin) {
|
|
5093
|
+
try {
|
|
5094
|
+
await execFileP(bin, ["-version"]);
|
|
5095
|
+
return true;
|
|
5096
|
+
} catch {
|
|
5097
|
+
return false;
|
|
5098
|
+
}
|
|
5099
|
+
}
|
|
5100
|
+
async function pictureChecks(assets) {
|
|
5101
|
+
const findings = [];
|
|
5102
|
+
const measured = [];
|
|
5103
|
+
const stills = [];
|
|
5104
|
+
let ffmpeg = null;
|
|
5105
|
+
for (const a of assets) {
|
|
5106
|
+
if (/\.png$/i.test(a.file)) {
|
|
5107
|
+
const img = decodePng(new Uint8Array(await readFile7(a.file)));
|
|
5108
|
+
if (!img) {
|
|
5109
|
+
findings.push({
|
|
5110
|
+
code: "unreadable",
|
|
5111
|
+
severity: "info",
|
|
5112
|
+
asset: a.destination,
|
|
5113
|
+
message: `${a.path} is not an 8-bit non-interlaced PNG; the picture checks cannot read it`,
|
|
5114
|
+
fixHint: "render stills through vos deliver or vos frames, which write readable PNGs"
|
|
5115
|
+
});
|
|
5116
|
+
measured.push({ destination: a.destination, measure: null });
|
|
5117
|
+
continue;
|
|
5118
|
+
}
|
|
5119
|
+
const m = measureStill(img);
|
|
5120
|
+
measured.push({ destination: a.destination, measure: m });
|
|
5121
|
+
findings.push(...stillFindings(a, img, m));
|
|
5122
|
+
if (a.spec?.kind !== "video")
|
|
5123
|
+
stills.push({
|
|
5124
|
+
destination: a.destination,
|
|
5125
|
+
hash: m.hash,
|
|
5126
|
+
time: null,
|
|
5127
|
+
genre: a.spec?.genre
|
|
5128
|
+
});
|
|
5129
|
+
continue;
|
|
5130
|
+
}
|
|
5131
|
+
if (/\.(mp4|webm|mov)$/i.test(a.file)) {
|
|
5132
|
+
if (ffmpeg === null) ffmpeg = await onPath("ffmpeg");
|
|
5133
|
+
if (!ffmpeg) {
|
|
5134
|
+
findings.push({
|
|
5135
|
+
code: "firstlast",
|
|
5136
|
+
severity: "info",
|
|
5137
|
+
asset: a.destination,
|
|
5138
|
+
message: "ffmpeg is not on PATH, so the first and last frames were not read",
|
|
5139
|
+
fixHint: "install ffmpeg to have the video checks run"
|
|
5140
|
+
});
|
|
5141
|
+
measured.push({ destination: a.destination, measure: null });
|
|
5142
|
+
continue;
|
|
5143
|
+
}
|
|
5144
|
+
const seconds = a.seconds ?? 0;
|
|
5145
|
+
const first = await videoFrame(a.file, 0, "ffmpeg");
|
|
5146
|
+
const last = await videoFrame(a.file, Math.max(0, seconds - 0.1), "ffmpeg");
|
|
5147
|
+
for (const [name, img] of [
|
|
5148
|
+
["first", first],
|
|
5149
|
+
["last", last]
|
|
5150
|
+
]) {
|
|
5151
|
+
if (!img) continue;
|
|
5152
|
+
const m = measureStill(img);
|
|
5153
|
+
if (name === "first") measured.push({ destination: a.destination, measure: m });
|
|
5154
|
+
const subject = m.card ?? { x: 0, y: 0, w: img.w, h: img.h };
|
|
5155
|
+
const ink = inkCoverage(img, subject);
|
|
5156
|
+
if (ink < BLANK_INK) {
|
|
5157
|
+
findings.push({
|
|
5158
|
+
code: "firstlast",
|
|
5159
|
+
severity: "error",
|
|
5160
|
+
asset: a.destination,
|
|
5161
|
+
message: `the ${name} frame is ${pct(ink)} ink: a ${name === "first" ? "cold open on nothing" : "clip that ends on nothing"}`,
|
|
5162
|
+
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)"
|
|
5163
|
+
});
|
|
5164
|
+
}
|
|
5165
|
+
if (m.bleed.length === 4 && a.spec?.genre !== "screenshot") {
|
|
5166
|
+
findings.push({
|
|
5167
|
+
code: "firstlast",
|
|
5168
|
+
severity: "warning",
|
|
5169
|
+
asset: a.destination,
|
|
5170
|
+
message: `the ${name} frame fills the frame on all four sides: no ground, no room`,
|
|
5171
|
+
fixHint: "present the cut in a look (vos deliver reads BRAND.md or --look)"
|
|
5172
|
+
});
|
|
5173
|
+
}
|
|
5174
|
+
}
|
|
5175
|
+
continue;
|
|
5176
|
+
}
|
|
5177
|
+
}
|
|
5178
|
+
findings.push(...duplicateFindings(stills));
|
|
5179
|
+
const order = { error: 0, warning: 1, info: 2 };
|
|
5180
|
+
findings.sort((a, b) => order[a.severity] - order[b.severity]);
|
|
5181
|
+
return { findings, measured };
|
|
5182
|
+
}
|
|
5183
|
+
function formatFinding(f) {
|
|
5184
|
+
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)}` : "";
|
|
5185
|
+
return `${f.severity} ${f.code} ${f.asset}${where}: ${f.message}
|
|
5186
|
+
fix: ${f.fixHint}`;
|
|
5187
|
+
}
|
|
5188
|
+
|
|
5189
|
+
// src/plugin/validateKit.ts
|
|
5190
|
+
import { existsSync as existsSync7 } from "fs";
|
|
5191
|
+
import { readFile as readFile8, stat as stat2 } from "fs/promises";
|
|
5192
|
+
import { dirname as dirname2, isAbsolute as isAbsolute2, join as join7 } from "path";
|
|
5193
|
+
import { DESTINATIONS as DESTINATIONS3 } from "@vosjs/studio-core";
|
|
5194
|
+
var PNG_SIG2 = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
3023
5195
|
function pngDimensions(bytes) {
|
|
3024
|
-
if (bytes.length < 24 || !bytes.subarray(0, 8).equals(
|
|
5196
|
+
if (bytes.length < 24 || !bytes.subarray(0, 8).equals(PNG_SIG2)) return null;
|
|
3025
5197
|
if (bytes.toString("ascii", 12, 16) !== "IHDR") return null;
|
|
3026
5198
|
return { w: bytes.readUInt32BE(16), h: bytes.readUInt32BE(20) };
|
|
3027
5199
|
}
|
|
3028
5200
|
function sniffImage(bytes) {
|
|
3029
|
-
if (bytes.subarray(0, 8).equals(
|
|
5201
|
+
if (bytes.subarray(0, 8).equals(PNG_SIG2)) return "png";
|
|
3030
5202
|
if (bytes.length >= 12 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP")
|
|
3031
5203
|
return "webp";
|
|
3032
5204
|
if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216) return "jpeg";
|
|
@@ -3036,7 +5208,7 @@ async function probeVideo(path) {
|
|
|
3036
5208
|
const MB = await import("mediabunny");
|
|
3037
5209
|
const input = new MB.Input({
|
|
3038
5210
|
formats: MB.ALL_FORMATS,
|
|
3039
|
-
source: new MB.BufferSource(new Uint8Array(await
|
|
5211
|
+
source: new MB.BufferSource(new Uint8Array(await readFile8(path)))
|
|
3040
5212
|
});
|
|
3041
5213
|
try {
|
|
3042
5214
|
const track = await input.getPrimaryVideoTrack();
|
|
@@ -3048,16 +5220,16 @@ async function probeVideo(path) {
|
|
|
3048
5220
|
}
|
|
3049
5221
|
}
|
|
3050
5222
|
var specById = new Map(
|
|
3051
|
-
|
|
5223
|
+
DESTINATIONS3.map((d) => [d.id, d])
|
|
3052
5224
|
);
|
|
3053
5225
|
var near = (a, b, tol) => Math.abs(a - b) <= tol;
|
|
3054
|
-
async function validateKit(kitPath) {
|
|
5226
|
+
async function validateKit(kitPath, opts = {}) {
|
|
3055
5227
|
const problems = [];
|
|
3056
5228
|
const warnings = [];
|
|
3057
5229
|
const measured = [];
|
|
3058
5230
|
let kit;
|
|
3059
5231
|
try {
|
|
3060
|
-
kit = JSON.parse(await
|
|
5232
|
+
kit = JSON.parse(await readFile8(kitPath, "utf8"));
|
|
3061
5233
|
} catch (e) {
|
|
3062
5234
|
return {
|
|
3063
5235
|
valid: false,
|
|
@@ -3074,13 +5246,14 @@ async function validateKit(kitPath) {
|
|
|
3074
5246
|
measured
|
|
3075
5247
|
};
|
|
3076
5248
|
}
|
|
3077
|
-
const base =
|
|
5249
|
+
const base = dirname2(kitPath);
|
|
3078
5250
|
const resolvePath = (p) => {
|
|
3079
|
-
if (
|
|
3080
|
-
const beside =
|
|
5251
|
+
if (isAbsolute2(p) && existsSync7(p)) return p;
|
|
5252
|
+
const beside = join7(base, p.split("/").pop() ?? p);
|
|
3081
5253
|
return beside;
|
|
3082
5254
|
};
|
|
3083
5255
|
const perDestination = /* @__PURE__ */ new Map();
|
|
5256
|
+
const pictureAssets = [];
|
|
3084
5257
|
for (const a of kit.assets) {
|
|
3085
5258
|
const id = a.destination ?? `${a.channel}-${a.asset}`;
|
|
3086
5259
|
const label = `${a.channel} ${a.asset}`;
|
|
@@ -3102,7 +5275,7 @@ async function validateKit(kitPath) {
|
|
|
3102
5275
|
let h = null;
|
|
3103
5276
|
let seconds = null;
|
|
3104
5277
|
if (/\.(png|jpe?g|webp)$/i.test(file)) {
|
|
3105
|
-
const head = Buffer.from(await
|
|
5278
|
+
const head = Buffer.from(await readFile8(file));
|
|
3106
5279
|
const kind = sniffImage(head);
|
|
3107
5280
|
if (file.toLowerCase().endsWith(".png") && kind !== "png")
|
|
3108
5281
|
problems.push(
|
|
@@ -3147,6 +5320,16 @@ async function validateKit(kitPath) {
|
|
|
3147
5320
|
);
|
|
3148
5321
|
}
|
|
3149
5322
|
measured.push({ destination: id, path: a.path, w, h, bytes, seconds });
|
|
5323
|
+
pictureAssets.push({
|
|
5324
|
+
destination: id,
|
|
5325
|
+
path: a.path,
|
|
5326
|
+
file,
|
|
5327
|
+
spec,
|
|
5328
|
+
text: a.text,
|
|
5329
|
+
seconds,
|
|
5330
|
+
composed: a.composed,
|
|
5331
|
+
shot: a.source === "poster" ? a.shot : void 0
|
|
5332
|
+
});
|
|
3150
5333
|
if (!spec) {
|
|
3151
5334
|
if (a.channel !== "demo")
|
|
3152
5335
|
warnings.push(
|
|
@@ -3184,13 +5367,25 @@ async function validateKit(kitPath) {
|
|
|
3184
5367
|
`${spec.channel} ${spec.asset}: spec wants ${spec.count.min}-${spec.count.max}, the kit has ${n}`
|
|
3185
5368
|
);
|
|
3186
5369
|
}
|
|
3187
|
-
|
|
5370
|
+
if (!opts.picture) {
|
|
5371
|
+
return { valid: problems.length === 0, problems, warnings, measured };
|
|
5372
|
+
}
|
|
5373
|
+
const picture = await pictureChecks(pictureAssets);
|
|
5374
|
+
const pictureErrors = picture.findings.filter((f) => f.severity === "error");
|
|
5375
|
+
return {
|
|
5376
|
+
valid: problems.length === 0 && pictureErrors.length === 0,
|
|
5377
|
+
problems,
|
|
5378
|
+
warnings,
|
|
5379
|
+
measured,
|
|
5380
|
+
picture: picture.findings,
|
|
5381
|
+
pictureMeasured: picture.measured
|
|
5382
|
+
};
|
|
3188
5383
|
}
|
|
3189
5384
|
|
|
3190
5385
|
// src/plugin/platform.ts
|
|
3191
5386
|
import { mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
3192
5387
|
import { homedir } from "os";
|
|
3193
|
-
import { join as
|
|
5388
|
+
import { join as join8 } from "path";
|
|
3194
5389
|
function platformOrigin(flags = {}) {
|
|
3195
5390
|
const legacyEnv = process.env.VOS_API_BASE?.trim();
|
|
3196
5391
|
const raw = flags.origin ?? flags.api ?? process.env.VOS_ORIGIN?.trim() ?? legacyEnv ?? "https://vos.so";
|
|
@@ -3223,7 +5418,7 @@ function parseVosId(input) {
|
|
|
3223
5418
|
function deriveSlug(title) {
|
|
3224
5419
|
return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50).replace(/-+$/, "") || "remix";
|
|
3225
5420
|
}
|
|
3226
|
-
var CREDENTIALS_PATH =
|
|
5421
|
+
var CREDENTIALS_PATH = join8(homedir(), ".config", "vos", "credentials");
|
|
3227
5422
|
function resolveCredential(explicit) {
|
|
3228
5423
|
const flag = explicit?.trim();
|
|
3229
5424
|
if (flag) return flag;
|
|
@@ -3246,7 +5441,7 @@ function requireCredential(explicit) {
|
|
|
3246
5441
|
return key;
|
|
3247
5442
|
}
|
|
3248
5443
|
function writeCredential(key) {
|
|
3249
|
-
mkdirSync(
|
|
5444
|
+
mkdirSync(join8(homedir(), ".config", "vos"), { recursive: true, mode: 448 });
|
|
3250
5445
|
writeFileSync(CREDENTIALS_PATH, `${key.trim()}
|
|
3251
5446
|
`, { mode: 384 });
|
|
3252
5447
|
return CREDENTIALS_PATH;
|
|
@@ -3290,7 +5485,7 @@ function readJsonFile(file) {
|
|
|
3290
5485
|
}
|
|
3291
5486
|
}
|
|
3292
5487
|
function readSyncState(dir) {
|
|
3293
|
-
const own = readJsonFile(
|
|
5488
|
+
const own = readJsonFile(join8(dir, SYNC_STATE_NAME));
|
|
3294
5489
|
if (own && typeof own.vosId === "string") {
|
|
3295
5490
|
return {
|
|
3296
5491
|
vosId: own.vosId,
|
|
@@ -3301,7 +5496,7 @@ function readSyncState(dir) {
|
|
|
3301
5496
|
...typeof own.remixOfId === "string" ? { remixOfId: own.remixOfId } : {}
|
|
3302
5497
|
};
|
|
3303
5498
|
}
|
|
3304
|
-
const push = readJsonFile(
|
|
5499
|
+
const push = readJsonFile(join8(dir, "push.json"));
|
|
3305
5500
|
if (push && typeof push.vosId === "string") {
|
|
3306
5501
|
return {
|
|
3307
5502
|
vosId: push.vosId,
|
|
@@ -3309,7 +5504,7 @@ function readSyncState(dir) {
|
|
|
3309
5504
|
...typeof push.pushedAt === "string" ? { pushedAt: push.pushedAt } : {}
|
|
3310
5505
|
};
|
|
3311
5506
|
}
|
|
3312
|
-
const meta = readJsonFile(
|
|
5507
|
+
const meta = readJsonFile(join8(dir, "meta.json"));
|
|
3313
5508
|
if (meta && typeof meta.id === "string") {
|
|
3314
5509
|
return {
|
|
3315
5510
|
vosId: meta.id,
|
|
@@ -3327,7 +5522,7 @@ function writeSyncState(dir, patch) {
|
|
|
3327
5522
|
...patch
|
|
3328
5523
|
};
|
|
3329
5524
|
writeFileSync(
|
|
3330
|
-
|
|
5525
|
+
join8(dir, SYNC_STATE_NAME),
|
|
3331
5526
|
`${JSON.stringify(merged, null, 2)}
|
|
3332
5527
|
`
|
|
3333
5528
|
);
|
|
@@ -3348,7 +5543,7 @@ function formatChanges(changes) {
|
|
|
3348
5543
|
|
|
3349
5544
|
// src/plugin/recorder.ts
|
|
3350
5545
|
import { readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
3351
|
-
import { join as
|
|
5546
|
+
import { join as join9 } from "path";
|
|
3352
5547
|
|
|
3353
5548
|
// src/plugin/recordingCap.ts
|
|
3354
5549
|
var HOSTED_RECORDING_CAP_SECONDS = 30 * 60;
|
|
@@ -3436,7 +5631,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
|
|
|
3436
5631
|
cdp.on("Page.screencastFrame", (ev) => {
|
|
3437
5632
|
const tsMs = ev.metadata.timestamp ? ev.metadata.timestamp * 1e3 : Date.now();
|
|
3438
5633
|
const file = `frame-${String(frameIdx++).padStart(5, "0")}.jpg`;
|
|
3439
|
-
writeFileSync2(
|
|
5634
|
+
writeFileSync2(join9(paths.framesDir, file), Buffer.from(ev.data, "base64"));
|
|
3440
5635
|
frames.push({ file, tMs: Math.max(0, Math.round(tsMs - t0)) });
|
|
3441
5636
|
cdp.send("Page.screencastFrameAck", { sessionId: ev.sessionId }).catch(() => {
|
|
3442
5637
|
});
|
|
@@ -3659,7 +5854,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
|
|
|
3659
5854
|
await sleep(200);
|
|
3660
5855
|
const pageTitle = await page.title().catch(() => "");
|
|
3661
5856
|
await context.close();
|
|
3662
|
-
const firstFrame = frames[0] ? jpegDims(readFileSync3(
|
|
5857
|
+
const firstFrame = frames[0] ? jpegDims(readFileSync3(join9(paths.framesDir, frames[0].file))) : null;
|
|
3663
5858
|
const meta = {
|
|
3664
5859
|
dpr: 1,
|
|
3665
5860
|
zoom: 1,
|
|
@@ -3767,9 +5962,9 @@ async function encodeRecording(browser, takeDir, onProgress) {
|
|
|
3767
5962
|
}
|
|
3768
5963
|
|
|
3769
5964
|
// src/plugin/plan.ts
|
|
3770
|
-
import { existsSync as
|
|
3771
|
-
import { readFile as
|
|
3772
|
-
import { join as
|
|
5965
|
+
import { existsSync as existsSync8 } from "fs";
|
|
5966
|
+
import { readFile as readFile9 } from "fs/promises";
|
|
5967
|
+
import { join as join10 } from "path";
|
|
3773
5968
|
import {
|
|
3774
5969
|
DEFAULT_FRAME_STYLE,
|
|
3775
5970
|
STYLE_FIELDS,
|
|
@@ -3962,10 +6157,10 @@ function retimeCut(prev, newSteps, newDurationMs) {
|
|
|
3962
6157
|
// src/plugin/plan.ts
|
|
3963
6158
|
var overlaps = (a, b) => a.in < b.out && b.in < a.out;
|
|
3964
6159
|
async function readDigestActivity(dir) {
|
|
3965
|
-
const file =
|
|
3966
|
-
if (!
|
|
6160
|
+
const file = join10(dir, "digest", "digest.json");
|
|
6161
|
+
if (!existsSync8(file)) return null;
|
|
3967
6162
|
try {
|
|
3968
|
-
const d = JSON.parse(await
|
|
6163
|
+
const d = JSON.parse(await readFile9(file, "utf8"));
|
|
3969
6164
|
return Array.isArray(d.activity) && d.activity.every((v) => typeof v === "number") ? d.activity : null;
|
|
3970
6165
|
} catch {
|
|
3971
6166
|
return null;
|
|
@@ -4078,16 +6273,16 @@ async function planTake(dir, opts = {}) {
|
|
|
4078
6273
|
|
|
4079
6274
|
// src/plugin/sync.ts
|
|
4080
6275
|
import { createHash } from "crypto";
|
|
4081
|
-
import { existsSync as
|
|
4082
|
-
import { readFile as
|
|
6276
|
+
import { existsSync as existsSync10 } from "fs";
|
|
6277
|
+
import { readFile as readFile10 } from "fs/promises";
|
|
4083
6278
|
import { createInterface } from "readline/promises";
|
|
4084
|
-
import { basename, join as
|
|
6279
|
+
import { basename, join as join13 } from "path";
|
|
4085
6280
|
import { lowerToComposition as lowerToComposition3, migrateHostedDoc as migrateHostedDoc2 } from "@vosjs/studio-core";
|
|
4086
6281
|
|
|
4087
6282
|
// src/plugin/media.ts
|
|
4088
|
-
import { createWriteStream, existsSync as
|
|
4089
|
-
import { writeFile as
|
|
4090
|
-
import { join as
|
|
6283
|
+
import { createWriteStream, existsSync as existsSync9 } from "fs";
|
|
6284
|
+
import { writeFile as writeFile8 } from "fs/promises";
|
|
6285
|
+
import { join as join11 } from "path";
|
|
4091
6286
|
import { Readable } from "stream";
|
|
4092
6287
|
import { pipeline } from "stream/promises";
|
|
4093
6288
|
var MIC_NAME = "mic.webm";
|
|
@@ -4108,8 +6303,8 @@ async function pullMedia(ctx, dir, doc, log) {
|
|
|
4108
6303
|
const url = doc.source[key];
|
|
4109
6304
|
const assetId = assetIdOf(url);
|
|
4110
6305
|
if (!assetId) continue;
|
|
4111
|
-
const target =
|
|
4112
|
-
if (
|
|
6306
|
+
const target = join11(dir, file);
|
|
6307
|
+
if (existsSync9(target)) {
|
|
4113
6308
|
result.kept.push(file);
|
|
4114
6309
|
} else {
|
|
4115
6310
|
const abs = /^https?:/.test(url) ? url : `${ctx.origin}${url}`;
|
|
@@ -4131,17 +6326,17 @@ async function pullMedia(ctx, dir, doc, log) {
|
|
|
4131
6326
|
}
|
|
4132
6327
|
doc.source[key] = file;
|
|
4133
6328
|
}
|
|
4134
|
-
const metaPath =
|
|
4135
|
-
if (!
|
|
4136
|
-
const cursorPath =
|
|
4137
|
-
if (!
|
|
4138
|
-
await
|
|
6329
|
+
const metaPath = join11(dir, "meta.json");
|
|
6330
|
+
if (!existsSync9(metaPath)) await writeJson(metaPath, doc.source.meta, true);
|
|
6331
|
+
const cursorPath = join11(dir, "cursor.json");
|
|
6332
|
+
if (!existsSync9(cursorPath)) await writeJson(cursorPath, doc.source.cursor);
|
|
6333
|
+
await writeFile8(join11(dir, "doc.json"), JSON.stringify(doc, null, 2));
|
|
4139
6334
|
return result;
|
|
4140
6335
|
}
|
|
4141
6336
|
|
|
4142
6337
|
// src/plugin/folder.ts
|
|
4143
|
-
import { mkdir as
|
|
4144
|
-
import { join as
|
|
6338
|
+
import { mkdir as mkdir6, writeFile as writeFile9 } from "fs/promises";
|
|
6339
|
+
import { join as join12 } from "path";
|
|
4145
6340
|
import { recipeHints } from "@vosjs/shared/frontmatter";
|
|
4146
6341
|
import { migrateHostedDoc } from "@vosjs/studio-core";
|
|
4147
6342
|
var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["json", "help", "media"]);
|
|
@@ -4317,25 +6512,25 @@ async function cmdPull(argv) {
|
|
|
4317
6512
|
throw new Error(apiError(`pull folder ${folder.slug}`, res));
|
|
4318
6513
|
const payload = res.body;
|
|
4319
6514
|
const out = strFlag(flags, "out") ?? folder.slug;
|
|
4320
|
-
await
|
|
6515
|
+
await mkdir6(join12(out, "recipes"), { recursive: true });
|
|
4321
6516
|
const lines = [];
|
|
4322
6517
|
const recipes = [];
|
|
4323
6518
|
for (const rec of payload.recipes) {
|
|
4324
|
-
const path =
|
|
4325
|
-
await
|
|
6519
|
+
const path = join12(out, "recipes", safeName(rec.filename));
|
|
6520
|
+
await writeFile9(path, rec.body ?? "");
|
|
4326
6521
|
recipes.push({ path, line: recipeLine(rec.filename, rec.body ?? "") });
|
|
4327
6522
|
}
|
|
4328
6523
|
for (const rec of payload.inheritedRecipes) {
|
|
4329
6524
|
const from = safeName(rec.folderSlug ?? "inherited");
|
|
4330
|
-
await
|
|
4331
|
-
const path =
|
|
6525
|
+
await mkdir6(join12(out, "recipes", "_inherited", from), { recursive: true });
|
|
6526
|
+
const path = join12(
|
|
4332
6527
|
out,
|
|
4333
6528
|
"recipes",
|
|
4334
6529
|
"_inherited",
|
|
4335
6530
|
from,
|
|
4336
6531
|
safeName(rec.filename)
|
|
4337
6532
|
);
|
|
4338
|
-
await
|
|
6533
|
+
await writeFile9(path, rec.body ?? "");
|
|
4339
6534
|
recipes.push({
|
|
4340
6535
|
path,
|
|
4341
6536
|
line: `${recipeLine(rec.filename, rec.body ?? "")} [inherited from ${rec.folderName ?? from}]`
|
|
@@ -4343,15 +6538,15 @@ async function cmdPull(argv) {
|
|
|
4343
6538
|
}
|
|
4344
6539
|
const members = [];
|
|
4345
6540
|
for (const v of payload.voses) {
|
|
4346
|
-
const dir =
|
|
4347
|
-
await
|
|
6541
|
+
const dir = join12(out, "members", safeName(v.slug || v.title || v.id));
|
|
6542
|
+
await mkdir6(dir, { recursive: true });
|
|
4348
6543
|
const cfg = await apiJson(origin, `/api/vos/${v.id}/config`, { key });
|
|
4349
6544
|
if (cfg.status !== 200) {
|
|
4350
6545
|
lines.push(`skipped ${v.title}: ${apiError("fetch config", cfg)}`);
|
|
4351
6546
|
continue;
|
|
4352
6547
|
}
|
|
4353
|
-
await
|
|
4354
|
-
|
|
6548
|
+
await writeFile9(
|
|
6549
|
+
join12(dir, "config.json"),
|
|
4355
6550
|
JSON.stringify(cfg.body.config, null, 2)
|
|
4356
6551
|
);
|
|
4357
6552
|
let take = false;
|
|
@@ -4362,8 +6557,8 @@ async function cmdPull(argv) {
|
|
|
4362
6557
|
{ key }
|
|
4363
6558
|
);
|
|
4364
6559
|
if (doc.status === 200) {
|
|
4365
|
-
await
|
|
4366
|
-
|
|
6560
|
+
await writeFile9(
|
|
6561
|
+
join12(dir, "doc.json"),
|
|
4367
6562
|
JSON.stringify(doc.body, null, 2)
|
|
4368
6563
|
);
|
|
4369
6564
|
take = true;
|
|
@@ -4480,7 +6675,7 @@ async function pushTake(dir, flags, r) {
|
|
|
4480
6675
|
throw new Error(`doc.json fails lint:
|
|
4481
6676
|
${lint.problems.join("\n ")}`);
|
|
4482
6677
|
}
|
|
4483
|
-
if (!
|
|
6678
|
+
if (!existsSync10(take.paths.recording)) {
|
|
4484
6679
|
throw new Error("no recording.webm in this take");
|
|
4485
6680
|
}
|
|
4486
6681
|
const ctx = apiContext(flags);
|
|
@@ -4512,7 +6707,7 @@ async function pushTake(dir, flags, r) {
|
|
|
4512
6707
|
throw new Error("push cancelled");
|
|
4513
6708
|
}
|
|
4514
6709
|
}
|
|
4515
|
-
const bytes = await
|
|
6710
|
+
const bytes = await readFile10(take.paths.recording);
|
|
4516
6711
|
const hash = createHash("sha256").update(bytes).digest("hex");
|
|
4517
6712
|
r.log(`uploading recording (${Math.round(bytes.length / 1024)} kB)\u2026`);
|
|
4518
6713
|
const upload = await api(ctx, "/assets/recording", {
|
|
@@ -4635,6 +6830,7 @@ async function pullTake(dir, flags, r) {
|
|
|
4635
6830
|
if (!vosId) {
|
|
4636
6831
|
throw new Error("this take was never pushed \u2014 pass --vos <id> to link it");
|
|
4637
6832
|
}
|
|
6833
|
+
const base = flags.since ?? state?.versionId ?? null;
|
|
4638
6834
|
const meta = await api(ctx, `/vos/${vosId}`);
|
|
4639
6835
|
if (meta.status !== 200) {
|
|
4640
6836
|
throw new Error(
|
|
@@ -4645,12 +6841,26 @@ async function pullTake(dir, flags, r) {
|
|
|
4645
6841
|
meta.json.vos?.currentVersionId ?? ""
|
|
4646
6842
|
);
|
|
4647
6843
|
if (!head) throw new Error("this vos has no versions");
|
|
4648
|
-
if (
|
|
4649
|
-
r.log(
|
|
6844
|
+
if (base === head) {
|
|
6845
|
+
r.log(
|
|
6846
|
+
flags.since ? "up to date \u2014 --since names the head" : "up to date \u2014 HEAD is your last push"
|
|
6847
|
+
);
|
|
6848
|
+
if (flags.check) {
|
|
6849
|
+
return {
|
|
6850
|
+
vosId,
|
|
6851
|
+
versionId: head,
|
|
6852
|
+
versionNumber: null,
|
|
6853
|
+
changed: false,
|
|
6854
|
+
checked: true,
|
|
6855
|
+
behind: 0,
|
|
6856
|
+
changes: [],
|
|
6857
|
+
protected: []
|
|
6858
|
+
};
|
|
6859
|
+
}
|
|
4650
6860
|
let media2;
|
|
4651
|
-
if (flags.media &&
|
|
6861
|
+
if (flags.media && existsSync10(join13(dir, "doc.json"))) {
|
|
4652
6862
|
const local = JSON.parse(
|
|
4653
|
-
await
|
|
6863
|
+
await readFile10(join13(dir, "doc.json"), "utf8")
|
|
4654
6864
|
);
|
|
4655
6865
|
media2 = await pullMedia(ctx, dir, local, r.log);
|
|
4656
6866
|
}
|
|
@@ -4662,15 +6872,45 @@ async function pullTake(dir, flags, r) {
|
|
|
4662
6872
|
media: media2
|
|
4663
6873
|
};
|
|
4664
6874
|
}
|
|
4665
|
-
|
|
6875
|
+
let walked = null;
|
|
6876
|
+
if (base) {
|
|
4666
6877
|
const changes = await api(
|
|
4667
6878
|
ctx,
|
|
4668
|
-
`/vos/${vosId}/changes?since=${
|
|
6879
|
+
`/vos/${vosId}/changes?since=${encodeURIComponent(base)}`
|
|
4669
6880
|
);
|
|
4670
6881
|
if (changes.status === 200) {
|
|
4671
|
-
r.log(
|
|
6882
|
+
r.log(
|
|
6883
|
+
flags.since ? `changes since ${base.slice(0, 8)}\u2026:` : "changes since your last push:"
|
|
6884
|
+
);
|
|
4672
6885
|
printChanges(r, changes.json.changes, changes.json.protected);
|
|
6886
|
+
walked = {
|
|
6887
|
+
changes: Array.isArray(changes.json.changes) ? changes.json.changes : [],
|
|
6888
|
+
protected: Array.isArray(changes.json.protected) ? changes.json.protected : []
|
|
6889
|
+
};
|
|
6890
|
+
if (changes.json.truncated === true) {
|
|
6891
|
+
r.log("walk truncated \u2014 more versions exist; pull again after syncing");
|
|
6892
|
+
}
|
|
6893
|
+
} else {
|
|
6894
|
+
r.log(
|
|
6895
|
+
`changelog unavailable (${changes.status}) \u2014 the head is v?; pass --since <versionId> you can read, or pull to sync`
|
|
6896
|
+
);
|
|
4673
6897
|
}
|
|
6898
|
+
} else {
|
|
6899
|
+
r.log(
|
|
6900
|
+
"no base to compare from (this take was linked with --vos) \u2014 pass --since <versionId> for the changelog"
|
|
6901
|
+
);
|
|
6902
|
+
}
|
|
6903
|
+
if (flags.check) {
|
|
6904
|
+
return {
|
|
6905
|
+
vosId,
|
|
6906
|
+
versionId: head,
|
|
6907
|
+
versionNumber: null,
|
|
6908
|
+
changed: false,
|
|
6909
|
+
checked: true,
|
|
6910
|
+
behind: walked ? walked.changes.length : null,
|
|
6911
|
+
changes: walked?.changes ?? [],
|
|
6912
|
+
protected: walked?.protected ?? []
|
|
6913
|
+
};
|
|
4674
6914
|
}
|
|
4675
6915
|
const docRes = await api(ctx, `/vos/${vosId}/versions/${head}/doc`);
|
|
4676
6916
|
if (docRes.status !== 200) {
|
|
@@ -4680,10 +6920,10 @@ async function pullTake(dir, flags, r) {
|
|
|
4680
6920
|
}
|
|
4681
6921
|
const hostedDoc = migrateHostedDoc2(docRes.json);
|
|
4682
6922
|
const doc = hostedDoc;
|
|
4683
|
-
if (
|
|
6923
|
+
if (existsSync10(join13(dir, RECORDING_NAME))) {
|
|
4684
6924
|
doc.source.videoKey = RECORDING_NAME;
|
|
4685
6925
|
}
|
|
4686
|
-
await writeJson(
|
|
6926
|
+
await writeJson(join13(dir, "doc.json"), doc, true);
|
|
4687
6927
|
const media = flags.media ? await pullMedia(ctx, dir, doc, r.log) : void 0;
|
|
4688
6928
|
const versions = await api(ctx, `/vos/${vosId}/versions`);
|
|
4689
6929
|
const headRow = (versions.json.versions ?? []).find((v) => v.id === head);
|
|
@@ -4702,10 +6942,10 @@ async function pullTake(dir, flags, r) {
|
|
|
4702
6942
|
}
|
|
4703
6943
|
|
|
4704
6944
|
// src/plugin/program.ts
|
|
4705
|
-
import { existsSync as
|
|
4706
|
-
import { mkdir as
|
|
6945
|
+
import { existsSync as existsSync11 } from "fs";
|
|
6946
|
+
import { mkdir as mkdir7, readFile as readFile11, writeFile as writeFile10 } from "fs/promises";
|
|
4707
6947
|
import { createInterface as createInterface2 } from "readline/promises";
|
|
4708
|
-
import { basename as basename2, dirname as
|
|
6948
|
+
import { basename as basename2, dirname as dirname3, join as join14 } from "path";
|
|
4709
6949
|
import {
|
|
4710
6950
|
CURRENT_CONFIG_VERSION,
|
|
4711
6951
|
migrateConfig,
|
|
@@ -4875,8 +7115,8 @@ function preflightConfig(parsed) {
|
|
|
4875
7115
|
}
|
|
4876
7116
|
function resolveConfigPath(target) {
|
|
4877
7117
|
if (target.endsWith(".json")) return target;
|
|
4878
|
-
const inDir =
|
|
4879
|
-
if (
|
|
7118
|
+
const inDir = join14(target, "config.json");
|
|
7119
|
+
if (existsSync11(inDir)) return inDir;
|
|
4880
7120
|
throw new UsageError(
|
|
4881
7121
|
`${target} has no config.json (and is not a take \u2014 no doc.json)`
|
|
4882
7122
|
);
|
|
@@ -4902,9 +7142,9 @@ async function cmdFetch(argv) {
|
|
|
4902
7142
|
throw new Error(apiError(`fetch config for ${vosId}`, cfg));
|
|
4903
7143
|
const slug = typeof vosMeta.slug === "string" && vosMeta.slug ? vosMeta.slug : vosId;
|
|
4904
7144
|
const out = strFlag(flags, "out") ?? slug;
|
|
4905
|
-
await
|
|
4906
|
-
await
|
|
4907
|
-
|
|
7145
|
+
await mkdir7(out, { recursive: true });
|
|
7146
|
+
await writeFile10(
|
|
7147
|
+
join14(out, "config.json"),
|
|
4908
7148
|
JSON.stringify(cfg.body.config, null, 2)
|
|
4909
7149
|
);
|
|
4910
7150
|
const title = typeof vosMeta.title === "string" ? vosMeta.title : "";
|
|
@@ -4929,12 +7169,12 @@ async function cmdFetch(argv) {
|
|
|
4929
7169
|
const hosted = migrateHostedDoc3(doc.body);
|
|
4930
7170
|
const { config: own } = await writeProgramDoc(out, hosted);
|
|
4931
7171
|
if (own) {
|
|
4932
|
-
await
|
|
7172
|
+
await writeFile10(join14(out, "config.json"), JSON.stringify(own, null, 2));
|
|
4933
7173
|
}
|
|
4934
7174
|
} else if (doc.status === 200) {
|
|
4935
7175
|
take = true;
|
|
4936
7176
|
const hosted = migrateHostedDoc3(doc.body);
|
|
4937
|
-
await
|
|
7177
|
+
await writeFile10(join14(out, "doc.json"), JSON.stringify(hosted, null, 2));
|
|
4938
7178
|
if (flags.media === true) {
|
|
4939
7179
|
const media = await pullMedia({ origin, key }, out, hosted, r.log);
|
|
4940
7180
|
mediaLine = media.downloaded.length ? ` + ${media.downloaded.map((m) => m.file).join(", ")}` : "";
|
|
@@ -5045,8 +7285,8 @@ async function cmdPushProgram(argv) {
|
|
|
5045
7285
|
origin: strFlag(flags, "origin"),
|
|
5046
7286
|
api: strFlag(flags, "api")
|
|
5047
7287
|
});
|
|
5048
|
-
const dir =
|
|
5049
|
-
const parsed = JSON.parse(await
|
|
7288
|
+
const dir = dirname3(source);
|
|
7289
|
+
const parsed = JSON.parse(await readFile11(source, "utf8"));
|
|
5050
7290
|
const pre = preflightConfig(parsed);
|
|
5051
7291
|
if (!pre.ok || !pre.config) {
|
|
5052
7292
|
for (const issue of pre.issues) r.log(`error ${issue}`);
|
|
@@ -5215,7 +7455,7 @@ Iterate with: vos push ${source} --vos ${created.id}`
|
|
|
5215
7455
|
async function cmdPullProgram(argv) {
|
|
5216
7456
|
const { positionals, flags } = parseArgs(argv, BOOLEAN_FLAGS2);
|
|
5217
7457
|
const target = positionals[0] ?? ".";
|
|
5218
|
-
const dir = target.endsWith(".json") ?
|
|
7458
|
+
const dir = target.endsWith(".json") ? dirname3(target) : target;
|
|
5219
7459
|
const r = createReporter(flags.json === true);
|
|
5220
7460
|
const origin = platformOrigin({
|
|
5221
7461
|
origin: strFlag(flags, "origin"),
|
|
@@ -5279,10 +7519,10 @@ async function cmdPullProgram(argv) {
|
|
|
5279
7519
|
const cfg = await apiJson(origin, `/api/vos/${vosId}/config`, { key });
|
|
5280
7520
|
if (cfg.status !== 200)
|
|
5281
7521
|
throw new Error(apiError(`fetch head config for ${vosId}`, cfg));
|
|
5282
|
-
const configPath =
|
|
7522
|
+
const configPath = join14(dir, "config.json");
|
|
5283
7523
|
let backedUp = false;
|
|
5284
|
-
if (
|
|
5285
|
-
await
|
|
7524
|
+
if (existsSync11(configPath)) {
|
|
7525
|
+
await writeFile10(join14(dir, "config.backup.json"), await readFile11(configPath));
|
|
5286
7526
|
backedUp = true;
|
|
5287
7527
|
}
|
|
5288
7528
|
let headConfig = cfg.body.config;
|
|
@@ -5301,7 +7541,7 @@ async function cmdPullProgram(argv) {
|
|
|
5301
7541
|
if (own) headConfig = own;
|
|
5302
7542
|
}
|
|
5303
7543
|
}
|
|
5304
|
-
await
|
|
7544
|
+
await writeFile10(configPath, JSON.stringify(headConfig, null, 2));
|
|
5305
7545
|
writeSyncState(dir, {
|
|
5306
7546
|
vosId,
|
|
5307
7547
|
versionId: typeof head.id === "string" ? head.id : since
|
|
@@ -5315,7 +7555,7 @@ async function cmdPullProgram(argv) {
|
|
|
5315
7555
|
protected: protectedIds,
|
|
5316
7556
|
changes,
|
|
5317
7557
|
out: configPath,
|
|
5318
|
-
backup: backedUp ?
|
|
7558
|
+
backup: backedUp ? join14(dir, "config.backup.json") : null
|
|
5319
7559
|
},
|
|
5320
7560
|
`Pulled ${changes.length} version${changes.length === 1 ? "" : "s"} \u2192 ${configPath}` + (backedUp ? ` (previous copy: config.backup.json)` : "") + `
|
|
5321
7561
|
Re-apply your edit on the new head, then: vos push ${configPath} --vos ${vosId}`
|
|
@@ -5369,24 +7609,12 @@ async function storeKey(key, origin, r) {
|
|
|
5369
7609
|
return EXIT_OK;
|
|
5370
7610
|
}
|
|
5371
7611
|
function isTakeDir(target) {
|
|
5372
|
-
|
|
5373
|
-
if (!statSync2(target).isDirectory()) return false;
|
|
5374
|
-
const docPath = join13(target, "doc.json");
|
|
5375
|
-
if (!existsSync9(docPath)) return false;
|
|
5376
|
-
try {
|
|
5377
|
-
const doc = JSON.parse(readFileSync4(docPath, "utf8"));
|
|
5378
|
-
return typeof doc === "object" && doc !== null && "source" in doc;
|
|
5379
|
-
} catch {
|
|
5380
|
-
return true;
|
|
5381
|
-
}
|
|
5382
|
-
} catch {
|
|
5383
|
-
return false;
|
|
5384
|
-
}
|
|
7612
|
+
return directoryKind(target) === "take";
|
|
5385
7613
|
}
|
|
5386
7614
|
async function readProgramDoc(dir, config) {
|
|
5387
|
-
const docPath =
|
|
5388
|
-
if (!
|
|
5389
|
-
const parsed = JSON.parse(await
|
|
7615
|
+
const docPath = join14(dir, "doc.json");
|
|
7616
|
+
if (!existsSync11(docPath)) return null;
|
|
7617
|
+
const parsed = JSON.parse(await readFile11(docPath, "utf8"));
|
|
5390
7618
|
if (typeof parsed !== "object" || parsed === null || "source" in parsed)
|
|
5391
7619
|
return null;
|
|
5392
7620
|
const raw = parsed;
|
|
@@ -5397,14 +7625,14 @@ async function writeProgramDoc(dir, hosted) {
|
|
|
5397
7625
|
const program = hosted.program && typeof hosted.program === "object" ? hosted.program : {};
|
|
5398
7626
|
const { config, ...rest } = program;
|
|
5399
7627
|
const onDisk = { ...hosted, program: rest };
|
|
5400
|
-
await
|
|
7628
|
+
await writeFile10(join14(dir, "doc.json"), JSON.stringify(onDisk, null, 2));
|
|
5401
7629
|
return {
|
|
5402
7630
|
config: config && typeof config === "object" ? config : null
|
|
5403
7631
|
};
|
|
5404
7632
|
}
|
|
5405
7633
|
|
|
5406
7634
|
// src/plugin/asset.ts
|
|
5407
|
-
import { readFileSync as
|
|
7635
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
5408
7636
|
import { basename as basename3, extname as extname2 } from "path";
|
|
5409
7637
|
var BOOLEAN_FLAGS3 = /* @__PURE__ */ new Set(["json", "help"]);
|
|
5410
7638
|
var MIME_BY_EXT = {
|
|
@@ -5458,7 +7686,7 @@ async function cmdPush(argv) {
|
|
|
5458
7686
|
for (const file of positionals) {
|
|
5459
7687
|
const name = basename3(file);
|
|
5460
7688
|
const mime = MIME_BY_EXT[extname2(name).toLowerCase()] ?? "application/octet-stream";
|
|
5461
|
-
const body =
|
|
7689
|
+
const body = readFileSync4(file);
|
|
5462
7690
|
const form = new FormData();
|
|
5463
7691
|
form.set("file", new Blob([body], { type: mime }), name);
|
|
5464
7692
|
if (folder) form.set("folderId", folder.id);
|
|
@@ -5512,7 +7740,7 @@ async function cmdAsset(argv) {
|
|
|
5512
7740
|
}
|
|
5513
7741
|
|
|
5514
7742
|
// src/plugin/recipe.ts
|
|
5515
|
-
import { readFileSync as
|
|
7743
|
+
import { readFileSync as readFileSync5 } from "fs";
|
|
5516
7744
|
import { basename as basename4 } from "path";
|
|
5517
7745
|
var BOOLEAN_FLAGS4 = /* @__PURE__ */ new Set(["json", "help"]);
|
|
5518
7746
|
var USAGE = "vos recipe push <file.md> (--folder <folderId|slug> | --asset <assetId>)";
|
|
@@ -5532,7 +7760,7 @@ async function cmdPush2(argv) {
|
|
|
5532
7760
|
throw new UsageError(USAGE);
|
|
5533
7761
|
}
|
|
5534
7762
|
const filename = recipeFilename(file);
|
|
5535
|
-
const body =
|
|
7763
|
+
const body = readFileSync5(file);
|
|
5536
7764
|
const r = createReporter(flags.json === true);
|
|
5537
7765
|
const origin = platformOrigin({
|
|
5538
7766
|
origin: strFlag(flags, "origin"),
|
|
@@ -5604,9 +7832,10 @@ async function cmdRecipe(argv) {
|
|
|
5604
7832
|
}
|
|
5605
7833
|
|
|
5606
7834
|
// src/plugin/brand.ts
|
|
5607
|
-
import { writeFile as
|
|
5608
|
-
import { resolve as
|
|
5609
|
-
import { parseFrontmatter } from "@vosjs/shared/frontmatter";
|
|
7835
|
+
import { writeFile as writeFile11 } from "fs/promises";
|
|
7836
|
+
import { resolve as resolve5 } from "path";
|
|
7837
|
+
import { parseFrontmatter as parseFrontmatter2 } from "@vosjs/shared/frontmatter";
|
|
7838
|
+
import { lookKindForGround } from "@vosjs/studio-core";
|
|
5610
7839
|
var HEX_RE = /#(?:[0-9a-f]{6}|[0-9a-f]{3})\b/gi;
|
|
5611
7840
|
function extractHexes(text) {
|
|
5612
7841
|
const out = [];
|
|
@@ -5627,13 +7856,13 @@ function rgbToHex(raw) {
|
|
|
5627
7856
|
if (!m) return normalizeHex(raw);
|
|
5628
7857
|
const alpha = m[4];
|
|
5629
7858
|
if (alpha !== void 0 && Number(alpha) === 0) return null;
|
|
5630
|
-
const
|
|
5631
|
-
return `#${
|
|
7859
|
+
const hex2 = (n) => Number(n).toString(16).padStart(2, "0");
|
|
7860
|
+
return `#${hex2(m[1])}${hex2(m[2])}${hex2(m[3])}`;
|
|
5632
7861
|
}
|
|
5633
|
-
function hsl(
|
|
5634
|
-
const r = parseInt(
|
|
5635
|
-
const g = parseInt(
|
|
5636
|
-
const b = parseInt(
|
|
7862
|
+
function hsl(hex2) {
|
|
7863
|
+
const r = parseInt(hex2.slice(1, 3), 16) / 255;
|
|
7864
|
+
const g = parseInt(hex2.slice(3, 5), 16) / 255;
|
|
7865
|
+
const b = parseInt(hex2.slice(5, 7), 16) / 255;
|
|
5637
7866
|
const max = Math.max(r, g, b);
|
|
5638
7867
|
const min = Math.min(r, g, b);
|
|
5639
7868
|
const l = (max + min) / 2;
|
|
@@ -5648,12 +7877,12 @@ function hsl(hex) {
|
|
|
5648
7877
|
}
|
|
5649
7878
|
return { h, s, l };
|
|
5650
7879
|
}
|
|
5651
|
-
function isSaturated(
|
|
5652
|
-
const { s, l } = hsl(
|
|
7880
|
+
function isSaturated(hex2) {
|
|
7881
|
+
const { s, l } = hsl(hex2);
|
|
5653
7882
|
return s >= 0.25 && l > 0.12 && l < 0.88;
|
|
5654
7883
|
}
|
|
5655
|
-
var isLight = (
|
|
5656
|
-
function
|
|
7884
|
+
var isLight = (hex2) => hsl(hex2).l >= 0.5;
|
|
7885
|
+
function mixHex2(a, b, t) {
|
|
5657
7886
|
const ch = (i) => Math.round(
|
|
5658
7887
|
parseInt(a.slice(i, i + 2), 16) * (1 - t) + parseInt(b.slice(i, i + 2), 16) * t
|
|
5659
7888
|
).toString(16).padStart(2, "0");
|
|
@@ -5679,7 +7908,7 @@ var FONT_HINT_RE = /\b(?:[Uu]se|[Uu]ses|[Ss]et in)\s+([A-Z][A-Za-z0-9]+(?:\s+[A-
|
|
|
5679
7908
|
var FONT_FAMILY_RE = /font-family\s*:\s*["']?([^;"',\n]+)/gi;
|
|
5680
7909
|
var LOGO_URL_RE = /https?:\/\/[^\s)"'<>]+?(?:logo|wordmark|mark|brand|icon)[^\s)"'<>]*?\.(?:svg|png|webp)/gi;
|
|
5681
7910
|
function parseDesignMd(text) {
|
|
5682
|
-
const fm =
|
|
7911
|
+
const fm = parseFrontmatter2(text);
|
|
5683
7912
|
const fonts = [];
|
|
5684
7913
|
for (const m of text.matchAll(FONT_HINT_RE)) {
|
|
5685
7914
|
const f = m[1].trim();
|
|
@@ -5790,7 +8019,7 @@ function composeBrand(input) {
|
|
|
5790
8019
|
p.bgA = `the body's background`;
|
|
5791
8020
|
const surfaceHexes = w.surfaces.map(rgbToHex).filter((h) => h !== null && h !== bgA);
|
|
5792
8021
|
const neutralSurface = mostCommon(surfaceHexes.filter((h) => !isSaturated(h)));
|
|
5793
|
-
const bgB = neutralSurface ??
|
|
8022
|
+
const bgB = neutralSurface ?? mixHex2(bgA, isLight(bgA) ? "#000000" : "#ffffff", 0.04);
|
|
5794
8023
|
p.bgB = neutralSurface ? `the most common section / card ground` : `no distinct surface found; bgA stepped 4% toward ${isLight(bgA) ? "black" : "white"}`;
|
|
5795
8024
|
const accentHexes = w.accents.map(rgbToHex).filter((h) => h !== null && isSaturated(h));
|
|
5796
8025
|
const themeHex = w.themeColor ? rgbToHex(w.themeColor) : null;
|
|
@@ -5800,7 +8029,7 @@ function composeBrand(input) {
|
|
|
5800
8029
|
const inkHex = rgbToHex(w.h1?.color ?? w.body.color) ?? (isLight(bgA) ? "#111111" : "#f5f5f5");
|
|
5801
8030
|
if (!accent) accent = inkHex;
|
|
5802
8031
|
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`;
|
|
5803
|
-
const bgC =
|
|
8032
|
+
const bgC = mixHex2(bgA, accent, 0.14);
|
|
5804
8033
|
p.bgC = `bgA tinted 14% toward the accent (a highlight ground)`;
|
|
5805
8034
|
p.ink = w.h1 ? `the h1's colour` : `the body's colour`;
|
|
5806
8035
|
const fontDisplay = design?.fonts[0] ?? (w.h1 ? firstFamily(w.h1.fontFamily) : firstFamily(w.body.fontFamily));
|
|
@@ -5831,8 +8060,10 @@ function composeBrand(input) {
|
|
|
5831
8060
|
ogImage: w.ogImage,
|
|
5832
8061
|
wordmark,
|
|
5833
8062
|
designMd: input.designUrl,
|
|
5834
|
-
llmsTxt: input.llmsUrl
|
|
8063
|
+
llmsTxt: input.llmsUrl,
|
|
8064
|
+
look: lookKindForGround(bgA)
|
|
5835
8065
|
};
|
|
8066
|
+
p.look = `from the body's ground (${bgA}): a paper site is a plate, a dark site is dark, else the gradient`;
|
|
5836
8067
|
return { kit, provenance: p, avoid: design?.avoid ?? [] };
|
|
5837
8068
|
}
|
|
5838
8069
|
function renderBrandMd(c, claim) {
|
|
@@ -5859,6 +8090,7 @@ function renderBrandMd(c, claim) {
|
|
|
5859
8090
|
`wordmark: ${q(k.wordmark)}`,
|
|
5860
8091
|
`designMd: ${q(k.designMd)}`,
|
|
5861
8092
|
`llmsTxt: ${q(k.llmsTxt)}`,
|
|
8093
|
+
`look: ${q(k.look)}`,
|
|
5862
8094
|
"---"
|
|
5863
8095
|
].join("\n");
|
|
5864
8096
|
const lines = [
|
|
@@ -5909,7 +8141,7 @@ async function cmdBrand(argv) {
|
|
|
5909
8141
|
throw new UsageError(`not a URL: ${raw}`);
|
|
5910
8142
|
}
|
|
5911
8143
|
const r = createReporter(flags.json === true);
|
|
5912
|
-
const out =
|
|
8144
|
+
const out = resolve5(strFlag(flags, "out") ?? "BRAND.md");
|
|
5913
8145
|
r.log(`reading ${origin}/design.md and /llms.txt\u2026`);
|
|
5914
8146
|
const [designText, llmsText] = await Promise.all([
|
|
5915
8147
|
fetchText(`${origin}/design.md`),
|
|
@@ -5943,7 +8175,7 @@ async function cmdBrand(argv) {
|
|
|
5943
8175
|
composition,
|
|
5944
8176
|
llms?.claim ?? design?.description ?? null
|
|
5945
8177
|
);
|
|
5946
|
-
await
|
|
8178
|
+
await writeFile11(out, md);
|
|
5947
8179
|
const k = composition.kit;
|
|
5948
8180
|
r.done(
|
|
5949
8181
|
{
|
|
@@ -5961,8 +8193,8 @@ async function cmdBrand(argv) {
|
|
|
5961
8193
|
}
|
|
5962
8194
|
|
|
5963
8195
|
// src/plugin/agentBrowser.ts
|
|
5964
|
-
import { writeFile as
|
|
5965
|
-
import { resolve as
|
|
8196
|
+
import { writeFile as writeFile12 } from "fs/promises";
|
|
8197
|
+
import { resolve as resolve6 } from "path";
|
|
5966
8198
|
function parseAgentBrowserLog(text) {
|
|
5967
8199
|
const records = [];
|
|
5968
8200
|
const problems = [];
|
|
@@ -6432,7 +8664,7 @@ async function cmdActions(argv) {
|
|
|
6432
8664
|
);
|
|
6433
8665
|
}
|
|
6434
8666
|
const r = createReporter(flags.json === true);
|
|
6435
|
-
const out =
|
|
8667
|
+
const out = resolve6(strFlag(flags, "out") ?? "actions.json");
|
|
6436
8668
|
const viewportFlag = strFlag(flags, "viewport");
|
|
6437
8669
|
let viewport;
|
|
6438
8670
|
if (viewportFlag) {
|
|
@@ -6440,8 +8672,8 @@ async function cmdActions(argv) {
|
|
|
6440
8672
|
if (!m) throw new UsageError("--viewport expects WxH, e.g. 1280x720");
|
|
6441
8673
|
viewport = { width: Number(m[1]), height: Number(m[2]) };
|
|
6442
8674
|
}
|
|
6443
|
-
const { readFile:
|
|
6444
|
-
const text = await
|
|
8675
|
+
const { readFile: readFile13 } = await import("fs/promises");
|
|
8676
|
+
const text = await readFile13(resolve6(input), "utf8");
|
|
6445
8677
|
const { records, problems } = parseAgentBrowserLog(text);
|
|
6446
8678
|
for (const p of problems) r.log(` ${p}`);
|
|
6447
8679
|
const result = convertAgentBrowser(records, {
|
|
@@ -6458,7 +8690,7 @@ async function cmdActions(argv) {
|
|
|
6458
8690
|
);
|
|
6459
8691
|
return EXIT_ERROR;
|
|
6460
8692
|
}
|
|
6461
|
-
await
|
|
8693
|
+
await writeFile12(out, `${JSON.stringify(result.actions, null, 2)}
|
|
6462
8694
|
`);
|
|
6463
8695
|
r.done(
|
|
6464
8696
|
{ ok: true, out, url: result.actions.url ?? null, ...summary(result) },
|
|
@@ -6481,6 +8713,7 @@ function summary(result) {
|
|
|
6481
8713
|
var BOOLEAN_FLAGS5 = /* @__PURE__ */ new Set([
|
|
6482
8714
|
"json",
|
|
6483
8715
|
"help",
|
|
8716
|
+
"picture",
|
|
6484
8717
|
"fresh",
|
|
6485
8718
|
"reuse",
|
|
6486
8719
|
"strict",
|
|
@@ -6490,7 +8723,9 @@ var BOOLEAN_FLAGS5 = /* @__PURE__ */ new Set([
|
|
|
6490
8723
|
"no-frames",
|
|
6491
8724
|
"media",
|
|
6492
8725
|
"print",
|
|
6493
|
-
"yes"
|
|
8726
|
+
"yes",
|
|
8727
|
+
"composed",
|
|
8728
|
+
"check"
|
|
6494
8729
|
]);
|
|
6495
8730
|
var MULTI_FLAGS2 = /* @__PURE__ */ new Set(["set", "override"]);
|
|
6496
8731
|
var HELP = `vos \u2014 record a browser flow, plan effects, render a product video; sync with vos.so
|
|
@@ -6501,11 +8736,12 @@ Take pipeline
|
|
|
6501
8736
|
vos plan <take> [--fresh] [--reuse [--from <doc.json>]] [--style <doc.json|vosId>] [--background <slug|url|none>] [--json]
|
|
6502
8737
|
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]
|
|
6503
8738
|
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]
|
|
6504
|
-
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]
|
|
8739
|
+
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]
|
|
6505
8740
|
vos digest <take> [--out dir] [--full 960] [--crop 640] [--no-frames] [--transcript <file.json>] [--style <doc.json|vosId>] [--json]
|
|
6506
8741
|
vos brand <url> [--out BRAND.md] [--json]
|
|
6507
8742
|
vos open <take> [--studio <url>] [--print]
|
|
6508
|
-
vos validate <actions.json|take> [--json]
|
|
8743
|
+
vos validate <actions.json|take|kit.json> [--picture] [--json]
|
|
8744
|
+
vos judge <kit.json> --against <MANIFEST.json> [--out dir] [--json]
|
|
6509
8745
|
vos actions from-agent-browser <steps.jsonl> [--out actions.json] [--url <url>] [--viewport WxH] [--json]
|
|
6510
8746
|
|
|
6511
8747
|
Platform (vos.so) \u2014 fetch, edit, push, pull, repeat
|
|
@@ -6581,7 +8817,8 @@ actions.json so anchors survive script edits.
|
|
|
6581
8817
|
record --strict exits 2 when any selector was skipped, the page never
|
|
6582
8818
|
reached networkidle, or the take hit --max-duration (the done event lists
|
|
6583
8819
|
skipped[] and capped either way). --max-duration defaults to the hosted
|
|
6584
|
-
recording cap
|
|
8820
|
+
recording cap, read live from GET /api/limits (30 min when the origin is
|
|
8821
|
+
unreachable): the capture stops there and says so in one line.
|
|
6585
8822
|
frames captures PNG stills at OUTPUT times (seconds or % of duration);
|
|
6586
8823
|
--at-zooms adds every zoom span's apex, --at-moments every digest moment \u2014
|
|
6587
8824
|
the agent's eyes AND exact-size posters/screenshots (--size WxH). render
|
|
@@ -6592,13 +8829,47 @@ channel specs (schema/channel-specs.json, verified sizes for the Chrome
|
|
|
6592
8829
|
Web Store, Product Hunt, X, LinkedIn, OG, GitHub, YouTube) drive stills at
|
|
6593
8830
|
exact pixels and video cuts, every artifact is VERIFIED against its spec
|
|
6594
8831
|
(px, bytes, duration; misses land in skipped[] with the reason), and
|
|
6595
|
-
kit.json beside the assets is the manifest. Still times
|
|
6596
|
-
|
|
6597
|
-
|
|
6598
|
-
|
|
6599
|
-
|
|
6600
|
-
|
|
6601
|
-
|
|
8832
|
+
kit.json beside the assets is the manifest. Still times come from the
|
|
8833
|
+
STORY: every step's end plus a 0.4 s settle (the response, not the
|
|
8834
|
+
travel), then the zoom apexes, then an even spread; each candidate is
|
|
8835
|
+
read once as the real page, blank ones (a wallpaper, an empty canvas) are
|
|
8836
|
+
dropped and two of one frame collapse to one, with every drop said in
|
|
8837
|
+
skipped[]. --times overrides with seconds, percents or step:<id>[+offset]
|
|
8838
|
+
(the id from actions.json); --range cuts every video destination.
|
|
8839
|
+
The LOOK presents the card: card-genre stills with no poster and every
|
|
8840
|
+
video cut sit on a ground (a cream plate, the house gradient, a dark plate
|
|
8841
|
+
with a light streak) at ~84% of the width with headroom, a soft ambient
|
|
8842
|
+
shadow plus a tight contact shadow, and a hairline when card and ground
|
|
8843
|
+
are both light; a wide frame runs the card off the bottom. --look picks a
|
|
8844
|
+
house look (or none for the pre-look crops); with no flag the BRAND.md
|
|
8845
|
+
beside the take (or --brand <file>) decides from its look role or its own
|
|
8846
|
+
ground (a paper site is a plate, a dark site is dark), and with no brand
|
|
8847
|
+
the house gradient. Screenshot-genre stills never take a look.
|
|
8848
|
+
Card-genre destinations (OG, LinkedIn, X, YouTube thumbnail, the CWS
|
|
8849
|
+
tile + marquee, GitHub social preview) COMPOSE by default: each renders
|
|
8850
|
+
from its destination's poster TEMPLATE (split-cover carries a headline
|
|
8851
|
+
column beside the shot; card-on-gradient is the shot alone on a mesh, the
|
|
8852
|
+
store's tile rule), filled with BRAND.md's colours and faces and the
|
|
8853
|
+
release's words, the shot baked as an object (padded, rounded, shadowed,
|
|
8854
|
+
a hairline on a light ground), PNG at exact pixels; kit.json records the
|
|
8855
|
+
template and the text boxes. The headline is LAUNCH.md's headline role
|
|
8856
|
+
beside the take or --headline (with none, the headline templates stand
|
|
8857
|
+
down for card-on-gradient, said); --kicker overrides the wordmark plus
|
|
8858
|
+
--release line. --poster names a bundled template for every card, your
|
|
8859
|
+
own template (a config.json or a hosted vos id carrying a template
|
|
8860
|
+
block), or none to keep the take path.
|
|
8861
|
+
Every video cut but the README loop opens on an ENTRANCE (tilt-in by
|
|
8862
|
+
default: the card swings in from a perspective pose and settles) and
|
|
8863
|
+
closes on an END CARD (the last frame holds 2.5 s while the card recedes
|
|
8864
|
+
and the headline, the release line and the wordmark rise); LAUNCH.md's
|
|
8865
|
+
entrance and endCard roles, or --entrance and --end-card, change or
|
|
8866
|
+
switch them off. A step's caption in actions.json lands as a lower-third
|
|
8867
|
+
at the step's moment on cuts that take words (--captions none to skip).
|
|
8868
|
+
Destinations that play sound (the X cut, the YouTube demo, the vertical
|
|
8869
|
+
cut) take a music bed from LAUNCH.md's music role (a catalog slug or a
|
|
8870
|
+
mood; --music overrides) and a click sound on every press when the take
|
|
8871
|
+
has no mic (--clicks none). The 9:16 cut is a reframe, not a letterbox:
|
|
8872
|
+
the crop follows the camera. --shot-time <t> picks the take moment (OUTPUT seconds;
|
|
6602
8873
|
default the first still time \u2014 pick a zoom apex, the cut's camera makes
|
|
6603
8874
|
the shot the feature, not the whole page); --poster-time <t> is the instant
|
|
6604
8875
|
inside the poster's OWN timeline (default 90% through it). Screenshot-genre
|
|
@@ -6608,7 +8879,26 @@ policy); --composed keeps the cut's camera and chrome instead. --set
|
|
|
6608
8879
|
path=value overrides the doc in memory for every render here (the user's
|
|
6609
8880
|
sets apply last). Store uploads stay manual: hand the human the kit
|
|
6610
8881
|
directory, then vos validate <kit.json> re-measures every asset from its
|
|
6611
|
-
bytes against the channel specs
|
|
8882
|
+
bytes against the channel specs; --picture adds what each asset LOOKS
|
|
8883
|
+
like, read from its pixels: blank (a wallpaper or an empty canvas where
|
|
8884
|
+
the product should be), duplicate (two stills of one frame), subject (the
|
|
8885
|
+
card off the 60 to 92% band, or a crop where a card was asked for),
|
|
8886
|
+
separation (a light card on a light ground with no shadow), halfsize (a
|
|
8887
|
+
tile that loses its edges when the store shrinks it), and, where the kit
|
|
8888
|
+
records its text boxes, sliced, safe and contrast (APCA Lc 60/75); a
|
|
8889
|
+
video's first and last frames are read through ffmpeg. Every finding
|
|
8890
|
+
carries a code, a severity, a fix hint and a box; an error fails the
|
|
8891
|
+
verdict beside the spec problems.
|
|
8892
|
+
judge puts the kit beside its REFERENCES: for every still that has a
|
|
8893
|
+
reference of its role in the manifest (a template family, a card genre),
|
|
8894
|
+
two sheets at a common height (the asset left, then right, so order
|
|
8895
|
+
cannot bias the call) and the rubric in words, plus judge.json, a slot
|
|
8896
|
+
per pair the judge fills (win true, false or null for a tie, with the
|
|
8897
|
+
rule numbers). No model runs inside the verb: the skill judges the
|
|
8898
|
+
sheets pairwise, both orders, and the verb reports the win rate beside
|
|
8899
|
+
the spec and picture counts. The reference set is the maker's own
|
|
8900
|
+
fixture folder with a MANIFEST.json (id, file, role, layout, facts,
|
|
8901
|
+
rule per asset).
|
|
6612
8902
|
brand writes the product's BRAND.md, witnessed: it reads /design.md when
|
|
6613
8903
|
the site publishes one (the convention beside /llms.txt: fonts, logo assets,
|
|
6614
8904
|
an avoid list), /llms.txt for the name and the claim, then the page itself
|
|
@@ -6670,7 +8960,7 @@ lint-gated, so a bad override fails like a bad doc.json):
|
|
|
6670
8960
|
first ready loop (the house backdrop the studio opens on), or a flat ground offline
|
|
6671
8961
|
`;
|
|
6672
8962
|
async function loadActions(file) {
|
|
6673
|
-
const raw = JSON.parse(await
|
|
8963
|
+
const raw = JSON.parse(await readFile12(file, "utf8"));
|
|
6674
8964
|
const errors = validateActions(raw);
|
|
6675
8965
|
if (errors.length)
|
|
6676
8966
|
throw new UsageError(`invalid actions file:
|
|
@@ -6726,16 +9016,16 @@ async function cmdRecord(argv) {
|
|
|
6726
9016
|
const url = strFlag(flags, "url") ?? actions.url;
|
|
6727
9017
|
if (!url)
|
|
6728
9018
|
throw new UsageError('no URL \u2014 set "url" in the actions file or pass --url');
|
|
6729
|
-
const outDir =
|
|
9019
|
+
const outDir = resolve7(strFlag(flags, "out") ?? "take");
|
|
6730
9020
|
const backdrop = await takeBackdrop(flags, r);
|
|
6731
9021
|
const maxDurationSeconds = await maxDuration(flags, r);
|
|
6732
|
-
if (
|
|
9022
|
+
if (existsSync12(join15(outDir, "meta.json"))) {
|
|
6733
9023
|
const { prevDoc, kept } = await prepareReRecord(outDir);
|
|
6734
9024
|
r.log(
|
|
6735
9025
|
`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" : "")
|
|
6736
9026
|
);
|
|
6737
9027
|
}
|
|
6738
|
-
await
|
|
9028
|
+
await mkdir8(outDir, { recursive: true });
|
|
6739
9029
|
const paths = await ensureTakeDir(outDir);
|
|
6740
9030
|
const browser = await launchBrowser();
|
|
6741
9031
|
try {
|
|
@@ -6776,7 +9066,7 @@ async function cmdRecord(argv) {
|
|
|
6776
9066
|
},
|
|
6777
9067
|
strictFail ? `STRICT: take recorded but incomplete \u2014 ${strictReason(rec)}; fix the flow and re-record.${skippedNote}` : `Take ready: ${outDir}
|
|
6778
9068
|
${(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}
|
|
6779
|
-
Next: edit ${
|
|
9069
|
+
Next: edit ${join15(outDir, "doc.json")} (optional), then: vos render ${outDir}`
|
|
6780
9070
|
);
|
|
6781
9071
|
return strictFail ? EXIT_USAGE : EXIT_OK;
|
|
6782
9072
|
} finally {
|
|
@@ -6800,25 +9090,25 @@ async function cmdCreate2(argv) {
|
|
|
6800
9090
|
const url = strFlag(flags, "url") ?? actions.url;
|
|
6801
9091
|
if (!url)
|
|
6802
9092
|
throw new UsageError('no URL \u2014 set "url" in the actions file or pass --url');
|
|
6803
|
-
const outDir =
|
|
9093
|
+
const outDir = resolve7(strFlag(flags, "out") ?? "take");
|
|
6804
9094
|
const fmtRaw = strFlag(flags, "format") ?? "webm";
|
|
6805
9095
|
if (fmtRaw !== "webm" && fmtRaw !== "mp4")
|
|
6806
9096
|
throw new UsageError("--format must be webm or mp4");
|
|
6807
9097
|
const format = fmtRaw;
|
|
6808
|
-
const out = positionals[0] ??
|
|
9098
|
+
const out = positionals[0] ?? join15(outDir, `out.${format}`);
|
|
6809
9099
|
const parallel = numFlag(flags, "parallel", 1);
|
|
6810
9100
|
if (!Number.isInteger(parallel) || parallel < 1 || parallel > 16) {
|
|
6811
9101
|
throw new UsageError("--parallel expects an integer between 1 and 16");
|
|
6812
9102
|
}
|
|
6813
9103
|
const backdrop = await takeBackdrop(flags, r);
|
|
6814
9104
|
const maxDurationSeconds = await maxDuration(flags, r);
|
|
6815
|
-
if (
|
|
9105
|
+
if (existsSync12(join15(outDir, "meta.json"))) {
|
|
6816
9106
|
const { prevDoc, kept } = await prepareReRecord(outDir);
|
|
6817
9107
|
r.log(
|
|
6818
9108
|
`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" : "")
|
|
6819
9109
|
);
|
|
6820
9110
|
}
|
|
6821
|
-
await
|
|
9111
|
+
await mkdir8(outDir, { recursive: true });
|
|
6822
9112
|
const paths = await ensureTakeDir(outDir);
|
|
6823
9113
|
const browser = await launchBrowser();
|
|
6824
9114
|
try {
|
|
@@ -6899,24 +9189,24 @@ async function cmdPlan(argv) {
|
|
|
6899
9189
|
);
|
|
6900
9190
|
const r = createReporter(flags.json === true);
|
|
6901
9191
|
if (flags.fresh === true) {
|
|
6902
|
-
await rm4(
|
|
9192
|
+
await rm4(join15(dir, "doc.json"), { force: true });
|
|
6903
9193
|
r.log("note: --fresh discarded the existing doc.json");
|
|
6904
9194
|
}
|
|
6905
9195
|
let reuse;
|
|
6906
9196
|
if (flags.reuse === true) {
|
|
6907
|
-
const from =
|
|
6908
|
-
if (!
|
|
9197
|
+
const from = resolve7(strFlag(flags, "from") ?? join15(dir, PREV_DOC_NAME));
|
|
9198
|
+
if (!existsSync12(from)) {
|
|
6909
9199
|
throw new UsageError(
|
|
6910
9200
|
`--reuse: ${from} does not exist \u2014 a re-record into this take writes doc.prev.json, or pass --from <doc.json>`
|
|
6911
9201
|
);
|
|
6912
9202
|
}
|
|
6913
9203
|
reuse = {
|
|
6914
9204
|
from,
|
|
6915
|
-
doc: JSON.parse(await
|
|
9205
|
+
doc: JSON.parse(await readFile12(from, "utf8"))
|
|
6916
9206
|
};
|
|
6917
9207
|
}
|
|
6918
9208
|
const style = await resolveStyleRef(flags);
|
|
6919
|
-
const backdrop =
|
|
9209
|
+
const backdrop = existsSync12(join15(dir, "doc.json")) ? null : await takeBackdrop(flags, r);
|
|
6920
9210
|
const s = await planTake(dir, {
|
|
6921
9211
|
...style ? { style } : {},
|
|
6922
9212
|
...reuse ? { reuse } : {},
|
|
@@ -6928,7 +9218,7 @@ async function cmdPlan(argv) {
|
|
|
6928
9218
|
${s.reuse.flagged.join("\n ")}` : "") : "";
|
|
6929
9219
|
r.done(
|
|
6930
9220
|
{
|
|
6931
|
-
take:
|
|
9221
|
+
take: resolve7(dir),
|
|
6932
9222
|
fresh: s.fresh,
|
|
6933
9223
|
cursorKept: s.cursorKept,
|
|
6934
9224
|
zoomAuto: s.zoomAuto,
|
|
@@ -6938,7 +9228,7 @@ async function cmdPlan(argv) {
|
|
|
6938
9228
|
...s.styleFrom ? { styleFrom: s.styleFrom, styleFields: s.styleFields } : {},
|
|
6939
9229
|
...s.reuse ? { reuse: s.reuse } : {}
|
|
6940
9230
|
},
|
|
6941
|
-
`${s.reuse ? "Reused" : s.fresh ? "Planned" : "Refreshed"} ${
|
|
9231
|
+
`${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}`
|
|
6942
9232
|
);
|
|
6943
9233
|
return EXIT_OK;
|
|
6944
9234
|
}
|
|
@@ -6958,7 +9248,7 @@ async function cmdRender(argv) {
|
|
|
6958
9248
|
if (fmtRaw !== "webm" && fmtRaw !== "mp4")
|
|
6959
9249
|
throw new UsageError("--format must be webm or mp4");
|
|
6960
9250
|
const format = fmtRaw;
|
|
6961
|
-
const out = positionals[1] ??
|
|
9251
|
+
const out = positionals[1] ?? join15(dir, `out.${format}`);
|
|
6962
9252
|
const parallel = numFlag(flags, "parallel", 1);
|
|
6963
9253
|
if (!Number.isInteger(parallel) || parallel < 1 || parallel > 16) {
|
|
6964
9254
|
throw new UsageError("--parallel expects an integer between 1 and 16");
|
|
@@ -7034,7 +9324,7 @@ async function cmdFrames(argv) {
|
|
|
7034
9324
|
if (!m) throw new UsageError("--size expects WxH (e.g. --size 1280x800)");
|
|
7035
9325
|
size = { width: Number(m[1]), height: Number(m[2]) };
|
|
7036
9326
|
}
|
|
7037
|
-
const duration = totalDuration2(
|
|
9327
|
+
const duration = totalDuration2(ratedSegments6(take.doc));
|
|
7038
9328
|
const frameRaw = strFlag(flags, "frame");
|
|
7039
9329
|
const timesRaw = strFlag(flags, "times");
|
|
7040
9330
|
const atZooms = flags["at-zooms"] === true;
|
|
@@ -7121,12 +9411,13 @@ async function cmdDeliver(argv) {
|
|
|
7121
9411
|
}
|
|
7122
9412
|
const take = await loadTake(dir);
|
|
7123
9413
|
if (!take.doc) throw new UsageError(`${dir} has no doc.json \u2014 run plan first`);
|
|
7124
|
-
const duration = totalDuration2(
|
|
9414
|
+
const duration = totalDuration2(ratedSegments6(take.doc));
|
|
7125
9415
|
const timesRaw = strFlag(flags, "times");
|
|
7126
9416
|
let times;
|
|
7127
9417
|
if (timesRaw !== void 0) {
|
|
7128
9418
|
try {
|
|
7129
|
-
|
|
9419
|
+
const doc = take.doc;
|
|
9420
|
+
times = timesRaw.split(",").map((s) => s.trim()).filter(Boolean).map((s) => resolveStepTime(doc, s) ?? parseTimes(s, duration)[0]);
|
|
7130
9421
|
} catch (e) {
|
|
7131
9422
|
throw new UsageError(e instanceof Error ? e.message : String(e));
|
|
7132
9423
|
}
|
|
@@ -7137,11 +9428,15 @@ async function cmdDeliver(argv) {
|
|
|
7137
9428
|
}
|
|
7138
9429
|
let poster;
|
|
7139
9430
|
const posterRef = strFlag(flags, "poster");
|
|
7140
|
-
if (posterRef
|
|
7141
|
-
|
|
9431
|
+
if (posterRef === "none") {
|
|
9432
|
+
poster = null;
|
|
9433
|
+
} else if (posterRef !== void 0 && templateByName(posterRef)) {
|
|
9434
|
+
poster = { from: `template ${posterRef}`, config: templateByName(posterRef) };
|
|
9435
|
+
} else if (posterRef !== void 0) {
|
|
9436
|
+
if (existsSync12(posterRef)) {
|
|
7142
9437
|
poster = {
|
|
7143
|
-
from:
|
|
7144
|
-
config: JSON.parse(await
|
|
9438
|
+
from: resolve7(posterRef),
|
|
9439
|
+
config: JSON.parse(await readFile12(posterRef, "utf8"))
|
|
7145
9440
|
};
|
|
7146
9441
|
} else {
|
|
7147
9442
|
const origin = platformOrigin({
|
|
@@ -7159,9 +9454,65 @@ async function cmdDeliver(argv) {
|
|
|
7159
9454
|
poster = { from: posterRef, config: res.config };
|
|
7160
9455
|
}
|
|
7161
9456
|
}
|
|
9457
|
+
let lookPick;
|
|
9458
|
+
try {
|
|
9459
|
+
lookPick = await resolveLook(dir, {
|
|
9460
|
+
look: strFlag(flags, "look"),
|
|
9461
|
+
brand: strFlag(flags, "brand")
|
|
9462
|
+
});
|
|
9463
|
+
} catch (e) {
|
|
9464
|
+
throw new UsageError(e instanceof Error ? e.message : String(e));
|
|
9465
|
+
}
|
|
9466
|
+
r.log(`look: ${lookPick.from}`);
|
|
9467
|
+
const launch = await readLaunchBesideTake(dir, strFlag(flags, "launch"));
|
|
9468
|
+
const lines = (v) => v === null || v === void 0 ? null : v.replace(/\\n/g, "\n");
|
|
9469
|
+
const words = {
|
|
9470
|
+
headline: lines(strFlag(flags, "headline") ?? launch?.roles.headline),
|
|
9471
|
+
kicker: lines(strFlag(flags, "kicker") ?? launch?.roles.kicker),
|
|
9472
|
+
brand: strFlag(flags, "brand-name") ?? lookPick.roles?.wordmark ?? null,
|
|
9473
|
+
release: strFlag(flags, "release") ?? null
|
|
9474
|
+
};
|
|
9475
|
+
if (launch) r.log(`words: ${launch.file}`);
|
|
9476
|
+
const launchRoles = { ...launch?.roles ?? {} };
|
|
9477
|
+
for (const [flag, role] of [
|
|
9478
|
+
["music", "music"],
|
|
9479
|
+
["entrance", "entrance"],
|
|
9480
|
+
["end-card", "endCard"],
|
|
9481
|
+
["captions", "captions"],
|
|
9482
|
+
["clicks", "clicks"]
|
|
9483
|
+
]) {
|
|
9484
|
+
const v = strFlag(flags, flag);
|
|
9485
|
+
if (v !== void 0) launchRoles[role] = v;
|
|
9486
|
+
}
|
|
9487
|
+
const soundWanted = channels.some(
|
|
9488
|
+
(c) => ["x", "youtube", "shorts-linkedin"].includes(c)
|
|
9489
|
+
);
|
|
9490
|
+
let catalog = null;
|
|
9491
|
+
if (soundWanted && !/^(none|off|no|false)$/i.test(launchRoles.music ?? "") && !/^(none|off|no|false)$/i.test(launchRoles.clicks ?? "")) {
|
|
9492
|
+
try {
|
|
9493
|
+
catalog = await fetchMusicCatalog(
|
|
9494
|
+
platformOrigin({
|
|
9495
|
+
origin: strFlag(flags, "origin"),
|
|
9496
|
+
api: strFlag(flags, "api")
|
|
9497
|
+
})
|
|
9498
|
+
);
|
|
9499
|
+
} catch (e) {
|
|
9500
|
+
r.log(`music catalog: ${e instanceof Error ? e.message : String(e)} \u2014 the cuts stay silent`);
|
|
9501
|
+
}
|
|
9502
|
+
}
|
|
9503
|
+
const captions = (take.actions?.steps ?? []).flatMap((s, i) => {
|
|
9504
|
+
const step = s;
|
|
9505
|
+
return typeof step.caption === "string" && step.caption.trim() ? [{ step: i, id: step.id, caption: step.caption.trim() }] : [];
|
|
9506
|
+
});
|
|
7162
9507
|
const browser = await launchBrowser();
|
|
7163
9508
|
try {
|
|
7164
9509
|
const result = await deliverTake(browser, dir, {
|
|
9510
|
+
look: lookPick.look,
|
|
9511
|
+
brandRoles: lookPick.roles,
|
|
9512
|
+
launchRoles,
|
|
9513
|
+
catalog,
|
|
9514
|
+
captions,
|
|
9515
|
+
words,
|
|
7165
9516
|
channels,
|
|
7166
9517
|
outDir: strFlag(flags, "out"),
|
|
7167
9518
|
release: strFlag(flags, "release"),
|
|
@@ -7211,14 +9562,14 @@ Store uploads stay manual: hand the human this directory and the manifest.`
|
|
|
7211
9562
|
async function resolveStyleRef(flags) {
|
|
7212
9563
|
const styleRef = strFlag(flags, "style");
|
|
7213
9564
|
if (!styleRef) return null;
|
|
7214
|
-
const file =
|
|
9565
|
+
const file = existsSync12(styleRef) ? resolve7(
|
|
7215
9566
|
styleRef,
|
|
7216
|
-
|
|
9567
|
+
existsSync12(join15(styleRef, "doc.json")) ? "doc.json" : ""
|
|
7217
9568
|
) : null;
|
|
7218
|
-
if (file &&
|
|
9569
|
+
if (file && existsSync12(file)) {
|
|
7219
9570
|
return {
|
|
7220
9571
|
from: file,
|
|
7221
|
-
doc: JSON.parse(await
|
|
9572
|
+
doc: JSON.parse(await readFile12(file, "utf8"))
|
|
7222
9573
|
};
|
|
7223
9574
|
}
|
|
7224
9575
|
const origin = platformOrigin({
|
|
@@ -7255,7 +9606,7 @@ async function cmdDigest(argv) {
|
|
|
7255
9606
|
const take = await loadTake(dir);
|
|
7256
9607
|
if (!take.doc) throw new UsageError(`${dir} has no doc.json \u2014 run plan first`);
|
|
7257
9608
|
const transcriptPath = strFlag(flags, "transcript");
|
|
7258
|
-
const transcript = transcriptPath ? parseTranscript(JSON.parse(await
|
|
9609
|
+
const transcript = transcriptPath ? parseTranscript(JSON.parse(await readFile12(transcriptPath, "utf8"))) : null;
|
|
7259
9610
|
const style = await resolveStyleRef(flags);
|
|
7260
9611
|
const noFrames = flags["no-frames"] === true;
|
|
7261
9612
|
let browser = null;
|
|
@@ -7286,7 +9637,7 @@ async function cmdDigest(argv) {
|
|
|
7286
9637
|
kinds,
|
|
7287
9638
|
frames: d.images.full,
|
|
7288
9639
|
crops: d.images.crop,
|
|
7289
|
-
sheet: d.images.sheet ?
|
|
9640
|
+
sheet: d.images.sheet ? join15(result.outDir, d.images.sheet) : null,
|
|
7290
9641
|
scenes: kinds.scene ?? 0,
|
|
7291
9642
|
sourceDuration: d.take.sourceDuration,
|
|
7292
9643
|
outputDuration: d.take.outputDuration,
|
|
@@ -7310,14 +9661,14 @@ async function cmdOpen(argv) {
|
|
|
7310
9661
|
if (!dir) throw new UsageError("vos open <take> [--studio <url>]");
|
|
7311
9662
|
const r = createReporter(flags.json === true);
|
|
7312
9663
|
const take = await loadTake(dir);
|
|
7313
|
-
if (!
|
|
9664
|
+
if (!existsSync12(take.paths.recording)) {
|
|
7314
9665
|
throw new UsageError(`${dir} has no recording.webm \u2014 re-run record`);
|
|
7315
9666
|
}
|
|
7316
9667
|
const studio = (strFlag(flags, "studio") ?? "http://localhost:6060").replace(
|
|
7317
9668
|
/\/+$/,
|
|
7318
9669
|
""
|
|
7319
9670
|
);
|
|
7320
|
-
const server = await startTakeServer(
|
|
9671
|
+
const server = await startTakeServer(resolve7(dir), {});
|
|
7321
9672
|
const url = `${studio}/studio?take=${encodeURIComponent(server.base)}`;
|
|
7322
9673
|
r.event({ event: "open", server: server.base, url });
|
|
7323
9674
|
r.log(`take served at ${server.base}`);
|
|
@@ -7342,22 +9693,57 @@ async function cmdOpen(argv) {
|
|
|
7342
9693
|
});
|
|
7343
9694
|
return EXIT_OK;
|
|
7344
9695
|
}
|
|
9696
|
+
async function cmdJudge(argv) {
|
|
9697
|
+
const { positionals, flags } = parseArgs(argv, BOOLEAN_FLAGS5);
|
|
9698
|
+
const target = positionals[0];
|
|
9699
|
+
const against = strFlag(flags, "against");
|
|
9700
|
+
if (!target || !against)
|
|
9701
|
+
throw new UsageError(
|
|
9702
|
+
"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)"
|
|
9703
|
+
);
|
|
9704
|
+
const r = createReporter(flags.json === true);
|
|
9705
|
+
const kitFile = target.endsWith("kit.json") ? target : join15(target, "kit.json");
|
|
9706
|
+
if (!existsSync12(kitFile)) throw new UsageError(`${kitFile}: no kit manifest`);
|
|
9707
|
+
if (!existsSync12(against)) throw new UsageError(`${against}: no reference manifest`);
|
|
9708
|
+
const result = await judgeKit(kitFile, against, strFlag(flags, "out"));
|
|
9709
|
+
const verdicts = JSON.parse(await readFile12(result.verdictFile, "utf8")).verdicts;
|
|
9710
|
+
const rate = winRate(verdicts);
|
|
9711
|
+
const lines = result.sheets.map(
|
|
9712
|
+
(s) => `${s.asset} vs ${s.reference}: ${s.sheetA}, ${s.sheetB}, ${s.rubric}`
|
|
9713
|
+
);
|
|
9714
|
+
r.done(
|
|
9715
|
+
{ ...result, winRate: rate },
|
|
9716
|
+
`Wrote ${result.sheets.length} sheet pair(s) to ${result.outDir}` + (lines.length ? `
|
|
9717
|
+
${lines.join("\n ")}` : "") + (result.skipped.length ? `
|
|
9718
|
+
skipped: ${result.skipped.join("\n skipped: ")}` : "") + `
|
|
9719
|
+
Judge each pair both ways (the rubric is beside it) and fill ${result.verdictFile}` + (rate.judged ? `
|
|
9720
|
+
Win rate so far: ${rate.wins}/${rate.judged} (${(rate.rate * 100).toFixed(0)}%); parity with the references is 50%, the marketability bar is 40%` : "")
|
|
9721
|
+
);
|
|
9722
|
+
return EXIT_OK;
|
|
9723
|
+
}
|
|
7345
9724
|
async function cmdValidate(argv) {
|
|
7346
9725
|
const { positionals, flags } = parseArgs(argv, BOOLEAN_FLAGS5);
|
|
7347
9726
|
const target = positionals[0];
|
|
7348
9727
|
if (!target) throw new UsageError("vos validate <actions.json|take>");
|
|
7349
9728
|
const r = createReporter(flags.json === true);
|
|
7350
|
-
const kitTarget = target.endsWith("kit.json") ? target :
|
|
9729
|
+
const kitTarget = target.endsWith("kit.json") ? target : existsSync12(join15(target, "kit.json")) && !isTakeDir(target) ? join15(target, "kit.json") : null;
|
|
7351
9730
|
if (kitTarget) {
|
|
7352
|
-
const
|
|
7353
|
-
const
|
|
9731
|
+
const picture = flags.picture === true;
|
|
9732
|
+
const verdict = await validateKit(kitTarget, { picture });
|
|
9733
|
+
const pictureLines = (verdict.picture ?? []).map(formatFinding);
|
|
9734
|
+
const pictureErrors = (verdict.picture ?? []).filter(
|
|
9735
|
+
(f) => f.severity === "error"
|
|
9736
|
+
).length;
|
|
9737
|
+
const tail = (verdict.warnings.length ? `
|
|
7354
9738
|
warnings:
|
|
7355
|
-
${verdict.warnings.join("\n ")}` : ""
|
|
9739
|
+
${verdict.warnings.join("\n ")}` : "") + (picture ? `
|
|
9740
|
+
picture: ${pictureErrors} problem(s), ${(verdict.picture ?? []).length - pictureErrors} note(s)` + (pictureLines.length ? `
|
|
9741
|
+
${pictureLines.join("\n ")}` : "") : "");
|
|
7356
9742
|
if (!verdict.valid) {
|
|
7357
9743
|
r.done(
|
|
7358
9744
|
{ ...verdict, target: kitTarget },
|
|
7359
|
-
`${kitTarget}
|
|
7360
|
-
${verdict.problems.join("\n ")}${tail}`
|
|
9745
|
+
`${kitTarget}:${verdict.problems.length ? `
|
|
9746
|
+
${verdict.problems.join("\n ")}` : ""}${tail}`
|
|
7361
9747
|
);
|
|
7362
9748
|
return EXIT_ERROR;
|
|
7363
9749
|
}
|
|
@@ -7372,9 +9758,9 @@ async function cmdValidate(argv) {
|
|
|
7372
9758
|
r.done({ valid: true, target }, `${target}: valid actions file`);
|
|
7373
9759
|
return EXIT_OK;
|
|
7374
9760
|
}
|
|
7375
|
-
if (!isTakeDir(target) &&
|
|
9761
|
+
if (!isTakeDir(target) && existsSync12(join15(target, "config.json"))) {
|
|
7376
9762
|
const parsed = JSON.parse(
|
|
7377
|
-
await
|
|
9763
|
+
await readFile12(join15(target, "config.json"), "utf8")
|
|
7378
9764
|
);
|
|
7379
9765
|
const pre = preflightConfig(parsed);
|
|
7380
9766
|
const problems2 = pre.ok ? [] : pre.issues.map((i) => `config.json: ${i}`);
|
|
@@ -7415,7 +9801,7 @@ async function cmdValidate(argv) {
|
|
|
7415
9801
|
const take = await loadTake(target);
|
|
7416
9802
|
const problems = [];
|
|
7417
9803
|
const warnings = [];
|
|
7418
|
-
if (!
|
|
9804
|
+
if (!existsSync12(take.paths.recording))
|
|
7419
9805
|
problems.push("missing recording.webm (re-run record)");
|
|
7420
9806
|
if (!take.doc) problems.push("missing doc.json (run plan)");
|
|
7421
9807
|
if (take.doc) {
|
|
@@ -7460,7 +9846,7 @@ async function cmdPush3(argv) {
|
|
|
7460
9846
|
const r = createReporter(flags.json === true);
|
|
7461
9847
|
const overrides = multi.override;
|
|
7462
9848
|
const result = await pushTake(
|
|
7463
|
-
|
|
9849
|
+
resolve7(dir),
|
|
7464
9850
|
{
|
|
7465
9851
|
key: strFlag(flags, "key"),
|
|
7466
9852
|
api: strFlag(flags, "api"),
|
|
@@ -7487,16 +9873,25 @@ async function cmdPull2(argv) {
|
|
|
7487
9873
|
const dir = positionals[0] ?? ".";
|
|
7488
9874
|
const r = createReporter(flags.json === true);
|
|
7489
9875
|
const result = await pullTake(
|
|
7490
|
-
|
|
9876
|
+
resolve7(dir),
|
|
7491
9877
|
{
|
|
7492
9878
|
key: strFlag(flags, "key"),
|
|
7493
9879
|
api: strFlag(flags, "api"),
|
|
7494
9880
|
origin: strFlag(flags, "origin"),
|
|
7495
9881
|
vos: strFlag(flags, "vos"),
|
|
7496
|
-
media: flags.media === true
|
|
9882
|
+
media: flags.media === true,
|
|
9883
|
+
since: strFlag(flags, "since"),
|
|
9884
|
+
check: flags.check === true
|
|
7497
9885
|
},
|
|
7498
9886
|
r
|
|
7499
9887
|
);
|
|
9888
|
+
if (result.checked) {
|
|
9889
|
+
r.done(
|
|
9890
|
+
{ ...result },
|
|
9891
|
+
result.behind === null ? `${result.vosId}: head is ${result.versionId.slice(0, 8)}\u2026 \u2014 no base to count from; run without --check to sync` : result.behind === 0 ? `${result.vosId}: up to date` : `${result.behind} version${result.behind === 1 ? "" : "s"} behind \u2014 run without --check to sync`
|
|
9892
|
+
);
|
|
9893
|
+
return EXIT_OK;
|
|
9894
|
+
}
|
|
7500
9895
|
const mediaLine = result.media ? result.media.downloaded.length ? ` + ${result.media.downloaded.map((m) => m.file).join(", ")} (footage home \u2014 digest/frames/render run here now)` : result.media.kept.length ? ` (footage already here: ${result.media.kept.join(", ")})` : " (no hosted media on this doc)" : "";
|
|
7501
9896
|
r.done(
|
|
7502
9897
|
{ ...result },
|
|
@@ -7530,6 +9925,8 @@ async function run(argv) {
|
|
|
7530
9925
|
return await cmdOpen(rest);
|
|
7531
9926
|
case "validate":
|
|
7532
9927
|
return await cmdValidate(rest);
|
|
9928
|
+
case "judge":
|
|
9929
|
+
return await cmdJudge(rest);
|
|
7533
9930
|
case "fetch":
|
|
7534
9931
|
return await cmdFetch(rest);
|
|
7535
9932
|
case "duplicate":
|
|
@@ -7611,4 +10008,4 @@ export {
|
|
|
7611
10008
|
convertAgentBrowser,
|
|
7612
10009
|
run
|
|
7613
10010
|
};
|
|
7614
|
-
//# sourceMappingURL=chunk-
|
|
10011
|
+
//# sourceMappingURL=chunk-MMKDYE57.js.map
|