@officexapp/vidfarm-devcli 0.21.27 → 0.21.29
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/.agents/skills/editor-capabilities/SKILL.md +52 -0
- package/.agents/skills/vidfarm/SKILL.md +58 -5
- package/.agents/skills/vidfarm/recipes/bulk-scripting-with-a-regime.md +65 -0
- package/.agents/skills/vidfarm/recipes/cutout-graphics-for-explainers.md +78 -7
- package/.agents/skills/vidfarm/recipes/local-edit-render-approve.md +4 -3
- package/.agents/skills/vidfarm/recipes/retheme-template.md +1 -1
- package/.agents/skills/vidfarm/references/assets-and-sourcing.md +3 -3
- package/.agents/skills/vidfarm/references/automation-and-local-dev.md +92 -1
- package/.agents/skills/vidfarm/references/editor-workflows.md +135 -6
- package/.agents/skills/vidfarm/references/hooks-and-virality.md +237 -0
- package/.agents/skills/vidfarm/references/onboarding.md +5 -5
- package/.agents/skills/vidfarm/references/primitives.md +5 -1
- package/.agents/skills/vidfarm/regimes/README.md +77 -0
- package/.agents/skills/vidfarm/regimes/explainer.QA_REGIME.md +82 -0
- package/.agents/skills/vidfarm/regimes/hooks.QA_REGIME.md +117 -0
- package/.agents/skills/vidfarm/regimes/product-demo.QA_REGIME.md +92 -0
- package/.agents/skills/vidfarm/regimes/short-form.QA_REGIME.md +163 -0
- package/.agents/skills/vidfarm/regimes/ugc-testimonial.QA_REGIME.md +82 -0
- package/SKILL.director.md +685 -32
- package/SKILL.md +22 -3
- package/demo/dist/app.js +103 -103
- package/dist/src/cli.js +987 -11
- package/dist/src/devcli/handoff.js +162 -0
- package/dist/src/devcli/interaction-mode.js +154 -0
- package/dist/src/devcli/qa-check.js +593 -0
- package/dist/src/devcli/qa-regime.js +396 -0
- package/dist/src/devcli/sticker-pack.js +396 -0
- package/dist/src/devcli/storyboard.js +243 -0
- package/package.json +8 -1
- package/public/serve-shells/tools-image.html +378 -265
- package/public/serve-shells/tools-video.html +760 -167
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
// Social-native QA check for the devcli — read a composition.html on disk and
|
|
2
|
+
// flag "HTML slop": landing-page furniture that shows up everywhere on the web
|
|
3
|
+
// and NEVER in a real TikTok/Reel/Short. Compositions are authored in HTML, so
|
|
4
|
+
// an agent that reaches for its web-page instincts produces gradient CTA
|
|
5
|
+
// capsules, benefit chip rows, and frosted cards floating over footage — the
|
|
6
|
+
// single loudest tell that a video was machine-made.
|
|
7
|
+
//
|
|
8
|
+
// It also judges ONE frame on its own terms: t=0. That frame is the poster every
|
|
9
|
+
// feed and share sheet freezes on, so a black open or a fade-up from black is a
|
|
10
|
+
// real defect even though it's a single frame of ~30 in the first second.
|
|
11
|
+
//
|
|
12
|
+
// DESIGN CONTRACT (read before adding a rule):
|
|
13
|
+
// * BLOCKLIST, never allowlist. We name specific, well-known web-furniture
|
|
14
|
+
// patterns. Anything we haven't named is legal. A composition that does
|
|
15
|
+
// something weird, ugly, hand-made, or wildly stylized must PASS — the
|
|
16
|
+
// check exists to stop slop, not to homogenize style. If a rule would fire
|
|
17
|
+
// on a legitimately creative choice, the rule is wrong; loosen it.
|
|
18
|
+
// * FEEDBACK, never a gate. `vidfarm qa` exits 0 even with errors unless the
|
|
19
|
+
// caller opts into `--strict`. Nothing in the render path depends on it.
|
|
20
|
+
// * PRECISION over recall. Every `error` needs TWO independent signals (e.g.
|
|
21
|
+
// action copy AND a button shape). Single weak signals are `warn` at most.
|
|
22
|
+
// A false positive teaches agents to ignore the tool; a false negative just
|
|
23
|
+
// means we missed one.
|
|
24
|
+
//
|
|
25
|
+
// Pure DOM (linkedom) — no ffmpeg, no network, no Chrome. Local-only: this is a
|
|
26
|
+
// devcli capability with no cloud/REST equivalent.
|
|
27
|
+
import { parseHTML } from "linkedom";
|
|
28
|
+
// ── The font regime ───────────────────────────────────────────────────────────
|
|
29
|
+
// Mirrors CAPTION_FONT_REGIME in composition-edit.ts / COMPOSITION_FONT_IMPORT
|
|
30
|
+
// in services/studio-project-adapter.ts. A family outside this set is not even
|
|
31
|
+
// imported by the composition, so it silently falls back to a web-default sans
|
|
32
|
+
// at render — which IS the slop look.
|
|
33
|
+
const FONT_REGIME = ["tiktok sans", "montserrat", "abel", "source code pro", "yesteryear"];
|
|
34
|
+
// Named separately so we can say "this is a website font" instead of the vaguer
|
|
35
|
+
// "not imported" — the more actionable message when an agent typed it on purpose.
|
|
36
|
+
const WEB_DEFAULT_FONTS = [
|
|
37
|
+
"inter", "roboto", "system-ui", "-apple-system", "arial", "helvetica", "helvetica neue",
|
|
38
|
+
"segoe ui", "times", "times new roman", "georgia", "verdana", "tahoma", "open sans", "lato", "nunito"
|
|
39
|
+
];
|
|
40
|
+
// Bootstrap / Tailwind / generic web-component class names. Matched as whole
|
|
41
|
+
// tokens against class attributes, so `btn-primary` hits and `subtle` doesn't.
|
|
42
|
+
const SLOP_CLASS_TOKENS = [
|
|
43
|
+
"btn", "button", "cta", "badge", "chip", "pill", "tag", "card", "panel", "navbar", "nav",
|
|
44
|
+
"hero", "jumbotron", "alert", "breadcrumb", "form-control", "input-group", "container",
|
|
45
|
+
"container-fluid", "row", "list-group", "well", "thumbnail", "media-object"
|
|
46
|
+
];
|
|
47
|
+
const SLOP_CLASS_PREFIXES = [
|
|
48
|
+
"col-", "rounded-full", "shadow-lg", "shadow-xl", "shadow-2xl", "backdrop-blur",
|
|
49
|
+
"bg-gradient-to-", "ring-", "btn-", "card-", "badge-", "nav-", "navbar-"
|
|
50
|
+
];
|
|
51
|
+
// Copy whose only job is to be clicked. Bare CTA words in a caption are FINE
|
|
52
|
+
// ("buy now" is a legitimate social line) — this list only escalates a finding
|
|
53
|
+
// when the text also sits inside a button shape.
|
|
54
|
+
const ACTION_COPY = /\b(sign\s?up|get\s?started|learn\s?more|book\s+a\s+(call|demo)|start\s+(your\s+)?free\s+trial|free\s+trial|try\s+(it\s+)?free|click\s+here|subscribe\s+now|shop\s+now|order\s+now|contact\s+us|join\s+now|download\s+now|register\s+now|read\s+more|see\s+plans|view\s+pricing)\b/i;
|
|
55
|
+
// The stock trust-badge phrases that show up in benefit chip rows.
|
|
56
|
+
const BENEFIT_COPY = /(no\s+credit\s+card|money[-\s]?back|cancel\s+any\s?time|24\/7|free\s+shipping|verified|guarantee|no\s+commitment|risk[-\s]?free)/i;
|
|
57
|
+
// Entrance transitions that leave frame 0 as a flat solid — the worst possible
|
|
58
|
+
// thumbnail, because the frame carries no information at all.
|
|
59
|
+
const BLANKING_INTROS = ["fade-black", "fade-white", "flash", "smoke"];
|
|
60
|
+
function numAttrOf(node, name) {
|
|
61
|
+
const raw = node?.getAttribute?.(name);
|
|
62
|
+
const value = Number.parseFloat(String(raw ?? ""));
|
|
63
|
+
return Number.isFinite(value) ? value : NaN;
|
|
64
|
+
}
|
|
65
|
+
/** Does this clip paint pixels? Mirrors gapsFromClips in composition-edit.ts. */
|
|
66
|
+
function isVisualClip(node) {
|
|
67
|
+
const kind = String(node?.getAttribute?.("data-layer-kind") ?? "").toLowerCase();
|
|
68
|
+
return node?.getAttribute?.("data-vf-timeline-proxy") === "video" || ["video", "image", "html", "shape"].includes(kind);
|
|
69
|
+
}
|
|
70
|
+
/** Is the clip on screen at t (seconds)? Layers with no duration are ignored. */
|
|
71
|
+
function coversTime(node, t) {
|
|
72
|
+
const start = numAttrOf(node, "data-start");
|
|
73
|
+
const duration = numAttrOf(node, "data-duration");
|
|
74
|
+
if (!Number.isFinite(start) || !Number.isFinite(duration) || duration <= 0)
|
|
75
|
+
return false;
|
|
76
|
+
return start <= t + 0.001 && start + duration > t + 0.001;
|
|
77
|
+
}
|
|
78
|
+
function textOf(node) {
|
|
79
|
+
return String(node?.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
80
|
+
}
|
|
81
|
+
function classTokens(node) {
|
|
82
|
+
return String(node?.getAttribute?.("class") ?? "").split(/\s+/).filter(Boolean);
|
|
83
|
+
}
|
|
84
|
+
function styleString(node) {
|
|
85
|
+
return String(node?.getAttribute?.("style") ?? "").toLowerCase();
|
|
86
|
+
}
|
|
87
|
+
/** Parse a CSS length in px from an inline style declaration. */
|
|
88
|
+
function pxOf(style, prop) {
|
|
89
|
+
const match = style.match(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*(-?[\\d.]+)px`));
|
|
90
|
+
return match ? Number(match[1]) : null;
|
|
91
|
+
}
|
|
92
|
+
function hasDecl(style, prop) {
|
|
93
|
+
return new RegExp(`(?:^|;)\\s*${prop}\\s*:`).test(style);
|
|
94
|
+
}
|
|
95
|
+
/** border-radius in px, treating any %/9999-style value as "fully rounded". */
|
|
96
|
+
function radiusPx(style) {
|
|
97
|
+
const match = style.match(/(?:^|;)\s*border-radius\s*:\s*([^;]+)/);
|
|
98
|
+
if (!match)
|
|
99
|
+
return 0;
|
|
100
|
+
const raw = match[1].trim();
|
|
101
|
+
if (/%/.test(raw))
|
|
102
|
+
return 9999;
|
|
103
|
+
const num = Number((raw.match(/(-?[\d.]+)px/) ?? [])[1] ?? 0);
|
|
104
|
+
return Number.isFinite(num) ? num : 0;
|
|
105
|
+
}
|
|
106
|
+
function primaryFamily(raw) {
|
|
107
|
+
return String(raw).split(",")[0].replace(/['"]/g, "").trim().toLowerCase();
|
|
108
|
+
}
|
|
109
|
+
function label(node, fallback) {
|
|
110
|
+
const value = node?.getAttribute?.("data-label") ||
|
|
111
|
+
node?.getAttribute?.("data-slug") ||
|
|
112
|
+
node?.getAttribute?.("data-hf-id") ||
|
|
113
|
+
node?.getAttribute?.("id") ||
|
|
114
|
+
"";
|
|
115
|
+
const text = textOf(node).slice(0, 40);
|
|
116
|
+
const name = String(value).trim() || fallback;
|
|
117
|
+
return text ? `${name} — "${text}"` : name;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* QA a composition's HTML. Pure: returns findings, mutates nothing. Safe on
|
|
121
|
+
* malformed input (returns a clean report rather than throwing) — a QA pass
|
|
122
|
+
* must never be the thing that breaks a render.
|
|
123
|
+
*/
|
|
124
|
+
export function qaCompositionHtml(html) {
|
|
125
|
+
const findings = [];
|
|
126
|
+
let root = null;
|
|
127
|
+
let document = null;
|
|
128
|
+
try {
|
|
129
|
+
({ document } = parseHTML(html));
|
|
130
|
+
root = document.querySelector("[data-composition-id]") ?? document.body;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return {
|
|
134
|
+
ok: true,
|
|
135
|
+
verdict: "clean",
|
|
136
|
+
errors: [],
|
|
137
|
+
warnings: [],
|
|
138
|
+
checked: { layers: 0, text_layers: 0, canvas: null }
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (!root) {
|
|
142
|
+
return { ok: true, verdict: "clean", errors: [], warnings: [], checked: { layers: 0, text_layers: 0, canvas: null } };
|
|
143
|
+
}
|
|
144
|
+
const push = (f) => findings.push(f);
|
|
145
|
+
const all = Array.from(root.querySelectorAll("*"));
|
|
146
|
+
const layers = Array.from(root.querySelectorAll("[data-start]"));
|
|
147
|
+
// Only layers that actually SHOW WORDS get judged on typography and placement.
|
|
148
|
+
// A `data-layer-kind="text"` div is also how compositions carry scrims,
|
|
149
|
+
// gradient washes, and colour blocks — those legitimately span the full frame
|
|
150
|
+
// and have no font at all, so judging them would be pure noise.
|
|
151
|
+
const textLayers = Array.from(root.querySelectorAll('[data-layer-kind="caption"], [data-layer-kind="text"]')).filter((node) => textOf(node).length > 0);
|
|
152
|
+
const canvasW = Number(root.getAttribute?.("data-width") ?? 0) || null;
|
|
153
|
+
const canvasH = Number(root.getAttribute?.("data-height") ?? 0) || null;
|
|
154
|
+
// ── Rule: clickable elements ────────────────────────────────────────────────
|
|
155
|
+
// Nothing in a video is clickable. A <button>, a link, or a form control in a
|
|
156
|
+
// composition is web instinct leaking through, full stop.
|
|
157
|
+
for (const node of all) {
|
|
158
|
+
const tag = String(node.tagName ?? "").toLowerCase();
|
|
159
|
+
if (tag === "button" || tag === "form" || tag === "input" || tag === "select" || tag === "textarea") {
|
|
160
|
+
push({
|
|
161
|
+
rule: "clickable-element",
|
|
162
|
+
severity: "error",
|
|
163
|
+
message: `<${tag}> in a composition — nothing in a video is clickable.`,
|
|
164
|
+
where: label(node, `<${tag}>`),
|
|
165
|
+
fix: "Delete the control. If it carried a call to action, say it as a timed caption line on the footage (or in the voiceover)."
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
else if (tag === "a" && node.getAttribute?.("href")) {
|
|
169
|
+
push({
|
|
170
|
+
rule: "clickable-element",
|
|
171
|
+
severity: "warn",
|
|
172
|
+
message: "<a href> in a composition — links do nothing in a rendered MP4.",
|
|
173
|
+
where: label(node, "<a>"),
|
|
174
|
+
fix: "Keep the text if it's a real caption (a spoken URL reads fine); drop the anchor and any button styling around it."
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// ── Rule: web-framework class names ────────────────────────────────────────
|
|
179
|
+
// Bootstrap/Tailwind furniture names. Two or more distinct hits is a strong
|
|
180
|
+
// signal someone built a landing page section; one is worth a nudge.
|
|
181
|
+
const frameworkHits = [];
|
|
182
|
+
for (const node of all) {
|
|
183
|
+
for (const token of classTokens(node)) {
|
|
184
|
+
const lower = token.toLowerCase();
|
|
185
|
+
if (SLOP_CLASS_TOKENS.includes(lower) || SLOP_CLASS_PREFIXES.some((p) => lower.startsWith(p))) {
|
|
186
|
+
frameworkHits.push({ node, token });
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (frameworkHits.length) {
|
|
192
|
+
const tokens = [...new Set(frameworkHits.map((h) => h.token))].slice(0, 6).join(", ");
|
|
193
|
+
push({
|
|
194
|
+
rule: "web-framework-classes",
|
|
195
|
+
severity: frameworkHits.length >= 2 ? "error" : "warn",
|
|
196
|
+
message: `${frameworkHits.length} element(s) using web-page class names (${tokens}) — this is a landing page, not a video.`,
|
|
197
|
+
where: label(frameworkHits[0].node, "element"),
|
|
198
|
+
fix: "Rebuild as timed text on the footage. Text belongs ON the frame, not inside a container with padding and a shadow."
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
// Only CSS-FRAMEWORK stylesheets — a jsdelivr <script> for GSAP/anime.js is a
|
|
202
|
+
// normal, encouraged part of a local composition and must never trip this.
|
|
203
|
+
if (/bootstrap[^"']*\.css|cdn\.tailwindcss\.com|tailwind[^"']*\.css|fontawesome[^"']*\.css|bulma[^"']*\.css|foundation[^"']*\.css/i.test(html)) {
|
|
204
|
+
push({
|
|
205
|
+
rule: "web-framework-classes",
|
|
206
|
+
severity: "error",
|
|
207
|
+
message: "A CSS framework stylesheet (Bootstrap/Tailwind/FontAwesome/Bulma) is linked into the composition.",
|
|
208
|
+
where: "<head>",
|
|
209
|
+
fix: "Remove it. Its defaults ARE the look to avoid, and renders should be offline-deterministic. (A <script> CDN for GSAP/anime.js is fine — this is about CSS frameworks.)"
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
// ── Rule: CTA button ───────────────────────────────────────────────────────
|
|
213
|
+
// Two signals required: action copy AND a button shape (pill/rounded fill).
|
|
214
|
+
// "BUY NOW" as a bare caption is legitimate social copy and must not fire.
|
|
215
|
+
for (const node of all) {
|
|
216
|
+
const text = textOf(node);
|
|
217
|
+
if (!text || text.length > 60 || !ACTION_COPY.test(text))
|
|
218
|
+
continue;
|
|
219
|
+
const style = styleString(node);
|
|
220
|
+
const radius = radiusPx(style);
|
|
221
|
+
const filled = /background(?:-color|-image)?\s*:/.test(style) && !/background[^;]*:\s*(none|transparent)/.test(style);
|
|
222
|
+
const gradient = /linear-gradient|radial-gradient/.test(style);
|
|
223
|
+
const shadowed = hasDecl(style, "box-shadow");
|
|
224
|
+
const buttonShape = (radius >= 16 && filled) || (gradient && radius >= 8) || (filled && shadowed && radius >= 8);
|
|
225
|
+
if (buttonShape) {
|
|
226
|
+
push({
|
|
227
|
+
rule: "cta-button",
|
|
228
|
+
severity: "error",
|
|
229
|
+
message: `CTA button ("${text.slice(0, 40)}") — a filled, rounded capsule with action copy. Pure landing page.`,
|
|
230
|
+
where: label(node, "cta"),
|
|
231
|
+
fix: "Kill the capsule. Deliver the CTA as a plain caption line in the font regime, spoken in the voiceover, or as an arrow pointing at real on-screen UI."
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// ── Rule: benefit chip / badge row ─────────────────────────────────────────
|
|
236
|
+
// "✓ No Credit Card Needed · ✓ 30-Min Trial" — a row of small rounded tags.
|
|
237
|
+
// Requires 2+ siblings that are each small, filled, and rounded.
|
|
238
|
+
const parents = new Map();
|
|
239
|
+
for (const node of all) {
|
|
240
|
+
const style = styleString(node);
|
|
241
|
+
const text = textOf(node);
|
|
242
|
+
if (!text || text.length > 45)
|
|
243
|
+
continue;
|
|
244
|
+
const filled = /background(?:-color|-image)?\s*:/.test(style) && !/background[^;]*:\s*(none|transparent)/.test(style);
|
|
245
|
+
const chipShape = radiusPx(style) >= 10 && filled;
|
|
246
|
+
const chipCopy = /^[✓✔✅•·]/.test(text) || BENEFIT_COPY.test(text);
|
|
247
|
+
if (!(chipShape && (chipCopy || text.split(" ").length <= 5)))
|
|
248
|
+
continue;
|
|
249
|
+
const parent = node.parentNode;
|
|
250
|
+
if (!parent)
|
|
251
|
+
continue;
|
|
252
|
+
const bucket = parents.get(parent) ?? [];
|
|
253
|
+
bucket.push(node);
|
|
254
|
+
parents.set(parent, bucket);
|
|
255
|
+
}
|
|
256
|
+
for (const [, bucket] of parents) {
|
|
257
|
+
if (bucket.length < 2)
|
|
258
|
+
continue;
|
|
259
|
+
push({
|
|
260
|
+
rule: "badge-chip-row",
|
|
261
|
+
severity: "error",
|
|
262
|
+
message: `A row of ${bucket.length} badge/chip pills (${bucket.slice(0, 3).map((n) => `"${textOf(n).slice(0, 22)}"`).join(", ")}) — trust badges are website furniture.`,
|
|
263
|
+
where: label(bucket[0], "chip"),
|
|
264
|
+
fix: "Say each benefit as its OWN timed caption line on the footage, one at a time, in the font regime. One idea per beat reads far better than a chip strip."
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
// ── Rule: card / panel / glassmorphism ─────────────────────────────────────
|
|
268
|
+
// A rounded box with a border, shadow, or frosted blur, holding more than one
|
|
269
|
+
// piece of content. A tight caption BAND is legal (small radius, one run) —
|
|
270
|
+
// this fires only once the band has become a container.
|
|
271
|
+
for (const node of all) {
|
|
272
|
+
const style = styleString(node);
|
|
273
|
+
if (!style)
|
|
274
|
+
continue;
|
|
275
|
+
const radius = radiusPx(style);
|
|
276
|
+
const frosted = /backdrop-filter\s*:\s*[^;]*blur/.test(style);
|
|
277
|
+
const bordered = /(?:^|;)\s*border\s*:\s*(?!0|none)/.test(style) || hasDecl(style, "border-width");
|
|
278
|
+
const shadowed = hasDecl(style, "box-shadow") && !/box-shadow\s*:\s*none/.test(style);
|
|
279
|
+
const childBoxes = Array.from(node.children ?? []).filter((c) => textOf(c)).length;
|
|
280
|
+
const filled = /background(?:-color|-image)?\s*:/.test(style) && !/background[^;]*:\s*(none|transparent)/.test(style);
|
|
281
|
+
if (frosted) {
|
|
282
|
+
push({
|
|
283
|
+
rule: "glass-card",
|
|
284
|
+
severity: "error",
|
|
285
|
+
message: "Glassmorphism (backdrop-filter blur) — a frosted web panel floating over the footage.",
|
|
286
|
+
where: label(node, "panel"),
|
|
287
|
+
fix: "Drop the panel. If the text needs legibility over busy footage, use a heavy outline (background_style outline) or a tight solid band that hugs the words."
|
|
288
|
+
});
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (radius >= 12 && (bordered || shadowed) && filled && childBoxes >= 2) {
|
|
292
|
+
push({
|
|
293
|
+
rule: "card-panel",
|
|
294
|
+
severity: "error",
|
|
295
|
+
message: `Card/panel: rounded ${Math.round(radius)}px box with ${bordered ? "a border" : "a drop shadow"} wrapping ${childBoxes} elements.`,
|
|
296
|
+
where: label(node, "card"),
|
|
297
|
+
fix: "Put the text directly on the frame. A legibility band is legal — radius ≤8px, no border, no shadow, hugging ONE text run — but a headline + subheading + URL inside one rounded box is a web card."
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// ── Rule: gradient text fill ───────────────────────────────────────────────
|
|
302
|
+
if (/background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/i.test(html)) {
|
|
303
|
+
push({
|
|
304
|
+
rule: "gradient-text",
|
|
305
|
+
severity: "error",
|
|
306
|
+
message: "Gradient-filled text (background-clip:text) — a SaaS hero headline treatment.",
|
|
307
|
+
where: "composition styles",
|
|
308
|
+
fix: "Use a solid fill. If you want emphasis, pop a SINGLE word in an accent color, or go heavier weight / ALL-CAPS."
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
// ── Rule: page structure elements ──────────────────────────────────────────
|
|
312
|
+
for (const node of all) {
|
|
313
|
+
const tag = String(node.tagName ?? "").toLowerCase();
|
|
314
|
+
if (tag === "table" || tag === "nav" || tag === "header" || tag === "footer") {
|
|
315
|
+
push({
|
|
316
|
+
rule: "page-structure",
|
|
317
|
+
severity: tag === "table" ? "warn" : "error",
|
|
318
|
+
message: `<${tag}> element — page chrome has no place in a video frame.`,
|
|
319
|
+
where: label(node, `<${tag}>`),
|
|
320
|
+
fix: tag === "table"
|
|
321
|
+
? "If it's real data, build it as an animated stat/number reveal instead of a static grid of cells."
|
|
322
|
+
: "Delete it and re-time its content as captions on the footage."
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
if (tag === "ul" || tag === "ol") {
|
|
326
|
+
const style = styleString(node);
|
|
327
|
+
// A list with markers switched OFF is just a layout container — fine.
|
|
328
|
+
if (!/list-style(?:-type)?\s*:\s*none/.test(style)) {
|
|
329
|
+
push({
|
|
330
|
+
rule: "bullet-list",
|
|
331
|
+
severity: "warn",
|
|
332
|
+
message: `<${tag}> with visible bullet markers — a slide/webpage list, not a social beat.`,
|
|
333
|
+
where: label(node, `<${tag}>`),
|
|
334
|
+
fix: "Reveal the points ONE AT A TIME as timed caption lines. If you want the list look, set list-style:none and animate each row in."
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// ── Rule: font regime ──────────────────────────────────────────────────────
|
|
340
|
+
for (const node of textLayers) {
|
|
341
|
+
const attr = String(node.getAttribute?.("data-font-family") ?? "").trim();
|
|
342
|
+
const style = styleString(node);
|
|
343
|
+
const cssMatch = style.match(/(?:^|;)\s*font-family\s*:\s*([^;]+)/);
|
|
344
|
+
const family = primaryFamily(attr || (cssMatch ? cssMatch[1] : ""));
|
|
345
|
+
if (family && !FONT_REGIME.includes(family)) {
|
|
346
|
+
const isWebDefault = WEB_DEFAULT_FONTS.includes(family) || family === "sans-serif" || family === "serif";
|
|
347
|
+
push({
|
|
348
|
+
rule: "font-regime",
|
|
349
|
+
severity: isWebDefault ? "error" : "warn",
|
|
350
|
+
message: isWebDefault
|
|
351
|
+
? `Text layer in "${family}" — a website body font. This alone makes a frame read as a screenshot of a web page.`
|
|
352
|
+
: `Text layer in "${family}", outside the composition's imported font regime — it will silently fall back at render.`,
|
|
353
|
+
where: label(node, "text layer"),
|
|
354
|
+
fix: `Use an imported display family: Montserrat (default), TikTok Sans, Abel, Source Code Pro, or Yesteryear — e.g. \`vidfarm set-style <dir> --layer <key> --font-family Montserrat\`. Local renders auto-coerce this, but the editor preview will not match until you fix it.`
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
// Weight: the TikTok caption look is heavy. Light weights are a legitimate
|
|
358
|
+
// creative choice for a title card, so this is only ever a nudge.
|
|
359
|
+
const weightRaw = node.getAttribute?.("data-font-weight") ?? (style.match(/(?:^|;)\s*font-weight\s*:\s*(\d{3})/) ?? [])[1];
|
|
360
|
+
const weight = Number(weightRaw);
|
|
361
|
+
if (Number.isFinite(weight) && weight > 0 && weight < 600) {
|
|
362
|
+
push({
|
|
363
|
+
rule: "font-weight",
|
|
364
|
+
severity: "warn",
|
|
365
|
+
message: `Caption weight ${weight} — social captions read at 700–900; anything lighter dissolves on a phone over moving footage.`,
|
|
366
|
+
where: label(node, "text layer"),
|
|
367
|
+
fix: "Bump to 700–900 unless the light weight is a deliberate title-card look."
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
// Size: 0 is invisible (a real bug we have shipped before); tiny text is
|
|
371
|
+
// unreadable at phone scale. Only judged when we know the canvas width.
|
|
372
|
+
const sizePx = pxOf(style, "font-size") ?? Number(node.getAttribute?.("data-font-size") ?? NaN);
|
|
373
|
+
if (Number.isFinite(sizePx)) {
|
|
374
|
+
if (sizePx === 0) {
|
|
375
|
+
push({
|
|
376
|
+
rule: "font-size",
|
|
377
|
+
severity: "error",
|
|
378
|
+
message: "font-size:0 — this text renders invisibly.",
|
|
379
|
+
where: label(node, "text layer"),
|
|
380
|
+
fix: "Set a real size: ~36–64px on a 1080-wide frame."
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
else if (canvasW && sizePx > 0 && sizePx < canvasW * 0.026) {
|
|
384
|
+
push({
|
|
385
|
+
rule: "font-size",
|
|
386
|
+
severity: "warn",
|
|
387
|
+
message: `font-size ${Math.round(sizePx)}px on a ${canvasW}px-wide canvas — too small to read on a phone.`,
|
|
388
|
+
where: label(node, "text layer"),
|
|
389
|
+
fix: `Aim for ~${Math.round(canvasW * 0.033)}–${Math.round(canvasW * 0.06)}px.`
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// ── Rule: caption safe zone ────────────────────────────────────────────────
|
|
395
|
+
// Local renders auto-normalize this, but the editor preview and cloud render
|
|
396
|
+
// do not — so flag it as a warning rather than silently relying on the fixer.
|
|
397
|
+
// PORTRAIT ONLY: the dead zones are phone-feed chrome (status bar, action rail,
|
|
398
|
+
// username block). A 16:9 or square composition has no such constraint.
|
|
399
|
+
if (canvasH && canvasW && canvasH > canvasW) {
|
|
400
|
+
for (const node of textLayers) {
|
|
401
|
+
const style = styleString(node);
|
|
402
|
+
const top = Number((style.match(/(?:^|;)\s*top\s*:\s*(-?[\d.]+)%/) ?? [])[1]);
|
|
403
|
+
const height = Number((style.match(/(?:^|;)\s*height\s*:\s*(-?[\d.]+)%/) ?? [])[1]);
|
|
404
|
+
if (!Number.isFinite(top))
|
|
405
|
+
continue;
|
|
406
|
+
const bottom = top + (Number.isFinite(height) ? height : 0);
|
|
407
|
+
if (top < 8 || bottom > 85) {
|
|
408
|
+
push({
|
|
409
|
+
rule: "caption-safe-zone",
|
|
410
|
+
severity: "warn",
|
|
411
|
+
message: `Text at ${top.toFixed(0)}%–${bottom.toFixed(0)}% spills into the phone-UI dead zone (safe band is 8%–85%).`,
|
|
412
|
+
where: label(node, "text layer"),
|
|
413
|
+
fix: "Pull it inside 8%–85% keeping its top-vs-bottom anchoring. `vidfarm render --target local` clamps this automatically; the editor preview and cloud render do not."
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// ── Rule: the first frame IS the thumbnail ─────────────────────────────────
|
|
419
|
+
// Frame 0 is one frame of ~30 in the first second, but every feed, share
|
|
420
|
+
// sheet, and player poster freezes on it — so it is seen by everyone who
|
|
421
|
+
// scrolls past and never presses play. A black open, a fade-up from black, or
|
|
422
|
+
// an empty frame waiting for the hook line throws away the only frame that
|
|
423
|
+
// has to sell the video. Judged at t=0 exactly, since that is the frame the
|
|
424
|
+
// poster is grabbed from.
|
|
425
|
+
const visualClips = layers.filter(isVisualClip);
|
|
426
|
+
const openingVisuals = visualClips.filter((node) => coversTime(node, 0));
|
|
427
|
+
if (visualClips.length && !openingVisuals.length) {
|
|
428
|
+
const earliest = visualClips
|
|
429
|
+
.map((node) => numAttrOf(node, "data-start"))
|
|
430
|
+
.filter((value) => Number.isFinite(value))
|
|
431
|
+
.sort((a, b) => a - b)[0];
|
|
432
|
+
push({
|
|
433
|
+
rule: "thumbnail-blank-open",
|
|
434
|
+
severity: "error",
|
|
435
|
+
message: Number.isFinite(earliest)
|
|
436
|
+
? `Nothing is on screen at t=0 — the first visual starts at ${earliest.toFixed(2)}s, so the thumbnail is a black frame.`
|
|
437
|
+
: "Nothing is on screen at t=0 — the thumbnail is a black frame.",
|
|
438
|
+
where: "timeline t=0",
|
|
439
|
+
fix: "Pull the opening clip back to start:0 (`vidfarm retime <dir> --layer <key> --start 0`), or place a deliberate hook frame there. The first frame is the poster every scroller judges the video by."
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
for (const node of openingVisuals) {
|
|
443
|
+
const intro = String(node.getAttribute?.("data-transition") ?? "").trim().toLowerCase();
|
|
444
|
+
if (!intro || intro === "none")
|
|
445
|
+
continue;
|
|
446
|
+
const blanking = BLANKING_INTROS.includes(intro);
|
|
447
|
+
push({
|
|
448
|
+
rule: "thumbnail-fade-in",
|
|
449
|
+
severity: blanking ? "error" : "warn",
|
|
450
|
+
message: blanking
|
|
451
|
+
? `The opening clip has a "${intro}" entrance — frame 0 is a flat ${intro === "fade-white" ? "white" : intro === "flash" ? "blown-out" : "black"} frame, so that becomes the thumbnail.`
|
|
452
|
+
: `The opening clip has a "${intro}" entrance — frame 0 is caught mid-animation (offset, blurred, or scaled), which is what the thumbnail freezes on.`,
|
|
453
|
+
where: label(node, "opening clip"),
|
|
454
|
+
fix: `Drop the entrance on the FIRST clip — it's an intro, not a junction (\`vidfarm transitions set <dir> --layer <key> --in none\`). Junction transitions between later clips are unaffected. If you truly want to open from black, add a held hook frame before it so the poster still shows something.`
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
// The strongest thumbnails carry the hook in words. Only a nudge: opening on a
|
|
458
|
+
// clean face or product shot is a legitimate call.
|
|
459
|
+
if (textLayers.length && !textLayers.some((node) => coversTime(node, 0))) {
|
|
460
|
+
const earliest = textLayers
|
|
461
|
+
.map((node) => numAttrOf(node, "data-start"))
|
|
462
|
+
.filter((value) => Number.isFinite(value))
|
|
463
|
+
.sort((a, b) => a - b)[0];
|
|
464
|
+
push({
|
|
465
|
+
rule: "thumbnail-no-hook-text",
|
|
466
|
+
severity: "warn",
|
|
467
|
+
message: Number.isFinite(earliest)
|
|
468
|
+
? `No on-screen text at t=0 (the first text lands at ${earliest.toFixed(2)}s) — the thumbnail carries no hook words.`
|
|
469
|
+
: "No on-screen text at t=0 — the thumbnail carries no hook words.",
|
|
470
|
+
where: "timeline t=0",
|
|
471
|
+
fix: "Start the hook line at 0 so the poster frame states the promise. Ignore this if the video deliberately opens on a clean face/product shot."
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
const errors = findings.filter((f) => f.severity === "error");
|
|
475
|
+
const warnings = findings.filter((f) => f.severity === "warn");
|
|
476
|
+
return {
|
|
477
|
+
ok: errors.length === 0,
|
|
478
|
+
verdict: errors.length ? "slop" : warnings.length ? "warnings" : "clean",
|
|
479
|
+
errors,
|
|
480
|
+
warnings,
|
|
481
|
+
checked: {
|
|
482
|
+
layers: layers.length,
|
|
483
|
+
text_layers: textLayers.length,
|
|
484
|
+
canvas: canvasW && canvasH ? `${canvasW}×${canvasH}` : null
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
function aspectLabel(width, height) {
|
|
489
|
+
if (!width || !height)
|
|
490
|
+
return null;
|
|
491
|
+
const gcd = (a, b) => (b ? gcd(b, a % b) : a);
|
|
492
|
+
const divisor = gcd(width, height) || 1;
|
|
493
|
+
return `${width / divisor}:${height / divisor}`;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Derive the facts a QA_REGIME.md can assert against. Pure, tolerant of
|
|
497
|
+
* malformed input (returns empty facts rather than throwing) — same contract as
|
|
498
|
+
* qaCompositionHtml, because a regime run must never break a render either.
|
|
499
|
+
*/
|
|
500
|
+
export function extractCompositionFacts(html) {
|
|
501
|
+
const empty = {
|
|
502
|
+
duration_sec: null,
|
|
503
|
+
canvas: { width: null, height: null, aspect: null },
|
|
504
|
+
visual_clips: [],
|
|
505
|
+
text_runs: [],
|
|
506
|
+
card_runs: [],
|
|
507
|
+
audio_layers: 0,
|
|
508
|
+
caption_layers: 0,
|
|
509
|
+
first_frame_visual: false,
|
|
510
|
+
first_frame_text: null,
|
|
511
|
+
first_text_at_sec: null,
|
|
512
|
+
max_simultaneous_text: 0,
|
|
513
|
+
off_regime_fonts: [],
|
|
514
|
+
all_text: ""
|
|
515
|
+
};
|
|
516
|
+
let root = null;
|
|
517
|
+
try {
|
|
518
|
+
const { document } = parseHTML(html);
|
|
519
|
+
root = document.querySelector("[data-composition-id]") ?? document.body;
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
return empty;
|
|
523
|
+
}
|
|
524
|
+
if (!root)
|
|
525
|
+
return empty;
|
|
526
|
+
const layers = Array.from(root.querySelectorAll("[data-start]"));
|
|
527
|
+
const width = Number(root.getAttribute?.("data-width") ?? 0) || null;
|
|
528
|
+
const height = Number(root.getAttribute?.("data-height") ?? 0) || null;
|
|
529
|
+
const duration = Number.parseFloat(String(root.getAttribute?.("data-duration") ?? ""));
|
|
530
|
+
const visualClips = layers.filter(isVisualClip).map((node) => ({
|
|
531
|
+
key: String(node.getAttribute?.("data-hf-id") || node.getAttribute?.("id") || ""),
|
|
532
|
+
kind: String(node.getAttribute?.("data-layer-kind") ?? "video").toLowerCase(),
|
|
533
|
+
start: numAttrOf(node, "data-start") || 0,
|
|
534
|
+
duration: numAttrOf(node, "data-duration") || 0
|
|
535
|
+
}));
|
|
536
|
+
const textRuns = [];
|
|
537
|
+
for (const node of Array.from(root.querySelectorAll('[data-layer-kind="caption"], [data-layer-kind="text"]'))) {
|
|
538
|
+
const text = textOf(node);
|
|
539
|
+
if (!text)
|
|
540
|
+
continue;
|
|
541
|
+
const style = styleString(node);
|
|
542
|
+
const cssFamily = (style.match(/(?:^|;)\s*font-family\s*:\s*([^;]+)/) ?? [])[1] ?? "";
|
|
543
|
+
const family = primaryFamily(String(node.getAttribute?.("data-font-family") ?? "") || cssFamily);
|
|
544
|
+
textRuns.push({
|
|
545
|
+
key: String(node.getAttribute?.("data-hf-id") || node.getAttribute?.("id") || ""),
|
|
546
|
+
kind: String(node.getAttribute?.("data-layer-kind") ?? "text").toLowerCase(),
|
|
547
|
+
text,
|
|
548
|
+
start: numAttrOf(node, "data-start") || 0,
|
|
549
|
+
duration: numAttrOf(node, "data-duration") || 0,
|
|
550
|
+
font: family || null
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
// Max text runs sharing the screen — swept at every run's start, which is the
|
|
554
|
+
// only instant the count can rise.
|
|
555
|
+
let maxSimultaneous = 0;
|
|
556
|
+
for (const probe of textRuns) {
|
|
557
|
+
const at = probe.start;
|
|
558
|
+
const overlapping = textRuns.filter((run) => run.start <= at + 0.001 && run.start + run.duration > at + 0.001).length;
|
|
559
|
+
if (overlapping > maxSimultaneous)
|
|
560
|
+
maxSimultaneous = overlapping;
|
|
561
|
+
}
|
|
562
|
+
const openingText = textRuns.filter((run) => run.start <= 0.001 && run.start + run.duration > 0.001);
|
|
563
|
+
const sortedStarts = textRuns.map((run) => run.start).sort((a, b) => a - b);
|
|
564
|
+
return {
|
|
565
|
+
duration_sec: Number.isFinite(duration) ? duration : null,
|
|
566
|
+
canvas: { width, height, aspect: aspectLabel(width, height) },
|
|
567
|
+
visual_clips: visualClips,
|
|
568
|
+
text_runs: textRuns,
|
|
569
|
+
card_runs: textRuns.filter((run) => run.kind === "text"),
|
|
570
|
+
audio_layers: layers.filter((node) => String(node.getAttribute?.("data-layer-kind") ?? "").toLowerCase() === "audio").length,
|
|
571
|
+
caption_layers: textRuns.filter((run) => run.kind === "caption").length,
|
|
572
|
+
first_frame_visual: visualClips.some((clip) => clip.start <= 0.001 && clip.start + clip.duration > 0.001),
|
|
573
|
+
first_frame_text: openingText.length ? openingText.map((run) => run.text).join(" ").trim() : null,
|
|
574
|
+
first_text_at_sec: sortedStarts.length ? sortedStarts[0] : null,
|
|
575
|
+
max_simultaneous_text: maxSimultaneous,
|
|
576
|
+
off_regime_fonts: [...new Set(textRuns.map((run) => run.font).filter((font) => Boolean(font) && !FONT_REGIME.includes(font)))],
|
|
577
|
+
all_text: textRuns.map((run) => run.text).join(" \n ")
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
/** Human-readable report body (no trailing summary line — the CLI adds that). */
|
|
581
|
+
export function formatQaReport(report, colors) {
|
|
582
|
+
const { red, yellow, dim, reset } = colors;
|
|
583
|
+
const lines = [];
|
|
584
|
+
for (const finding of [...report.errors, ...report.warnings]) {
|
|
585
|
+
const isError = finding.severity === "error";
|
|
586
|
+
const mark = isError ? `${red}✗ SLOP${reset}` : `${yellow}! warn${reset}`;
|
|
587
|
+
lines.push(`${mark} ${dim}[${finding.rule}]${reset} ${finding.message}`);
|
|
588
|
+
lines.push(` ${dim}at${reset} ${finding.where}`);
|
|
589
|
+
lines.push(` ${dim}→${reset} ${finding.fix}`);
|
|
590
|
+
}
|
|
591
|
+
return lines.join("\n");
|
|
592
|
+
}
|
|
593
|
+
//# sourceMappingURL=qa-check.js.map
|