SLOPSHOPPER

frameever-dev

FrameEver dev pane: grouped buttons for pnpm fe (run, check, export, deps, release notes, package)

newpanecommandtoastprocess
★ 4v0.3.0NOASSERTIONupdated 2026-10-25AniBullet/FrameEver/.claude/skills/frameever-dev
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · frameever-dev
│ ┃ FrameEver 开发 ✕ › fix the failing auth test and add an audit log call │ ┃ 运行 │ ┃ [ 运行应用 ] [ 热重载开发 ] [ 查看日志 ] 中文 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ 检查 ⏺ Update(src/auth.ts) │ ┃ [ 编译检查 ] [ 测试 ] [ 全部测试 ] [ 代码检查 ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ 导出测试录像 ⎿ 3 pass, 1 fail │ ┃ [ H.264 ] [ HEVC ] [ AV1 ] │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 依赖与发布 │ ┃ [ 依赖检查 ] [ 升级依赖 ] [ 预览更新记录 ] [ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ 打开文件夹 › /fe-dev │ ┃ [ 测试数据 ] [ 导出视频 ] [ 日志 ] [ 安装包 ⎿ frameever-dev: FrameEver 开发面板已打开。 │ ┃ │ ┃ 点按钮开始,所有按钮都调用 pnpm │ ┃ fe。“运行应用”用独立的测试数据。发版在终端运 │ ┃ 行 pnpm fe release │ ┃ <版本>,会先跑全部测试并请你确认。 │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · FrameEver 开发
运行 [ 运行应用 ] [ 热重载开发 ] [ 查看日志 ] 中文 English 检查 [ 编译检查 ] [ 测试 ] [ 全部测试 ] [ 代码检查 ] [ 格式化 ] [ 导出测试录像 [ H.264 ] [ HEVC ] [ AV1 ] 依赖与发布 [ 依赖检查 ] [ 升级依赖 ] [ 预览更新记录 ] [ 打包安装程序 ] 打开文件夹 [ 测试数据 ] [ 导出视频 ] [ 日志 ] [ 安装包 ] 点按钮开始,所有按钮都调用 pnpm fe。“运行应用”用独立的测试数据。发版在终端运行 pnpm fe release <版本>,会先跑全部测试并请你确认。
README

<img src="apps/desktop-gpui/icons/128x128@2x.png" alt="FrameEver" width="112" />

FrameEver · 帧刻

一刻,凝成永恒

纯本地的 Windows 录屏与剪辑工具:不限时长、不用账号、不上传任何内容、完全免费。

Release Platform License

下载 · 更新日志 · 反馈问题


为什么用 FrameEver

  • 只在你的电脑上:录制、剪辑、字幕识别、压缩全部在本机完成,内容不会离开你的电脑。
  • 没有限制:没有时长上限、没有数量上限、没有付费功能、没有水印。
  • 原生、轻快:基于 Cap 0.6.1 的原生 GPUI 界面,用 Rust 编写,直接用 GPU 绘制。
  • 文件小:导出支持 HEVC 和 AV1,同样画质下文件约为 H.264 的 1/3 到 1/4。
  • 中文友好:完整的简体中文界面,中文用 HarmonyOS Sans 字体。

功能

录制

  • 三种模式:Studio(录完进编辑器精修)、即时录制(录完直接得到视频)、截图
  • 录制全屏、窗口或任意区域,可叠加摄像头画面,支持摄像头背景虚化
  • 同时录制麦克风和系统声音
  • 提词器、全局快捷键、录制前倒计时

编辑

  • 时间轴剪辑:裁剪、分段、删除片段
  • 自动缩放跟随光标,光标美化
  • 背景、圆角、阴影、画面边框
  • 按键显示轨道,适合教程录制
  • 本地 AI 字幕:Whisper 或 Parakeet 模型,离线识别,可在时间轴上逐句调整

导出与管理

  • MP4(H.264 / HEVC / AV1)、GIF、MOV
  • 即时录制可自动压缩为 HEVC,原版与压缩版随时切换或删除
  • 截图编辑器:标注、裁剪、美化

其他

  • 命令行工具 frameever,可供脚本、自动化规则和 AI 智能体调用(说明)
  • 首次启动自动导入 Cap-aniBullet 2.x 的设置,原来的录制文件继续显示,位置不变

安装

从 Releases 下载 FrameEver_<版本>_x64-setup.exe 并运行。支持 Windows 10 / 11(64 位)。安装后应用会自动检查更新。

隐私

FrameEver 只在以下情况联网,且不会发送你录制的任何内容:

  • 检查和下载更新(来自本仓库的 GitHub Releases)
  • 首次使用 AI 字幕时下载识别模型
  • 你主动点击的链接,在浏览器里打开

和 Cap 原版的区别

Cap 原版FrameEver
录制时长与数量免费版有限制无限制
账号需要注册不需要
分享与存储上传到云端只保存在本机
AI 字幕云端服务本地模型,离线
导出编码H.264H.264、HEVC、AV1
界面语言英文简体中文、英文
平台macOS、WindowsWindows(macOS 版在 Windows 1.0 之后)

开发

pnpm fe setup     # 准备开发环境(首次)
pnpm fe dev       # 用独立的测试数据运行应用
pnpm fe test      # 运行测试
pnpm fe package   # 打包安装程序到 dist/

完整说明见 BUILD.md,贡献前请阅读 CONTRIBUTING.md。技术栈:Rust、GPUI、FFmpeg、Whisper / Parakeet。

协议与致谢

FrameEver 基于 Cap 改造,遵循 AGPL-3.0 协议:保留原始版权声明、公开全部源代码。部分模块(frameever-camera*、scap-*)为 MIT 协议,见各模块的 LICENSE。FrameEver 是独立项目,与 Cap 官方无关,不使用 Cap 的名称和标志。

感谢 Cap 团队和所有贡献者。界面字体使用 Inter(SIL OFL 1.1)和华为 HarmonyOS Sans。


如果 FrameEver 对你有帮助,欢迎点个 ⭐ Star

<img src=".github/images/wechat.png" width="180" alt="微信赞赏码" /> <sub>微信 WeChat</sub>

Source 2 files
hooks/register.tsx 312 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { AppLanguage, JobState } from '../types'
5
6const PANE = 'frameever-dev'
7const TITLE = 'FrameEver 开发'
8const MAX_LINES = 400
9
10const jobs = atom({ plugin: 'frameever-dev', key: 'jobs' } as const, {})
11const selected = atom({ plugin: 'frameever-dev', key: 'selected' } as const, null)
12const language = atom({ plugin: 'frameever-dev', key: 'language' } as const, 'zh-CN')
13
14type Job = {
15  id: string
16  label: string
17  args: (lang: AppLanguage, version: string) => string[]
18  needsVersion?: boolean
19  launchesApp?: boolean
20  primary?: boolean
21}
22
23type Group = { title: string; jobs: Job[] }
24
25const fixed = (...args: string[]) => () => args
26
27async function nextVersion($: EngineInterface) {
28  const changelog = await $.fs.read(`${root}\\CHANGELOG.md`).catch(() => '')
29  return changelog.match(/^## \[(\d+\.\d+\.\d+[^\]]*)\]/m)?.[1] ?? '0.0.0'
30}
31
32const GROUPS: Group[] = [
33  {
34    title: '运行',
35    jobs: [
36      { id: 'dev', label: '运行应用', args: lang => ['dev', '--lang', lang], launchesApp: true, primary: true },
37      { id: 'dev-watch', label: '热重载开发', args: lang => ['dev', '--watch', '--lang', lang], launchesApp: true },
38      { id: 'logs', label: '查看日志', args: fixed('logs', '--lines', '300') },
39    ],
40  },
41  {
42    title: '检查',
43    jobs: [
44      { id: 'check', label: '编译检查', args: fixed('check') },
45      { id: 'test', label: '测试', args: fixed('test') },
46      { id: 'test-all', label: '全部测试', args: fixed('test', '--all') },
47      { id: 'lint', label: '代码检查', args: fixed('lint') },
48      { id: 'fmt', label: '格式化', args: fixed('fmt') },
49      { id: 'doctor', label: '环境检查', args: fixed('doctor') },
50    ],
51  },
52  {
53    title: '导出测试录像',
54    jobs: [
55      { id: 'export-h264', label: 'H.264', args: fixed('export', 'h264') },
56      { id: 'export-hevc', label: 'HEVC', args: fixed('export', 'hevc') },
57      { id: 'export-av1', label: 'AV1', args: fixed('export', 'av1') },
58    ],
59  },
60  {
61    title: '依赖与发布',
62    jobs: [
63      { id: 'deps', label: '依赖检查', args: fixed('deps') },
64      { id: 'deps-apply', label: '升级依赖', args: fixed('deps', '--apply') },
65      { id: 'notes', label: '预览更新记录', args: (_, version) => ['notes', version], needsVersion: true },
66      { id: 'package', label: '打包安装程序', args: fixed('package') },
67      { id: 'setup', label: '准备环境', args: fixed('setup') },
68    ],
69  },
70]
71
72const ALL_JOBS = GROUPS.flatMap(group => group.jobs)
73
74const FOLDERS = [
75  { key: 'open-testdata', label: '测试数据', path: 'apps\\desktop-gpui\\target\\testdata' },
76  { key: 'open-exports', label: '导出视频', path: 'apps\\desktop-gpui\\target\\testdata\\exports' },
77  { key: 'open-logs', label: '日志', path: 'apps\\desktop-gpui\\target\\testdata\\logs' },
78  { key: 'open-dist', label: '安装包', path: 'dist' },
79]
80
81const LANGUAGES: { value: AppLanguage; label: string }[] = [
82  { value: 'zh-CN', label: '中文' },
83  { value: 'en', label: 'English' },
84]
85
86const DEV_PID_FILE = 'apps\\desktop-gpui\\target\\testdata\\fe-dev.pid'
87
88const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, 'g')
89
90const STATUS_LABEL = { running: '运行中', passed: '通过', failed: '失败', stopped: '已停止' }
91
92let root = '.'
93const running = new Map<string, { stop: () => void; isStopped: boolean }>()
94
95async function append($: EngineInterface, id: string, lines: string[]) {
96  if (lines.length === 0) {
97    return
98  }
99  await update($, jobs, all => {
100    const job = all[id]
101    if (!job) {
102      return all
103    }
104    return { ...all, [id]: { ...job, lines: [...job.lines, ...lines].slice(-MAX_LINES) } }
105  })
106}
107
108async function stopApp($: EngineInterface) {
109  const pid = await $.fs.read(`${root}\\${DEV_PID_FILE}`).catch(() => '')
110  if (/^\d+$/.test(pid.trim())) {
111    await $.process.run(['taskkill', '/F', '/T', '/PID', pid.trim()]).catch(() => undefined)
112  }
113}
114
115async function runJob($: EngineInterface, job: Job) {
116  if (running.has(job.id)) {
117    await update($, selected, () => job.id)
118    return
119  }
120  const version = job.needsVersion ? await nextVersion($) : ''
121  const args = job.args(await read($, language), version)
122  const startedAt = await $.clock.now()
123  await update($, jobs, all => ({
124    ...all,
125    [job.id]: {
126      status: 'running' as const,
127      startedAt,
128      endedAt: null,
129      lines: [`$ pnpm fe ${args.join(' ')}`],
130    },
131  }))
132  await update($, selected, () => job.id)
133
134  const handle = { stop: () => {}, isStopped: false }
135  running.set(job.id, handle)
136  let status: JobState['status'] = 'passed'
137  try {
138    const stream = $.process.spawn({
139      argv: ['node', 'scripts/fe.mjs', ...args],
140      cwd: root,
141      env: { FORCE_COLOR: '0' },
142    })
143    handle.stop = () => {
144      handle.isStopped = true
145      void stream.return(undefined as never)
146    }
147    let partial = ''
148    let next = await stream.next()
149    while (!next.done) {
150      const text = (partial + next.value.text).replace(ANSI, '')
151      const pieces = text.split(/\r?\n/)
152      partial = pieces.pop() ?? ''
153      await append($, job.id, pieces)
154      next = await stream.next()
155    }
156    await append($, job.id, partial ? [partial] : [])
157    if (handle.isStopped) {
158      status = 'stopped'
159    } else if (next.value?.code !== 0) {
160      status = 'failed'
161    }
162  } catch (error) {
163    status = handle.isStopped ? 'stopped' : 'failed'
164    if (!handle.isStopped) {
165      await append($, job.id, [String(error instanceof Error ? error.message : error)])
166    }
167  }
168  if (job.launchesApp) {
169    await stopApp($)
170  }
171  running.delete(job.id)
172  const endedAt = await $.clock.now()
173  await update($, jobs, all => {
174    const state = all[job.id]
175    return state ? { ...all, [job.id]: { ...state, status, endedAt } } : all
176  })
177  if (status !== 'stopped') {
178    $.ui.toast(`${job.label}:${status === 'passed' ? '完成' : '失败'}`)
179  }
180}
181
182export const register: Register = on => {
183  on('session.start', async ($, e, next) => {
184    const started = await next(e)
185    root = e.cwd || '.'
186    await $.command.register({
187      name: 'fe-dev',
188      description: 'Open the FrameEver dev pane (pnpm fe: run, check, test, export, deps, package)',
189    })
190    void $.ui.open({ id: PANE, title: TITLE })
191    return started
192  })
193
194  on('command.run', { command: 'fe-dev' }, async $ => {
195    await $.ui.open({ id: PANE, title: TITLE })
196    return { text: 'FrameEver 开发面板已打开。' }
197  })
198
199  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
200    const { Box, Button, Text } = $.ui.resolve(e)
201    const all = await read($, jobs)
202    const shown = await read($, selected)
203    const lang = await read($, language)
204    const now = await $.clock.now()
205    const seconds = (job: JobState) => Math.round(((job.endedAt ?? now) - job.startedAt) / 1000)
206    const color = (job: JobState) =>
207      job.status === 'passed' ? 'success' : job.status === 'failed' ? 'error' : 'subtle'
208    const output = shown ? all[shown] : undefined
209    const shownJob = ALL_JOBS.find(job => job.id === shown)
210    const started = ALL_JOBS.filter(job => all[job.id])
211    const room = Math.max(8, (e.viewport?.rows ?? 40) - 22)
212
213    const row = (title: string, children: RenderChildren) => (
214      <Box key={`group-${title}`} flexDirection="column">
215        <Text bold>{title}</Text>
216        <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
217          {children}
218        </Box>
219      </Box>
220    )
221
222    return (
223      <Box flexDirection="column" gap={1}>
224        {GROUPS.map(group =>
225          row(group.title, [
226            ...group.jobs.map(job => (
227              <Button
228                key={`run-${job.id}`}
229                label={all[job.id]?.status === 'running' ? `${job.label} …` : job.label}
230                variant={job.primary ? 'primary' : undefined}
231                onPress={() => {
232                  void runJob($, job)
233                }}
234              />
235            )),
236            ...(group.title === '运行'
237              ? LANGUAGES.map(option => (
238                  <Button
239                    key={`lang-${option.value}`}
240                    label={option.label}
241                    plain
242                    dimColor={lang !== option.value}
243                    onPress={() => update($, language, () => option.value)}
244                  />
245                ))
246              : []),
247          ]),
248        )}
249        {row(
250          '打开文件夹',
251          FOLDERS.map(folder => (
252            <Button
253              key={folder.key}
254              label={folder.label}
255              dimColor
256              onPress={async () => {
257                await $.process.run(['explorer', `${root}\\${folder.path}`]).catch(() => undefined)
258              }}
259            />
260          )),
261        )}
262        {started.length > 0 &&
263          row(
264            '任务',
265            started.map(job => {
266              const state = all[job.id]!
267              return (
268                <Button
269                  key={`show-${job.id}`}
270                  label={`${job.label} ${STATUS_LABEL[state.status]} ${seconds(state)}s`}
271                  plain
272                  dimColor={shown !== job.id}
273                  onPress={() => update($, selected, () => job.id)}
274                />
275              )
276            }),
277          )}
278        {output && shownJob ? (
279          <Box flexDirection="column">
280            <Box flexDirection="row" columnGap={1}>
281              <Text>
282                {shownJob.label} · <Text color={color(output)}>{STATUS_LABEL[output.status]}</Text> ·{' '}
283                {seconds(output)}s
284              </Text>
285              {output.status === 'running' && (
286                <Button
287                  key="stop-shown"
288                  label="停止"
289                  dimColor
290                  onPress={() => {
291                    running.get(shownJob.id)?.stop()
292                  }}
293                />
294              )}
295            </Box>
296            {output.lines.slice(-room).map((line, index) => (
297              <Text key={`line-${index}`} dimColor>
298                {line || ' '}
299              </Text>
300            ))}
301          </Box>
302        ) : (
303          <Text dimColor>
304            点按钮开始,所有按钮都调用 pnpm fe。“运行应用”用独立的测试数据。发版在终端运行 pnpm fe release
305            &lt;版本&gt;,会先跑全部测试并请你确认。
306          </Text>
307        )}
308      </Box>
309    )
310  })
311}
312
types/index.d.ts 21 lines
1export type JobStatus = 'running' | 'passed' | 'failed' | 'stopped'
2
3export type JobState = {
4  status: JobStatus
5  startedAt: number
6  endedAt: number | null
7  lines: string[]
8}
9
10export type AppLanguage = 'zh-CN' | 'en'
11
12declare module 'claude-code' {
13  interface PluginState {
14    'frameever-dev': {
15      jobs: Record<string, JobState>
16      selected: string | null
17      language: AppLanguage
18    }
19  }
20}
21