Content
Hook
Hooks
What wind-comic runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
Install
$ npx -y skills add ChrisChen667788/wind-comic --agent claude-codeShips with wind-comic. Installing the plugin gets these hooks.
Where it lives
- hooks/use-audio-waveform.tsGitHub
Read the script
'use client'; /** * v3.1.3 P1 — Real audio waveform via Web Audio API. * * 用法: * const waveform = useAudioWaveform(audioUrl); * // waveform = null (loading / no url / 失败) 或 Float32Array(N) — N 个 [0..1] 振幅采样 * * 缓存: * - 同一 URL 永远只 decode 1 次 (per session) * - 模块内 Map<url, Promise<Float32Array>>, 多个调用并发等同一 promise * * 解码: * - fetch(url) → arrayBuffer → AudioContext.decodeAudioData * - 降采样到 N=600 个点 (足够 timeline 段宽渲染) * - 取每个 bucket 的 max abs(sample) 作振幅 → 跟波形图视觉对齐 * * 注意: * - decode 是 CPU 任务, 大 mp3 (>5MB) 可能阻塞主线程 100ms+ * - 失败 (404 / CORS / unsupported codec) → 缓存空 array, 不重试 * - SSR safe — typeof window === 'undefined' 时返 null */ import { useEffect, useState } from 'react'; const SAMPLES_PER_TRACK = 600; export interface DecodedAudio { /** 归一化波形采样 0..1, 长度 = SAMPLES_PER_TRACK (600) */ waveform: Float32Array; /** mp3 总时长 (秒) — 切片用 */ durationSec: number; } /** url → DecodedAudio (resolved) | Promise<DecodedAudio> (in-flight) */ const cache = new Map<string, DecodedAudio | Promise<DecodedAudio>>(); const EMPTY: DecodedAudio = { waveform: new Float32Array(0), durationSec: 0 }; async function decodeOnce(url: string): Promise<DecodedAudio> { if (typeof window === 'undefined') return EMPTY; const AudioCtx = (window.AudioContext || (window as any).webkitAudioContext) as typeof AudioContext; if (!AudioCtx) return EMPTY; let response: Response; try { response = await fetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); } catch (e) { console.warn('[useAudioWaveform] fetch failed:', e); return EMPTY; } let buf: ArrayBuffer; try { buf = await response.arrayBuffer(); } catch (e) { console.warn('[useAudioWaveform] arrayBuffer failed:', e); return EMPTY; } const ctx = new AudioCtx(); let audioBuffer: AudioBuffer; try { audioBuffer = await ctx.decodeAudioData(buf); } catch (e) { console.warn('[useAudioWaveform] decodeAudioData failed:', e); try { void ctx.close(); } catch { /* ignore */ } return EMPTY; } // 取第 0 channel mono. 多 channel mp3 我们简化只用 left/mono. const channelData = audioBuffer.getChannelData(0); const total = channelData.length; const bucketSize = Math.max(1, Math.floor(total / SAMPLES_PER_TRACK)); const out = new Float32Array(SAMPLES_PER_TRACK); for (let i = 0; i < SAMPLES_PER_TRACK; i++) { const start = i * bucketSize; const end = Math.min(total, start + bucketSize); let peak = 0; for (let j = start; j < end; j++) { const v = Math.abs(channelData[j]); if (v > peak) peak = v; } out[i] = peak; } const durationSec = audioBuffer.duration; try { void ctx.close(); } catch { /* ignore */ } return { waveform: out, durationSec }; } function getOrFetch(url: string): Promise<DecodedAudio> { const cached = cache.get(url); if (cached instanceof Promise) return cached; if (cached) return Promise.resolve(cached); const p = decodeOnce(url).then((d) => { cache.set(url, d); return d; }); cache.set(url, p); return p; } /** * React hook — 给一个 audio URL 返 decoded 波形 + 时长. * URL 为空或正在加载时返 null. SSR 阶段始终返 null. */ export function useAudioWaveform(audioUrl: string | undefined | null): DecodedAudio | null { const [decoded, setDecoded] = useState<DecodedAudio | null>(() => { if (!audioUrl) return null; const cached = cache.get(audioUrl); if (cached && !(cached instanceof Promise)) return cached; return null; }); useEffect(() => { if (!audioUrl) { setDecoded(null); return; } let cancelled = false; getOrFetch(audioUrl).then((d) => { if (!cancelled) setDecoded(d.waveform.length > 0 ? d : null); }); return () => { cancelled = true; }; }, [audioUrl]); return decoded; } /** * 切片波形 — 整段 mp3 是 fullDurationSec, segment 从 startSec 持续 durationSec. * 返回切片范围内的 N 个采样 (从 full waveform 里按比例取). * * fullDurationSec 不传时按 segment 占据的归一化范围估算 (0-1). * 实际使用时建议传 full mp3 时长 (从 AudioBuffer.duration 取). */ export function sliceWaveform( decoded: DecodedAudio, startSec: number, durationSec: number, outputBars = 48, ): Float32Array { const full = decoded.waveform; const fullDurationSec = decoded.durationSec; if (full.length === 0 || fullDurationSec <= 0 || durationSec <= 0) { return new Float32Array(0); } const totalSamples = full.length; const startIdx = Math.max(0, Math.floor((startSec / fullDurationSec) * totalSamples)); const endIdx = Math.min(totalSamples, Math.ceil(((startSec + durationSec) / fullDurationSec) * totalSamples)); const segmentSize = Math.max(1, endIdx - startIdx); const bucketSize = Math.max(1, Math.floor(segmentSize / outputBars)); const out = new Float32Array(outputBars); for (let i = 0; i < outputBars; i++) { const a = startIdx + i * bucketSize; const b = Math.min(endIdx, a + bucketSize); let peak = 0; for (let j = a; j < b; j++) { const v = full[j]; if (v > peak) peak = v; } out[i] = peak; } return out; } - hooks/use-focus-trap.tsGitHub
Read the script
'use client'; import { useEffect, useRef } from 'react'; const FOCUSABLE = 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])'; /** * v10.3.5 a11y: 模态焦点管理 hook —— 一处实现,模态复用。 * - Escape 关闭:挂在 document(capture)上,不依赖焦点恰好落在模态内 * - 焦点陷阱:Tab / Shift+Tab 在容器内循环,不会跑到下方页面 * - 打开即把焦点移入容器(首个可聚焦元素,否则容器本身) * - 关闭/卸载时把焦点归还打开前的触发元素 * * 用法:const ref = useFocusTrap<HTMLDivElement>(open, onClose); * <div ref={ref} role="dialog" aria-modal="true" tabIndex={-1}>…</div> * * onClose 存进 ref,故即使父级每次传新箭头函数,effect 也只在 active 变化时重挂(焦点不会乱跳)。 */ export function useFocusTrap<T extends HTMLElement>(active: boolean, onClose?: () => void) { const ref = useRef<T>(null); const onCloseRef = useRef(onClose); onCloseRef.current = onClose; useEffect(() => { if (!active) return; const node = ref.current; if (!node) return; const prevFocused = document.activeElement as HTMLElement | null; // 不用 offsetWidth 判可见(jsdom 全 0、fixed 定位 offsetParent 为 null),改按属性过滤 const visibleFocusables = () => Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE)).filter( (el) => !el.hasAttribute('hidden') && el.getAttribute('aria-hidden') !== 'true', ); // 初始焦点移入 const initial = visibleFocusables()[0]; (initial ?? node).focus?.(); const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onCloseRef.current?.(); return; } if (e.key !== 'Tab') return; const items = visibleFocusables(); if (items.length === 0) { e.preventDefault(); node.focus(); return; } const first = items[0]; const last = items[items.length - 1]; const activeEl = document.activeElement; if (e.shiftKey && (activeEl === first || activeEl === node)) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && activeEl === last) { e.preventDefault(); first.focus(); } }; document.addEventListener('keydown', onKey, true); return () => { document.removeEventListener('keydown', onKey, true); // 焦点归还触发器(若它还在文档里) if (prevFocused && document.contains(prevFocused)) prevFocused.focus?.(); }; }, [active]); return ref; } - hooks/use-locale.tsGitHub
Read the script
'use client'; /** * v5.0 — 当前 locale hook. * * 优先级: localStorage('qfmj-locale') > 浏览器 navigator.language > 'zh-CN'. * setLocale 持久化 + 广播 (同 tab 多组件同步) + 设 <html lang>. */ import { useCallback, useEffect, useState } from 'react'; import { normalizeLocale, getTranslations, type Locale } from '@/lib/i18n'; const KEY = 'qfmj-locale'; const EVT = 'qfmj-locale-change'; function readInitial(): Locale { if (typeof window === 'undefined') return 'zh-CN'; const saved = localStorage.getItem(KEY); if (saved) return normalizeLocale(saved); return normalizeLocale(navigator.language); } export function useLocale() { const [locale, setLocaleState] = useState<Locale>('zh-CN'); useEffect(() => { setLocaleState(readInitial()); const onChange = (e: Event) => { const next = (e as CustomEvent<Locale>).detail; if (next) setLocaleState(next); }; window.addEventListener(EVT, onChange); return () => window.removeEventListener(EVT, onChange); }, []); const setLocale = useCallback((next: Locale) => { const norm = normalizeLocale(next); if (typeof window !== 'undefined') { localStorage.setItem(KEY, norm); document.documentElement.lang = norm; window.dispatchEvent(new CustomEvent(EVT, { detail: norm })); } setLocaleState(norm); }, []); return { locale, setLocale, t: getTranslations(locale) }; } - hooks/use-multi-audio-waveform.tsGitHub
Read the script
'use client'; /** * v3.2 P3.2 — Multi-mp3 segment BGM waveform. * * v3.1.3 P1 的 useAudioWaveform 只能解一个 mp3. 多幕 BGM 每幕一个 mp3, 需要把 * 几个 mp3 在 timeline 上拼成一条逻辑波形 — 这文件干这事. * * 用法: * const segments = [ * { id: 'act1', audioUrl: '/api/bgm/1.mp3', startSec: 0, durationSec: 30 }, * { id: 'act2', audioUrl: '/api/bgm/2.mp3', startSec: 30, durationSec: 35 }, * ]; * const decoded = useMultiAudioWaveform(segments); * // decoded.bars = Float32Array(N), 0..1 振幅 * // 切片: sliceMultiWaveform(decoded, segments, sliceStartSec, sliceDurationSec) * * 复用 useAudioWaveform 的 single-mp3 cache — 同一 URL 永远只 decode 1 次. */ import { useEffect, useState } from 'react'; import { useAudioWaveform, sliceWaveform, type DecodedAudio } from './use-audio-waveform'; export interface MultiSegment { id: string; audioUrl: string | undefined | null; /** 在 timeline 上的全局起始秒 */ startSec: number; /** 在 timeline 上的全局持续秒 */ durationSec: number; } export interface DecodedMultiAudio { /** 每个 segment 的 decoded 波形 (undecoded / 失败 → null) */ segments: Array<{ id: string; decoded: DecodedAudio | null }>; } /** * 解码 N 个 mp3 segment 的波形. 每个 audioUrl 走 useAudioWaveform 的 cache, * 同一 URL 跨多段只 decode 1 次. * * 注意: * - 等所有段 decode 完才返回 final, 中间状态 segments[].decoded 是 null * - 任何 segment 失败不阻塞其他, 失败的 decoded 永远 null * - segment 长度 0 时不调用 hook (避免无谓 fetch) */ export function useMultiAudioWaveform(segments: MultiSegment[]): DecodedMultiAudio { // 每段独立 hook 调用 — React 要求 hook 数量稳定, 所以这里我们假设 segments 长度不变. // 实际业务里 BGM 段数 = act 数, 一旦剧本生成完就稳定, 不会动态变. const decoded = segments.map((s) => useAudioWaveform(s.audioUrl)); const [snap, setSnap] = useState<DecodedMultiAudio>({ segments: segments.map((s) => ({ id: s.id, decoded: null })), }); useEffect(() => { setSnap({ segments: segments.map((s, i) => ({ id: s.id, decoded: decoded[i] })), }); // we treat segments array reference as stable in callers; deps key on its identity + decoded contents // eslint-disable-next-line react-hooks/exhaustive-deps }, [segments, ...decoded]); return snap; } /** * 从多段 decoded 里切出 `[startSec, startSec + durationSec)` 范围的波形. * * 算法: * 1. 找出与切片范围相交的所有 segments * 2. 对每个相交 segment, 用 sliceWaveform 切出该段贡献的局部波形 * 3. 按 segment 在时间轴上的位置, 把局部波形拼到 output Float32Array * 4. 跨段 gap 区域 (没 segment 覆盖) 填 0 * * 返回 Float32Array(outputBars), 与单段 sliceWaveform 接口对齐. */ export function sliceMultiWaveform( decoded: DecodedMultiAudio, segments: MultiSegment[], startSec: number, durationSec: number, outputBars = 48, ): Float32Array { const out = new Float32Array(outputBars); if (durationSec <= 0 || outputBars <= 0) return out; const endSec = startSec + durationSec; const barWidth = durationSec / outputBars; // 索引 segments[i].decoded 通过 id 匹配, 而不是 i —— React 强制 hook 顺序稳定但 // 调用方可能 reorder segments. id 匹配最稳. const decodedById = new Map(decoded.segments.map((s) => [s.id, s.decoded])); for (const seg of segments) { const segEnd = seg.startSec + seg.durationSec; const overlapStart = Math.max(startSec, seg.startSec); const overlapEnd = Math.min(endSec, segEnd); if (overlapEnd <= overlapStart) continue; // no overlap const d = decodedById.get(seg.id); if (!d || d.waveform.length === 0) continue; // segment-local 时间 (相对 seg.startSec) const localStart = overlapStart - seg.startSec; const localDur = overlapEnd - overlapStart; // 这块 overlap 在 output 里占多少 bars const outStartBar = Math.max(0, Math.floor((overlapStart - startSec) / barWidth)); const outEndBar = Math.min(outputBars, Math.ceil((overlapEnd - startSec) / barWidth)); const segBars = Math.max(1, outEndBar - outStartBar); const local = sliceWaveform(d, localStart, localDur, segBars); for (let i = 0; i < segBars; i++) { const outIdx = outStartBar + i; if (outIdx >= 0 && outIdx < outputBars) { // 重叠区 (理论上 BGM segment 不该重叠, 但 robust 处理) 取 max out[outIdx] = Math.max(out[outIdx], local[i] || 0); } } } return out; } - hooks/use-segment-locks.tsGitHub
Read the script
'use client'; /** * v3.1.3 P4 — Y.Map 段编辑锁 (timeline 多人协作时防冲突). * * 设计: * - 共享 Y.Map<segmentKey, LockEntry> 在每个 project doc 里 ("segmentLocks" map) * - LockEntry = { userId, userName, color, lockedAt (epoch ms) } * - 拖动开始时 client 尝试 acquire — 若已有他人锁 → 拒绝, UI 给提示 * - mouseup 释放; 网络掉/未释放 → 30s 后服务端无关心, 其他 client 视为 stale 自动忽略 * * 为什么不用 awareness: * awareness 是"会话级 ephemeral", 任一 client 关 tab 立即清掉 — 但若拖动到一半 * 网络抖, 锁会被瞬时丢, 出现"我以为锁着实际没锁"的竞态. * Y.Map 是 Yjs CRDT 持久化数据, 即便客户端断网 30s 重连, 锁状态也保留. * * 锁的 stale 处理: * tryAcquire 时检查现有 entry 的 lockedAt, 距 now > STALE_AFTER_MS 视为过期可抢. * 保守 30s — 比 awareness 30s timeout 一致. */ import { useEffect, useState, useCallback } from 'react'; import * as Y from 'yjs'; import { useYjs } from '@/hooks/use-yjs'; export interface LockEntry { userId: string; userName: string; color: string; lockedAt: number; } export const STALE_AFTER_MS = 30_000; export interface SegmentLocksApi { /** 当前已知锁 map */ locks: Record<string, LockEntry>; /** 试图获取一个段的锁; 返回 true = 拿到, false = 被他人锁住 */ tryAcquire: (segmentKey: string) => boolean; /** 主动释放一个锁 (例如 mouseup) */ release: (segmentKey: string) => void; /** 当前用户的 locks (调用方 cleanup 用) */ myLocks: string[]; } const NULL_API: SegmentLocksApi = { locks: {}, tryAcquire: () => true, // 未连 yjs 时, 退化为单人模式, 永远允许 release: () => { /* no-op */ }, myLocks: [], }; function isStale(entry: LockEntry, now: number): boolean { return now - entry.lockedAt > STALE_AFTER_MS; } export function useSegmentLocks( projectId: string | null, currentUser: { id: string; name: string; color: string } | null, ): SegmentLocksApi { const yjs = useYjs(projectId && currentUser ? `project-${projectId}` : null); const [locks, setLocks] = useState<Record<string, LockEntry>>({}); // 订阅 Y.Map 变化 → 同步到 React state useEffect(() => { if (!yjs) return; const map = yjs.doc.getMap<LockEntry>('segmentLocks'); const sync = () => { const out: Record<string, LockEntry> = {}; const now = Date.now(); map.forEach((v, k) => { if (!v || typeof v !== 'object') return; // 过期的不放到本地 state — 避免 UI 显示"假锁" if (isStale(v as LockEntry, now)) return; out[k] = v as LockEntry; }); setLocks(out); }; map.observe(sync); sync(); return () => map.unobserve(sync); }, [yjs]); // tryAcquire: 检查 → 写入. CRDT 上 Y.Map.set 是原子的, 写完 observe 会立刻在自己端 fire. // 两人同时 acquire 同一段, Yjs 会按 clientId 决定胜者; 输的一方 observe 会看到 winner 的值, // 这里通过 setTimeout 0 二次确认是不是自己 — 不是就回滚 (rare path, 但保 correctness). const tryAcquire = useCallback((segmentKey: string): boolean => { if (!yjs || !currentUser) return true; // 未连接 → 单人模式允许 const map = yjs.doc.getMap<LockEntry>('segmentLocks'); const existing = map.get(segmentKey); const now = Date.now(); if (existing && existing.userId !== currentUser.id && !isStale(existing, now)) { return false; // 被别人锁 } // 自己 / stale 锁 / 未锁 → 写入 const entry: LockEntry = { userId: currentUser.id, userName: currentUser.name, color: currentUser.color, lockedAt: now, }; yjs.doc.transact(() => map.set(segmentKey, entry)); return true; }, [yjs, currentUser]); const release = useCallback((segmentKey: string): void => { if (!yjs || !currentUser) return; const map = yjs.doc.getMap<LockEntry>('segmentLocks'); const cur = map.get(segmentKey); if (cur && cur.userId === currentUser.id) { yjs.doc.transact(() => map.delete(segmentKey)); } }, [yjs, currentUser]); // tab close / 组件卸载 → 主动释放本用户所有锁 (Yjs 不会 onClose 帮我们清) useEffect(() => { if (!yjs || !currentUser) return; const cleanup = () => { const map = yjs.doc.getMap<LockEntry>('segmentLocks'); yjs.doc.transact(() => { map.forEach((v, k) => { if ((v as LockEntry)?.userId === currentUser.id) map.delete(k); }); }); }; window.addEventListener('beforeunload', cleanup); return () => { window.removeEventListener('beforeunload', cleanup); cleanup(); }; }, [yjs, currentUser]); if (!yjs || !currentUser) return NULL_API; const myLocks = Object.entries(locks) .filter(([, v]) => v.userId === currentUser.id) .map(([k]) => k); return { locks, tryAcquire, release, myLocks }; } - hooks/use-yjs.tsGitHub
Read the script
'use client'; /** * v3.0 P0.2 — useYjs hook: 连接 ws://host:1234/<docName>, 暴露 Y.Doc + awareness. * * 用法: * const { doc, awareness, status } = useYjs('project-abc123'); * const arr = doc.getArray<{...}>('comments'); * useEffect(() => { * const onChange = () => setComments(arr.toArray()); * arr.observe(onChange); * return () => arr.unobserve(onChange); * }, [arr]); * * Awareness presence: * awareness.setLocalStateField('user', { id, name, avatarUrl, color }); * awareness.on('change', () => setPresence([...awareness.getStates().values()])); * * 容错: * - WS 没启 / 网络断 → status='disconnected', 但 doc 仍可用 (本地 mutation 仍生效, 重连后同步) * - 同一 docName 多次 mount 共享同一 doc 实例 (避免重复连接) */ import { useEffect, useMemo, useState } from 'react'; import * as Y from 'yjs'; import { WebsocketProvider } from 'y-websocket'; interface YjsRegistryEntry { doc: Y.Doc; provider: WebsocketProvider; refCount: number; } const registry = new Map<string, YjsRegistryEntry>(); function defaultWsUrl(): string { if (typeof window === 'undefined') return 'ws://localhost:1234'; // 浏览器环境: 默认连同 host 但端口换 1234. 生产环境用 wss + 反代. const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'; const wsHost = process.env.NEXT_PUBLIC_YJS_WS_URL || `${proto}://${window.location.hostname}:1234`; return wsHost; } export type YjsStatus = 'connecting' | 'connected' | 'disconnected'; export interface UseYjsResult { doc: Y.Doc; provider: WebsocketProvider; status: YjsStatus; } export function useYjs(docName: string | null | undefined): UseYjsResult | null { const [status, setStatus] = useState<YjsStatus>('connecting'); // v4.0.1 fix: 把 doc/provider 放进 state (而非 ref), 让 return 值能被 useMemo 稳定化. // 之前每次 render 都返回新对象 {doc,provider,status} → 消费方 [yjs] deps 的 effect // 每帧重跑 → setLocalStateField → awareness change → setState → 死循环 // (Maximum update depth exceeded). 现在只在 entry/status 真变时才换引用. const [entry, setEntry] = useState<{ doc: Y.Doc; provider: WebsocketProvider } | null>(null); useEffect(() => { if (!docName) { setEntry(null); return; } // 进入注册表 let reg = registry.get(docName); if (!reg) { const doc = new Y.Doc(); const provider = new WebsocketProvider(defaultWsUrl(), docName, doc, { connect: true, }); reg = { doc, provider, refCount: 0 }; registry.set(docName, reg); } reg.refCount++; setEntry({ doc: reg.doc, provider: reg.provider }); const provider = reg.provider; const updateStatus = () => { // y-websocket 3.x 的 provider 有 'status' 事件 + ws.readyState if (provider.wsconnected) setStatus('connected'); else if (provider.wsconnecting) setStatus('connecting'); else setStatus('disconnected'); }; updateStatus(); const onStatus = () => updateStatus(); provider.on('status', onStatus); provider.on('connection-close', onStatus); provider.on('connection-error', onStatus); return () => { provider.off('status', onStatus); provider.off('connection-close', onStatus); provider.off('connection-error', onStatus); setEntry(null); const e = registry.get(docName); if (!e) return; e.refCount--; if (e.refCount <= 0) { // 没人用了, 关连接 try { e.provider.destroy(); } catch { /* ignore */ } registry.delete(docName); } }; }, [docName]); // 稳定引用: 只有 entry (docName 变) 或 status 变时才换对象, 避免 render-loop return useMemo<UseYjsResult | null>(() => { if (!entry) return null; return { doc: entry.doc, provider: entry.provider, status }; }, [entry, status]); } - hooks/useAutoSave.tsGitHub
- hooks/useBreakpoint.tsGitHub
- hooks/useLazyImage.tsGitHub
- hooks/useProjects.tsGitHub
- hooks/useSettings.tsGitHub
All 11 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Ships withwind-comic
Multi-agent AI pipeline that turns one line of text into a finished short-form drama: script, cinematic storyboards, character-consistent video. Provider-agnostic (OpenAI/Claude, MJ, Minimax, Veo/Sora, fal, ComfyUI). MIT.
Get the whole plugin
Stats
446
Stars
43
Forks
Active
Maintenance
TypeScript
Language
MIT
License
25m ago
Last commit
3mo ago
Created
Repo: ChrisChen667788/wind-comic

