// ===== KIMI VIDEO FINITE STATE MACHINE (INITIAL REFACTOR) ===== // All code in English per contribution guidelines. // Lightweight, additive (non-breaking) layer offering explicit state & transition validation. // Designed for incremental integration: if unavailable, existing logic in kimi-videos.js still works. (function () { const STATES = Object.freeze({ NEUTRAL: "neutral", LISTENING: "listening", SPEAKING_POS: "speakingPositive", SPEAKING_NEG: "speakingNegative", DANCING: "dancing" }); // Priority aligns with emotion system weights (kept local for resilience if emotion system not yet loaded). const PRIORITY = Object.freeze({ dancing: 10, speakingPositive: 4, speakingNegative: 4, listening: 7, neutral: 3 }); // Mapping external (context/emotion) -> canonical state. function resolveState(context, emotion) { // Direct context mapping precedence. if (!context && emotion) context = emotion; switch (context) { case "dancing": return STATES.DANCING; case "listening": return STATES.LISTENING; case "speakingNegative": return STATES.SPEAKING_NEG; case "speakingPositive": return STATES.SPEAKING_POS; case "speaking": { if (emotion === "negative") return STATES.SPEAKING_NEG; return STATES.SPEAKING_POS; } default: break; } // Emotion-based fallback (positive/negative map to speaking variants) if (emotion === "negative") return STATES.SPEAKING_NEG; if (["positive", "romantic", "laughing", "surprise", "confident", "flirtatious", "kiss", "android", "sensual", "love"].includes(emotion)) return STATES.SPEAKING_POS; return STATES.NEUTRAL; } function stateToCategory(state) { switch (state) { case STATES.SPEAKING_POS: return "speakingPositive"; case STATES.SPEAKING_NEG: return "speakingNegative"; case STATES.LISTENING: return "listening"; case STATES.DANCING: return "dancing"; case STATES.NEUTRAL: return "neutral"; default: return "neutral"; } } // Transition guard rules. // Return {allow:boolean, reason?, downgradeState?} function validateTransition(from, to, ctx) { if (from === to) return { allow: true }; // Dancing sticky: if still inside sticky window, block except recovery. if (from === STATES.DANCING && ctx && ctx.now < ctx.stickyUntil) { return { allow: false, reason: "dancing-sticky" }; } // Speaking variant switches allowed without neutral bridge. if ((from === STATES.SPEAKING_POS || from === STATES.SPEAKING_NEG) && (to === STATES.SPEAKING_POS || to === STATES.SPEAKING_NEG)) { return { allow: true }; } // While speaking (sticky) block downgrade to neutral unless sticky elapsed or higher priority. if ((from === STATES.SPEAKING_POS || from === STATES.SPEAKING_NEG) && to === STATES.NEUTRAL && ctx && ctx.now < ctx.stickyUntil) { return { allow: false, reason: "speaking-sticky" }; } return { allow: true }; } class KimiVideoFSM { constructor() { this.currentState = STATES.NEUTRAL; this._stickyUntil = 0; } getState() { return this.currentState; } getPriority(state) { if (window.kimiEmotionSystem && window.kimiEmotionSystem.getPriorityWeight) { return window.kimiEmotionSystem.getPriorityWeight(state); } return PRIORITY[state] || 3; } // Compute sticky duration based on state & optional text length (for speaking) use same heuristic as existing manager. computeSticky(state, opts = {}) { if (state === STATES.DANCING) { return window.KIMI_VIDEO_CONFIG?.sticky?.dancingMs || 9500; } if (state === STATES.SPEAKING_POS || state === STATES.SPEAKING_NEG) { const cps = window.KIMI_VIDEO_CONFIG?.ttsCharsPerSecond || 14; const len = opts.utteranceLength || 0; const est = len ? Math.min( window.KIMI_VIDEO_CONFIG?.sticky?.speakingMaxMs || 16000, Math.max(window.KIMI_VIDEO_CONFIG?.sticky?.speakingMinMs || 5000, Math.round((len / cps) * 1000 + 1200)) ) : window.KIMI_VIDEO_CONFIG?.sticky?.speakingMs || 15000; return est; } return 0; } resolve(context, emotion) { const st = resolveState(context, emotion); return { state: st, category: stateToCategory(st) }; } canTransition(targetState) { const now = Date.now(); const res = validateTransition(this.currentState, targetState, { now, stickyUntil: this._stickyUntil }); return res; } transition(targetState, options = {}) { const evalRes = this.canTransition(targetState); if (!evalRes.allow) return { changed: false, reason: evalRes.reason }; this.currentState = targetState; if (options.sticky) { this._stickyUntil = Date.now() + this.computeSticky(targetState, options); } else if (this._stickyUntil && Date.now() >= this._stickyUntil) { this._stickyUntil = 0; } return { changed: true, state: this.currentState, stickyUntil: this._stickyUntil }; } force(targetState) { this.currentState = targetState; return { changed: true, forced: true, state: this.currentState }; } isStickyActive() { return Date.now() < this._stickyUntil; } getStickyRemaining() { return Math.max(0, this._stickyUntil - Date.now()); } } // Global exposure window.KimiVideoFSM = KimiVideoFSM; window.createKimiVideoFSM = function () { return new KimiVideoFSM(); }; })(); export {}; // ES module no-op export for bundlers.