import type { LintContext, HyperframeLintFinding } from "../context"; /** Extract a bracket-balanced array literal starting at the `[` found by `varMatch`. */ // fallow-ignore-next-line complexity function extractArrayLiteral(src: string, varMatch: RegExpExecArray): string | null { const openIdx = varMatch.index + varMatch[0].length - 1; let depth = 0; let inStr = false; let strChar = ""; for (let i = openIdx; i < src.length; i++) { const c = src[i]!; if (inStr) { if (c === "\\") { i++; continue; } if (c === strChar) inStr = false; } else if (c === '"' || c === "'") { inStr = true; strChar = c; } else if (c === "[") { depth++; } else if (c === "]") { depth--; if (depth === 0) return src.slice(openIdx, i + 1); } } return null; } export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ // caption_exit_missing_hard_kill ({ scripts, styles, options, rootCompositionId }) => { const findings: HyperframeLintFinding[] = []; // Only the ACTUAL captions composition. A content frame that merely mentions // "karaoke" / "caption-*" in a comment (or uses an unrelated forEach + opacity:0 // screen-swap) is NOT captions — gating here prevents the false positive that fired // on a content frame whose only caption signal was a descriptive comment. const isCaptionComposition = Boolean(options.filePath && /caption/i.test(options.filePath)) || rootCompositionId === "captions" || styles.some((s) => /\.caption[-_]?(?:group|word|line|block)\b|\.cg-/.test(s.content)); if (!isCaptionComposition) return findings; for (const script of scripts) { const content = script.content; const hasExitTween = /\.to\s*\([^,]+,\s*\{[^}]*opacity\s*:\s*0/.test(content); const hasHardKill = /\.set\s*\([^,]+,\s*\{[^}]*(?:visibility\s*:\s*["']hidden["']|opacity\s*:\s*0)/.test( content, ); const hasCaptionLoop = /forEach|\.forEach\s*\(/.test(content) && /karaoke|caption[-_]?(?:group|word|line|block)|cg-/.test(content); if (hasCaptionLoop && hasExitTween && !hasHardKill) { findings.push({ code: "caption_exit_missing_hard_kill", severity: "error", message: "Caption exit animations (tl.to with opacity: 0) detected without a hard tl.set kill. " + "Exit tweens can fail when karaoke word-level tweens conflict, leaving captions stuck on screen.", fixHint: 'Add `tl.set(groupEl, { opacity: 0, visibility: "hidden" }, group.end)` after every ' + "exit tl.to animation as a deterministic kill.", }); } } return findings; }, // caption_text_overflow_risk ({ styles }) => { const findings: HyperframeLintFinding[] = []; for (const style of styles) { const captionBlocks = style.content.matchAll( /(\.caption[-_]?(?:group|container|text|line|word)|#caption[-_]?container)\s*\{([^}]+)\}/gi, ); for (const [, selector, body] of captionBlocks) { if (!body) continue; const hasNowrap = /white-space\s*:\s*nowrap/i.test(body); const hasMaxWidth = /max-width/i.test(body); if (hasNowrap && !hasMaxWidth) { findings.push({ code: "caption_text_overflow_risk", severity: "warning", selector: (selector ?? "").trim(), message: `Caption selector "${(selector ?? "").trim()}" has white-space: nowrap but no max-width. Long phrases will clip off-screen.`, fixHint: "Add max-width: 1600px (landscape) or max-width: 900px (portrait) and overflow: hidden.", }); } } } return findings; }, // caption_transcript_not_inline // fallow-ignore-next-line complexity ({ scripts, styles, options }) => { const findings: HyperframeLintFinding[] = []; // Only check files that look like caption compositions const isCaptionFile = (options.filePath && /caption/i.test(options.filePath)) || styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content)); if (!isCaptionFile) return findings; const allScript = scripts.map((s) => s.content).join("\n"); const hasInlineTranscript = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*\[/.test( allScript, ); const hasFetchTranscript = /fetch\s*\(\s*["'][^"']*transcript/i.test(allScript); if (!hasInlineTranscript && hasFetchTranscript) { findings.push({ code: "caption_transcript_not_inline", severity: "error", message: "Captions composition loads transcript via fetch(). The studio caption editor " + "requires an inline `var TRANSCRIPT = [...]` array to detect and edit captions.", fixHint: 'Embed the transcript as `var TRANSCRIPT = [{ "text": "...", "start": 0, "end": 1 }, ...]` ' + "with JSON-quoted property keys. See the captions skill for details.", }); } if (hasInlineTranscript) { // Verify the inline transcript can be parsed. // Use a balanced-bracket scan instead of a regex to correctly handle // nested arrays (e.g. word-level timing arrays inside each entry). const varStart = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*\[/.exec(allScript); const transcriptJson = varStart ? extractArrayLiteral(allScript, varStart) : null; if (transcriptJson) { try { JSON.parse(transcriptJson); } catch { findings.push({ code: "caption_transcript_parse_error", severity: "error", message: "Inline TRANSCRIPT array is not valid JSON. The studio caption editor may fail " + "to parse it. Common cause: unquoted property keys with apostrophes in text.", fixHint: 'Use JSON-quoted keys: { "text": "don\'t", "start": 0, "end": 1 } instead of ' + '{ text: "don\'t", start: 0, end: 1 }.', }); } } } return findings; }, // caption_container_relative_position ({ styles }) => { const findings: HyperframeLintFinding[] = []; for (const style of styles) { const captionBlocks = style.content.matchAll( /(\.caption[-_]?(?:group|container|text|line)|#caption[-_]?container)\s*\{([^}]+)\}/gi, ); for (const [, selector, body] of captionBlocks) { if (!body) continue; if (/position\s*:\s*relative/i.test(body)) { findings.push({ code: "caption_container_relative_position", severity: "error", selector: (selector ?? "").trim(), message: `Caption selector "${(selector ?? "").trim()}" uses position: relative which causes overflow and breaks caption stacking.`, fixHint: "Use position: absolute for all caption elements.", }); } } } return findings; }, // caption_overflow_clips_scaled_words ({ styles, scripts }) => { const findings: HyperframeLintFinding[] = []; const hasScaledWords = scripts.some( (s) => /scale\s*:\s*1\.[2-9]/.test(s.content) && /caption|word|cg-/.test(s.content), ); if (!hasScaledWords) return findings; for (const style of styles) { const captionBlocks = style.content.matchAll( /(\.caption[-_]?(?:group|container)|#caption[-_]?(?:layer|container))\s*\{([^}]+)\}/gi, ); for (const [, selector, body] of captionBlocks) { if (!body) continue; if (/overflow\s*:\s*hidden/i.test(body)) { findings.push({ code: "caption_overflow_clips_scaled_words", severity: "error", selector: (selector ?? "").trim(), message: `"${(selector ?? "").trim()}" has overflow: hidden but GSAP scales caption words above 1.0x. Scaled emphasis words and their glow effects will be clipped.`, fixHint: "Use overflow: visible on caption containers. Rely on fitTextFontSize with reduced maxWidth to prevent overflow instead.", }); } } } return findings; }, // caption_textshadow_on_group_container ({ scripts, styles }) => { const findings: HyperframeLintFinding[] = []; const isCaptionFile = styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content)); if (!isCaptionFile) return findings; for (const script of scripts) { // Detect textShadow tweened on a group container (div with child word spans) const groupShadowPattern = /\.to\s*\(\s*(?:div|groupEl|el|captionEl|document\.getElementById\s*\(\s*["']cg-)\s*[^,]*,\s*\{[^}]*textShadow/g; // Also catch selector-based targeting of group containers const selectorShadowPattern = /\.to\s*\(\s*["'](?:#cg-\d+|\.caption[-_]?group)["']\s*,\s*\{[^}]*textShadow/g; if (groupShadowPattern.test(script.content) || selectorShadowPattern.test(script.content)) { findings.push({ code: "caption_textshadow_on_group_container", severity: "warning", message: "textShadow is tweened on a caption group container. When children have semi-transparent " + "color (e.g., inactive karaoke words at rgba opacity), the glow renders as a visible " + "rectangle behind the entire group.", fixHint: "Apply textShadow to individual active word elements instead of the group container. " + "Use scale on the group for bass-reactive pulsing.", }); } } return findings; }, // caption_fittext_scale_mismatch // fallow-ignore-next-line complexity ({ scripts }) => { const findings: HyperframeLintFinding[] = []; for (const script of scripts) { const content = script.content; const fitTextMatch = content.match(/fitTextFontSize\s*\([^)]*maxWidth\s*:\s*(\d+)/); if (!fitTextMatch) continue; const maxWidth = parseInt(fitTextMatch[1] ?? "0", 10); if (!maxWidth) continue; // Find max scale on caption words const scaleMatches = [...content.matchAll(/scale\s*:\s*(1\.\d+)/g)]; const captionContext = /caption|word|cg-|karaoke/i.test(content); if (!captionContext || scaleMatches.length === 0) continue; let maxScale = 1; for (const m of scaleMatches) { const val = parseFloat(m[1] ?? "1"); if (val > maxScale) maxScale = val; } // Check if maxWidth * maxScale exceeds safe bounds (1920 - reasonable margins) const effectiveWidth = maxWidth * maxScale; if (effectiveWidth > 1760) { findings.push({ code: "caption_fittext_scale_mismatch", severity: "warning", message: `fitTextFontSize uses maxWidth: ${maxWidth}px but emphasis words scale up to ${maxScale}x. ` + `Effective width ${Math.round(effectiveWidth)}px may overflow the composition (1920px minus margins).`, fixHint: `Reduce maxWidth to ${Math.floor(1700 / maxScale)}px to leave headroom for scaled emphasis words.`, }); } } return findings; }, ];