fix: preserve user-added headings during clip merge

Unrecognized ## headings no longer fall back to kind='motion' (which
rewrote the heading text and re-sorted the section). Unknown sections
keep their exact text and sort after all known sections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
liyachen 2026-07-18 17:44:04 -04:00
parent 0a0eeaf066
commit 7f5d9f15cc
2 changed files with 24 additions and 4 deletions

View file

@ -131,7 +131,7 @@ export function ensurePublished(content: string, published?: string | null): str
return `${fm}\npublished: ${published}${content.slice(end)}`;
}
interface ParsedSection { kind: SectionKind; startSeconds: number; text: string; }
interface ParsedSection { kind: SectionKind | null; startSeconds: number; text: string; }
function kindOf(heading: string): SectionKind | null {
// Substring match so an emoji prefix (## 🎬 内容) still resolves to its kind.
@ -185,7 +185,7 @@ function parseSections(body: string): { head: string; sections: ParsedSection[]
let curHeading = '';
const flush = () => {
if (cur) {
const kind = kindOf(curHeading) ?? 'motion';
const kind = kindOf(curHeading);
const m = curHeading.match(/(\d+)s/);
sections.push({ kind, startSeconds: m ? parseInt(m[1], 10) : 0, text: cur.join('\n') });
}
@ -243,15 +243,20 @@ export function mergeSection(existing: string, section: NewSection): { content:
}
const incoming: ParsedSection = { kind: section.kind, startSeconds: section.startSeconds, text: section.text };
// Unknown-kind sections (a user's hand-written heading) sort after every
// known kind, keeping their relative order via the stable sort below.
const rank = (k: SectionKind | null) => (k === null ? KINDS.length : KINDS.indexOf(k));
const all = [...sections, incoming].sort((a, b) =>
KINDS.indexOf(a.kind) - KINDS.indexOf(b.kind) || a.startSeconds - b.startSeconds,
rank(a.kind) - rank(b.kind) || (a.kind === null || b.kind === null ? 0 : a.startSeconds - b.startSeconds),
);
const ordered = renumber(all);
const newFrontmatter = addDimension(frontmatter, section.kind);
const dims = KINDS.filter((k) => ordered.some((s) => s.kind === k));
const newHead = syncOverview(head, newFrontmatter, dims).replace(/\s+$/, '');
const renderedSections = ordered.map((s) => stripTrailingRule(emojiHeading(s.text, s.kind))).join('\n\n---\n\n');
const renderedSections = ordered
.map((s) => stripTrailingRule(s.kind === null ? s.text : emojiHeading(s.text, s.kind)))
.join('\n\n---\n\n');
const newBody = [newHead, '', renderedSections, ''].join('\n');
return { content: `${newFrontmatter}${newBody}`, skipped: false };
}

View file

@ -116,6 +116,21 @@ test('a ## line inside a user code fence is NOT treated as a section', () => {
expect(r.content).toContain('## ✨ 动效 ② · 130s138s');
});
test('a hand-written unknown heading survives a merge untouched, placed after known sections', () => {
let c = buildAnchor(meta);
c = mergeSection(c, keyframeSection({ url: meta.videoUrl, platform: 'youtube', start: 45, end: 52, frameNames: ['k.png'] })).content;
c = c + '\n\n---\n\n## 我的分析\n\n这是我的想法\n';
const r = mergeSection(c, keyframeSection({ url: meta.videoUrl, platform: 'youtube', start: 130, end: 138, frameNames: ['k2.png'] }));
expect(r.content).toContain('## 我的分析');
expect(r.content).toContain('这是我的想法');
expect(r.content.indexOf('## 我的分析')).toBeGreaterThan(r.content.lastIndexOf('## ✨ 动效'));
const r2 = mergeSection(r.content, screenshotSection(['s.png']));
expect((r2.content.match(/## 我的分析/g) || []).length).toBe(1);
expect(r2.content).toContain('这是我的想法');
expect(r2.content.indexOf('## 我的分析')).toBeGreaterThan(r2.content.lastIndexOf('## 📸 截图'));
});
test('hook section embeds the whole video from start (no end) + frames + 字幕', () => {
const s = hookSection({ url: meta.videoUrl, platform: 'youtube', endSeconds: 15, frameNames: ['f1.png'], transcript: 'hello' });
expect(s.kind).toBe('content');