/** * Seedance 2.0 content-filter logic. * * A faithful TypeScript port of the content-filter functions in * `skills/video-replicator/scripts/seedance_client.py` * (pre_validate_prompt, sanitize_prompt L1/L2, is_content_violation, * _suggest_recovery). Pure and deterministic — no I/O, no network. * * Seedance enforces strict server-side safety filters (prompt scan + input * analysis). Filters tightened significantly after launch due to Hollywood IP * backlash, deepfake concerns, and Chinese regulatory requirements. Risk * categories (per the source skill): * * HIGH RISK (almost always blocked): * - Real human faces/photos as reference images * - Children/minors * - Celebrity/public figure names or strong likenesses * - Copyrighted characters/IP (Marvel, Disney, Star Wars, anime named, ...) * - NSFW / nudity / sexual / suggestive content * - Political figures or sensitive political topics * * MEDIUM RISK (sometimes blocked, retry with sanitization): * - Realistic human figures without stylization cues * - Brand names, trademarks, product names * - Violence/combat (graphic) * * SAFE (reliable): * - Generic "young adult figure", "elegant dancer", "athletic person" * - Fully stylized: anime, cartoon, 3D render, illustration, painting * - Camera/motion descriptors: dolly, pan, push in, slow zoom, cinematic * - Environment/nature/architecture without identifiable real places */ export type ContentFilterLevel = 'HIGH' | 'MEDIUM' | 'LOW'; export interface ContentFilterWarning { level: ContentFilterLevel; reason: string; match: string; } // ============================================================================ // Content Violation Detection // ============================================================================ // Content violation errors from Chinese content moderation (retryable with // sanitization). Ported verbatim from _CONTENT_VIOLATION_PATTERNS. const CONTENT_VIOLATION_PATTERNS: string[] = [ 'content violates regulations', 'violates regulations', 'error code: 2038', 'error code:2038', '违规', // Chinese: "violation" '内容违规', // Chinese: "content violation" '内容不合规', // Chinese: "content non-compliant" '审核不通过', // Chinese: "review failed" '内容安全', // Chinese: "content safety" '触发安全', // Chinese: "triggered safety" ]; // Regex for media references that must be preserved during sanitization. const MEDIA_REF_PATTERN = /@(?:image|video|audio)\d+/g; // Patterns for brand/product names and marketing language. const BRAND_PATTERNS: RegExp[] = [ /\b[A-Z][a-z]+[A-Z]\w*\b/g, // CamelCase: ChronoLux, SmartWatch /\b[A-Z]{2,}(?:\s+[A-Z]{2,})*\b/g, // ALL CAPS multi-word: LUXURY BRAND /"[^"]{2,30}"/g, // Quoted names: "ChronoLux" /'[^']{2,30}'/g, // Single-quoted names /(?:™|®|©)/g, // Trademark symbols ]; /** * ALL-CAPS tokens the toolchain EMITS ITSELF, exempt from the ALL-CAPS brand * rule above. * * That rule is not brand detection — it deletes every two-plus-letter uppercase * token unconditionally. Applied to our own prompts it removed all ten Seedance * block headers, so a level-1 retry submitted a packet whose block structure was * gone: every `SUBJECT LOCK:` became a bare `:`. The prompt system's entire * contract lives in those labels, and `checkSeedanceBlockOrder` would reject the * filter's own output. * * These are STRUCTURAL LABELS and TECHNICAL TERMS, not brands. A real brand in * caps (IKEA, NASA) is still stripped — this is an exemption list, not an * off switch. * * Fragments matter: the regex breaks runs on non-letters, so `SCENE & MOOD` * arrives as `SCENE` and `MOOD`, and `CROSS-FRAME RULES` as `CROSS` and * `FRAME RULES`. The entries below are the tokens as the regex actually sees * them, verified by running it over real emitter output rather than by reading * the source literals. */ const TOOLCHAIN_ALLCAPS_TOKENS: ReadonlySet = new Set([ // Seedance 13-block headers (seedance-blocks.ts, filmmaking-prompts.ts). 'SCENE', 'MOOD', 'FRAME MAP', 'SUBJECT LOCK', 'CROSS', 'FRAME RULES', 'MOVEMENT', 'LAST FRAME', 'WORLD PLATE', 'SOUND BED', 'CAPTURE REALISM', 'CAMERA CAPTURE', // Directive blocks. `NO ON-SCREEN TEXT` splits on the hyphen into two runs; // both halves are listed because a run only survives when EVERY token in it // is known vocabulary. Verified against real emitter output below, not read // off the source literal. 'NO ON', 'SCREEN TEXT', 'CAPTURE CADENCE', 'ATMOSPHERE', // Named directive blocks, listed as WHOLE headers rather than as their // component words: adding bare 'THE'/'IS'/'OF' to this set would wave through // any all-caps run built from them. Without these, an L1 retry deletes the // header and leaves the body behind — a directive block with no directive, // which is the same structural failure this exemption set exists to prevent. 'THE STROBE IS THE DEFINING FEATURE OF THIS SEQUENCE', // Lipsync headers. These are NOT on the retry path today — `--lipsync` is // multi-shot-only, operator-pasted output that never reaches sanitizePrompt. // They are listed so that wiring lipsync into a packet later does not // silently lose them. Caveat: the quoted seal words the closure block emits // ("MY", "UP") match the quoted-name brand pattern, which this set cannot // exempt — that needs handling at the pattern level if it ever ships. 'THE SINGING IS THE PRIMARY SUBJECT OF THIS SEQUENCE', 'THE LINE', 'VERBATIM', 'THE PATTERN OF CLOSURES', 'THE MOUTH IS ALWAYS VISIBLE AND ALWAYS READABLE', 'ONLY', // SINGLE_FULL_FRAME_GUARD // Product-packet headers (filmmaking-prompts.ts:833-849). 'FORMAT', 'PRODUCT', 'REFERENCE', 'CAMERA', 'STYLE', 'AUDIO', 'TIMELINE', // Product beat labels, uppercased at filmmaking-prompts.ts:851. 'HOOK', 'FEATURE', 'BENEFIT', 'CTA', 'HERO ANGLE', 'ROTATION', 'BACK', 'RETURN', 'LOOK', // Multi-shot emitters. 'SHOT', 'SFX', 'VO', // Technical / format acronyms. Descriptive vocabulary, not brands: removing // them silently changes what is being asked for (losing SFX turns // "a hook SFX up front" into "a hook up front"). 'IMAX', 'CGI', 'PBR', 'DSLR', 'FOV', 'HD', 'UHD', 'LUT', 'RAW', 'VFX', ]); /** * Keep an ALL-CAPS run only when EVERY token in it is toolchain vocabulary. * `SUBJECT LOCK` survives; `LUXURY BRAND` does not; a mixed run like * `CAMERA NIKON` does not, because the unknown token is the reason to strip. */ function isToolchainAllCaps(match: string): boolean { if (TOOLCHAIN_ALLCAPS_TOKENS.has(match)) return true; const tokens = match.split(/\s+/).filter(Boolean); return tokens.length > 1 && tokens.every((t) => TOOLCHAIN_ALLCAPS_TOKENS.has(t)); } const MARKETING_WORDS = /\b(?:revolutionary|groundbreaking|world[- ]?class|best[- ]?in[- ]?class|premium|luxury|exclusive|limited[- ]?edition|award[- ]?winning|patented|proprietary|trademarked|branded|signature|unrivaled|unmatched|unparalleled|superior|ultimate|#\d+\s+(?:selling|rated|ranked)|market[- ]?leading|buy\s+now|order\s+today|shop\s+now|get\s+yours|discount|sale|offer|deal|promo|coupon|free\s+shipping)\b/gi; // High-risk terms: celebrities, copyrighted characters, political figures. // These are near-certain filter triggers — level 1 sanitization replaces them. const CELEBRITY_IP_PATTERNS = /\b(?:taylor\s+swift|beyoncé|beyonce|selena\s+gomez|ariana\s+grande|tom\s+cruise|brad\s+pitt|leonardo\s+dicaprio|scarlett\s+johansson|elon\s+musk|jeff\s+bezos|bill\s+gates|steve\s+jobs|barack\s+obama|donald\s+trump|vladimir\s+putin|xi\s+jinping|spider[- ]?man|iron\s+man|captain\s+america|thor|hulk|black\s+widow|batman|superman|wonder\s+woman|joker|harley\s+quinn|mickey\s+mouse|minnie\s+mouse|elsa|anna|olaf|moana|simba|darth\s+vader|yoda|luke\s+skywalker|stormtrooper|r2[- ]?d2|harry\s+potter|hermione|dumbledore|voldemort|mario|luigi|pikachu|goku|naruto|luffy|shrek|donkey|fiona|stranger\s+things|eleven|eleven\s+hopper|arcane|jinx|vi\s+jinx|jayce|marvel|avengers|x[- ]?men|dc\s+comics|star\s+wars|disney|pixar|netflix\s+original|hbo\s+max|prime\s+video)\b/gi; // High-risk: minors / children (highest risk category per ByteDance policy). const MINOR_PATTERNS = /\b(?:child|children|kid|kids|boy|girl|baby|infant|toddler|teenager|teen|adolescent|juvenile|minor|underage|youth|young\s+child|little\s+girl|little\s+boy|school\s+child|school\s+kid|pre[- ]?teen|tween|\d+[- ]?year[- ]?old(?!\s*(?:tree|building|car|wine|whiskey|aged))|age\s+[1-9]\b|age\s+1[0-7]\b)\b/gi; // Pre-validation risk levels. const HIGH: ContentFilterLevel = 'HIGH'; const MEDIUM: ContentFilterLevel = 'MEDIUM'; const LOW: ContentFilterLevel = 'LOW'; /** * Check if an error message indicates a content regulation violation * (error code 2038). These errors come from Seedance's Chinese content * moderation and are retryable with prompt sanitization. * * Port of `is_content_violation`. */ export function isContentViolation(errorMsg: string): boolean { if (!errorMsg) { return false; } const lower = errorMsg.toLowerCase(); for (const pattern of CONTENT_VIOLATION_PATTERNS) { if (lower.includes(pattern.toLowerCase())) { return true; } } return false; } /** * Check a prompt for likely content filter triggers before submission. * * Returns a list of warnings, each with `{ level, reason, match }`. * HIGH — near-certain filter triggers. * MEDIUM — sometimes pass but should be reworded if errors occur. * LOW — advisory only. * * Port of `pre_validate_prompt`. */ export function preValidatePrompt(prompt: string): ContentFilterWarning[] { const warnings: ContentFilterWarning[] = []; // HIGH: Celebrity names / copyrighted IP let match = firstMatch(CELEBRITY_IP_PATTERNS, prompt); if (match) { warnings.push({ level: HIGH, reason: 'Celebrity name or copyrighted character detected. Replace with generic description.', match, }); } // HIGH: Children / minors (highest priority category per ByteDance policy) match = firstMatch(MINOR_PATTERNS, prompt); if (match) { warnings.push({ level: HIGH, reason: "Minor/child reference detected. Use 'young adult' (mid-20s) instead, or remove age.", match, }); } // HIGH: NSFW / sexual / nudity const nsfwTerms = /\b(?:nude|naked|nudity|topless|bottomless|nsfw|sexy|sexual|erotic|lingerie|underwear|bikini\s+(?:model|shoot)|intimate\s+(?:scene|moment|touch)|suggestive|revealing\s+(?:outfit|clothing|dress)|cleavage|seductive)\b/gi; match = firstMatch(nsfwTerms, prompt); if (match) { warnings.push({ level: HIGH, reason: 'NSFW/sexual content detected. Keep characters fully clothed in neutral contexts.', match, }); } // HIGH: Political figures / sensitive politics const politicalTerms = /\b(?:president|prime\s+minister|senator|congressman|chancellor|communist\s+party|ccp|politburo|government\s+official|protest|revolution|coup|dictator|regime|propaganda)\b/gi; match = firstMatch(politicalTerms, prompt); if (match) { warnings.push({ level: HIGH, reason: 'Political content detected. Avoid political figures and events entirely.', match, }); } // MEDIUM: Real human faces as references (flag if prompt strongly implies // realistic portraiture) const portraitTerms = /\b(?:real\s+person|real\s+face|photo[- ]?realistic\s+(?:person|face|human)|portrait\s+photo|headshot|selfie|face\s+swap|deepfake|based\s+on\s+(?:a\s+)?(?:photo|image|picture)\s+of)\b/gi; match = firstMatch(portraitTerms, prompt); if (match) { warnings.push({ level: MEDIUM, reason: 'Photo-realistic portrait reference detected. Use stylized/illustrated references only.', match, }); } // MEDIUM: Violence / gore const violenceTerms = /\b(?:gore|blood(?:y|ied)?|decapitat|dismember|brutal\s+(?:fight|kill|death)|graphic\s+(?:violence|injury|wound)|murder|execution|torture|combat\s+with\s+(?:blood|injury|death)|war\s+crime)\b/gi; match = firstMatch(violenceTerms, prompt); if (match) { warnings.push({ level: MEDIUM, reason: "Graphic violence detected. Use 'energetic movement' or 'graceful action' instead.", match, }); } // MEDIUM: Trademark symbols or very specific brand names match = firstMatch(/(?:™|®|©)/g, prompt); if (match) { warnings.push({ level: MEDIUM, reason: 'Trademark symbol detected. Remove brand marks from prompts.', match, }); } // LOW: Anime named characters (sometimes flagged post-Feb 2026) const animeNamed = /\b(?:goku|naruto\s+uzumaki|luffy|sasuke|vegeta|nezuko|tanjiro|levi\s+ackerman|mikasa|eren\s+yeager|jujutsu|demon\s+slayer)\b/gi; match = firstMatch(animeNamed, prompt); if (match) { warnings.push({ level: LOW, reason: "Named anime character detected. Use generic 'anime-style warrior/hero' instead.", match, }); } return warnings; } /** * Sanitize a prompt to avoid content regulation violations. * * @param prompt Original prompt text (may contain @image1/@video1/@audio1 refs). * @param level Sanitization aggressiveness: * 1 = Light: strip brand names, celebrity/IP names, marketing superlatives, * replace minors with "young adult". * 2 = Heavy: reduce to pure camera motion + generic visual description. * * Media references (@image1, @video1, @audio1) are extracted and re-prefixed * at the start (where Seedance expects them). * * Port of `sanitize_prompt`. */ export function sanitizePrompt(prompt: string, level = 1): string { // Extract and preserve media references (@image1, @video1, @audio1). const mediaRefs = prompt.match(MEDIA_REF_PATTERN) ?? []; // Remove media refs from text for processing, rejoin later. let text = prompt.replace(MEDIA_REF_PATTERN, '').trim(); if (level === 1) { text = sanitizeLevel1(text); } else if (level >= 2) { text = sanitizeLevel2(text); } // Rejoin media refs at the start (where Seedance expects them). if (mediaRefs.length > 0) { const prefix = mediaRefs.join(' '); text = `${prefix} ${text}`.trim(); } return text; } /** Light sanitization: strip brand names, celebrity/IP names, marketing language. */ function sanitizeLevel1(input: string): string { // Strip celebrity names and copyrighted IP (near-certain filter triggers). let text = input.replace(CELEBRITY_IP_PATTERNS, ''); // Replace minor/child references with a safe generic alternative. // Use "young adult" rather than empty string to preserve sentence meaning. text = text.replace(MINOR_PATTERNS, 'young adult'); // Strip brand/product names and marketing superlatives. The ALL-CAPS rule // exempts the toolchain's own structural labels and technical acronyms — // without that, this loop deletes every Seedance block header and the retry // submits a packet with no block structure at all. for (const pattern of BRAND_PATTERNS) { text = text.replace(pattern, (match) => (isToolchainAllCaps(match) ? match : '')); } text = text.replace(MARKETING_WORDS, ''); // Clean up whitespace artifacts — but NOT line structure. `\s` includes `\n`, // so the original `/\s{2,}/g` flattened every prompt onto a single line: a // 13-block packet came back as one run-on string, and the timecoded multi-shot // format lost the paragraph breaks its own parser needs. Collapse runs of // spaces/tabs only, then squeeze 3+ newlines back to a blank line. text = text.replace(/[^\S\n]{2,}/g, ' '); text = text.replace(/[^\S\n]*\n[^\S\n]*/g, '\n'); text = text.replace(/\n{3,}/g, '\n\n'); text = text.replace(/\s+([,.])/g, '$1'); text = text.replace(/^[,.\s]+/, ''); text = text.trim(); return text; } /** Heavy sanitization: reduce to camera motion + generic visual description. */ function sanitizeLevel2(text: string): string { const cameraPattern = /(?:camera|pan|tilt|dolly|tracking|push|pull|zoom|orbit|crane|aerial|close[- ]?up|wide[- ]?shot|medium[- ]?shot|establish|follow|static|slow[- ]?motion|time[- ]?lapse|forward|backward|left|right|up|down|in|out|smooth|gentle|dramatic|cinematic|sweeping)\s*\w*/gi; const cameraFrags = text.match(cameraPattern) ?? []; const visualPattern = /(?:person|object|product|scene|interior|exterior|room|table|surface|background|light|shadow|color|bright|dark|warm|cool|soft|natural|ambient|golden[- ]?hour|sunset|sunrise|morning|evening|night)\s*\w*/gi; const visualFrags = text.match(visualPattern) ?? []; const parts: string[] = []; if (cameraFrags.length > 0) { parts.push(cameraFrags.slice(0, 5).join(' ')); } if (visualFrags.length > 0) { parts.push(visualFrags.slice(0, 5).join(' ')); } if (parts.length > 0) { return parts.join('. ').trim(); } return 'Smooth camera movement across the scene'; } /** * Suggest a fix based on the error message. * * Port of `_suggest_recovery` (the `prompt` argument in the Python source is * unused, so it is omitted here). */ export function suggestRecovery(errorMsg: string): string { const msg = (errorMsg ?? '').toLowerCase(); const suggestions: string[] = []; if (msg.includes('real person') || msg.includes('content filter') || msg.includes('2038')) { suggestions.push( 'Upload image to Asset Library first (Asset:// URI bypasses real-person filter)', ); } if (msg.includes('celebrity') || msg.includes('public figure')) { suggestions.push('Remove celebrity references. Describe the character generically instead'); } if (msg.includes('violence') || msg.includes('gore')) { suggestions.push("Reduce violence level. Use 'action sequence' instead of explicit violence"); } if (msg.includes('timeout') || msg.includes('timed out')) { suggestions.push('Try shorter duration (5s instead of 15s) or simpler prompt'); } if (msg.includes('too long') || msg.includes('token')) { suggestions.push('Shorten the prompt — remove redundant descriptions'); } if (msg.includes('nsfw') || msg.includes('adult')) { suggestions.push('Remove NSFW content. Keep it PG-13 for Seedance'); } if (suggestions.length === 0) { suggestions.push('Try simplifying the prompt or using a different scene description'); } return suggestions.join(' | '); } /** * Run a global regex against `text` once and return the first match string, * or `null`. Resets `lastIndex` so the shared global regexes stay stateless * across calls (Python's `re.search` has no such statefulness). */ function firstMatch(pattern: RegExp, text: string): string | null { pattern.lastIndex = 0; const m = pattern.exec(text); pattern.lastIndex = 0; return m ? m[0] : null; }