/** * Anti-slop lexicon — a PURE, deterministic detector over prompt text. No I/O, * no Date, no Math.random. * * Flags empty "hype" language that a video model cannot act on (it spends the * conditioning budget on nothing and pushes output toward generic), and points * each finding at the observable production detail that should replace it. * * The lexicon (six slop classes + the weak->observable replacement table) is * adapted from the MIT-licensed `Emily2040/seedance-2.0` repo * (`references/anti-slop-lexicon.md`, pinned commit 63b32dc). English only. * * Advisory by construction: `detectSlop` never rewrites text — it returns * findings. Wiring (prompt-lint) reports them as `warning`-severity issues, so * the lint pass/fail contract is unaffected. * * Conservative by design (low false-positive): the unambiguous classes * (borrowed-token, feel-suffix, adjective-stack of 2+, negation deny-list) fire * confidently; the single bare-evaluator rule guards the one word the codebase * itself emits (`cinematic` is allowed inside the named style "cinematic * grounded realism"). The negation class uses a curated quality-insurance * DENY-list, so constraint-slot negation ("no on-screen text") is inherently * never flagged. `v1` intentionally omits the tag-salad class: legitimate * continuity enumerations ("face, hair, wardrobe, silhouette") are * indistinguishable from image-prompt keyword dumps without false positives. */ export type SlopClass = | 'empty-evaluator' | 'borrowed-token' | 'negation-slop' | 'adjective-stack' | 'feel-suffix'; export interface SlopFinding { /** Which slop class the match belongs to. */ class: SlopClass; /** The exact offending substring (original casing). */ match: string; /** The observable production language to use instead. */ suggestion: string; } export interface DetectSlopOptions { /** * Canonical tool-emitted blocks to strip before scanning, so the linter never * flags the toolchain's own wording (e.g. SINGLE_FULL_FRAME_GUARD legitimately * contains "cinematic shot" and many "no …" constraint phrases). */ ignore?: string[]; } /** * Weak phrase (lowercased) -> the observable production language that earns it. * Verbatim from the lexicon's Replacement Table. Exported so callers/tests can * surface the canonical guidance. */ export const SLOP_REPLACEMENTS: Record = { cinematic: 'shot scale, camera move, lighting, grade', epic: 'physical scale, stakes, crowd size, lens distance', beautiful: 'color, texture, composition, material, light behavior', stunning: 'visible contrast, reveal, movement, or detail', breathtaking: 'visible contrast, reveal, movement, or detail', dynamic: 'specific movement, speed, and endpoint', dramatic: 'blocking, shadow, silence, or camera pressure', 'ultra-realistic': 'material behavior, skin texture, lens artifacts, natural motion', 'hyper-realistic': 'material behavior, skin texture, lens artifacts, natural motion', magical: 'particle behavior, glow source, motion path, interaction', professional: 'product lighting setup, clean background, controlled camera', masterpiece: 'delete; quality is not a request', 'award-winning': 'delete; quality is not a request', '8k': 'delete; resolution is a render setting, not prose', '4k': 'delete; resolution is a render setting, not prose', uhd: 'delete; resolution is a render setting, not prose', 'ultra-hd': 'delete; resolution is a render setting, not prose', 'high quality': 'delete; resolution is a render setting, not prose', 'highly detailed': 'the two details that matter, named', 'insanely detailed': 'the two details that matter, named', gorgeous: 'pick the single detail that matters', mesmerizing: 'pick the single detail that matters', majestic: 'pick the single detail that matters', spectacular: 'pick the single detail that matters', 'visually striking': 'the one frame the viewer remembers, described', }; const GENERIC_EVAL_SUGGESTION = 'name the observable detail that earns it'; const STACK_SUGGESTION = 'pick the single detail that matters — three synonyms make one weak claim'; const FEEL_SUGGESTION = 'name the physical cause of the feeling, not the feeling'; const NEGATION_SUGGESTION = 'describe what IS there; reserve negation for the constraint slot (e.g. "no on-screen text")'; /** Empty-evaluator vocabulary (also the adjective-stack vocabulary). */ const EVALUATOR_TOKENS = [ 'cinematic', 'epic', 'beautiful', 'stunning', 'breathtaking', 'gorgeous', 'mesmerizing', 'dynamic', 'dramatic', 'magical', 'professional', 'majestic', 'spectacular', 'jaw-dropping', 'awe-inspiring', 'ultra-realistic', 'hyper-realistic', ] as const; /** Borrowed image-model tokens — resolution/quality settings masquerading as prose. */ const BORROWED_TOKENS = [ '8k', '4k', '16k', 'uhd', 'ultra-hd', 'ultra hd', 'masterpiece', 'award-winning', 'award winning', 'trending on artstation', 'artstation', 'unreal engine', 'octane render', 'octane', 'highly detailed', 'insanely detailed', 'high quality', 'hyperdetailed', 'ultra-detailed', ] as const; /** Feel-suffix vibe words (English subset; CJK feel-suffixes are out of scope). */ const FEEL_TOKENS = ['vibey', 'vibes', 'vibe'] as const; /** * Quality-insurance negation DENY-list. Only these fire; everything else * (including constraint-slot "no on-screen text / no watermark / no captions") * is inherently safe. */ const NEGATION_DENY = [ 'no blur', 'no blurry', 'no artifacts', 'no artifact', 'no distortion', 'no extra fingers', 'no extra limbs', 'no bad anatomy', 'no deformed', 'no deformities', 'no disfigured', 'no jpeg artifacts', 'no lowres', 'no low-res', 'no worst quality', 'no mutated', 'no mutation', 'no ugly', 'no oversaturated', ] as const; function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** A literal-phrase matcher with word boundaries, case-insensitive, global. */ function phraseRe(phrase: string): RegExp { return new RegExp(`\\b${escapeRegExp(phrase)}\\b`, 'gi'); } /** Per-evaluator matcher; `cinematic` carries a lookahead so the named style passes. */ function evaluatorRe(token: string): RegExp { if (token === 'cinematic') { // "cinematic" is a pervasive LEGITIMATE adjective on concrete nouns — both in // generated packets (cinematic shot/sequence/storyboard/grade/lighting/panels) // and normal film language. Flag it ONLY as a hollow evaluator: intensifier- // preceded ("very cinematic"), at a sentence boundary ("make it cinematic."), // or modifying a vibe/quality word ("cinematic vibes"). Never "cinematic ". return /\b(?:very|super|ultra|so|more|most|extremely|truly|incredibly|highly)\s+cinematic\b|\bcinematic\b(?=\s*(?:[.;!?)]|$)|\s+(?:masterpiece|vibes?|feel(?:ing)?|aesthetic|quality)\b)/gi; } return phraseRe(token); } interface Occ { token: string; start: number; end: number; text: string; } interface IndexedFinding extends SlopFinding { start: number; } /** Collect every match of `re` in `text` as occurrences. */ function collect(re: RegExp, text: string): Array<{ start: number; end: number; text: string }> { const out: Array<{ start: number; end: number; text: string }> = []; re.lastIndex = 0; let m: RegExpExecArray | null; while ((m = re.exec(text)) !== null) { out.push({ start: m.index, end: m.index + m[0].length, text: m[0] }); if (m.index === re.lastIndex) re.lastIndex++; // guard against zero-length loops } return out; } // Gap between two adjacent evaluator hits that still counts as one stack: only // separators / a conjunction (", ", " and ", " & "). const STACK_SEPARATOR_RE = /^[\s,&]*(?:and[\s,&]*)?$/i; function evaluatorOccurrences(text: string): Occ[] { const occ: Occ[] = []; for (const token of EVALUATOR_TOKENS) { for (const hit of collect(evaluatorRe(token), text)) { occ.push({ token, start: hit.start, end: hit.end, text: hit.text }); } } occ.sort((a, b) => a.start - b.start); return occ; } function evaluatorSuggestion(token: string): string { return SLOP_REPLACEMENTS[token.toLowerCase()] ?? GENERIC_EVAL_SUGGESTION; } function borrowedSuggestion(token: string): string { return SLOP_REPLACEMENTS[token.toLowerCase()] ?? 'delete; quality is a render setting, not prose'; } /** * Detect anti-slop in `text`. Pure: depends only on its arguments. Returns * findings in first-appearance order, deduplicated by (class, lowercased match). */ export function detectSlop(text: string, opts: DetectSlopOptions = {}): SlopFinding[] { let scan = text; for (const block of opts.ignore ?? []) { if (block) scan = scan.split(block).join(' '); } const findings: IndexedFinding[] = []; // 1) Evaluators: group adjacent hits into stacks (>=2) vs single bare evaluators. const occ = evaluatorOccurrences(scan); let group: Occ[] = []; const flush = (): void => { if (group.length === 0) return; if (group.length >= 2) { const first = group[0]; const last = group[group.length - 1]; findings.push({ class: 'adjective-stack', match: scan.slice(first.start, last.end), suggestion: STACK_SUGGESTION, start: first.start, }); } else { const o = group[0]; findings.push({ class: 'empty-evaluator', match: o.text, suggestion: evaluatorSuggestion(o.token), start: o.start, }); } group = []; }; for (const o of occ) { if (group.length === 0) { group = [o]; continue; } const prev = group[group.length - 1]; if (STACK_SEPARATOR_RE.test(scan.slice(prev.end, o.start))) { group.push(o); } else { flush(); group = [o]; } } flush(); // 2) Borrowed image-model tokens. for (const token of BORROWED_TOKENS) { for (const hit of collect(phraseRe(token), scan)) { findings.push({ class: 'borrowed-token', match: hit.text, suggestion: borrowedSuggestion(token), start: hit.start, }); } } // 3) Feel-suffix vibe words. for (const token of FEEL_TOKENS) { for (const hit of collect(phraseRe(token), scan)) { findings.push({ class: 'feel-suffix', match: hit.text, suggestion: FEEL_SUGGESTION, start: hit.start, }); } } // 4) Quality-insurance negation (deny-list only). for (const phrase of NEGATION_DENY) { for (const hit of collect(phraseRe(phrase), scan)) { findings.push({ class: 'negation-slop', match: hit.text, suggestion: NEGATION_SUGGESTION, start: hit.start, }); } } // Order by first appearance, then dedupe by (class, lowercased match). findings.sort((a, b) => a.start - b.start); const seen = new Set(); const out: SlopFinding[] = []; for (const f of findings) { const key = `${f.class}${f.match.toLowerCase()}`; if (seen.has(key)) continue; seen.add(key); out.push({ class: f.class, match: f.match, suggestion: f.suggestion }); } return out; }