diff --git a/components/canvas/smart-edit/EditPreview.tsx b/components/canvas/smart-edit/EditPreview.tsx index e1a405e1f..41751f146 100644 --- a/components/canvas/smart-edit/EditPreview.tsx +++ b/components/canvas/smart-edit/EditPreview.tsx @@ -19,6 +19,8 @@ export function EditPreview({ onPlaying, onError, fit = false, + cover = false, + externalClock = false, selectedClipId, selectedCaptionId, onSelectClip, @@ -27,6 +29,8 @@ export function EditPreview({ onCaptionPosition, }: { fit?: boolean; + cover?: boolean; + externalClock?: boolean; selectedClipId?: string; selectedCaptionId?: string; onSelectClip?: (id: string) => void; @@ -58,12 +62,14 @@ export function EditPreview({ }, []); const timeline = planTimeline(plan), visibleTime = Math.min(time * SECOND, timeline.duration - 1), - current = timeline.clips.filter((c) => visibleTime >= c.start && visibleTime < c.end), + current = timeline.clips.filter( + (c) => visibleTime >= c.renderStart && visibleTime < c.renderEnd, + ), subs = plan.subtitles ? editableCaptions(plan).filter((s) => time * SECOND >= s.start && time * SECOND < s.end) : []; useEffect(() => { - if (!playing) return; + if (!playing || externalClock) return; let frame = 0, last = 0; const tick = (now: number) => { @@ -91,7 +97,7 @@ export function EditPreview({ cancelAnimationFrame(frame); document.removeEventListener("visibilitychange", hide); }; - }, [playing]); + }, [playing, externalClock]); useEffect(() => { for (const clip of timeline.clips) { const video = videos.current.get(clip.id); @@ -101,20 +107,28 @@ export function EditPreview({ video.pause(); continue; } - const target = clip.sourceIn / SECOND + (time - clip.start / SECOND) * clip.speed; + const target = Math.max( + clip.sourceIn / SECOND, + Math.min( + clip.sourceOut / SECOND - 1 / 30, + clip.sourceIn / SECOND + (time - clip.start / SECOND) * clip.speed, + ), + ); + const held = time * SECOND < clip.start || time * SECOND >= clip.end; if (Number.isFinite(target) && Math.abs(video.currentTime - target) > 0.1) video.currentTime = target; video.playbackRate = clip.speed; const index = timeline.clips.findIndex((c) => c.id === clip.id), next = timeline.clips[index + 1]; let gain = 1; - if (clip.overlap && time * SECOND < clip.start + clip.overlap) - gain = (time * SECOND - clip.start) / clip.overlap; - if (next?.overlap && time * SECOND > next.start) - gain = Math.min(gain, (clip.end - time * SECOND) / next.overlap); - video.volume = clip.audioDetached ? 0 : Math.min(1, Math.max(0, clip.gain * gain)); - if (playing) - void video.play().catch(() => { + if (clip.overlap && time * SECOND < clip.transitionStart + clip.overlap) + gain = (time * SECOND - clip.transitionStart) / clip.overlap; + if (next?.overlap && time * SECOND > next.transitionStart) + gain = Math.min(gain, (next.transitionStart + next.overlap - time * SECOND) / next.overlap); + video.volume = clip.audioDetached || held ? 0 : Math.min(1, Math.max(0, clip.gain * gain)); + if (playing && !held) + void video.play().catch((error) => { + if (error?.name === "AbortError") return; onPlaying(false); onError("预览播放失败,请检查素材链接或点击播放重试"); }); @@ -135,11 +149,23 @@ export function EditPreview({ fadeIn = Math.min(clip.fadeIn, length), fadeOut = Math.min(clip.fadeOut, length); const gain = - Math.min(1, fadeIn ? relative / fadeIn : 1) * - Math.min(1, fadeOut ? (length - relative) / fadeOut : 1); + Math.min( + 1, + fadeIn + ? (clip.fadeInStart || 0) + ((1 - (clip.fadeInStart || 0)) * relative) / fadeIn + : 1, + ) * + Math.min( + 1, + fadeOut + ? (clip.fadeOutEnd || 0) + + ((1 - (clip.fadeOutEnd || 0)) * (length - relative)) / fadeOut + : 1, + ); audio.volume = Math.max(0, Math.min(1, clip.gain * gain)); if (playing) - void audio.play().catch(() => { + void audio.play().catch((error) => { + if (error?.name === "AbortError") return; onPlaying(false); onError("独立音轨无法播放"); }); @@ -155,7 +181,8 @@ export function EditPreview({ if (Math.abs(audio.currentTime - target) > 0.15) audio.currentTime = target; audio.volume = Math.min(1, plan.music.gain); if (playing) - void audio.play().catch(() => { + void audio.play().catch((error) => { + if (error?.name === "AbortError") return; onPlaying(false); onError("背景音乐无法播放"); }); @@ -166,8 +193,12 @@ export function EditPreview({ const ratio = ratioWidth / ratioHeight; const height = Math.min(frameSize.height || 720, (frameSize.width || 1280) / ratio); const scale = height / 720; - const incomingClip = current.find((c) => c.overlap && visibleTime < c.start + c.overlap); - const progress = incomingClip ? (visibleTime - incomingClip.start) / incomingClip.overlap : 1; + const incomingClip = current.find( + (c) => c.overlap && visibleTime < c.transitionStart + c.overlap, + ); + const progress = incomingClip + ? (visibleTime - incomingClip.transitionStart) / incomingClip.overlap + : 1; const [gesture, setGesture] = useState<{ clipId?: string; captionId?: string; @@ -291,7 +322,7 @@ export function EditPreview({ >
{ const asset = plan.assets.find((a) => a.id === clip.assetId)!; const active = current.some((c) => c.id === clip.id), - incoming = clip.overlap && visibleTime < clip.start + clip.overlap; + incoming = clip.overlap && visibleTime < clip.transitionStart + clip.overlap; return (
-
-
- - - {transition?.duration ? transitionNames[transition.type] : "未选择转场"} - +
+
名称
+
+ {transition?.duration ? transitionNames[transition.type] : "未选择转场"}
-
- - { - if (Number.isFinite(e.target.valueAsNumber)) - setTransition( - transition?.type || "fade", - clamp(e.target.valueAsNumber, 0, 0.5), - ); - }} - /> -
-
+ 时长 + + setTransition(transition?.type || "fade", value)} /> + {transition?.duration && transitionIndex > 0 ? ( + + ) : ( +
选择片段接点和转场,预览实际效果
+ )} + {transition?.duration && + Math.abs(timeline.clips[transitionIndex].overlap / SECOND - transition.duration) > + 0.04 ? ( +

+ 相邻片段较短,实际转场时长为{" "} + {(timeline.clips[transitionIndex].overlap / SECOND).toFixed(1)} 秒 +

+ ) : null}
@@ -1287,6 +1299,8 @@ export function SmartEditEditor({ onClick={() => { setTransitionsOpen((v) => !v); setPanel(transitionsOpen ? null : "transition"); + setLoopTransition(true); + setPlaying(false); }} > @@ -1579,11 +1593,21 @@ export function SmartEditEditor({ (transitionsOpen || c.overlap > 0) && ( +
+ setText(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === "Enter") e.currentTarget.blur(); + }} + /> + s +
+ +
+ ); +} + function ValueField({ label, suffix, diff --git a/components/canvas/smart-edit/TransitionPreview.tsx b/components/canvas/smart-edit/TransitionPreview.tsx new file mode 100644 index 000000000..a31484c95 --- /dev/null +++ b/components/canvas/smart-edit/TransitionPreview.tsx @@ -0,0 +1,75 @@ +"use client"; +import { useEffect, useMemo, useState } from "react"; +import { type EditPlan, planTimeline, SECOND } from "@/lib/video-canvas/smart-edit/contract"; +import { EditPreview } from "./EditPreview"; + +/** Preview the selected edit boundary with the same timing/compositing used by the editor. */ +export function TransitionPreview({ + plan, + index, + cover = false, +}: { + plan: EditPlan; + index: number; + cover?: boolean; +}) { + const preview = useMemo( + () => ({ + ...plan, + subtitles: false, + music: undefined, + audioClips: [], + clips: plan.clips + .slice(index - 1, index + 1) + .map((c) => ({ ...c, gain: 0, audioDetached: true })), + }), + [plan, index], + ); + const timeline = useMemo(() => planTimeline(preview), [preview]); + const boundary = timeline.clips[1]; + const start = Math.max(0, boundary.transitionStart / SECOND - 0.25); + const end = Math.min( + timeline.duration / SECOND, + (boundary.transitionStart + boundary.overlap) / SECOND + 0.25, + ); + const [time, setTime] = useState(start), + [error, setError] = useState(""), + [visible, setVisible] = useState(true); + useEffect(() => { + const change = () => setVisible(!document.hidden); + document.addEventListener("visibilitychange", change); + return () => document.removeEventListener("visibilitychange", change); + }, []); + useEffect(() => { + let request = 0, + epoch = 0; + setError(""); + setTime(start); + const tick = (now: number) => { + if (document.hidden) epoch = 0; + else { + if (!epoch) epoch = now; + setTime(start + (((now - epoch) / 1000) % (end - start))); + } + request = requestAnimationFrame(tick); + }; + request = requestAnimationFrame(tick); + return () => cancelAnimationFrame(request); + }, [start, end, preview]); + return ( +
+ {}} + onError={setError} + fit + cover={cover} + /> + {error &&
{error}
} +
+ ); +} diff --git a/docs/smart-edit-subfeature-audit-2026-09-17.md b/docs/smart-edit-subfeature-audit-2026-09-17.md new file mode 100644 index 000000000..dd6d4ce86 --- /dev/null +++ b/docs/smart-edit-subfeature-audit-2026-09-17.md @@ -0,0 +1,57 @@ +# 智能剪辑子功能再核对 + +范围:2026-09-17 在用户指定的 LibTV 项目逐层展开编辑器,结合此前节点输入卡片核对记录。只报告可见界面与实际操作,不推断参考站私有实现。用户随后明确要求直接使用参考网页动态预览图。所有新增代码只在本地;继续遵守“本地完整验收后再更新服务器”。 + +## 本轮转场差异及修复 + +| 子功能 | 参考实测 | 本地处理 | +| --- | --- | --- | +| 库分类与类型 | 基础转场:淡入淡出、黑场过渡、白场过渡 | 三项保留;当前展开未见其他分类,不等同于完整特效库 | +| 卡片素材 | 三张动态WebP,人物/场景演示;75帧、3秒 | 原始文件保存到public/video-canvas/transitions,替换原素材缩略图+渐变遮罩 | +| 选中样式 | 灰色底、蓝色边框和文字 | 本地CSS已对应调整 | +| 动态效果预览 | 右侧90px高预览当前相邻视频;主画面展示选中转场 | 新增TransitionPreview,复用编辑器合成时序;小预览静音循环、关闭卸载;普通播放/拖动播放头可退出效果演示 | +| 时长输入 | 最小0.2、最大4、步长0.1秒,左右减/加按钮 | 修正旧0–0.5秒上限;输入失焦提交,避免输入小数时截断 | +| 总片长 | 把已有白场2.2改成0.2、再恢复2.2,总片长仍14秒 | 新设置使用接点居中的转场,保留剪辑总长;旧保存方案保留原重叠语义,避免自动改旧工程 | +| 时间轴转场区域 | 覆盖接点两侧的矩形区域 | 按实际生效时长展示整个轨道高度的区域 | +| 极短相邻片段 | 本轮未穷尽所有参考边界 | 本地约束防止相邻转场争用同一片段,面板提示实际生效时长;不谎报完整4秒 | +| 删除 | 右下删除,恢复片段接点 | 已保留;新转场删除不改变总片长 | +| 实际黑白场 | 黑/白覆盖从透明达到全色,再恢复下一视频 | 修正FFmpeg内置曲线与浏览器线性遮罩差异;以两个原生fade经过纯色中间帧合成,避免慢速逐像素表达式 | +| 分离音轨联动 | 参考源无音轨,未验证其音频细节 | 本地补齐居中转场的半程音量包络,分离后保持声音增益 | + +导出使用的原生滤镜依据[FFmpeg官方xfade说明](https://ffmpeg.org/ffmpeg-filters.html#xfade),效果以本地像素采样验证,未声称取得参考站私有算法。 + +## 其他入口逐项状态 + +| 入口/子项 | 核对结果 | 状态 | +| --- | --- | --- | +| 视频预览四角缩放、移动 | 参考可见四角手柄;本地已有画面变换与实际导出验证 | 已实现,见前批验收 | +| 视频参数:速度、时长 | 参考两项均可输入、上下调;本地已提供 | 入口已对齐,参考完整速度范围仍待边界核对 | +| 视频音量增益 | 参考0dB可点到+0.5dB;本地最高0dB且步进1dB | **待补齐正增益及步长**,预览放大需要音频增益节点,不能只放宽表单 | +| 视频右键:音视频分离 | 参考当前无音轨源显示禁用;本地有声源已可分离 | 已实现基础分离;参考有声源分支仍待核对 | +| 视频右键:人声分离 | 参考可见带子菜单的入口;当前源无音轨,未执行 | **待接入/验证真实人声分离**,不放置假按钮冒充成功 | +| 视频右键:删除 | 可见删除入口 | 本地已有,保留至少一个视频片段 | +| 时间轴:分割、向左裁剪、向右裁剪 | 与选中片段、播放头位置联动禁用 | 本地已有,前批真实操作验证 | +| 时间轴:缩放、吸附、折叠预览、拖动高度 | 参考工具栏可见 | 本地已有,前批浏览器验证;参考吸附默认关闭,本地默认开启,默认状态仍有差异 | +| 字幕:显示/隐藏、列表、搜索、新建 | 参考均可展开;本轮临时新建字幕检查后已删除恢复 | 本地已有 | +| 字幕:编辑文字、移动、时间范围 | 参考提供文字面板和起止手柄 | 本地已有,前批浏览器及实际烧录验证 | +| 字幕:字体 | 参考有搜索框、“全部字体”、Source Han/Noto/Roboto/Inter等长列表;本地只有一个字体选项 | **待补齐可搜索字体库和导出字体映射** | +| 字幕:字号 | 本次参考默认56,范围12–72;本地默认28、范围8–160(720基准) | **待统一显示单位和字号范围**,不能仅照抄数值导致导出大小翻倍 | +| 字幕:字间距 | 参考0–100;本地-5–30 | **待对齐范围/单位** | +| 字幕:颜色、六种预设 | 参考默认/白黑/黑白/黄黑/白粉/粉黑 | 本地六种已有,不能据此推导所有字体效果一致 | +| 字幕:描边粗细 | 参考0–20;本地0–10 | **待对齐范围/单位** | +| 字幕:描边不透明度 | 参考专门作用于描边;本地opacity作用于整行文字 | **待拆分描边透明度**,此前“透明度已支持”的记录不代表语义一致 | +| 字幕:应用到全部 | 参考有底部应用按钮 | 本地已有,字体库扩展后需覆盖字体字段 | +| 导出:本地/画布 | 参考菜单只有这两个目的地 | 本地已有;完整线上页面联动仍未发布验收 | +| 独立音轨更深功能 | 当前参考素材无音轨,不能以灰色入口断言无其他功能 | **待有声样本核对**;本地独立音轨已有裁剪、移动、分割、增益和淡入淡出 | +| 画布节点模式/参数/连接 | 参考原项目七素材、素材混剪、16:9/1080P/20s;详情见前批文档 | 已有此前核对记录,不重复宣称本轮重测所有模式 | + +## 证据与发布边界 + +- 动态图片逐文件SHA-256、帧数及周期:素材目录README和`.qa/smart-edit/transition-assets.json`。 +- TypeScript及16项智能剪辑合同回归:`transition-parity-tsc.log`、`transition-parity-helpers.log`。 +- 完整helpers回归:491项通过、1项跳过,见`transition-parity-all-helpers.log`。 +- 本地浏览器效果截图:`.qa/smart-edit/transition-reference-assets-local.jpg`;已检查动态卡片、循环预览、整段播放及4秒转场。 +- 真实合成回归:`transition-parity-ffmpeg.log`;三种效果均验证2.2秒/4秒居中转场、保持4秒总长和中点像素。 +- 高清与连续短片段:`transition-stress-native.log`。使用独立本地组件与本地FFmpeg,未把模拟的导出任务回调当作真实Next API端到端成功。 +- 参考项目的2.2秒白场和0dB参数已恢复;临时新建字幕已清理;没有扣费生成或发布。 +- 字体、人声分离、字幕细节和正增益这些差异仍然存在,**不能标记“全部功能已齐/全站像素级一致”**。服务器更新继续冻结。 diff --git a/docs/smart-edit-ui-parity-2026-09-17.md b/docs/smart-edit-ui-parity-2026-09-17.md index f80185b57..09fdd58ba 100644 --- a/docs/smart-edit-ui-parity-2026-09-17.md +++ b/docs/smart-edit-ui-parity-2026-09-17.md @@ -46,3 +46,9 @@ 本轮类型检查通过,智能剪辑合同测试15项通过;真实FFmpeg覆盖既有两种全局转场、三种逐接点转场、六种比例及新增画面/音频/字幕编辑。浏览器测试仍使用真实组件的独立本地入口,不把模拟任务回调当作线上端到端验证。转场库与完整特效库是不同范围:参考站目前展开可见的基础转场为三项,未确认的其他特效不能登记为已完成。 证据保存在本地 `.qa/smart-edit/local-extension-helpers.log`、`local-extension-tsc.log`、`local-extension-ffmpeg.log` 和 `transition-library-local.png`。这些测试产物不随源码提交。 + +## 动态转场与逐项再核对 + +后续按用户要求直接采用参考站原始动态WebP(三张、每张75帧/3秒),并补齐当前相邻素材的右侧/主画面转场预览。新增0.2–4秒、0.1秒步进及不缩短总片长的居中转场;修正黑白场中点和独立音轨音量包络。三种转场2.2秒/4秒、1080P及12片段混用均已本地真实合成验证;整段播放可退出转场循环,完成后自动停止。 + +逐层核对仍发现字体库、字幕数值/描边透明度、正值音量增益、人声分离等差异。详见 [子功能逐项清单](smart-edit-subfeature-audit-2026-09-17.md),不要把此前概括性“已实现”解释为参考站所有子功能均已一致。该批继续保持本地,服务器未更新。 diff --git a/lib/video-canvas/smart-edit/contract.ts b/lib/video-canvas/smart-edit/contract.ts index 916ff6ff5..c42e37d73 100644 --- a/lib/video-canvas/smart-edit/contract.ts +++ b/lib/video-canvas/smart-edit/contract.ts @@ -46,7 +46,9 @@ export type TimelineCaption = z.infer; export type CaptionStyle = z.infer; export const defaultCaptionStyle: CaptionStyle = captionStyleSchema.parse({}); const transitionSchema = z.object({ - duration: z.number().min(0).max(0.5), + duration: z.number().min(0).max(4), + // Centered transitions preserve the edit duration; absent keeps saved legacy overlaps. + alignment: z.enum(["overlap", "center"]).optional(), type: z.enum(["fade", "fadeblack", "fadewhite"]), }); export const assetInputSchema = z.object({ @@ -98,6 +100,8 @@ export const audioClipSchema = z gain: z.number().min(0).max(1).default(1), fadeIn: z.number().min(0).max(5).default(0), fadeOut: z.number().min(0).max(5).default(0), + fadeInStart: z.number().min(0).max(1).optional(), + fadeOutEnd: z.number().min(0).max(1).optional(), }) .refine((c) => c.sourceOut - c.sourceIn >= 200000); export type AudioClip = z.infer; @@ -158,32 +162,57 @@ export function editOutputSize(plan: Pick) { export function planTimeline(input: EditPlan) { const plan = editPlanSchema.parse(input), ids = new Set(); + const durations = plan.clips.map((c) => + Math.round((Math.round(((c.sourceOut - c.sourceIn) / c.speed / SECOND) * 30) * SECOND) / 30), + ); + const centered = plan.clips.map((c) => c.transition?.alignment === "center"); + const overlaps = plan.clips.map((c, i) => + i + ? Math.min( + Math.round((c.transition?.duration ?? plan.transition) * SECOND), + centered[i] ? durations[i] * 2 : Math.floor(durations[i] / 3), + centered[i] ? durations[i - 1] * 2 : Math.floor(durations[i - 1] / 3), + ) + : 0, + ); + // Neighboring transitions cannot consume the same frames in a short middle clip. + for (let i = 0; i < durations.length; i++) { + const incoming = overlaps[i] / (centered[i] ? 2 : 1), + outgoing = (overlaps[i + 1] || 0) / (centered[i + 1] ? 2 : 1); + if (incoming + outgoing > durations[i]) { + const factor = durations[i] / (incoming + outgoing); + overlaps[i] = Math.floor(overlaps[i] * factor); + if (i + 1 < overlaps.length) overlaps[i + 1] = Math.floor(overlaps[i + 1] * factor); + } + } + const before = overlaps.map((d, i) => + centered[i] ? Math.round((Math.floor(((d / SECOND) * 30) / 2) * SECOND) / 30) : 0, + ); + const after = overlaps.map((d, i) => (centered[i] ? d - before[i] : 0)); let cursor = 0; const clips = plan.clips.map((clip, index) => { if (ids.has(clip.id)) throw Error("片段 ID 重复"); ids.add(clip.id); const asset = plan.assets.find((a) => a.id === clip.assetId); if (!asset || clip.sourceOut > asset.duration) throw Error("片段超出原始素材范围"); - const duration = Math.round( - (Math.round(((clip.sourceOut - clip.sourceIn) / clip.speed / SECOND) * 30) * SECOND) / 30, - ); - const previous = index ? plan.clips[index - 1] : null; - const overlap = previous - ? Math.min( - Math.round((clip.transition?.duration ?? plan.transition) * SECOND), - Math.floor(duration / 3), - Math.floor((previous.sourceOut - previous.sourceIn) / previous.speed / 3), - ) - : 0; - cursor -= overlap; + const duration = durations[index], + overlap = overlaps[index]; + if (!centered[index]) cursor -= overlap; const start = cursor; cursor += duration; + const padBefore = before[index], + padAfter = after[index + 1] || 0; return { ...clip, start, end: cursor, duration, overlap, + padBefore, + padAfter, + renderStart: start - padBefore, + renderEnd: cursor + padAfter, + transitionStart: start - padBefore, transitionType: clip.transition?.type || "fade", }; }); diff --git a/lib/video-canvas/smart-edit/render.ts b/lib/video-canvas/smart-edit/render.ts index 5da9f94bb..257176175 100644 --- a/lib/video-canvas/smart-edit/render.ts +++ b/lib/video-canvas/smart-edit/render.ts @@ -44,7 +44,11 @@ export async function renderEditPlan( transformHeight = Math.max(2, Math.round((height * t.scale) / 2) * 2); const transformX = Math.round((width - transformWidth) / 2 + t.x * width), transformY = Math.round((height - transformHeight) / 2 + t.y * height); - const normalize = `setpts=(PTS-STARTPTS)/${clip.speed},scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30,format=yuv420p`; + const pad = + clip.padBefore || clip.padAfter + ? `,tpad=start_mode=clone:start_duration=${clip.padBefore / SECOND}:stop_mode=clone:stop_duration=${clip.padAfter / SECOND}` + : ""; + const normalize = `setpts=(PTS-STARTPTS)/${clip.speed},scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30,trim=duration=${clip.duration / SECOND},format=yuv420p${pad}`; const transformed = t.scale !== 1 || t.x !== 0 || t.y !== 0; await renderFfmpeg([ ...inputs, @@ -59,9 +63,9 @@ export async function renderEditPlan( ] : ["-vf", normalize]), "-af", - `asetpts=PTS-STARTPTS,atempo=${clip.speed},aresample=48000,aformat=channel_layouts=stereo,volume=${clip.audioDetached ? 0 : clip.gain},apad`, + `asetpts=PTS-STARTPTS,atempo=${clip.speed},aresample=48000,aformat=channel_layouts=stereo,volume=${clip.audioDetached ? 0 : clip.gain},atrim=duration=${clip.duration / SECOND}${clip.padBefore ? `,adelay=${Math.round(clip.padBefore / 1000)}:all=1` : ""},apad`, "-t", - String(clip.duration / SECOND), + String((clip.duration + clip.padBefore + clip.padAfter) / SECOND), "-c:v", "libx264", "-preset", @@ -98,31 +102,52 @@ export async function renderEditPlan( } else { // Normalize first: all xfade inputs have identical pixel format, timebase and dimensions. const filters: string[] = normalized.map( - (_, i) => `[${i}:v]settb=AVTB,setpts=PTS-STARTPTS[vin${i}]`, + (_, i) => `[${i}:v]format=yuv444p,settb=AVTB,setpts=PTS-STARTPTS[vin${i}]`, + ); + const audioFilters = timeline.clips.map( + (c, i) => + `[${i}:a]atrim=duration=${(c.duration + c.padBefore + c.padAfter) / SECOND},asetpts=PTS-STARTPTS[ain${i}]`, ); let video = "vin0", - audio = "0:a"; + audio = "ain0"; for (let i = 1; i < normalized.length; i++) { const clip = timeline.clips[i]; - if (clip.overlap) - filters.push( - `[${video}][vin${i}]xfade=transition=${clip.transitionType}:duration=${clip.overlap / SECOND}:offset=${clip.start / SECOND}[v${i}]`, - `[${audio}][${i}:a]acrossfade=d=${clip.overlap / SECOND}:c1=tri:c2=tri[a${i}]`, - ); - else filters.push(`[${video}][${audio}][vin${i}][${i}:a]concat=n=2:v=1:a=1[v${i}][a${i}]`); + if (clip.overlap) { + const length = clip.overlap / SECOND, + offset = clip.transitionStart / SECOND; + if (clip.transitionType === "fade") { + filters.push( + `[${video}][vin${i}]xfade=transition=fade:duration=${length}:offset=${offset}[v${i}]`, + ); + } else { + // Two native fades meet at a solid-color midpoint, matching the browser overlay. + // Avoid custom per-pixel expressions: they are prohibitively slow at 1080P. + const half = length / 2, + color = clip.transitionType === "fadeblack" ? "black" : "white"; + filters.push( + `color=c=${color}:s=${width}x${height}:r=30:d=${length},format=yuv444p,settb=AVTB[solid${i}]`, + `[vin${i}]trim=start=${half},setpts=PTS-STARTPTS[tail${i}]`, + `[${video}][solid${i}]xfade=transition=fade:duration=${half}:offset=${offset}[dip${i}]`, + `[dip${i}][tail${i}]xfade=transition=fade:duration=${half}:offset=${offset + half}[v${i}]`, + ); + } + audioFilters.push(`[${audio}][ain${i}]acrossfade=d=${length}:c1=tri:c2=tri[a${i}]`); + } else { + filters.push(`[${video}][vin${i}]concat=n=2:v=1:a=0[v${i}]`); + audioFilters.push(`[${audio}][ain${i}]concat=n=2:v=0:a=1[a${i}]`); + } video = `v${i}`; audio = `a${i}`; } + const videoFile = path.join(dir, "joined-video.mp4"), + audioFile = path.join(dir, "joined-audio.m4a"); await renderFfmpeg([ ...normalized.flatMap((file) => ["-threads", "2", "-i", file]), "-filter_complex", filters.join(";"), "-map", `[${video}]`, - "-map", - `[${audio}]`, - "-t", - String(duration), + "-an", "-c:v", "libx264", "-preset", @@ -135,8 +160,34 @@ export async function renderEditPlan( "12M", "-threads", "2", + "-pix_fmt", + "yuv420p", + videoFile, + ]); + // Keep framesync video and full-length audio fades in separate passes. Otherwise + // FFmpeg 4 can finish the audio graph before emitting buffered video at offset 0. + await renderFfmpeg([ + ...normalized.flatMap((file) => ["-i", file]), + "-filter_complex", + audioFilters.join(";"), + "-map", + `[${audio}]`, + "-vn", "-c:a", "aac", + audioFile, + ]); + await renderFfmpeg([ + "-i", + videoFile, + "-i", + audioFile, + "-map", + "0:v:0", + "-map", + "1:a:0", + "-c", + "copy", joined, ]); } @@ -177,8 +228,12 @@ export async function renderEditPlan( const length = clip.duration / SECOND, fadeIn = Math.min(clip.fadeIn, length), fadeOut = Math.min(clip.fadeOut, length); + const offsetEnvelope = + clip.fadeInStart || clip.fadeOutEnd + ? `,volume='${fadeIn ? `min(1,${clip.fadeInStart || 0}+(1-${clip.fadeInStart || 0})*t/${fadeIn})` : "1"}*${fadeOut ? `min(1,${clip.fadeOutEnd || 0}+(1-${clip.fadeOutEnd || 0})*(${length}-t)/${fadeOut})` : "1"}':eval=frame` + : `${fadeIn ? `,afade=t=in:d=${fadeIn}` : ""}${fadeOut ? `,afade=t=out:st=${length - fadeOut}:d=${fadeOut}` : ""}`; filters.push( - `[${audioInput}:a]asetpts=PTS-STARTPTS,atempo=${clip.speed},aresample=48000,aformat=channel_layouts=stereo,volume=${clip.gain},atrim=duration=${length}${fadeIn ? `,afade=t=in:d=${fadeIn}` : ""}${fadeOut ? `,afade=t=out:st=${length - fadeOut}:d=${fadeOut}` : ""},adelay=${Math.round(clip.start / 1000)}:all=1[track${index}]`, + `[${audioInput}:a]asetpts=PTS-STARTPTS,atempo=${clip.speed},aresample=48000,aformat=channel_layouts=stereo,volume=${clip.gain},atrim=duration=${length}${offsetEnvelope},adelay=${Math.round(clip.start / 1000)}:all=1[track${index}]`, ); mixLabels.push(`[track${index}]`); audioInput++; diff --git a/public/video-canvas/transitions/README.md b/public/video-canvas/transitions/README.md new file mode 100644 index 000000000..40279202a --- /dev/null +++ b/public/video-canvas/transitions/README.md @@ -0,0 +1,13 @@ +# 转场库动态预览素材 + +按用户明确要求从参考站可见的转场卡片取得原始动态 WebP,原字节保存,不截成静态图片、不生成替代图,也不使用运行时外链。每张为 75 帧、3 秒循环。 + +来源页面:https://www.liblib.tv/canvas + +| 本地文件 | 参考页面实际图片地址 | SHA-256 | +| --- | --- | --- | +| fade.webp | https://libtv-res.liblib.art/upload-images/7769a090dda6411dbed73c8da22ea01e/52744c87f20f2d682118c51360237c8ecf2e5cf0.webp | bee8d24aa6db9c4898645e4540fa647fbd720d2640ecda823be26b1c7ef59579 | +| fadeblack.webp | https://libtv-res.liblib.art/upload-images/7769a090dda6411dbed73c8da22ea01e/33b60ac24835284581b432ec3dc6770c28c4cdfd.webp | dbfdb0f9b17389a5239fecee2322d7eef64a9cb387600d7b77e9ec0a05408f66 | +| fadewhite.webp | https://libtv-res.liblib.art/upload-images/7769a090dda6411dbed73c8da22ea01e/887e2a7ed6539e258a98fa01d003ca60cb1a4c79.webp | 648d6025e39206ddbc5f63b8a6415d62b5a44f1ca9d58822d9df58928dec9ce9 | + +卡片图片只用于展示效果类别。右侧预览与成片合成使用用户选中的相邻素材,不把示例图作为用户成片素材。 diff --git a/public/video-canvas/transitions/fade.webp b/public/video-canvas/transitions/fade.webp new file mode 100644 index 000000000..d8be461b8 Binary files /dev/null and b/public/video-canvas/transitions/fade.webp differ diff --git a/public/video-canvas/transitions/fadeblack.webp b/public/video-canvas/transitions/fadeblack.webp new file mode 100644 index 000000000..1421ae6a0 Binary files /dev/null and b/public/video-canvas/transitions/fadeblack.webp differ diff --git a/public/video-canvas/transitions/fadewhite.webp b/public/video-canvas/transitions/fadewhite.webp new file mode 100644 index 000000000..0ab0f13d5 Binary files /dev/null and b/public/video-canvas/transitions/fadewhite.webp differ diff --git a/scripts/qa/video-canvas-smart-edit.cjs b/scripts/qa/video-canvas-smart-edit.cjs index f8590be6d..b0c20d6c6 100644 --- a/scripts/qa/video-canvas-smart-edit.cjs +++ b/scripts/qa/video-canvas-smart-edit.cjs @@ -211,6 +211,59 @@ const fs = require("node:fs"), assert.equal(probe.height % 2, 0); console.log("PASS: real 480P " + ratio + " " + probe.width + "×" + probe.height); } + for (const type of ["fade", "fadeblack", "fadewhite"]) { + for (const duration of [2.2, 4]) { + const out = path.join(dir, `center-${type}-${duration}`); + fs.mkdirSync(out, { recursive: true }); + const centered = { + ...plan, + resolution: "480p", + clips: [ + { ...plan.clips[0], sourceOut: 2e6 }, + { + ...plan.clips[0], + id: "incoming", + sourceIn: 2e6, + sourceOut: 4e6, + transition: { type, duration, alignment: "center" }, + }, + ], + }; + const result = await renderEditPlan(centered, files, undefined, out), + probe = await probeMedia(result.file); + assert.ok( + Math.abs(probe.duration - 4e6) < 150000, + `centered transition must keep 4 seconds: ${JSON.stringify(probe)}`, + ); + const pixel = execFileSync("ffmpeg", [ + "-v", + "error", + "-ss", + "2", + "-i", + result.file, + "-frames:v", + "1", + "-vf", + "crop=2:2:400:200", + "-f", + "rawvideo", + "-pix_fmt", + "rgb24", + "-", + ]); + if (type === "fadeblack") + assert.ok(pixel[0] < 30 && pixel[2] < 30, `black midpoint ${pixel.slice(0, 3)}`); + if (type === "fadewhite") + assert.ok( + pixel[0] > 220 && pixel[1] > 220 && pixel[2] > 220, + `white midpoint ${pixel.slice(0, 3)}`, + ); + if (type === "fade") + assert.ok(pixel[0] > 80 && pixel[2] > 80, `crossfade midpoint ${pixel.slice(0, 3)}`); + console.log(`PASS: centered ${type} ${duration}s preserves 4s edit; midpoint pixel verified`); + } + } // Verify edited geometry, detached audio, gain, fades and black tail in actual encoded media. const sine = path.join(dir, "steady-tone.mp4"); execFileSync("ffmpeg", [ @@ -347,6 +400,46 @@ const fs = require("node:fs"), assert.ok(rms(delayed.file, 1.05, 0.1) < middleRms * 0.4, "audio fade in"); assert.ok(rms(delayed.file, 2.8, 0.1) < middleRms * 0.5, "audio fade out"); assert.ok(pixelAt(delayed.file, 2.6, 200, 200)[0] < 10, "audio beyond video uses black tail"); + const withCenter = { + ...base, + clips: [ + base.clips[0], + { + ...base.clips[0], + id: "center-audio", + sourceIn: 2e6, + sourceOut: 4e6, + transition: { type: "fadeblack", duration: 2.2, alignment: "center" }, + }, + ], + }; + const centerSound = await actual("center-original-audio", withCenter); + const centerDetached = await actual("center-detached-audio", { + ...withCenter, + clips: [withCenter.clips[0], { ...withCenter.clips[1], audioDetached: true }], + audioClips: [ + { + id: "center-detached", + assetId: "tone", + start: 2e6, + sourceIn: 2e6, + sourceOut: 4e6, + speed: 1, + gain: 1, + fadeIn: 1.1, + fadeOut: 0, + fadeInStart: 0.5, + }, + ], + }); + for (const time of [1.3, 2.1, 2.6, 3.5]) { + const ratio = rms(centerDetached.file, time, 0.1) / rms(centerSound.file, time, 0.1); + assert.ok( + ratio > 0.85 && ratio < 1.15, + `detaching centered-transition audio must preserve its envelope at ${time}s: ${ratio}`, + ); + } + console.log("PASS: centered transition audio envelope preserved after detachment"); const captioned = await actual("caption-position", { ...base, subtitles: true, diff --git a/tests/routes/video-canvas-smart-edit.test.ts b/tests/routes/video-canvas-smart-edit.test.ts index 16be6bfd6..bcebbad77 100644 --- a/tests/routes/video-canvas-smart-edit.test.ts +++ b/tests/routes/video-canvas-smart-edit.test.ts @@ -309,6 +309,32 @@ test("per-boundary transitions override the global fade and stay attached on reo ]; expect(() => planTimeline(p)).toThrow("字幕 ID 重复"); }); +test("centered transitions preserve the edit length and safely bound adjacent short clips", () => { + const p = plan(); + p.clips = [0, 1, 2].map((i) => ({ + ...p.clips[0], + id: String(i), + sourceIn: 0, + sourceOut: 2 * SECOND, + transition: { type: "fadeblack" as const, duration: 4, alignment: "center" as const }, + })); + const t = planTimeline(p); + expect(t.duration).toBe(6 * SECOND); + expect(t.clips.map((c) => c.start)).toEqual([0, 2 * SECOND, 4 * SECOND]); + expect(t.clips[1].transitionStart + t.clips[1].overlap).toBeLessThanOrEqual( + t.clips[2].transitionStart, + ); + p.clips = p.clips.slice(0, 2); + const full = planTimeline(p); + expect(full.duration).toBe(4 * SECOND); + expect(full.clips[1].transitionStart).toBe(0); + expect(full.clips[1].overlap).toBe(4 * SECOND); + expect(full.clips[0].padAfter).toBe(2 * SECOND); + expect(full.clips[1].padBefore).toBe(2 * SECOND); + p.clips[1].transition!.duration = 2.2; + expect(planTimeline(p).duration).toBe(4 * SECOND); + expect(planTimeline(p).clips[1].transitionStart).toBe(0.9 * SECOND); +}); test("sequence import preserves all seven connected clips without AI selection or five-second truncation", () => { const assets = Array.from({ length: 7 }, (_, i) => ({