From f8684767ab15fec01fad526a2b0c51671b4b72e4 Mon Sep 17 00:00:00 2001 From: fanhuanjie Date: Sun, 5 Jul 2026 15:25:30 +0800 Subject: [PATCH 1/2] feat: add secondary permission prompt for irreversible bash deletions Add a separate "bash_delete" permission that intercepts destructive commands (rm, rmdir, unlink, shred, git reset --hard, git clean -f, git branch -D, git push --force, etc.) and forces an extra confirmation before execution. This permission is distinct from the regular "bash" permission so a broad `bash: allow` rule cannot silently pre-approve deletions. Set MIMOCODE_AUTO_APPROVE_DELETE=true to opt out of the second prompt. --- .../cli/cmd/tui/routes/session/permission.tsx | 25 +++++++ packages/opencode/src/flag/flag.ts | 10 +++ packages/opencode/src/tool/bash.ts | 69 +++++++++++++++++ packages/opencode/test/tool/bash.test.ts | 74 +++++++++++++++++++ 4 files changed, 178 insertions(+) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 61ffa5707..82b0a44ee 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -306,6 +306,31 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } } + if (permission === "bash_delete") { + const meta = props.request.metadata ?? {} + const command = typeof meta["command"] === "string" ? meta["command"] : "" + const deletes = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string") + return { + icon: "✗", + title: "Confirm irreversible deletion", + body: ( + + + {"$ " + command} + + 0}> + + Detected deletions + + {(cmd) => {"- " + cmd}} + + + + + ), + } + } + if (permission === "task") { const type = typeof data.subagent_type === "string" ? data.subagent_type : "Unknown" const desc = typeof data.description === "string" ? data.description : "" diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 8fa980939..d38c88797 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -66,6 +66,16 @@ export const Flag = { MIMOCODE_DISABLE_TERMINAL_TITLE: truthy("MIMOCODE_DISABLE_TERMINAL_TITLE"), MIMOCODE_SHOW_TTFD: truthy("MIMOCODE_SHOW_TTFD"), MIMOCODE_PERMISSION: process.env["MIMOCODE_PERMISSION"], + + // Defaults to false. When false, the bash tool intercepts irreversible + // deletion commands (rm, rmdir, unlink, shred, del, erase, rd, remove-item, + // and git destructive subcommands like reset --hard / clean -f / branch -D / + // worktree remove / push --force / stash drop|clear / tag -d) and forces an + // extra permission prompt with permission="bash_delete" — separate from the + // normal bash-permission ask so it can't be silently pre-approved by a broad + // `bash: allow` rule. Set MIMOCODE_AUTO_APPROVE_DELETE=true to trust the + // model with deletes and skip the second confirmation. + MIMOCODE_AUTO_APPROVE_DELETE: truthy("MIMOCODE_AUTO_APPROVE_DELETE"), MIMOCODE_DISABLE_DEFAULT_PLUGINS: truthy("MIMOCODE_DISABLE_DEFAULT_PLUGINS"), MIMOCODE_DISABLE_LSP_DOWNLOAD: truthy("MIMOCODE_DISABLE_LSP_DOWNLOAD"), MIMOCODE_ENABLE_EXPERIMENTAL_MODELS: truthy("MIMOCODE_ENABLE_EXPERIMENTAL_MODELS"), diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index c135e2e72..a81ed5cd8 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -54,6 +54,36 @@ const FILES = new Set([ const FLAGS = new Set(["-destination", "-literalpath", "-path"]) const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) +// Irreversible file/directory removal commands. Names are matched +// case-insensitively for PowerShell; bash is case-sensitive. +const DELETE_COMMANDS = new Set([ + "rm", + "rmdir", + "unlink", + "shred", + // Windows / PowerShell removal verbs and their common aliases. `remove-item` + // is the canonical verb; `ri`, `rd`, `del`, `erase` are aliases. + "del", + "erase", + "rd", + "remove-item", + "ri", +]) + +// git subcommands that destroy history, working tree state, or remote branches. +// Value is the set of tokens (flag or subcommand keyword) that must appear +// anywhere in the argv for the invocation to count as destructive. An empty +// set means the subcommand is destructive on its own. +const GIT_DESTRUCTIVE = new Map>([ + ["reset", new Set(["--hard"])], + ["clean", new Set(["-f", "-ff", "-fd", "-fdx", "-df", "-dfx", "-fx", "--force"])], + ["branch", new Set(["-D", "--delete"])], + ["tag", new Set(["-d", "--delete"])], + ["worktree", new Set(["remove"])], + ["push", new Set(["--force", "-f"])], + ["stash", new Set(["drop", "clear"])], +]) + const Parameters = z.object({ command: z.string().describe("The command to execute"), timeout: z.number().describe("Optional timeout in milliseconds").optional(), @@ -85,6 +115,7 @@ type Scan = { dirs: Set patterns: Set always: Set + deletes: Set } type Chunk = { @@ -137,6 +168,24 @@ function commands(node: Node) { return node.descendantsOfType("command").filter((child): child is Node => Boolean(child)) } +// Returns true when `tokens` (the flat argv of a single command node) invokes +// an irreversible deletion — either a direct removal command (rm, remove-item, +// …) or a destructive git subcommand (git reset --hard, git clean -f, …). +// `ps` toggles PowerShell case-insensitive matching. +function isDelete(tokens: string[], ps: boolean) { + if (tokens.length === 0) return false + const head = ps ? tokens[0].toLowerCase() : tokens[0] + if (DELETE_COMMANDS.has(head)) return true + if (head === "git" && tokens.length >= 2) { + const sub = tokens[1] + const flags = GIT_DESTRUCTIVE.get(sub) + if (!flags) return false + if (flags.size === 0) return true + return tokens.slice(2).some((tok) => flags.has(tok)) + } + return false +} + function unquote(text: string) { if (text.length < 2) return text const first = text[0] @@ -308,6 +357,22 @@ const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) }) }) +// Secondary confirmation for irreversible deletion commands. Uses its own +// permission type ("bash_delete") so a broad `bash: allow` rule can't silently +// pre-approve it. `MIMOCODE_AUTO_APPROVE_DELETE=true` skips the ask entirely +// for callers who explicitly opt out of the extra prompt. +const askDelete = Effect.fn("BashTool.askDelete")(function* (ctx: Tool.Context, scan: Scan, command: string) { + if (Flag.MIMOCODE_AUTO_APPROVE_DELETE) return + if (scan.deletes.size === 0) return + const patterns = Array.from(scan.deletes) + yield* ctx.ask({ + permission: "bash_delete", + patterns, + always: ["*"], + metadata: { command, deletes: patterns }, + }) +}) + function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { if (process.platform === "win32" && PS.has(name)) { const prefixed = `${Shell.POWERSHELL_UTF8_PREFIX}${command}` @@ -401,6 +466,7 @@ export const BashTool = Tool.define( dirs: new Set(), patterns: new Set(), always: new Set(), + deletes: new Set(), } for (const node of commands(root)) { @@ -422,6 +488,8 @@ export const BashTool = Tool.define( scan.patterns.add(source(node)) scan.always.add(BashArity.prefix(tokens).join(" ") + " *") } + + if (isDelete(tokens, ps)) scan.deletes.add(source(node)) } return scan @@ -690,6 +758,7 @@ export const BashTool = Tool.define( const scan = yield* collect(root, cwd, ps, shell) if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) yield* ask(ctx, scan) + yield* askDelete(ctx, scan, params.command) // Interactive mode: hand terminal to user for direct interaction if (params.interactive) { diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 087d4748a..c9e967d8c 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -294,6 +294,80 @@ describe("tool.bash permissions", () => { }) }) + each("asks for bash_delete when running rm inside the project", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "victim.txt"), "x") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initBash() + const requests: Array> = [] + await Effect.runPromise( + bash.execute( + { + command: "rm victim.txt", + description: "Remove victim.txt", + }, + capture(requests), + ), + ) + const deleteReq = requests.find((r) => r.permission === "bash_delete") + expect(deleteReq).toBeDefined() + expect(deleteReq!.patterns).toContain("rm victim.txt") + expect(deleteReq!.metadata.command).toBe("rm victim.txt") + }, + }) + }) + + each("asks for bash_delete on destructive git subcommands", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initBash() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + Effect.runPromise( + bash.execute( + { + command: "git reset --hard HEAD", + description: "Hard reset", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + }, + }) + }) + + each("does not ask for bash_delete on non-destructive commands", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initBash() + const requests: Array> = [] + await Effect.runPromise( + bash.execute( + { + command: "echo hello", + description: "Echo hello", + }, + capture(requests), + ), + ) + expect(requests.find((r) => r.permission === "bash_delete")).toBeUndefined() + }, + }) + }) + if (process.platform === "win32") { if (bash) { test( From 836e04edbf9a0016740387b0cd176be6ce5d08ce Mon Sep 17 00:00:00 2001 From: fanhuanjie Date: Sun, 5 Jul 2026 17:41:34 +0800 Subject: [PATCH 2/2] feat: add html-to-video pipeline skill --- packages/opencode/src/cli/cmd/tui/i18n/en.ts | 1 + packages/opencode/src/cli/cmd/tui/i18n/es.ts | 1 + packages/opencode/src/cli/cmd/tui/i18n/fr.ts | 1 + packages/opencode/src/cli/cmd/tui/i18n/ja.ts | 1 + packages/opencode/src/cli/cmd/tui/i18n/ru.ts | 1 + .../opencode/src/cli/cmd/tui/i18n/skill.ts | 1 + packages/opencode/src/cli/cmd/tui/i18n/zh.ts | 1 + packages/opencode/src/cli/cmd/tui/i18n/zht.ts | 1 + packages/opencode/src/flag/flag.ts | 12 +- packages/opencode/src/session/prompt.ts | 2 +- .../.bundle/html-to-video-pipeline/LICENSE | 202 +++++++++++++++ .../.bundle/html-to-video-pipeline/SKILL.md | 236 ++++++++++++++++++ .../html-to-video-pipeline/evals/evals.json | 33 +++ .../references/duration-probe.md | 63 +++++ .../references/ffmpeg-cheatsheet.md | 132 ++++++++++ .../references/font-wait.md | 68 +++++ .../references/frame-stepping.md | 88 +++++++ .../references/multi-composition.md | 110 ++++++++ .../opencode/src/skill/builtin/extract.ts | 10 +- packages/opencode/src/skill/index.ts | 6 +- transformer-video/record.mjs | 131 ++++++++++ 21 files changed, 1089 insertions(+), 12 deletions(-) create mode 100644 packages/opencode/src/skill/builtin/.bundle/html-to-video-pipeline/LICENSE create mode 100644 packages/opencode/src/skill/builtin/.bundle/html-to-video-pipeline/SKILL.md create mode 100644 packages/opencode/src/skill/builtin/.bundle/html-to-video-pipeline/evals/evals.json create mode 100644 packages/opencode/src/skill/builtin/.bundle/html-to-video-pipeline/references/duration-probe.md create mode 100644 packages/opencode/src/skill/builtin/.bundle/html-to-video-pipeline/references/ffmpeg-cheatsheet.md create mode 100644 packages/opencode/src/skill/builtin/.bundle/html-to-video-pipeline/references/font-wait.md create mode 100644 packages/opencode/src/skill/builtin/.bundle/html-to-video-pipeline/references/frame-stepping.md create mode 100644 packages/opencode/src/skill/builtin/.bundle/html-to-video-pipeline/references/multi-composition.md create mode 100644 transformer-video/record.mjs diff --git a/packages/opencode/src/cli/cmd/tui/i18n/en.ts b/packages/opencode/src/cli/cmd/tui/i18n/en.ts index b2a7a6aa9..b83df9fe4 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/en.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/en.ts @@ -205,6 +205,7 @@ export const dict: Record = { "tui.skill.self-extend.description": "Extend your own capabilities with new skills, tools, and hooks", "tui.skill.frontend-design.description": "Guidance for distinctive, intentional visual UI design", "tui.skill.loop.description": "Schedule a prompt to run on a recurring interval", + "tui.skill.html-to-video-pipeline.description": "Short-video magic — make short videos with HTML", // Language switching "tui.command.language.switch.title": "Switch language", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/es.ts b/packages/opencode/src/cli/cmd/tui/i18n/es.ts index af4e03058..3e12ae93c 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/es.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/es.ts @@ -281,6 +281,7 @@ export const dict = { "tui.skill.self-extend.description": "Amplía tus propias capacidades con nuevas skills, herramientas y hooks", "tui.skill.frontend-design.description": "Guía para un diseño visual de UI distintivo e intencional", "tui.skill.loop.description": "Programar un prompt para ejecutarse en un intervalo recurrente", + "tui.skill.html-to-video-pipeline.description": "El arma definitiva para vídeos cortos — crea vídeos cortos con HTML", // Language switching "tui.command.language.switch.title": "Cambiar idioma", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/fr.ts b/packages/opencode/src/cli/cmd/tui/i18n/fr.ts index 4083af05b..62a6d48e5 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/fr.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/fr.ts @@ -269,6 +269,7 @@ export const dict = { "tui.skill.self-extend.description": "Étendez vos capacités avec de nouvelles skills, outils et hooks", "tui.skill.frontend-design.description": "Conseils pour un design d'interface visuel distinctif et intentionnel", "tui.skill.loop.description": "Planifier l'exécution récurrente d'un prompt", + "tui.skill.html-to-video-pipeline.description": "L'arme ultime pour vidéos courtes — créez des vidéos courtes avec du HTML", // Language switching "tui.command.language.switch.title": "Changer de langue", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/ja.ts b/packages/opencode/src/cli/cmd/tui/i18n/ja.ts index 53eba0171..e10750925 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/ja.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/ja.ts @@ -218,6 +218,7 @@ export const dict = { "tui.skill.self-extend.description": "新しいスキル・ツール・フックで自身の能力を拡張", "tui.skill.frontend-design.description": "個性的で意図的な UI ビジュアルデザインのガイド", "tui.skill.loop.description": "プロンプトを一定間隔で繰り返し実行するようスケジュール", + "tui.skill.html-to-video-pipeline.description": "ショート動画の神ツール - HTML でショート動画を制作", // Language switching "tui.command.language.switch.title": "言語を切り替え", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/ru.ts b/packages/opencode/src/cli/cmd/tui/i18n/ru.ts index 9709bfedb..7b3db7184 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/ru.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/ru.ts @@ -284,6 +284,7 @@ export const dict = { "tui.skill.self-extend.description": "Расширьте свои возможности новыми skills, инструментами и hooks", "tui.skill.frontend-design.description": "Руководство по выразительному, осмысленному визуальному дизайну UI", "tui.skill.loop.description": "Запланировать запуск промпта с периодичностью", + "tui.skill.html-to-video-pipeline.description": "Магический инструмент для коротких видео — создавайте короткие видео с помощью HTML", // Language switching "tui.command.language.switch.title": "Сменить язык", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/skill.ts b/packages/opencode/src/cli/cmd/tui/i18n/skill.ts index 2e2ef8edb..4e4c5c724 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/skill.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/skill.ts @@ -7,6 +7,7 @@ const BUILTIN = new Set([ "self-extend", "frontend-design", "loop", + "html-to-video-pipeline", ]) export function skillDescription( diff --git a/packages/opencode/src/cli/cmd/tui/i18n/zh.ts b/packages/opencode/src/cli/cmd/tui/i18n/zh.ts index 9f5dfb641..f106d0844 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/zh.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/zh.ts @@ -194,6 +194,7 @@ export const dict = { "tui.skill.self-extend.description": "通过新技能、工具与钩子扩展自身能力", "tui.skill.frontend-design.description": "具备鲜明主张的前端视觉设计指南", "tui.skill.loop.description": "按固定周期循环运行提示词", + "tui.skill.html-to-video-pipeline.description": "短视频神器 - 利用 HTML 制作短视频", // Language switching "tui.command.language.switch.title": "切换语言", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/zht.ts b/packages/opencode/src/cli/cmd/tui/i18n/zht.ts index 4a36665ed..9551813b2 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/zht.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/zht.ts @@ -194,6 +194,7 @@ export const dict = { "tui.skill.self-extend.description": "透過新技能、工具與掛鉤擴充自身能力", "tui.skill.frontend-design.description": "具備鮮明主張的前端視覺設計指引", "tui.skill.loop.description": "依固定週期循環執行提示詞", + "tui.skill.html-to-video-pipeline.description": "短影片神器 - 利用 HTML 製作短影片", // Language switching "tui.command.language.switch.title": "切換語言", diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index d38c88797..9ad894da9 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -240,12 +240,12 @@ export const Flag = { get MIMOCODE_DISABLE_BUILTIN_SKILLS() { return truthy("MIMOCODE_DISABLE_BUILTIN_SKILLS") }, - // Disables the built-in document-processing skills (docx, pdf, pptx, xlsx) - // while keeping the rest of the builtin bundle available. Defaults to false - // (all four skills are extracted and loaded). Set - // MIMOCODE_DISABLE_DOCUMENT_SKILLS=true to skip them. - get MIMOCODE_DISABLE_DOCUMENT_SKILLS() { - return truthy("MIMOCODE_DISABLE_DOCUMENT_SKILLS") + // Disables the built-in official skills (docx, pdf, pptx, xlsx, + // html-to-video-pipeline) while keeping the rest of the builtin bundle + // available. Defaults to false (all skills are extracted and loaded). Set + // MIMOCODE_DISABLE_OFFICIAL_SKILLS=true to skip them. + get MIMOCODE_DISABLE_OFFICIAL_SKILLS() { + return truthy("MIMOCODE_DISABLE_OFFICIAL_SKILLS") }, get MIMOCODE_DISABLE_PROJECT_CONFIG() { return truthy("MIMOCODE_DISABLE_PROJECT_CONFIG") diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8314728c0..be8671f54 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -561,7 +561,7 @@ export const layer = Layer.effect( } const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant") - if (!Flag.MIMOCODE_DISABLE_BUILTIN_SKILLS && !Flag.MIMOCODE_DISABLE_DOCUMENT_SKILLS) { + if (!Flag.MIMOCODE_DISABLE_BUILTIN_SKILLS && !Flag.MIMOCODE_DISABLE_OFFICIAL_SKILLS) { const fileCandidates = userMessage.parts.flatMap((p) => { if (p.type !== "file") return [] const filenameFromSource = diff --git a/packages/opencode/src/skill/builtin/.bundle/html-to-video-pipeline/LICENSE b/packages/opencode/src/skill/builtin/.bundle/html-to-video-pipeline/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/packages/opencode/src/skill/builtin/.bundle/html-to-video-pipeline/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/opencode/src/skill/builtin/.bundle/html-to-video-pipeline/SKILL.md b/packages/opencode/src/skill/builtin/.bundle/html-to-video-pipeline/SKILL.md new file mode 100644 index 000000000..4569e3a82 --- /dev/null +++ b/packages/opencode/src/skill/builtin/.bundle/html-to-video-pipeline/SKILL.md @@ -0,0 +1,236 @@ +--- +name: html-to-video-pipeline +description: Reliable HTML-to-MP4 rendering via headless browser recording (Playwright/Puppeteer) + ffmpeg — the ordering, gotchas, and verification steps you MUST get right or the output silently rots. Trigger whenever the user is building or debugging any pipeline that turns an HTML/CSS/JS page (single-file, multi-composition, GSAP-driven, or `@keyframes`-driven) into a video file, including headless recording, screen capture of a web page, deterministic frame-by-frame capture, multi-scene concatenation, or engine-mixed video output. Also trigger when the symptom sounds like: font swap flashing in the opening frames (FOUT), the first few seconds of the video are frozen/dead, animations play during page load and get truncated, concatenated segments produce a video whose duration is wildly wrong (e.g., 8s becomes 35s), `file://` loaded HTML fails to fetch its sub-scenes, the exported video is soft/blurry compared to the browser, or playback stutters/looks choppy despite passing a high `-r` fps to ffmpeg. Use even for one-off scripts — the failure modes here are subtle enough that starting from scratch usually reintroduces them. +--- + +# HTML → Video: the pipeline that actually works + +Turning a live HTML page into an MP4 sounds like a one-liner ("Playwright records, ffmpeg encodes") and the first draft always looks fine — until you play back the export and notice text flashing in a fallback font, three dead seconds at the start, or a "10-second" clip that clocks in at 35s. This skill captures the specific ordering, waits, and encoder flags that make the output trustworthy. + +Think of the pipeline as five phases with a single invariant between them: + +``` +launch → hold @ frame 0 → align capture start with animation start → record → trim & encode +``` + +The invariant: **no pixel of the animation should render before capture is ready, AND capture must not start before every asset that affects layout has loaded.** Every gotcha below is a violation of that invariant. + +Quick checklist (each item is expanded below): + +1. `viewport` must exactly equal `recordVideo.size` — mismatch silently rescales the output soft/blurry. +2. Freeze CSS animations via `addInitScript` **before** `goto()`. +3. `goto` with `domcontentloaded`, never `load`. +4. Pre-inline `data-composition-src` sub-scenes — `file://` blocks client-side `fetch`. +5. Font wait = stylesheet `` load → `face.load()` per face → `fonts.ready` → 2 rAFs. +6. Probe duration with infinite-iteration / `repeat: -1` guards. +7. Unfreeze = t=0; trim `leadInMs − 120ms` with `-ss` *before* `-i`. +8. Concat: same engine → demuxer `-c copy`; mixed engines → concat *filter* + re-encode. +9. Verify with ffprobe duration + full decode + sampled frames. Always. + +There are two capture modes, and picking the wrong one wastes a day: + +- **Mode A — real-time `recordVideo`** (the phases below). Simple, wall-clock = clip length. But the screencast is best-effort variable frame rate (~25fps) and competes with the animation for CPU. Good enough for previews and ≤30fps deliverables on an idle machine. +- **Mode B — deterministic frame stepping** (pause everything, seek to `i / fps`, screenshot, encode the PNG sequence). Exact fps, every frame rendered, no lead-in trim, immune to machine load. Required when the output must be genuinely smooth ≥30fps or renders run on loaded CI. See "Deterministic frame stepping" below. + +## Prerequisites — install these first + +Two hard requirements: a headless browser driver (Playwright or Puppeteer) and `ffmpeg`. Everything below assumes both are installed and on `PATH`. + +### 1. Headless browser driver + +Playwright is the default in this skill (its `recordVideo` API is what the pipeline is written against). Puppeteer works too but you'll wire recording yourself via CDP screencast — pick Playwright unless you have a reason not to. + +```bash +# Playwright (recommended) — npm / pnpm / bun all work +npm install playwright +pnpm add playwright +bun add playwright + +# Then install the actual browser binaries (this is the step people forget): +npx playwright install chromium # just Chromium — enough for this pipeline +npx playwright install --with-deps chromium # Linux/CI: also install shared libs +``` + +Verify: `npx playwright --version` prints the driver version, and `node -e "require('playwright').chromium.launch().then(b => b.close())"` exits cleanly. + +Puppeteer alternative (only if you already have it in the project): + +```bash +npm install puppeteer # bundles a matching Chromium automatically +# or, to reuse a system Chrome: +npm install puppeteer-core # no bundled browser — you supply executablePath +``` + +### 2. ffmpeg + +The encode/trim/concat/verify steps all shell out to `ffmpeg` (and `ffprobe`, which ships in the same package). It is **not** an npm dependency — install it at the OS level. + +```bash +# macOS +brew install ffmpeg + +# Debian / Ubuntu +sudo apt-get update && sudo apt-get install -y ffmpeg + +# Fedora / RHEL +sudo dnf install ffmpeg # requires RPM Fusion enabled + +# Windows +winget install Gyan.FFmpeg # or: choco install ffmpeg +``` + +Verify: `ffmpeg -version` and `ffprobe -version` both print. If a script spawns `ffmpeg` and gets `ENOENT`, the binary isn't on `PATH` — see the graceful-failure snippet in `references/ffmpeg-cheatsheet.md`. + +Do **not** rely on the `ffmpeg-static` / `@ffmpeg-installer/ffmpeg` npm packages for production: their bundled builds skip codecs and encoder flags you'll hit sooner or later (e.g. `libx264` presets, `tpad`). A system ffmpeg is the durable choice. + +### 3. Optional — ImageMagick (verification only) + +Only needed if you want the "did anything render?" pixel-mean check from Phase 6. Skip it otherwise. + +```bash +brew install imagemagick # macOS +sudo apt-get install imagemagick # Debian/Ubuntu +``` + +Verify: `magick -version` (ImageMagick 7) or `convert -version` (ImageMagick 6). + +## The pipeline + +### Phase 1 — Launch and prepare the recorder + +Use `playwright.chromium.launch({ headless: true })` and a context configured with `recordVideo: { dir, size: { width, height } }`. Two sizing rules: + +- **`viewport` must exactly equal `recordVideo.size`.** When they differ, Playwright rescales the screencast to fit the recording size and the whole output goes soft. Derive both from the same constants. +- `recordVideo` captures at CSS-pixel resolution; `deviceScaleFactor` does **not** raise recording resolution. For a 1080p export use a 1920×1080 viewport, not 960×540 @ 2x. + +Recording begins the instant the context exists — treat that timestamp as the WebM's t=0 and remember it (`tWebmStart = Date.now()`); you will need it in Phase 5 to trim the dead opening. + +Know the recorder's ceiling: the screencast runs at a variable ~25fps and drops frames under CPU load. The `-r ` flag in Phase 5 makes the container constant-frame-rate but only duplicates frames — it cannot add smoothness. If stutter matters, switch to frame stepping (Mode B) instead of fighting this. + +### Phase 2 — Freeze animations BEFORE the page parses + +This is the single most-missed step. Pure-CSS `@keyframes` animations begin the moment the element is styled — there is no JS trigger to hold. If you `goto()` the HTML and then start waiting for fonts (which takes seconds), the CSS timeline has *already been running the whole time*. The recording captures the fallback font swapping to the real face mid-clip, and the opening beats of the animation are lost to the font wait. + +Fix: inject a global freeze **via `page.addInitScript()`**, which runs before any of the document's own scripts and before CSS is applied, so the timeline is paused at frame 0 the moment it exists: + +```js +await page.addInitScript(() => { + const style = document.createElement('style'); + style.id = '__freeze'; + style.textContent = + '*, *::before, *::after { animation-play-state: paused !important;' + + ' -webkit-animation-play-state: paused !important; }'; + const attach = () => (document.head || document.documentElement).appendChild(style); + if (document.head || document.documentElement) attach(); + else document.addEventListener('DOMContentLoaded', attach, { once: true }); + window.__unfreeze = () => document.getElementById('__freeze')?.remove(); +}); +``` + +This does not stop GSAP tweens driven by JS (see Phase 4 for those) — for GSAP-driven templates, register the master timeline paused and expose a `window.__playAll()` you call in Phase 4. + +### Phase 3 — Load with `domcontentloaded`, not `load` + +`waitUntil: 'load'` blocks on every external asset, including cross-origin videos with no CORS headers. Chromium will retry those for ~4s before giving up, and those 4s get burned into the recording as a frozen first scene. Use `waitUntil: 'domcontentloaded'` — the DOM + synchronous inline scripts (GSAP, your player) are ready, and you handle fonts explicitly next. + +If the template is multi-composition (scenes loaded via `data-composition-src` and client-side `fetch`), you must **pre-inline** those sub-files into the entry HTML on the Node side and write it to a sibling `.tmp.html`. Chromium blocks `file://` fetch, so client-side scene loaders that work in a browser tab will silently produce an empty shell in a headless recording. See `references/multi-composition.md`. + +### Phase 4 — Wait for fonts properly, then release + +`document.fonts.ready` alone is **not enough** and this is the second most-missed step. Under `domcontentloaded`, the Google Fonts (or any external) `` has usually not returned yet. Until its CSS arrives, its `@font-face` rules are not in `document.fonts` at all — so `fonts.ready` sees an empty set and resolves *instantly*. Recording proceeds, the CSS lands mid-clip, faces download, and the swap happens on-camera. + +The correct sequence, all inside a `page.evaluate()` with an 8s hard cap so a wedged CDN can't stall forever: + +1. Wait for every `` to `load` or `error` — that registers `@font-face` rules into `document.fonts`. +2. For each registered face, call `face.load()` explicitly. `font-display: swap` defers the download until something paints with that face — if your first frame doesn't happen to use the face, it never fetches without this. +3. `await fonts.ready`. +4. Two rAFs so layout settles on the real glyph metrics before frame 0. + +Then, and only then: + +5. Drive playback: for GSAP multi-composition templates, call `window.__playAll()` to start every registered (paused) master timeline from 0. For pure-CSS templates, this is a no-op. +6. Call `window.__unfreeze()` to remove the freeze style. **This is the true t=0 of the animation.** Record the wall-clock offset from `tWebmStart` (`leadInMs = Date.now() - tWebmStart`) — this is what you trim in Phase 5. + +The full JS for Phase 4 is in `references/font-wait.md`. + +### Phase 5 — Probe duration, record, then trim and encode + +**Probe duration first** (skip this only if you know the exact intended length). The user's requested `duration` may be shorter than the animation, in which case you'd cut the animation mid-play. Take the maximum of: + +- The longest **non-infinite** CSS animation (`animation-duration + animation-delay` for entries where `animation-iteration-count !== 'infinite'`). +- The longest **finite** GSAP tween. Do **not** use `gsap.globalTimeline.totalDuration()` — a `repeat: -1` tween (blinking cursor, looping background) makes it ~1e10s. Walk `globalTimeline.getChildren(true, true, true)` and skip anything with `repeat() === -1`. + +Add a ~400ms settle so the last animation frame is captured, cap at 30s so a stray huge value can't run away. If your caller passed an *explicit* per-frame duration (e.g. "each scene is exactly 4s"), do **not** extend — treat it as a hard cap and pad the tail if the animation finished early (see below). If duration was 'auto', extend to the probed length. + +**Record** for `totalDuration`, then `context.close()`. Playwright drops the WebM in `recordDir`. + +**Encode with ffmpeg**, applying two corrections: + +``` +ffmpeg -y \ + [ -ss <(leadInMs - 120) / 1000> ] # trim dead lead-in, back off 120ms so we don't clip frame 1 + -i input.webm \ + [ -vf tpad=stop_mode=clone:stop_duration= ] # pad tail only when duration is explicit + -t \ # trim to exact length (recordVideo sometimes overshoots) + -r \ + -c:v libx264 -pix_fmt yuv420p -preset medium -crf 20 \ + -movflags +faststart \ + out.mp4 +``` + +The `-ss` back-off matters: recorder start jitter and rounding can steal the first real frame if you trim to exactly `leadInMs`. A couple of extra still frames at the head are invisible; a missing opening beat is obvious. + +## Deterministic frame stepping (Mode B) + +When the deliverable must be genuinely smooth at an exact fps — or the render runs on a loaded CI box where real-time capture visibly stutters — don't record in real time at all. Keep Phases 1–4 (minus `recordVideo`), then: pause every timeline, seek to `i / fps`, `page.screenshot()` each frame, and encode the PNG sequence with `ffmpeg -framerate `. Duration is exact by construction, there is no lead-in to trim, and every single frame is fully rendered regardless of machine load. + +It works when the page's motion is declaratively seekable — CSS `@keyframes` (via `document.getAnimations()` + `currentTime`), WAAPI, and GSAP (`gsap.globalTimeline.time(t)`). It does **not** work for `