motion.detect
Analyze web page CSS animations, transitions, and keyframes to detect motion patterns and identify performance or accessibility issues.
Instructions
Detect/classify motion patterns from web page. Parses CSS animations, transitions, keyframes. Warns about performance/accessibility issues.
Input Schema
TableJSON Schema
| Name | Required | Description | Default |
|---|---|---|---|
| pageId | No | WebPage ID (UUID, from DB) | |
| html | Yes | HTML content (direct, max 10MB) | |
| css | No | Additional CSS content (max 5MB) | |
| includeInlineStyles | No | Parse inline styles (default: true) | |
| includeStyleSheets | No | Parse stylesheets (default: true) | |
| minDuration | No | Minimum duration to detect (ms, default: 0) | |
| maxPatterns | No | Max patterns to detect (default: 100) | |
| includeWarnings | No | Include warnings (default: true) | |
| min_severity | No | Minimum severity level to include in warnings (default: info) | info |
| includeSummary | No | Include summary (default: true) | |
| verbose | No | Verbose mode: include rawCss (default: false) | |
| fetchExternalCss | No | Fetch external CSS from <link> tags (default: true) | |
| baseUrl | No | Base URL for resolving relative CSS paths (required if fetchExternalCss is true) | |
| externalCssOptions | No | Options for external CSS fetching | |
| save_to_db | No | Save detected patterns to motion_patterns table with embeddings (default: true) | |
| detection_mode | No | Detection mode: 'css' (requires html/pageId) for static CSS parsing without browser, 'video' (default, requires url) for visual motion detection with frame capture, 'runtime' (requires url) for JS-driven animations, 'hybrid' (requires url) for CSS+runtime combined. | video |
| url | No | Target URL for video/runtime/hybrid modes. Required when detection_mode='video', 'runtime', or 'hybrid'. | |
| detect_js_animations | No | Enable JavaScript animation detection via CDP + Web Animations API. Requires Playwright. Default: false (disabled for performance). | |
| timeout | No | Overall timeout in milliseconds (30000-600000, default: 180000 = 3 minutes). On timeout, returns partial results with warnings (graceful degradation). |
Implementation Reference
- The 'motionDetectHandler' function acts as the main entry point (orchestrator) for the 'motion.detect' MCP tool. It validates input, handles timeouts, and delegates detection tasks to appropriate sub-modules (e.g., video, runtime, hybrid, CSS) based on the 'detection_mode'.
export async function motionDetectHandler(input: unknown): Promise<MotionDetectOutput> { const startTime = Date.now(); if (isDevelopment()) { logger.info("[MCP Tool] motion.detect called", { hasInput: input !== null && input !== undefined, }); } // 入力バリデーション let validated: MotionDetectInput; try { validated = motionDetectInputSchema.parse(input); } catch (error) { if (error instanceof ZodError) { const errorWithHints = createValidationErrorWithHints(error, "motion.detect"); const detailedMessage = formatMultipleDetailedErrors(errorWithHints.errors); const formattedErrors = formatZodError(error); if (isDevelopment()) { logger.error("[MCP Tool] motion.detect validation error", { errors: errorWithHints.errors, }); } return { success: false, error: { code: MOTION_MCP_ERROR_CODES.VALIDATION_ERROR, message: `Validation error:\n${detailedMessage}`, details: { errors: formattedErrors, detailedErrors: errorWithHints.errors, }, }, }; } if (isDevelopment()) { logger.error("[MCP Tool] motion.detect validation error", { error }); } return { success: false, error: { code: MOTION_MCP_ERROR_CODES.VALIDATION_ERROR, message: error instanceof Error ? error.message : "Invalid input", }, }; } // ===================================================== // タイムアウトチェック (v0.1.0) // バリデーション完了後、既にタイムアウトしている場合は即座にgraceful degradation // ===================================================== const timeout = validated.timeout ?? DEFAULT_MOTION_TIMEOUT; const elapsedAfterValidation = Date.now() - startTime; if (elapsedAfterValidation >= timeout) { if (isDevelopment()) { logger.warn("[MCP Tool] motion.detect timeout before processing", { timeout, elapsed: elapsedAfterValidation, }); } return createTimeoutResponse("validation", elapsedAfterValidation); } // 残りの時間でタイムアウト付き処理を実行 const remainingTimeout = timeout - elapsedAfterValidation; // ===================================================== // Video Mode Detection // ===================================================== if (validated.detection_mode === "video" && validated.url) { try { return await withTimeout( handleVideoMode(validated, startTime), remainingTimeout, "video", startTime ); } catch (error) { if (error instanceof MotionTimeoutError) { return createTimeoutResponse(error.phase, error.elapsedMs); } throw error; } } // ===================================================== // Runtime Mode Detection // ===================================================== if (validated.detection_mode === "runtime" && validated.url) { try { return await withTimeout( handleRuntimeMode(validated, startTime), remainingTimeout, "runtime", startTime ); } catch (error) { if (error instanceof MotionTimeoutError) { return createTimeoutResponse(error.phase, error.elapsedMs); } throw error; } } // ===================================================== // Hybrid Mode Detection (CSS + Runtime) // ===================================================== if (validated.detection_mode === "hybrid" && validated.url) { try { return await withTimeout( handleHybridMode(validated, startTime), remainingTimeout, "hybrid", startTime ); } catch (error) { if (error instanceof MotionTimeoutError) { return createTimeoutResponse(error.phase, error.elapsedMs); } throw error; } } // ===================================================== // Library Only Mode Detection (v0.1.0) // 注: library_only モードは layout_first モード経由で使用される // 直接呼び出しの場合はデフォルトモードにフォールバック // ===================================================== if (validated.detection_mode === "library_only") { if (isDevelopment()) { logger.info( "[MCP Tool] library_only mode: falling back to css mode with JS animation detection", { url: validated.url, } ); } // デフォルトモードで処理(layout_first経由で呼び出される場合はJS animation optionsが設定済み) } // ===================================================== // CSS Mode Detection (Default) // ===================================================== try { return await withTimeout( handleDefaultMode(validated, startTime), remainingTimeout, "css", startTime ); } catch (error) { if (error instanceof MotionTimeoutError) { return createTimeoutResponse(error.phase, error.elapsedMs); } throw error; } } // ===================================================== // WebPage Auto-Create Helper (v0.1.0) // URL modeで自動的にWebPageレコードを作成・取得 // ===================================================== /** * URL modeでWebPageを自動作成または取得 * * motion.detect URL modeで実行時に: * 1. 既存のWebPageをURLで検索(完全一致) * 2. 存在しなければ新規WebPageレコードを作成 * 3. webPageIdを返す(savePatternsToDbに渡す) * * @param url - 対象URL * @returns webPageIdとcreatedフラグ、またはエラー時はnull */ async function findOrCreateWebPageForUrl( url: string ): Promise<{ webPageId: string; created: boolean } | null> { try { const webPageService = getWebPageService(); const result = await webPageService.findOrCreateByUrl(url, { sourceType: "user_provided", usageScope: "inspiration_only", }); if (isDevelopment()) { logger.info("[MCP Tool] motion.detect WebPage findOrCreate result", { url, webPageId: result.id, created: result.created, }); } return { webPageId: result.id, created: result.created, }; } catch (error) { if (isDevelopment()) { logger.warn("[MCP Tool] motion.detect WebPage findOrCreate failed", { url, error: error instanceof Error ? error.message : "Unknown error", }); } // エラー時はnullを返す(graceful degradation: 保存は source_url のみで続行) return null; } } // ===================================================== // Video Mode Handler // ===================================================== async function handleVideoMode( validated: MotionDetectInput, startTime: number ): Promise<MotionDetectOutput> { if (isDevelopment()) { logger.info("[MCP Tool] motion.detect using video mode", { url: validated.url, videoOptions: validated.video_options, }); } try { const { videoInfo, patterns, warnings } = await executeVideoDetection( validated.url!, validated.video_options ); // Phase0: WebPage自動作成(v0.1.0) // URL modeではWebPageを自動作成し、web_page_idを取得 let webPageId: string | undefined; let webPageCreated = false; if (validated.save_to_db) { const webPageResult = await findOrCreateWebPageForUrl(validated.url!); if (webPageResult) { webPageId = webPageResult.webPageId; webPageCreated = webPageResult.created; } } // Phase5: Frame Capture実行 let frameCaptureResult = null; let frameCaptureError: { code: string; message: string } | undefined; let frameCaptureProcessingTimeMs = 0; if (validated.enable_frame_capture === true) { const frameCaptureStartTime = Date.now(); try { const frameCaptureOpts = validated.frame_capture_options ?? {}; const frameCaptureExecOpts: { scroll_px_per_frame?: number; frame_interval_ms?: number; output_dir?: string; output_format?: "png" | "jpeg"; filename_pattern?: string; viewport?: { width: number; height: number }; } = { scroll_px_per_frame: frameCaptureOpts.scroll_px_per_frame ?? 15, frame_interval_ms: frameCaptureOpts.frame_interval_ms ?? 33, output_dir: frameCaptureOpts.output_dir ?? "/tmp/reftrix-frames/", output_format: frameCaptureOpts.output_format ?? "png", filename_pattern: frameCaptureOpts.filename_pattern ?? "frame-{0000}.png", }; if (validated.video_options?.viewport) { frameCaptureExecOpts.viewport = validated.video_options.viewport; } frameCaptureResult = await executeFrameCapture(validated.url!, frameCaptureExecOpts); frameCaptureProcessingTimeMs = Date.now() - frameCaptureStartTime; } catch (fcErr) { frameCaptureProcessingTimeMs = Date.now() - frameCaptureStartTime; const errorObj = fcErr as Error & { code?: string }; frameCaptureError = { code: errorObj.name === "SSRFBlockedError" ? "FRAME_CAPTURE_SSRF_BLOCKED" : "FRAME_CAPTURE_ERROR", message: fcErr instanceof Error ? fcErr.message : "Frame capture failed", }; } } // Phase3: Lighthouse実行 const lighthouseOptions = validated.lighthouse_options as LighthouseOptions | undefined; const lighthouseResults = await executeLighthouseIfEnabled(validated.url!, lighthouseOptions); // Phase4: AnimationMetrics実行 const animationMetricsResults = await executeAnimationMetricsIfEnabled( validated.analyze_metrics, patterns, lighthouseResults.metrics, validated.analyze_metrics_options as AnalyzeMetricsOptions | undefined ); // Phase5: Frame Image Analysis実行 const frameAnalysisResults = await executeFrameImageAnalysisIfEnabled( validated.analyze_frames, validated.frame_analysis_options as FrameImageAnalysisInputOptions | undefined, frameCaptureResult?.output_dir, validated.frame_capture_options?.output_dir, frameCaptureResult?.total_frames // キャプチャされたフレーム数を渡す ); // Phase6 v0.1.0: JS Animation検出実行 const jsAnimationResults = await executeJSAnimationDetectionWithUrl( validated.url!, validated.detect_js_animations, validated.js_animation_options as JSAnimationOptions | undefined ); // Phase7: Frame Analysis DB保存(非同期、メインレスポンスをブロックしない) let frameAnalysisSaveResult: | { saved: boolean; savedCount: number; patternIds: string[]; embeddingIds: string[]; reason?: string | undefined; byCategory?: | { animationZones: number; layoutShifts: number; motionVectors: number; } | undefined; } | undefined; if (validated.save_to_db && frameAnalysisResults.result) { const frameAnalysisSaveResultPromise = executeFrameAnalysisSave( frameAnalysisResults.result, validated.url ); // 非同期でDB保存を実行し、結果を待つ // エラー時は警告ログのみ出力し、メインレスポンスには影響しない try { frameAnalysisSaveResult = await frameAnalysisSaveResultPromise; if (isDevelopment()) { logger.info("[MCP Tool] motion.detect frame analysis DB save completed", { saved: frameAnalysisSaveResult.saved, savedCount: frameAnalysisSaveResult.savedCount, patternIds: frameAnalysisSaveResult.patternIds.length, }); } } catch (saveError) { if (isDevelopment()) { logger.warn("[MCP Tool] motion.detect frame analysis DB save failed", { error: saveError instanceof Error ? saveError.message : "Unknown error", }); } // エラー時も結果を返す(saved: false) frameAnalysisSaveResult = { saved: false, savedCount: 0, patternIds: [], embeddingIds: [], reason: saveError instanceof Error ? saveError.message : "DB save failed", }; } } // Phase8: Motion Pattern DB保存(v0.1.0) // webPageId をセットしてパターンを保存(source_url は後方互換性のため両方セット) let patternSaveResult: SaveResultWithDebug | undefined; if (validated.save_to_db && patterns.length > 0) { if (isDevelopment()) { logger.info("[MCP Tool] motion.detect saving patterns to DB (video mode)", { patternsCount: patterns.length, webPageId, webPageCreated, url: validated.url, }); } // webPageId(自動作成または既存)と source_url(後方互換性)の両方を渡す patternSaveResult = await savePatternsToDb(patterns, webPageId, validated.url); } // Phase9: JS Animation Pattern DB保存(v0.1.0) let jsAnimationSaveResult: JSAnimationSaveResultWrapper | undefined; if (validated.save_to_db && jsAnimationResults.result) { jsAnimationSaveResult = await saveJSAnimationsToDb( jsAnimationResults.result, webPageId, validated.url ); } // 警告をマージ const allWarnings = [ ...warnings, ...lighthouseResults.warnings, ...animationMetricsResults.warnings, ...frameAnalysisResults.warnings, ...jsAnimationResults.warnings, ]; // WebGL/Canvas検出警告(patterns=0件 かつ detect_js_animations=false の場合) const webglWarning = generateWebglDetectionWarning( patterns.length, validated.detect_js_animations ?? false ); if (webglWarning) { allWarnings.push(webglWarning); if (isDevelopment()) { logger.info("[MCP Tool] motion.detect WebGL detection warning added (video mode)", { patternCount: patterns.length, detectJsAnimations: validated.detect_js_animations ?? false, }); } } const processingTimeMs = Date.now() - startTime; const summary: MotionSummary = { totalPatterns: patterns.length, byType: countByType(patterns), byTrigger: countByTrigger(patterns), byCategory: countByCategory(patterns), averageDuration: calculateAverageDuration(patterns), hasInfiniteAnimations: false, complexityScore: calculateComplexityScore(patterns), }; const metadata: MotionMetadata = { detectedAt: new Date().toISOString(), processingTimeMs, schemaVersion: "0.1.0", detection_mode: "video", lighthouse_processing_time_ms: lighthouseResults.processingTimeMs, analyze_metrics_processing_time_ms: animationMetricsResults.processingTimeMs, frame_analysis_processing_time_ms: frameAnalysisResults.processingTimeMs, frame_capture_processing_time_ms: validated.enable_frame_capture ? frameCaptureProcessingTimeMs : undefined, js_animation_processing_time_ms: jsAnimationResults.processingTimeMs, }; return { success: true, data: { pageId: webPageId, // v0.1.0: 自動作成または既存のWebPage ID patterns, warnings: validated.includeWarnings !== false ? allWarnings : undefined, summary: validated.includeSummary !== false ? summary : undefined, metadata, saveResult: patternSaveResult?.saveResult, // v0.1.0: パターン保存結果 video_info: videoInfo, lighthouse_metrics: lighthouseResults.metrics, lighthouse_error: lighthouseResults.error, lighthouse_save_result: lighthouseResults.saveResult, animation_metrics: animationMetricsResults.metrics, animation_metrics_error: animationMetricsResults.error, frame_analysis: frameAnalysisResults.result, frame_analysis_error: frameAnalysisResults.error, frame_analysis_save_result: frameAnalysisSaveResult, frame_capture: frameCaptureResult ? { total_frames: frameCaptureResult.total_frames, output_dir: frameCaptureResult.output_dir, config: frameCaptureResult.config, files: frameCaptureResult.files, duration_ms: frameCaptureResult.duration_ms, } : undefined, frame_capture_error: frameCaptureError, js_animations: jsAnimationResults.result, js_animations_error: jsAnimationResults.error, js_animation_save_result: jsAnimationSaveResult, // v0.1.0: JS Animation保存結果 }, }; } catch (error) { if (isDevelopment()) { logger.error("[MCP Tool] motion.detect video mode error", { error }); } let errorCode: MotionMcpErrorCode = MOTION_MCP_ERROR_CODES.VIDEO_RECORD_ERROR; if (error instanceof SSRFBlockedError) { errorCode = MOTION_MCP_ERROR_CODES.SSRF_BLOCKED; } else if (error instanceof FrameAnalysisError) { errorCode = MOTION_MCP_ERROR_CODES.FRAME_ANALYSIS_ERROR; } else if (error instanceof VideoRecordError) { errorCode = error.message.includes("timeout") || error.message.includes("Timeout") ? MOTION_MCP_ERROR_CODES.VIDEO_TIMEOUT_ERROR : MOTION_MCP_ERROR_CODES.VIDEO_RECORD_ERROR; } return { success: false, error: { code: errorCode, message: error instanceof Error ? error.message : "Video detection failed", }, }; } } // ===================================================== // Runtime Mode Handler // ===================================================== async function handleRuntimeMode( validated: MotionDetectInput, startTime: number ): Promise<MotionDetectOutput> { if (isDevelopment()) { logger.info("[MCP Tool] motion.detect using runtime mode", { url: validated.url, runtimeOptions: validated.runtime_options, }); } try { const { patterns, warnings, runtime_info } = await executeRuntimeDetection( validated.url!, validated.runtime_options ); // Phase0: WebPage自動作成(v0.1.0) let webPageId: string | undefined; let webPageCreated = false; if (validated.save_to_db) { const webPageResult = await findOrCreateWebPageForUrl(validated.url!); if (webPageResult) { webPageId = webPageResult.webPageId; webPageCreated = webPageResult.created; } } // v0.1.0: JS Animation検出実行 const jsAnimationResults = await executeJSAnimationDetectionWithUrl( validated.url!, validated.detect_js_animations, validated.js_animation_options as JSAnimationOptions | undefined ); // Motion Pattern DB保存(v0.1.0) let patternSaveResult: SaveResultWithDebug | undefined; if (validated.save_to_db && patterns.length > 0) { if (isDevelopment()) { logger.info("[MCP Tool] motion.detect saving patterns to DB (runtime mode)", { patternsCount: patterns.length, webPageId, webPageCreated, url: validated.url, }); } patternSaveResult = await savePatternsToDb(patterns, webPageId, validated.url); } // Phase: JS Animation Pattern DB保存(v0.1.0) let jsAnimationSaveResult: JSAnimationSaveResultWrapper | undefined; if (validated.save_to_db && jsAnimationResults.result) { jsAnimationSaveResult = await saveJSAnimationsToDb( jsAnimationResults.result, webPageId, validated.url ); } // 警告をマージ const allWarnings = [...warnings, ...jsAnimationResults.warnings]; // WebGL/Canvas検出警告(patterns=0件 かつ detect_js_animations=false の場合) const webglWarning = generateWebglDetectionWarning( patterns.length, validated.detect_js_animations ?? false ); if (webglWarning) { allWarnings.push(webglWarning); if (isDevelopment()) { logger.info("[MCP Tool] motion.detect WebGL detection warning added (runtime mode)", { patternCount: patterns.length, detectJsAnimations: validated.detect_js_animations ?? false, }); } } const processingTimeMs = Date.now() - startTime; const summary: MotionSummary = { totalPatterns: patterns.length, byType: countByType(patterns), byTrigger: countByTrigger(patterns), byCategory: countByCategory(patterns), averageDuration: calculateAverageDuration(patterns), hasInfiniteAnimations: patterns.some((p) => p.animation?.iterations === "infinite"), complexityScore: calculateComplexityScore(patterns), }; const metadata: MotionMetadata = { detectedAt: new Date().toISOString(), processingTimeMs, schemaVersion: "0.1.0", detection_mode: "runtime", js_animation_processing_time_ms: jsAnimationResults.processingTimeMs, }; return { success: true, data: { pageId: webPageId, // v0.1.0: 自動作成または既存のWebPage ID patterns, warnings: validated.includeWarnings !== false ? allWarnings : undefined, summary: validated.includeSummary !== false ? summary : undefined, metadata, saveResult: patternSaveResult?.saveResult, // v0.1.0: パターン保存結果 runtime_info, js_animations: jsAnimationResults.result, js_animations_error: jsAnimationResults.error, js_animation_save_result: jsAnimationSaveResult, // v0.1.0: JS Animation保存結果 }, }; } catch (error) { if (isDevelopment()) { logger.error("[MCP Tool] motion.detect runtime mode error", { error }); } return { success: false, error: { code: error instanceof SSRFBlockedError ? MOTION_MCP_ERROR_CODES.SSRF_BLOCKED : MOTION_MCP_ERROR_CODES.INTERNAL_ERROR, message: error instanceof Error ? error.message : "Runtime detection failed", }, }; } } // ===================================================== // Hybrid Mode Handler // ===================================================== async function handleHybridMode( validated: MotionDetectInput, startTime: number ): Promise<MotionDetectOutput> { if (isDevelopment()) { logger.info("[MCP Tool] motion.detect using hybrid mode", { url: validated.url, runtimeOptions: validated.runtime_options, }); } try { // 1. Runtime検出を実行 const runtimeResult = await executeRuntimeDetection(validated.url!, validated.runtime_options); // 2. CSS検出も実行 const { chromium } = await import("playwright"); let browser = null; let htmlContent = ""; try { browser = await chromium.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"], }); const context = await browser.newContext(); const page = await context.newPage(); // WebGL/3Dサイト対応: domcontentloadedで待機(loadは3Dサイトで非常に時間がかかる) await page.goto(validated.url!, { waitUntil: "domcontentloaded", timeout: 30000 }); htmlContent = await page.content(); } catch { if (isDevelopment()) { logger.warn("[MCP Tool] motion.detect hybrid mode: failed to get HTML for CSS detection"); } } finally { // ブラウザリソースを確実に解放 if (browser) { await browser.close().catch(() => {}); } } let cssPatterns: MotionPattern[] = []; let cssWarnings: MotionWarning[] = []; if (htmlContent) { const cssResult = defaultDetect(htmlContent, undefined, { includeInlineStyles: validated.includeInlineStyles, includeStyleSheets: validated.includeStyleSheets, minDuration: validated.minDuration, maxPatterns: validated.maxPatterns, verbose: validated.verbose, }); cssPatterns = cssResult.patterns; cssWarnings = cssResult.warnings; } // 3. パターンをマージ const runtimePatternIds = new Set(runtimeResult.patterns.map((p) => p.id)); const uniqueCssPatterns = cssPatterns.filter((p) => !runtimePatternIds.has(p.id)); const mergedPatterns = [...runtimeResult.patterns, ...uniqueCssPatterns]; const mergedWarnings = [...runtimeResult.warnings, ...cssWarnings]; // 4. JS Animation検出実行(v0.1.0) const jsAnimationResults = await executeJSAnimationDetectionWithUrl( validated.url!, validated.detect_js_animations, validated.js_animation_options as JSAnimationOptions | undefined ); mergedWarnings.push(...jsAnimationResults.warnings); // WebGL/Canvas検出警告(patterns=0件 かつ detect_js_animations=false の場合) const webglWarning = generateWebglDetectionWarning( mergedPatterns.length, validated.detect_js_animations ?? false ); if (webglWarning) { mergedWarnings.push(webglWarning); if (isDevelopment()) { logger.info("[MCP Tool] motion.detect WebGL detection warning added (hybrid mode)", { patternCount: mergedPatterns.length, detectJsAnimations: validated.detect_js_animations ?? false, }); } } // Phase0: WebPage自動作成(v0.1.0) let webPageId: string | undefined; let webPageCreated = false; if (validated.save_to_db) { const webPageResult = await findOrCreateWebPageForUrl(validated.url!); if (webPageResult) { webPageId = webPageResult.webPageId; webPageCreated = webPageResult.created; } } // Motion Pattern DB保存(v0.1.0) let patternSaveResult: SaveResultWithDebug | undefined; if (validated.save_to_db && mergedPatterns.length > 0) { if (isDevelopment()) { logger.info("[MCP Tool] motion.detect saving patterns to DB (hybrid mode)", { patternsCount: mergedPatterns.length, webPageId, webPageCreated, url: validated.url, }); } patternSaveResult = await savePatternsToDb(mergedPatterns, webPageId, validated.url); } // Phase: JS Animation Pattern DB保存(v0.1.0) let jsAnimationSaveResult: JSAnimationSaveResultWrapper | undefined; if (validated.save_to_db && jsAnimationResults.result) { jsAnimationSaveResult = await saveJSAnimationsToDb( jsAnimationResults.result, webPageId, validated.url ); } const processingTimeMs = Date.now() - startTime; const summary: MotionSummary = { totalPatterns: mergedPatterns.length, byType: countByType(mergedPatterns), byTrigger: countByTrigger(mergedPatterns), byCategory: countByCategory(mergedPatterns), averageDuration: calculateAverageDuration(mergedPatterns), hasInfiniteAnimations: mergedPatterns.some((p) => p.animation?.iterations === "infinite"), complexityScore: calculateComplexityScore(mergedPatterns), }; const metadata: MotionMetadata = { detectedAt: new Date().toISOString(), processingTimeMs, schemaVersion: "0.1.0", detection_mode: "hybrid", js_animation_processing_time_ms: jsAnimationResults.processingTimeMs, }; const hybridInfo = { runtime_patterns_count: runtimeResult.patterns.length, css_patterns_count: uniqueCssPatterns.length, total_merged_patterns: mergedPatterns.length, }; return { success: true, data: { pageId: webPageId, // v0.1.0: 自動作成または既存のWebPage ID patterns: mergedPatterns, warnings: validated.includeWarnings !== false ? mergedWarnings : undefined, summary: validated.includeSummary !== false ? summary : undefined, metadata: { ...metadata, hybrid_info: hybridInfo, }, saveResult: patternSaveResult?.saveResult, // v0.1.0: パターン保存結果 runtime_info: runtimeResult.runtime_info, js_animations: jsAnimationResults.result, // v0.1.0: JS Animation検出結果 js_animations_error: jsAnimationResults.error, js_animation_save_result: jsAnimationSaveResult, // v0.1.0: JS Animation保存結果 }, }; } catch (error) { if (isDevelopment()) { logger.error("[MCP Tool] motion.detect hybrid mode error", { error }); } return { success: false, error: { code: error instanceof SSRFBlockedError ? MOTION_MCP_ERROR_CODES.SSRF_BLOCKED : MOTION_MCP_ERROR_CODES.INTERNAL_ERROR, message: error instanceof Error ? error.message : "Hybrid detection failed", }, }; } } // ===================================================== // Default (CSS) Mode Handler // ===================================================== async function handleDefaultMode( validated: MotionDetectInput, startTime: number ): Promise<MotionDetectOutput> { let html = validated.html; let css = validated.css; let pageId: string | undefined; // pageIdが指定されている場合はDBから取得 if (validated.pageId && !html) { try { const serviceFactory = getMotionDetectServiceFactory(); const service = serviceFactory?.(); if (!service?.getPageById) { return { success: false, error: { code: MOTION_MCP_ERROR_CODES.SERVICE_UNAVAILABLE, message: "Page service is not available", }, }; } const page = await service.getPageById(validated.pageId); if (!page) { return { success: false, error: { code: MOTION_MCP_ERROR_CODES.PAGE_NOT_FOUND, message: `Page not found: ${validated.pageId}`, }, }; } html = page.htmlContent; css = page.cssContent; pageId = page.id; } catch (error) { if (isDevelopment()) { logger.error("[MCP Tool] motion.detect DB error", { error }); } return { success: false, error: { code: MOTION_MCP_ERROR_CODES.DB_ERROR, message: error instanceof Error ? error.message : "Database error", }, }; } } if (!html) { return { success: false, error: { code: MOTION_MCP_ERROR_CODES.VALIDATION_ERROR, message: "No HTML content provided", }, }; } return handleCssMode(validated, html, css, pageId, startTime); } // ===================================================== // Lighthouse Helper // ===================================================== interface LighthouseResult { metrics: ReturnType<typeof getLighthouseDetectorService> extends null ? null : LighthouseDetailedResult["metrics"] | null; error?: { code: string; message: string } | undefined; warnings: MotionWarning[]; processingTimeMs?: number | undefined; saveResult?: { saved: boolean } | undefined; } async function executeLighthouseIfEnabled( url: string, options: LighthouseOptions | undefined ): Promise<LighthouseResult> { if (!options?.enabled) { return { metrics: null, warnings: [], processingTimeMs: undefined }; } const warnings: MotionWarning[] = []; const startTime = Date.now(); const lighthouseService = getLighthouseDetectorService(); if (!lighthouseService) { warnings.push({ code: MOTION_WARNING_CODES.LIGHTHOUSE_UNAVAILABLE, severity: "warning", message: "Lighthouse detector service factory not configured", }); return { metrics: null, warnings, processingTimeMs: Date.now() - startTime }; } try { const isAvailable = await lighthouseService.isAvailable(); if (!isAvailable) { warnings.push({ code: MOTION_WARNING_CODES.LIGHTHOUSE_UNAVAILABLE, severity: "warning", message: "Lighthouse service is not available", }); return { metrics: null, warnings, processingTimeMs: Date.now() - startTime }; } const analyzeOpts: { categories?: string[]; throttling?: boolean; timeout?: number; } = { categories: options.categories || ["performance"], throttling: options.throttling ?? false, }; if (options.timeout !== undefined) { analyzeOpts.timeout = options.timeout; } const result = await lighthouseService.analyze(url, analyzeOpts); const returnResult: LighthouseResult = { metrics: result.metrics, warnings, processingTimeMs: Date.now() - startTime, }; if (options.save_to_db) { returnResult.saveResult = { saved: true }; } return returnResult; } catch (err) { const errorObj = err as Error & { code?: string }; const isTimeout = errorObj.message?.toLowerCase().includes("timeout") || errorObj.message?.toLowerCase().includes("timed out") || errorObj.code === "TIMEOUT"; return { metrics: null, error: { code: isTimeout ? "LIGHTHOUSE_TIMEOUT" : "LIGHTHOUSE_ERROR", message: err instanceof Error ? err.message : "Lighthouse analysis failed", }, warnings, processingTimeMs: Date.now() - startTime, }; } } // ===================================================== // Animation Metrics Helper // ===================================================== interface AnimationMetricsResultWrapper { metrics: AnimationMetricsResult | null; error?: { code: string; message: string } | undefined; warnings: MotionWarning[]; processingTimeMs?: number | undefined; } async function executeAnimationMetricsIfEnabled( enabled: boolean | undefined, patterns: MotionPattern[], lighthouseMetrics: LighthouseDetailedResult["metrics"] | null | undefined, options: AnalyzeMetricsOptions | undefined ): Promise<AnimationMetricsResultWrapper> { if (!enabled) { return { metrics: null, warnings: [], processingTimeMs: undefined }; } const warnings: MotionWarning[] = []; const startTime = Date.now(); const collector = getAnimationMetricsCollector(); if (!collector) { warnings.push({ code: MOTION_WARNING_CODES.ANIMATION_METRICS_UNAVAILABLE, severity: "warning", message: "Animation metrics collector factory not configured", }); return { metrics: null, warnings, processingTimeMs: Date.now() - startTime }; } try { const result = await collector.analyze({ patterns, lighthouseMetrics: lighthouseMetrics ?? null, }); const metrics: AnimationMetricsResult = { patternImpacts: result.patternImpacts.map((impact) => ({ patternId: impact.patternId, patternName: impact.patternName, impactScore: impact.score, layoutImpact: Math.min(100, impact.factors.filter((f) => f.includes("layout")).length * 25), renderImpact: Math.min( 100, impact.factors.filter((f) => f.includes("paint") || f.includes("render")).length * 25 ), cpuImpact: Math.min( 100, impact.factors.filter((f) => f.includes("cpu") || f.includes("duration")).length * 25 ), severity: impact.impactLevel === "high" ? "critical" : impact.impactLevel, details: { factors: impact.factors }, })), overallScore: result.overallScore, clsContributors: options?.include_cls_contributors !== false ? result.clsContributors.map((c) => ({ selector: c.patternName, contribution: c.estimatedContribution, relatedPatternId: c.patternId, })) : [], layoutTriggeringProperties: result.layoutTriggeringProperties, recommendations: options?.include_recommendations !== false ? result.recommendations.map((r) => ({ id: `rec-${r.category}-${r.priority}`, priority: r.priority as "high" | "medium" | "low", category: r.category.includes("transform") || r.category.includes("layout") ? "layout" : r.category.includes("paint") ? "rendering" : r.category.includes("animation") || r.category.includes("duration") ? "animation" : "accessibility", title: r.description.split(".")[0] || r.description, description: r.description, affectedPatterns: r.affectedPatternIds, estimatedImpact: r.estimatedImprovement ? 0.5 : 0.3, effort: r.priority === "high" ? "low" : r.priority === "medium" ? "medium" : "high", })) : [], lighthouseAvailable: result.lighthouseAvailable, analyzedAt: result.analyzedAt, }; return { metrics, warnings, processingTimeMs: Date.now() - startTime }; } catch (err) { const errorObj = err as Error & { code?: string }; const isTimeout = errorObj.message?.toLowerCase().includes("timeout") || errorObj.code === "TIMEOUT"; return { metrics: null, error: { code: isTimeout ? "ANIMATION_METRICS_TIMEOUT" : "ANIMATION_METRICS_ERROR", message: err instanceof Error ? err.message : "Animation metrics analysis failed", }, warnings, processingTimeMs: Date.now() - startTime, }; } } // ===================================================== // Frame Image Analysis Helper // ===================================================== interface FrameAnalysisResultWrapper { result: FrameImageAnalysisOutput | null; error?: { code: string; message: string } | undefined; warnings: MotionWarning[]; processingTimeMs?: number | undefined; } async function executeFrameImageAnalysisIfEnabled( enabled: boolean | undefined, options: FrameImageAnalysisInputOptions | undefined, capturedOutputDir: string | undefined, configuredOutputDir: string | undefined, capturedTotalFrames: number | undefined // キャプチャされたフレーム数(古いフレームとの混在防止) ): Promise<FrameAnalysisResultWrapper> { if (!enabled) { return { result: null, warnings: [], processingTimeMs: undefined }; } const warnings: MotionWarning[] = []; const startTime = Date.now(); const service = getFrameImageAnalysisService(); if (!service) { warnings.push({ code: MOTION_WARNING_CODES.FRAME_ANALYSIS_UNAVAILABLE, severity: "warning", message: "Frame image analysis service factory not configured", }); return { result: null, warnings, processingTimeMs: Date.now() - startTime }; } if (!service.isAvailable()) { warnings.push({ code: MOTION_WARNING_CODES.FRAME_ANALYSIS_UNAVAILABLE, severity: "warning", message: "Frame image analysis service is not available", }); return { result: null, warnings, processingTimeMs: Date.now() - startTime }; } const frameDir = options?.frame_dir ?? capturedOutputDir ?? configuredOutputDir ?? "/tmp/reftrix-frames/"; try { const result = await service.analyze(frameDir, { sampleInterval: options?.sample_interval ?? 10, diffThreshold: options?.diff_threshold ?? 0.1, clsThreshold: options?.cls_threshold ?? 0.05, motionThreshold: options?.motion_threshold ?? 50, outputDiffImages: options?.output_diff_images ?? false, parallel: options?.parallel ?? true, scrollPxPerFrame: 15, // キャプチャされたフレーム数のみを分析対象にする(exactOptionalPropertyTypes対応) ...(capturedTotalFrames !== undefined ? { maxFrames: capturedTotalFrames } : {}), }); return { result, warnings, processingTimeMs: Date.now() - startTime }; } catch (err) { const errorObj = err as Error & { code?: string }; const isTimeout = errorObj.message?.toLowerCase().includes("timeout") || errorObj.code === "TIMEOUT"; return { result: null, error: { code: isTimeout ? "FRAME_ANALYSIS_TIMEOUT" : "FRAME_ANALYSIS_ERROR", message: err instanceof Error ? err.message : "Frame image analysis failed", }, warnings, - apps/mcp-server/src/tools/motion/detect.tool.ts:1762-1903 (registration)The 'motionDetectToolDefinition' contains the tool's MCP definition, including the name 'motion.detect', description, and input schema.
export const motionDetectToolDefinition = { name: "motion.detect", description: "Detect/classify motion patterns from web page. Parses CSS animations, transitions, keyframes. Warns about performance/accessibility issues.", annotations: { title: "Motion Detect", readOnlyHint: true, idempotentHint: true, openWorldHint: false, }, inputSchema: { type: "object" as const, properties: { pageId: { type: "string", format: "uuid", description: "WebPage ID (UUID, from DB)", }, html: { type: "string", minLength: 1, maxLength: 10000000, description: "HTML content (direct, max 10MB)", }, css: { type: "string", maxLength: 5000000, description: "Additional CSS content (max 5MB)", }, includeInlineStyles: { type: "boolean", default: true, description: "Parse inline styles (default: true)", }, includeStyleSheets: { type: "boolean", default: true, description: "Parse stylesheets (default: true)", }, minDuration: { type: "number", minimum: 0, maximum: 60000, default: 0, description: "Minimum duration to detect (ms, default: 0)", }, maxPatterns: { type: "number", minimum: 1, maximum: 4000, default: 100, description: "Max patterns to detect (default: 100)", }, includeWarnings: { type: "boolean", default: true, description: "Include warnings (default: true)", }, min_severity: { type: "string", enum: ["info", "warning", "error"], default: "info", description: "Minimum severity level to include in warnings (default: info)", }, includeSummary: { type: "boolean", default: true, description: "Include summary (default: true)", }, verbose: { type: "boolean", default: false, description: "Verbose mode: include rawCss (default: false)", }, fetchExternalCss: { type: "boolean", default: true, description: "Fetch external CSS from <link> tags (default: true)", }, baseUrl: { type: "string", format: "uri", description: "Base URL for resolving relative CSS paths (required if fetchExternalCss is true)", }, externalCssOptions: { type: "object", description: "Options for external CSS fetching", properties: { timeout: { type: "number", minimum: 1000, maximum: 30000, default: 5000, description: "Fetch timeout in ms (default: 5000)", }, maxConcurrent: { type: "number", minimum: 1, maximum: 10, default: 5, description: "Max concurrent fetches (default: 5)", }, }, }, save_to_db: { type: "boolean", default: true, description: "Save detected patterns to motion_patterns table with embeddings (default: true)", }, detection_mode: { type: "string", enum: ["css", "video", "runtime", "hybrid"], default: "video", description: "Detection mode: 'css' (requires html/pageId) for static CSS parsing without browser, 'video' (default, requires url) for visual motion detection with frame capture, 'runtime' (requires url) for JS-driven animations, 'hybrid' (requires url) for CSS+runtime combined.", }, url: { type: "string", format: "uri", description: "Target URL for video/runtime/hybrid modes. Required when detection_mode='video', 'runtime', or 'hybrid'.", }, detect_js_animations: { type: "boolean", default: false, description: "Enable JavaScript animation detection via CDP + Web Animations API. Requires Playwright. Default: false (disabled for performance).", }, timeout: { type: "integer", minimum: 30000, maximum: 600000, default: 180000, description: "Overall timeout in milliseconds (30000-600000, default: 180000 = 3 minutes). On timeout, returns partial results with warnings (graceful degradation).", }, }, required: ["html"], }, };