From 534435a4ae74ee6683cafa0e99925fb7d5ae5ec0 Mon Sep 17 00:00:00 2001 From: liyachen Date: Thu, 16 Jul 2026 21:01:18 -0400 Subject: [PATCH] feat(settings): bundle bilingual SOPs with one-click install into /SOPs --- esbuild.config.mjs | 1 + jest.config.js | 5 +- src/bundled-sops.ts | 41 +++++++++++++++ src/locales/en.json | 4 ++ src/locales/zh.json | 4 ++ src/main.ts | 10 ++++ src/md.d.ts | 4 ++ src/settings.ts | 18 ++++++- src/sops/en/Cover Analysis SOP.md | 44 ++++++++++++++++ src/sops/en/Video Hook Analysis SOP.md | 47 +++++++++++++++++ src/sops/en/Video Keyframe Analysis SOP.md | 61 ++++++++++++++++++++++ src/sops/zh/封面拆解学习 SOP.md | 45 ++++++++++++++++ src/sops/zh/视频Hook分析 SOP.md | 48 +++++++++++++++++ src/sops/zh/视频关键帧分析 SOP.md | 59 +++++++++++++++++++++ tests/__mocks__/md-stub.ts | 1 + tests/bundled-sops.test.ts | 43 +++++++++++++++ 16 files changed, 433 insertions(+), 2 deletions(-) create mode 100644 src/bundled-sops.ts create mode 100644 src/md.d.ts create mode 100644 src/sops/en/Cover Analysis SOP.md create mode 100644 src/sops/en/Video Hook Analysis SOP.md create mode 100644 src/sops/en/Video Keyframe Analysis SOP.md create mode 100644 src/sops/zh/封面拆解学习 SOP.md create mode 100644 src/sops/zh/视频Hook分析 SOP.md create mode 100644 src/sops/zh/视频关键帧分析 SOP.md create mode 100644 tests/__mocks__/md-stub.ts create mode 100644 tests/bundled-sops.test.ts diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 3ef1bae..555a9b4 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -10,6 +10,7 @@ const ctx = await esbuild.context({ external: ['obsidian', 'electron', 'node:*'], format: 'cjs', target: 'ES2018', + loader: { '.md': 'text' }, logLevel: 'info', sourcemap: prod ? false : 'inline', treeShaking: true, diff --git a/jest.config.js b/jest.config.js index 8f6fa62..2328f71 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,5 +2,8 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', - moduleNameMapper: { '^obsidian$': '/tests/__mocks__/obsidian.ts' }, + moduleNameMapper: { + '^obsidian$': '/tests/__mocks__/obsidian.ts', + '\\.md$': '/tests/__mocks__/md-stub.ts', + }, }; diff --git a/src/bundled-sops.ts b/src/bundled-sops.ts new file mode 100644 index 0000000..9a5c61d --- /dev/null +++ b/src/bundled-sops.ts @@ -0,0 +1,41 @@ +import coverZh from './sops/zh/封面拆解学习 SOP.md'; +import hookZh from './sops/zh/视频Hook分析 SOP.md'; +import keyframeZh from './sops/zh/视频关键帧分析 SOP.md'; +import coverEn from './sops/en/Cover Analysis SOP.md'; +import hookEn from './sops/en/Video Hook Analysis SOP.md'; +import keyframeEn from './sops/en/Video Keyframe Analysis SOP.md'; + +export interface BundledSop { filename: string; content: string; } + +export interface SopInstallOps { + fileExists(path: string): boolean; + ensureFolder(path: string): Promise; + create(path: string, content: string): Promise; +} + +export const BUNDLED_SOPS: BundledSop[] = [ + { filename: '封面拆解学习 SOP.md', content: coverZh }, + { filename: '视频Hook分析 SOP.md', content: hookZh }, + { filename: '视频关键帧分析 SOP.md', content: keyframeZh }, + { filename: 'Cover Analysis SOP.md', content: coverEn }, + { filename: 'Video Hook Analysis SOP.md', content: hookEn }, + { filename: 'Video Keyframe Analysis SOP.md', content: keyframeEn }, +]; + +// Writes bundled SOPs into /SOPs. Existing files are never overwritten, +// and no sopPath setting is touched: the user picks which SOP applies where. +export async function installBundledSops( + ops: SopInstallOps, baseFolder: string, sops: BundledSop[] = BUNDLED_SOPS, +): Promise<{ written: string[]; skipped: string[] }> { + const folder = `${(baseFolder || 'Clips').trim().replace(/\/+$/, '') || 'Clips'}/SOPs`; + await ops.ensureFolder(folder); + const written: string[] = []; + const skipped: string[] = []; + for (const s of sops) { + const path = `${folder}/${s.filename}`; + if (ops.fileExists(path)) { skipped.push(path); continue; } + await ops.create(path, s.content); + written.push(path); + } + return { written, skipped }; +} diff --git a/src/locales/en.json b/src/locales/en.json index 15d7311..7230c2a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -21,6 +21,10 @@ "settings.port.desc": "Default 17183. Only change it if the port is taken; after changing, set the same value on the extension's welcome page (Advanced → Port), or the two sides will disconnect. Restart Obsidian afterwards.", "settings.maxFrames.name": "Max frames", "settings.maxFrames.desc": "Maximum frames kept per hook / keyframe clip (1 to 20). Default 5.", + "settings.installSops.name": "Bundled SOPs", + "settings.installSops.desc": "Three ready-made analysis SOPs (cover, hook, keyframe), each in Chinese and English. One click writes them into /SOPs. Existing files are never overwritten. Point the SOP path fields below at the ones you want to use.", + "settings.installSops.button": "Install", + "notice.sopsInstalled": "Installed {count} SOP file(s) into {folder}. Skipped {skipped} already there.", "settings.sop.thumbnail": "Cover SOP path", "settings.sop.screenshot": "Screenshot SOP path", "settings.sop.hook": "Hook SOP path", diff --git a/src/locales/zh.json b/src/locales/zh.json index 1f9bcb9..c992299 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -21,6 +21,10 @@ "settings.port.desc": "默认 17183。仅当端口被占用时才需要改;改完必须在扩展的引导页(高级 → 端口)改成同一个值,否则两边会断开。改后重启 Obsidian。", "settings.maxFrames.name": "抽帧数量上限", "settings.maxFrames.desc": "Hook / 关键帧模式最多保存几帧(1–20)。默认 5。", + "settings.installSops.name": "内置 SOP", + "settings.installSops.desc": "三份现成的分析 SOP(封面、Hook、关键帧),中英各一份。点一下写入 <根文件夹>/SOPs,已存在的文件不会被覆盖。想启用哪份,把下面对应的 SOP 路径指向它。", + "settings.installSops.button": "安装", + "notice.sopsInstalled": "已安装 {count} 份 SOP 到 {folder},跳过 {skipped} 份已存在的文件。", "settings.sop.thumbnail": "封面 SOP 路径", "settings.sop.screenshot": "截图 SOP 路径", "settings.sop.hook": "Hook SOP 路径", diff --git a/src/main.ts b/src/main.ts index 8aad3bf..92ebc83 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,6 +5,7 @@ import { DEFAULT_SETTINGS, VaultAutopilotSettingTab, normalizePort, emptyToDefau import { PluginSettings, ClipMode } from './types'; import { createServer, ClipPayload } from './server'; import { routeClip, VaultOps } from './clip-router'; +import { SopInstallOps } from './bundled-sops'; import { t, setLanguage } from './i18n'; export default class VaultAutopilotPlugin extends Plugin { @@ -64,6 +65,15 @@ export default class VaultAutopilotPlugin extends Plugin { } } + // Narrow vault access for the settings tab's bundled-SOP installer. + sopInstallOps(): SopInstallOps { + return { + fileExists: (p) => this.app.vault.getAbstractFileByPath(p) != null, + ensureFolder: (p) => this.ensureFolder(p), + create: async (p, content) => { await this.app.vault.create(p, content); }, + }; + } + // First successful save per mode: tell the user where it landed and that the // location is changeable — they can't design folders before seeing output. private async maybeFirstSaveNotice(mode: ClipMode, notePath: string): Promise { diff --git a/src/md.d.ts b/src/md.d.ts new file mode 100644 index 0000000..f73d61b --- /dev/null +++ b/src/md.d.ts @@ -0,0 +1,4 @@ +declare module '*.md' { + const content: string; + export default content; +} diff --git a/src/settings.ts b/src/settings.ts index eb85ac0..e93ed19 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,7 +1,8 @@ -import { App, PluginSettingTab, Setting, TextComponent } from 'obsidian'; +import { App, Notice, PluginSettingTab, Setting, TextComponent } from 'obsidian'; import type VaultAutopilotPlugin from './main'; import { PluginSettings } from './types'; import { t, setLanguage, Language } from './i18n'; +import { installBundledSops } from './bundled-sops'; export const DEFAULT_SETTINGS: PluginSettings = { language: 'en', @@ -208,6 +209,21 @@ export class VaultAutopilotSettingTab extends PluginSettingTab { } })); + new Setting(containerEl) + .setName(t('settings.installSops.name')) + .setDesc(t('settings.installSops.desc')) + .addButton(b => b + .setButtonText(t('settings.installSops.button')) + .onClick(async () => { + const { written, skipped } = await installBundledSops( + this.plugin.sopInstallOps(), this.plugin.settings.baseFolder); + new Notice(t('notice.sopsInstalled', { + count: written.length, + folder: `${this.plugin.settings.baseFolder}/SOPs`, + skipped: skipped.length, + }), 8000); + })); + const sopModes = [ ['thumbnail', t('settings.sop.thumbnail')], ['screenshot', t('settings.sop.screenshot')], diff --git a/src/sops/en/Cover Analysis SOP.md b/src/sops/en/Cover Analysis SOP.md new file mode 100644 index 0000000..7e7a9db --- /dev/null +++ b/src/sops/en/Cover Analysis SOP.md @@ -0,0 +1,44 @@ +--- +title: Cover Analysis SOP2 +type: note +permalink: obsidian-sop/02-writing-and-content/cover-analysis-sop2 +--- + +# Cover and Title Analysis Prompt + +_After reading this document, output your analysis directly using the framework below._ + +--- + +## Output Structure + +**Description** +State in one sentence what is in the cover: is there a person, what are they doing, are there props or large text, what does the background roughly look like. + +**Cover Hook** +What reaction did the first glance trigger: curiosity, disbelief, feeling seen, wanting to know the answer. Explain what caused this reaction and why this reaction would make someone click in. + +**Title Hook** (required when there is a title, skip when there is no title) +What is the first question that comes to mind right after reading the title. That question is the hook. Explain how the title creates this question and why you would want to click in to find the answer. + +**Collaboration** (required when there is a title, skip when there is no title) +When the cover and title are combined, does the reason to click become stronger, or is either one enough on its own? + +**Special Treatment** (conditional, not every cover has this) +Trigger condition: the cover has a special visual effect, noticeable font treatment, or a composition technique worth learning. A plain "face plus background plus text" does not trigger this. +When triggered, write three points: +- What this effect or technique is +- Why it was done this way +- How to replicate it + +--- + +## Tone Requirements + +Write like you are telling someone "look at this image, what do you feel after seeing it," not writing a design report. + +Do not use: +- Phrases like "a natural visual entry point," "visual hierarchy," "forms a complete reason to click" +- Long parallel sentences, AI summary tone + +Do this instead: 2 to 3 sentences per item, short, direct, conversational. diff --git a/src/sops/en/Video Hook Analysis SOP.md b/src/sops/en/Video Hook Analysis SOP.md new file mode 100644 index 0000000..39fbf8a --- /dev/null +++ b/src/sops/en/Video Hook Analysis SOP.md @@ -0,0 +1,47 @@ +--- +title: Video Hook Analysis SOP +type: note +permalink: obsidian-sop/05-aesthetic-collection/video-hook-analysis-sop +--- + +# Video Hook Analysis Prompt + +_After reading this document, output your Hook analysis directly using the framework below._ + +> The job of a hook is to make people stay, not to make them click in (that is the job of the cover and title). When analyzing, always focus on this one thing: whether through words or visuals, how does this opening keep people from leaving? + +--- + +## Input + +- Screenshot of the video's opening frames (0 to 15 seconds) +- Subtitle text (use it if available, otherwise analyze only the visuals and note that there is no subtitle) + +--- + +## Output Structure + +**Hook Type** +Choose one or two from the list below and explain why in one sentence: +Suspense / Conflict / Value Promise / Identity / Visual Impact / Counterintuitive / Story Opening + +**What Was Said** +Analyze the first sentence or first few sentences of the opening: how does this sentence grab attention right away? What sentence pattern, what promise, what number, or what contrast is used? + +**How the Visuals Support It** +Do not describe frame by frame, such as "the first frame is X, the second frame is Y." Analyze what the visuals are doing and why doing it this way makes people stay. Is the relationship between the visuals and the words one of amplification or one of supplement? + +**How to Replicate** +Give a template or steps that can be directly applied, specific down to the sentence pattern and visual treatment. Do not write something like "use a similar technique." + +**My Thoughts** +(leave blank, this is for you to fill in yourself) + +--- + +## Tone Requirements + +Write like you are describing how you felt watching this video, not writing an analysis report. + +Do not use: visual hierarchy, information density, reinforces cognition, frame by frame listing +Do this instead: short sentences, conversational, no more than 3 to 5 sentences per item diff --git a/src/sops/en/Video Keyframe Analysis SOP.md b/src/sops/en/Video Keyframe Analysis SOP.md new file mode 100644 index 0000000..84ddb92 --- /dev/null +++ b/src/sops/en/Video Keyframe Analysis SOP.md @@ -0,0 +1,61 @@ +--- +title: Video Keyframe Analysis SOP +type: note +permalink: obsidian-sop/05-aesthetic-collection/video-keyframe-analysis-sop +--- + +# Video Keyframe Analysis SOP + +> Input: multiple keyframe screenshots from a segment of the video (filename prefix `keyframe-`). +> Output: a motion analysis note with two layers of content: design understanding plus implementation conventions. +> Purpose: can be used as a reference for adaptation (understanding the framework and feel), and can also be handed directly to an Agent to implement without back and forth. + +--- + +## Output Format + +``` +# Motion Analysis - {Video Title} · Motion {N} · {start}s-{end}s + +## Keyframe Description +(Describe in frame order: what elements are in the frame, what changes + between frames. Describe objectively, do not explain the reason.) + +## Motion Logic +(The design intent behind these changes: why it moves this way, what the + rhythm is, what feeling it should give the viewer. Describe the "framework" + clearly, not specific numbers, so the framework can be reused when the + content changes.) + +## Implementation Conventions +(The following are conventions that must be followed every time you build + this type of HTML motion overlay. No need to ask again, no need to change + them:) + +- The background is always transparent, for overlaying on top of the real footage +- Adding the `?preview` URL parameter shows a dark gray background `#1a1a1a`, to make it easier to preview white elements; remove the parameter when rendering +- A fixed CONFIG block at the top of the file: + DURATION = [video duration in ms] ← controls the length of the rendered video + FPS = 30 + [other content parameters] ← specific to this motion, listed below +- File saved in `Raw/Superpower/` + +## My Thoughts +(leave blank) +``` + +--- + +## Filling Rules + +- **Keyframe Description**: only describe "what you see," write frame by frame, highlight the changes between frames +- **Motion Logic**: answer "why it was done this way," focus on the design framework, do not hardcode specific values (values are content parameters and will change) +- **Implementation Conventions**: this section is exactly the same format every time, copy it over directly, do not modify it + +--- + +## Handling Multiple Frames + +- Describe in chronological order, do not skip frames +- When the difference between frames is small (continuous animation): focus on the overall sense of motion, do not list frame by frame +- When the difference between frames is large (there are cuts or transitions): focus on analyzing the rhythm and logic of the transitions diff --git a/src/sops/zh/封面拆解学习 SOP.md b/src/sops/zh/封面拆解学习 SOP.md new file mode 100644 index 0000000..c8570cc --- /dev/null +++ b/src/sops/zh/封面拆解学习 SOP.md @@ -0,0 +1,45 @@ +--- +title: 封面拆解学习 SOP2 +type: note +permalink: obsidian-sop/02-写作与内容/封面拆解学习-sop2 +--- + +# 封面与标题分析 Prompt + +_读完这份文档后,直接按以下框架输出分析。_ + +--- + +## 输出结构 + +**描述** +一句话说清楚封面里有什么:有没有人物、在做什么、有没有道具或大字、背景大概是什么。 + +**封面钩子** +第一眼触发了什么反应——好奇、不信、被看见、想知道答案……说清楚是什么让你有这个反应,以及为什么这个反应会让你点进去。 + +**标题钩子**(有标题时必填,无标题时跳过) +读完标题之后脑子里冒出来的第一个问题是什么——那个问题就是钩子。说清楚它是怎么制造出这个问题的,以及为什么你会想点进去找答案。 + +**协作**(有标题时必填,无标题时跳过) +封面和标题加在一起,点击的理由有没有变得更强?还是说一个就够了? + +**特殊处理**(条件触发,不是每张都有) +触发条件:封面有特殊视觉效果、明显的字体处理、或值得学习的构图手法。普通的「人脸 + 背景 + 文字」不触发。 +触发时写三点: +- 这个效果或技法是什么 +- 为什么这样做 +- 怎么复刻 + +--- + +## 语气要求 + +像在跟人说"你看这张图,你看完什么感觉",不是在写设计报告。 + +不要用: +- "天然视觉入口"、"视觉层级"、"构成完整的点击理由" +- 长排比句、AI 总结腔 + +要做到:每项 2-3 句,短,直接,口语感。 + diff --git a/src/sops/zh/视频Hook分析 SOP.md b/src/sops/zh/视频Hook分析 SOP.md new file mode 100644 index 0000000..6baf135 --- /dev/null +++ b/src/sops/zh/视频Hook分析 SOP.md @@ -0,0 +1,48 @@ +--- +title: 视频Hook分析 SOP +type: note +permalink: obsidian-sop/05-审美积累/视频-hook-分析-sop +--- + +# 视频 Hook 分析 Prompt + +_读完这份文档后,直接按以下框架输出 Hook 分析。_ + +> Hook 的作用是让人留下,不是让人点进来(那是封面和标题的事)。分析时始终围绕这一件事:这段开场无论是话还是画面,怎么让人不走? + +--- + +## 输入 + +- 视频开场帧截图(0–15 秒) +- 字幕文本(有则用,没有则只分析画面并注明) + +--- + +## 输出结构 + +**Hook 类型** +从以下选一个或两个,一句话说为什么: +悬念型 / 冲突型 / 价值承诺型 / 身份认同型 / 视觉冲击型 / 反常识型 / 故事切入型 + +**他说了什么** +分析开场的第一句话或前几句话:这句话如何在第一时间抢占注意力?是什么句式、什么承诺、什么数字、还是什么反差? + +**画面怎么配合** +不要逐帧描述"第一帧是X,第二帧是Y"。分析画面在做什么事、为什么这样做能让人留下来。画面和话之间是放大关系还是补充关系? + +**如何复制** +给出可以直接套用的模板或操作步骤,具体到句式、画面处理方式。不能写"用类似手法"。 + +**我的想法** +(留空——这是你自己填的) + +--- + +## 语气要求 + +像在跟人描述你看这段视频的感受,不是在写分析报告。 + +不要用:视觉层级、信息密度、强化认知、逐帧罗列 +要做到:短句,口语感,每项 3-5 句以内 + diff --git a/src/sops/zh/视频关键帧分析 SOP.md b/src/sops/zh/视频关键帧分析 SOP.md new file mode 100644 index 0000000..d3bcf95 --- /dev/null +++ b/src/sops/zh/视频关键帧分析 SOP.md @@ -0,0 +1,59 @@ +--- +title: 视频关键帧分析 SOP +type: note +permalink: obsidian-sop/05-审美积累/视频关键帧分析-sop +--- + +# 视频关键帧分析 SOP + +> 输入:视频某段时间内的多张关键帧截图(文件名前缀 `keyframe-`)。 +> 输出:一份动效分析笔记,两层内容:设计理解 + 实现约定。 +> 目的:既能作为改编参考(理解框架和感觉),也能直接交给 Agent 实现而不需要 back and forth。 + +--- + +## 输出格式 + +``` +# 动效分析 — {视频标题} · 动效{N} · {start}s–{end}s + +## 关键帧描述 +(按帧顺序描述:画面里有什么元素,帧与帧之间发生了什么变化。 + 客观描述,不解释原因。) + +## 动效逻辑 +(这些变化背后的设计意图:为什么这样运动、节奏感是什么、 + 想给观众什么感受。写清楚"框架",而不是具体数字—— + 这样改内容时框架还能复用。) + +## 实现约定 +(以下是每次做这类 HTML 动效 overlay 都必须遵守的约定, + 不需要再问,不需要再改:) + +- 背景永远透明,用于叠加实拍视频 +- 加 `?preview` URL 参数时显示深灰背景 `#1a1a1a`,方便预览白色元素;渲染时去掉参数 +- 文件顶部固定 CONFIG 块: + DURATION = [视频时长 ms] ← 控制渲染出来的视频长度 + FPS = 30 + [其他内容参数] ← 这个动效特有,列在下面 +- 文件保存在 `Raw/Superpower/` + +## 我的想法 +(留空) +``` + +--- + +## 填写规则 + +- **关键帧描述**:只描述"看到了什么",逐帧写,帧间变化重点标出 +- **动效逻辑**:回答"为什么这样做",聚焦设计框架,不要写死具体数值(数值是内容参数,会变) +- **实现约定**:这一块每次格式完全一样,直接复制过去,不要修改 + +--- + +## 多帧处理 + +- 按时间顺序描述,不要跳帧 +- 帧间差异小(连续动画):重点写整体运动感,不要逐帧罗列 +- 帧间差异大(有剪辑切换):重点分析切换节奏和逻辑 diff --git a/tests/__mocks__/md-stub.ts b/tests/__mocks__/md-stub.ts new file mode 100644 index 0000000..7c71eb1 --- /dev/null +++ b/tests/__mocks__/md-stub.ts @@ -0,0 +1 @@ +export default '# stub SOP\nnon-empty stub content'; diff --git a/tests/bundled-sops.test.ts b/tests/bundled-sops.test.ts new file mode 100644 index 0000000..2a4aaaa --- /dev/null +++ b/tests/bundled-sops.test.ts @@ -0,0 +1,43 @@ +import { installBundledSops, BUNDLED_SOPS, BundledSop, SopInstallOps } from '../src/bundled-sops'; + +function makeOps(existing: string[] = []) { + const created: Record = {}; + const ops: SopInstallOps = { + fileExists: (p) => existing.includes(p) || p in created, + ensureFolder: jest.fn().mockResolvedValue(undefined), + create: jest.fn(async (p: string, c: string) => { created[p] = c; }), + }; + return { ops, created }; +} + +const sops: BundledSop[] = [ + { filename: 'A.md', content: 'aaa' }, + { filename: 'B.md', content: 'bbb' }, +]; + +describe('installBundledSops', () => { + test('writes every bundled file under /SOPs', async () => { + const { ops, created } = makeOps(); + const r = await installBundledSops(ops, 'Clips', sops); + expect(r.written).toEqual(['Clips/SOPs/A.md', 'Clips/SOPs/B.md']); + expect(r.skipped).toEqual([]); + expect(created['Clips/SOPs/A.md']).toBe('aaa'); + expect(ops.ensureFolder).toHaveBeenCalledWith('Clips/SOPs'); + }); + test('never overwrites an existing file', async () => { + const { ops, created } = makeOps(['Clips/SOPs/A.md']); + const r = await installBundledSops(ops, 'Clips', sops); + expect(r.written).toEqual(['Clips/SOPs/B.md']); + expect(r.skipped).toEqual(['Clips/SOPs/A.md']); + expect(created['Clips/SOPs/A.md']).toBeUndefined(); + }); + test('empty base falls back to Clips', async () => { + const { ops } = makeOps(); + const r = await installBundledSops(ops, '', sops); + expect(r.written[0]).toBe('Clips/SOPs/A.md'); + }); + test('ships six bundled SOPs, three per language', () => { + expect(BUNDLED_SOPS).toHaveLength(6); + for (const s of BUNDLED_SOPS) expect(s.content.length).toBeGreaterThan(0); + }); +});