生活与工具
#web
ui-demo
使用 Playwright 录制精美的 UI 演示视频。当用户要求创建 Web 应用的演示、导览、屏幕录制或教程视频时使用。生成带有可见光标、自然节奏和专业感的 WebM 视频。
DeepseekModel
官方收录技能
质量 优秀 · 90
v1.0.0
获取
https://deepseekmodel.com/api/download.php?id=affaan-m-ecc-docs-zh-cn-skills-ui-demo-skill-md&format=skill
下载 .skill
标准格式,含 system_prompt 与 model_config,导入任意 Agent 框架即可使用
.skill 文件中 system_prompt 字段的实际内容。
name ui-demo description 使用 Playwright 录制精美的 UI 演示视频。当用户要求创建 Web 应用的演示、导览、屏幕录制或教程视频时使用。生成带有可见光标、自然节奏和专业感的 WebM 视频。 origin ECC UI 演示视频录制器 使用 Playwright 的视频录制功能,配合注入的光标覆盖层、自然的节奏和叙事流程,录制精美的 Web 应用演示视频。 使用场景 用户要求制作"演示视频"、"屏幕录制"、"操作演示"或"教程" 用户希望以视觉方式展示某个功能或工作流程 用户需要为文档、入职培训或利益相关者演示制作视频 三阶段流程 每个演示都需经历三个阶段: 探索 -> 排练 -> 录制 。切勿直接跳至录制阶段。 阶段 1:探索 在编写任何脚本之前,先探索目标页面,了解实际内容。 原因 你无法为未见过的内容编写脚本。字段可能是 <input> 而非 <textarea> ,下拉菜单可能是自定义组件而非 <select> ,评论框可能支持 @mentions 或 #tags 。假设会无声地破坏录制。 方法 导航至流程中的每个页面,并转储其交互元素: // Run this for each page in the flow BEFORE writing the demo script const fields = await page. evaluate ( () => { const els = []; document . querySelectorAll ( 'input, select, textarea, button, [contenteditable]' ). forEach ( el => { if (el. offsetParent !== null ) { els. push ({ tag : el. tagName , type : el. type || '' , name : el. name || '' , placeholder : el. placeholder || '' , text : el. textContent ?. trim (). substring ( 0 , 40 ) || '' , contentEditable : el. contentEditable === 'true' , role : el. getAttribute ( 'role' ) || '' , }); } }); return els; }); console . log ( JSON . stringify (fields, null , 2 )); 需要关注的内容 表单字段 :它们是 <select> 、 <input> 、自定义下拉菜单还是组合框? 选择选项 :转储选项的值和文本。占位符通常包含 value="0" 或 value="" ,看起来非空。使用 Array.from(el.options).map(o => ({ value: o.value, text: o.text })) 。跳过文本包含"选择"或值为 "0" 的选项。 富文本 :评论框是否支持 @mentions 、 #tags 、Markdown 或表情符号?检查占位符文本。 必填字段 :哪些字段会阻止表单提交?检查标签中的 required 、 * ,并尝试提交空表单以查看验证错误。 动态内容 :字段是否在填写其他字段后出现? 按钮标签 :确切的文本,如 "Submit" 、 "Submit Request" 或 "Send" 。 表格列标题 :对于表格驱动的模态框,将每个 input[type="number"] 映射到其列标题,而不是假设所有数字输入都表示相同含义。 输出 每个页面的字段映射,用于在脚本中编写正确的选择器。示例: /purchase-requests/new: - 预算代码: <select> (页面上的第一个下拉框,4个选项) - 期望交付日期: <input type="date"> - 背景说明: <textarea> (非输入框) - BOM表: 可内联编辑的单元格,包含 span.cursor-pointer -> input 模式 - 提交: <button> 文本="提交" /purchase-requests/N (详情): - 评论: <input placeholder="输入消息..."> 支持 @用户 和 #PR 标签 - 发送: <button> 文本="发送" (在输入内容前处于禁用状态) 阶段 2:排练 在不录制的情况下运行所有步骤。验证每个选择器都能解析。 原因 静默的选择器失败是演示录制中断的主要原因。排练可以在浪费录制之前发现它们。 方法 使用 ensureVisible ,一个记录日志并大声报错的包装器: async function ensureVisible ( page, locator, label ) { const el = typeof locator === 'string' ? page. locator (locator). first () : locator; const visible = await el. isVisible (). catch ( () => false ); if (!visible) { const msg = `REHEARSAL FAIL: " ${label} " not found - selector: ${ typeof locator === 'string' ? locator : '(locator object)' } ` ; console . error (msg); const found = await page. evaluate ( () => { return Array . from ( document . querySelectorAll ( 'button, input, select, textarea, a' )) . filter ( el => el. offsetParent !== null ) . map ( el => ` ${el.tagName} [ ${el.type || '' } ] " ${el.textContent?.trim().substring( 0 , 30 )} "` ) . join ( '\n ' ); }); console . error ( ' Visible elements:\n ' + found); return false ; } console . log ( `REHEARSAL OK: " ${label} "` ); return true ; } 排练脚本结构 const steps = [ { label : 'Login email field' , selector : '#email' }, { label : 'Login submit' , selector : 'button[type="submit"]' }, { label : 'New Request button' , selector : 'button:has-text("New Request")' }, { label : 'Budget Code select' , selector : 'select' }, { label : 'Delivery date' , selector : 'input[type="date"]:visible' }, { label : 'Description field' , selector : 'textarea:visible' }, { label : 'Add Item button' , selector : 'button:has-text("Add Item")' }, { label : 'Submit button' , selector : 'button:has-text("Submit")' }, ]; let allOk = true ; for ( const step of steps) { if (! await ensureVisible (page, step. selector , step. label )) { allOk = false ; } } if (!allOk) { console . error ( 'REHEARSAL FAILED - fix selectors before recording' ); process. exit ( 1 ); } console . log ( 'REHEARSAL PASSED - all selectors verified' ); 排练失败时 读取可见元素转储。 找到正确的选择器。 更新脚本。 重新运行排练。 仅在所有选择器通过后才继续。 阶段 3:录制 仅在探索和排练通过后,才创建录制。 录制原则 1. 叙事流程 将视频规划为一个故事。遵循用户指定的顺序,或使用此默认顺序: 入口 :登录或导航至起始点 背景 :平移周围环境,让观众定位 操作 :执行主要工作流程步骤 变体 :展示次要功能,如设置、主题或本地化 结果 :展示结果、确认或新状态 2. 节奏 登录后: 4s 导航后: 3s 点击按钮后: 2s 主要步骤之间: 1.5-2s 最终操作后: 3s 输入延迟:每个字符 25-40ms 3. 光标覆盖层 注入一个跟随鼠标移动的 SVG 箭头光标: async function injectCursor ( page ) { await page. evaluate ( () => { if ( document . getElementById ( 'demo-cursor' )) return ; const cursor = document . createElement ( 'div' ); cursor. id = 'demo-cursor' ; cursor. innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M5 3L19 12L12 13L9 20L5 3Z" fill="white" stroke="black" stroke-width="1.5" stroke-linejoin="round"/> </svg>` ; cursor. style . cssText = ` position: fixed; z-index: 999999; pointer-events: none; width: 24px; height: 24px; transition: left 0.1s, top 0.1s; filter: drop-shadow(1px 1px 2px rgba(0,0,0,0.3)); ` ; cursor. style . left = '0px' ; cursor. style . top = '0px' ; document . body . appendChild (cursor); document . addEventListener ( 'mousemove' , ( e ) => { cursor. style . left = e. clientX + 'px' ; cursor. style . top = e. clientY + 'px' ; }); }); } 每次页面导航后调用 injectCursor(page) ,因为覆盖层会在导航时被销毁。 4. 鼠标移动 切勿瞬移光标。在点击前移动到目标: async function moveAndClick ( page, locator, label, opts = {} ) { const { postClickDelay = 800 , ...clickOpts } = opts; const el = typeof locator === 'string' ? page. locator (locator). first () : locator; const visible = await el. isVisible (). catch ( () => false ); if (!visible) { console . error ( `WARNING: moveAndClick skipped - " ${label} " not visible` ); return false ; } try { await el. scrollIntoViewIfNeeded (); await page. waitForTimeout ( 300 ); const box = await el. boundingBox (); if (box) { await page. mouse . move (box. x + box. width / 2 , box. y + box. height / 2 , { steps : 10 }); await page. waitForTimeout ( 400 ); } await el. click (clickOpts); } catch (e) { console . error ( `WARNING: moveAndClick failed on " ${label} ": ${e.message} ` ); return false ; } await page. waitForTimeout (postClickDelay); return true ; } 每次调用都应包含描述性的 label 以便调试。 5. 输入 可见地输入,而非瞬间填充: async function typeSlowly ( page, locator, text, label, charDelay = 35 ) { const el = typeof locator === 'string' ? page. locator (locator). first () : locator; const visible = await el. isVisible (). catch ( () => false ); if (!visible) { console . error ( `WARNING: typeSlowly skipped - " ${label} " not visible` ); return false ; } await moveAndClick (page, el, label); await el. fill ( '' ); await el. pressSequentially (text, { delay : charDelay }); await page. waitForTimeout ( 500 ); return true ; } 6. 滚动 使用平滑滚动而非跳跃: await page. evaluate ( () => window . scrollTo ({ top : 400 , behavior : 'smooth' })); await page. waitForTimeout ( 1500 ); 7. 仪表盘平移 展示仪表盘或概览页面时,将光标移过关键元素: async function panElements ( page, selector, maxCount = 6 ) { const elements = await page. locator (selector). all (); for ( let i = 0 ; i < Math . min (elements. length , maxCount); i++) { try { const box = await elements[i]. boundingBox (); if (box && box. y < 700 ) { await page. mouse . move (box. x + box. width / 2 , box. y + box. height / 2 , { steps : 8 }); await page. waitForTimeout ( 600 ); } } catch (e) { console . warn ( `WARNING: panElements skipped element ${i} (selector: " ${selector} "): ${e.message} ` ); } } } 8. 字幕 在视口底部注入一个字幕栏: async function injectSubtitleBar ( page ) { await page. evaluate ( () => { if ( document . getElementById ( 'demo-subtitle' )) return ; const bar = document . createElement ( 'div' ); bar. id = 'demo-subtitle' ; bar. style . cssText = ` position: fixed; bottom: 0; left: 0; right: 0; z-index: 999998; text-align: center; padding: 12px 24px; background: rgba(0, 0, 0, 0.75); color: white; font-family: -apple-system, "Segoe UI", sans-serif; font-size: 16px; font-weight: 500; letter-spacing: 0.3px; transition: opacity 0.3s; pointer-events: none; ` ; bar. textContent = '' ; bar. style . opacity = '0' ; document . body . appendChild (bar); }); } async function showSubtitle ( page, text ) { await page. evaluate ( ( t ) => { const bar = document . getElementById ( 'demo-subtitle' ); if (!bar) return ; if (t) { bar. textContent = t; bar. style . opacity = '1' ; } else { bar. style . opacity = '0' ; } }, text); if (text) await page. waitForTimeout ( 800 ); } 每次导航后,将 injectSubtitleBar(page) 与 injectCursor(page) 一起调用。 使用模式: await showSubtitle (page, 'Step 1 - Logging in' ); await showSubtitle (page, 'Step 2 - Dashboard overview' ); await showSubtitle (page, '' ); 指南: 保持字幕文本简短,最好在 60 个字符以内。 使用 Step N - Action 格式以保持一致性。 在长时间暂停且界面可以自我说明时清除字幕。 脚本模板 'use strict' ; const { chromium } = require ( 'playwright' ); const path = require ( 'path' ); const fs = require ( 'fs' ); const BASE_URL = process. env . QA_BASE_URL || 'http://localhost:3000' ; const VIDEO_DIR = path. join (__dirname, 'screenshots' ); const OUTPUT_NAME = 'demo-FEATURE.webm' ; const REHEARSAL = process. argv . includes ( '--rehearse' ); // Paste injectCursor, injectSubtitleBar, showSubtitle, moveAndClick,
Agent 识别该技能的关键词,点击任意一个即可复制。
该技能未提供触发词。
下载的 .skill 包内含以下字段。
| 字段 | 说明 |
|---|---|
| format | 格式标识(skill/v1) |
| skill_id | 技能唯一 ID |
| name | 技能名称 |
| version | 版本号 |
| description | 技能描述 |
| category | 所属分类(数组) |
| trigger_words | 触发词列表 |
| tags | 标签列表 |
| source | 来源标识 |
| source_url | 来源链接(本页地址) |
| exported_at | 导出时间(每次下载生成) |
| system_prompt | 系统提示词正文 |
| model_config | 模型参数:provider / model / temperature / max_tokens / top_p |
| examples | 示例 |
| install_guide | 各平台导入说明(Coze / Dify / Claude / 自定义框架) |