Match smart edit transition previews and centered effects to reference

This commit is contained in:
134 2026-09-17 19:05:17 +08:00
parent 048d50c357
commit 431ca03fac
14 changed files with 702 additions and 152 deletions

View File

@ -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({
>
<div
style={
fit
fit && !cover
? {
position: "absolute",
width: height * ratio,
@ -308,7 +339,7 @@ export function EditPreview({
{timeline.clips.map((clip) => {
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 (
<video
key={clip.id}
@ -317,9 +348,10 @@ export function EditPreview({
else videos.current.delete(clip.id);
}}
src={asset.url}
muted={clip.audioDetached || clip.gain === 0}
playsInline
preload="metadata"
className="absolute inset-0 h-full w-full object-contain"
className={`absolute inset-0 h-full w-full ${cover ? "object-cover" : "object-contain"}`}
style={{
visibility: active ? "visible" : "hidden",
transform: (() => {

View File

@ -415,49 +415,125 @@
}
.root :global(.se-transition-list) {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
padding: 6px 4px;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px;
}
.root :global(.se-transition-card) {
min-width: 0;
text-align: left;
padding: 0;
color: #aaa;
padding: 4px;
border-radius: 4px;
color: #999;
font-size: 12px;
line-height: 18px;
}
.root :global(.se-transition-card:hover),
.root :global(.se-transition-card.selected) {
background: #363636;
}
.root :global(.se-transition-card.selected) {
color: white;
color: #8bc7d8;
}
.root :global(.se-transition-demo) {
display: block;
height: 100px;
position: relative;
width: 100%;
aspect-ratio: 1;
max-width: 100px;
overflow: hidden;
border: 1px solid #303030;
border: 1px solid #ffffff0d;
border-radius: 8px;
background: #171717;
margin-bottom: 4px;
}
.root :global(.se-transition-demo video) {
.root :global(.se-transition-demo img) {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.root :global(.se-transition-card.selected .se-transition-demo) {
border-color: #eee;
border: 2px solid #a1d7e8;
}
.root :global(.se-transition-demo:after) {
content: "";
.root :global(.se-transition-settings .se-small-label) {
display: block;
margin-bottom: 8px;
}
.root :global(.se-transition-name) {
font-size: 12px;
line-height: 18px;
margin-bottom: 16px;
}
.root :global(.se-transition-duration) {
display: flex;
gap: 6px;
height: 32px;
margin-bottom: 16px;
font-size: 12px;
}
.root :global(.se-transition-duration button) {
width: 32px;
flex: none;
background: #353535;
border-radius: 8px;
}
.root :global(.se-transition-duration > div) {
display: flex;
align-items: center;
min-width: 0;
background: #2b2b2b;
border: 1px solid #444;
border-radius: 8px;
flex: 1;
padding: 0 8px;
}
.root :global(.se-transition-duration input) {
min-width: 0;
width: 100%;
height: 30px;
background: transparent;
border: 0;
outline: 0;
color: #eee;
appearance: textfield;
}
.root :global(.se-transition-duration input::-webkit-inner-spin-button) {
appearance: none;
}
.root :global(.se-transition-duration span) {
color: #888;
}
.root :global(.se-transition-preview) {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
}
.root :global(.se-transition-settings .se-transition-preview),
.root :global(.se-transition-placeholder) {
height: 90px;
border-radius: 8px;
border: 1px solid #ffffff1a;
}
.root :global(.se-transition-placeholder) {
display: flex;
align-items: center;
justify-content: center;
color: #888;
font-size: 12px;
padding: 12px;
text-align: center;
}
.root :global(.se-transition-preview-error) {
position: absolute;
inset: 0;
background: linear-gradient(120deg, transparent, #ddd8);
}
.root :global(.se-transition-demo.black:after) {
background: linear-gradient(120deg, transparent, #000e);
}
.root :global(.se-transition-demo.white:after) {
background: linear-gradient(120deg, transparent, #fffe);
display: flex;
align-items: center;
justify-content: center;
background: #171717;
color: #fca5a5;
padding: 8px;
font-size: 12px;
}
.root :global(.se-timeline) {
position: relative;
@ -676,6 +752,13 @@
align-items: center;
justify-content: center;
}
.root :global(.se-junction.has-transition) {
top: 0;
height: 64px;
background: #ffffff12;
border-color: #ddd;
border-radius: 8px;
}
.root :global(.se-junction svg) {
width: 14px;
height: 14px;

View File

@ -24,6 +24,7 @@ import {
Search,
} from "lucide-react";
import { EditPreview } from "./EditPreview";
import { TransitionPreview } from "./TransitionPreview";
import styles from "./SmartEditEditor.module.css";
import {
planTimeline,
@ -125,6 +126,7 @@ export function SmartEditEditor({
[captionId, setCaptionId] = useState<string>(),
[transitionsOpen, setTransitionsOpen] = useState(false),
[transitionIndex, setTransitionIndex] = useState(1),
[loopTransition, setLoopTransition] = useState(false),
[zoom, setZoom] = useState(20),
[viewportWidth, setViewportWidth] = useState(() =>
typeof window === "undefined" ? 1280 : window.innerWidth,
@ -208,7 +210,8 @@ export function SmartEditEditor({
return;
}
const id = crypto.randomUUID(),
t = timeline.clips[clipIndex];
t = timeline.clips[clipIndex],
next = timeline.clips[clipIndex + 1];
apply({
...plan,
clips: plan.clips.map((c) => (c.id === clip.id ? { ...c, audioDetached: true } : c)),
@ -222,8 +225,10 @@ export function SmartEditEditor({
speed: clip.speed,
gain: clip.gain,
start: t.start,
fadeIn: t.overlap / SECOND,
fadeOut: (timeline.clips[clipIndex + 1]?.overlap || 0) / SECOND,
fadeIn: (t.overlap - t.padBefore) / SECOND,
fadeInStart: t.overlap ? t.padBefore / t.overlap : 0,
fadeOut: next ? (next.overlap - t.padAfter) / SECOND : 0,
fadeOutEnd: next?.overlap ? t.padAfter / next.overlap : 0,
},
],
});
@ -238,6 +243,7 @@ export function SmartEditEditor({
if (caption) changeCaption(caption.id, { style: { ...captionStyle, ...patch } });
};
const seek = (seconds: number) => {
setLoopTransition(false);
setTime(clamp(seconds, 0, duration));
setPlaying(false);
};
@ -547,6 +553,7 @@ export function SmartEditEditor({
setContext(undefined);
};
const togglePlay = () => {
setLoopTransition(false);
if (time >= duration) setTime(0);
setPlaying((v) => !v);
};
@ -642,12 +649,16 @@ export function SmartEditEditor({
setPanel("caption");
};
const transition = plan.clips[transitionIndex]?.transition;
const setTransition = (type: "fade" | "fadeblack" | "fadewhite", value = 0.3) => {
const setTransition = (
type: "fade" | "fadeblack" | "fadewhite",
value = transition?.duration || 1,
) => {
if (!plan.clips[transitionIndex]) {
setError("至少需要两个片段才能添加转场");
return;
}
changeClip(transitionIndex, { transition: { type, duration: value } });
setLoopTransition(true);
changeClip(transitionIndex, { transition: { type, duration: value, alignment: "center" } });
};
const openContext = (e: React.MouseEvent, kind: "clip" | "audio" | "caption" | "timeline") => {
e.preventDefault();
@ -761,10 +772,12 @@ export function SmartEditEditor({
className={`se-transition-card ${transition?.type === type && transition.duration ? "selected" : ""}`}
onClick={() => setTransition(type as keyof typeof transitionNames)}
>
<span
className={`se-transition-demo ${type === "fadeblack" ? "black" : type === "fadewhite" ? "white" : ""}`}
>
<Thumb url={plan.assets[0].url} time={plan.clips[0].sourceIn / SECOND} />
<span className="se-transition-demo">
<img
src={`/video-canvas/transitions/${type}.webp`}
alt={name}
draggable={false}
/>
</span>
{name}
</button>
@ -782,42 +795,50 @@ export function SmartEditEditor({
}}
>
<div className="se-preview-stage">
<EditPreview
plan={plan}
time={time}
playing={playing}
onTime={setTime}
onPlaying={setPlaying}
onError={setError}
fit
selectedClipId={selected}
selectedCaptionId={captionId}
onSelectClip={(id) => {
setSelected(id);
setPanel("video");
setPlaying(false);
}}
onSelectCaption={(id) => {
setCaptionId(id);
setPanel("caption");
setSubTab("text");
setCaptionsOpen(true);
setPlaying(false);
}}
onTransform={(id, transform) =>
changeClip(
plan.clips.findIndex((c) => c.id === id),
{ transform },
)
}
onCaptionPosition={(id, position) => {
const c = captions.find((c) => c.id === id);
if (c)
changeCaption(id, {
style: { ...(c.style || defaultCaptionStyle), ...position },
});
}}
/>
{panel === "transition" &&
transition?.duration &&
transitionIndex > 0 &&
loopTransition &&
!playing ? (
<TransitionPreview plan={plan} index={transitionIndex} />
) : (
<EditPreview
plan={plan}
time={time}
playing={playing}
onTime={setTime}
onPlaying={setPlaying}
onError={setError}
fit
selectedClipId={selected}
selectedCaptionId={captionId}
onSelectClip={(id) => {
setSelected(id);
setPanel("video");
setPlaying(false);
}}
onSelectCaption={(id) => {
setCaptionId(id);
setPanel("caption");
setSubTab("text");
setCaptionsOpen(true);
setPlaying(false);
}}
onTransform={(id, transform) =>
changeClip(
plan.clips.findIndex((c) => c.id === id),
{ transform },
)
}
onCaptionPosition={(id, position) => {
const c = captions.find((c) => c.id === id);
if (c)
changeCaption(id, {
style: { ...(c.style || defaultCaptionStyle), ...position },
});
}}
/>
)}
</div>
{exportWaiting && (
<div className="se-progress" role="status">
@ -934,7 +955,7 @@ export function SmartEditEditor({
(audioClip.sourceOut - audioClip.sourceIn) / audioClip.speed / SECOND,
)}
precision={1}
onChange={(fadeIn) => changeAudio(audioClip.id, { fadeIn })}
onChange={(fadeIn) => changeAudio(audioClip.id, { fadeIn, fadeInStart: 0 })}
/>
<ValueField
label="淡出时长"
@ -946,7 +967,7 @@ export function SmartEditEditor({
(audioClip.sourceOut - audioClip.sourceIn) / audioClip.speed / SECOND,
)}
precision={1}
onChange={(fadeOut) => changeAudio(audioClip.id, { fadeOut })}
onChange={(fadeOut) => changeAudio(audioClip.id, { fadeOut, fadeOutEnd: 0 })}
/>
</div>
</aside>
@ -966,45 +987,36 @@ export function SmartEditEditor({
<X />
</Icon>
</div>
<div className="se-panel-scroll">
<div className="se-field">
<label>名称</label>
<span style={{ padding: "0 12px", fontSize: 13 }}>
{transition?.duration ? transitionNames[transition.type] : "未选择转场"}
</span>
<div className="se-panel-scroll se-transition-settings">
<div className="se-small-label">名称</div>
<div className="se-transition-name">
{transition?.duration ? transitionNames[transition.type] : "未选择转场"}
</div>
<div className="se-field">
<label htmlFor="se-transition-length">时长</label>
<input
id="se-transition-length"
aria-label="转场时长(秒)"
type="number"
min="0"
max="0.5"
step="0.05"
disabled={!transition?.duration}
value={transition?.duration || 0}
onChange={(e) => {
if (Number.isFinite(e.target.valueAsNumber))
setTransition(
transition?.type || "fade",
clamp(e.target.valueAsNumber, 0, 0.5),
);
}}
/>
</div>
<div
style={{
background: "#161616",
borderRadius: 8,
aspectRatio: "16/9",
marginTop: 20,
}}
<label className="se-small-label" htmlFor="se-transition-length">
时长
</label>
<TransitionDuration
value={transition?.duration || 0}
disabled={!transition?.duration}
onChange={(value) => setTransition(transition?.type || "fade", value)}
/>
{transition?.duration && transitionIndex > 0 ? (
<TransitionPreview plan={plan} index={transitionIndex} cover />
) : (
<div className="se-transition-placeholder">选择片段接点和转场,预览实际效果</div>
)}
{transition?.duration &&
Math.abs(timeline.clips[transitionIndex].overlap / SECOND - transition.duration) >
0.04 ? (
<p className="se-muted">
相邻片段较短,实际转场时长为{" "}
{(timeline.clips[transitionIndex].overlap / SECOND).toFixed(1)} 秒
</p>
) : null}
</div>
<div className="se-panel-foot">
<button disabled={!transition?.duration} onClick={() => setTransition("fade", 0)}>
删除转场
删除
</button>
</div>
</aside>
@ -1287,6 +1299,8 @@ export function SmartEditEditor({
onClick={() => {
setTransitionsOpen((v) => !v);
setPanel(transitionsOpen ? null : "transition");
setLoopTransition(true);
setPlaying(false);
}}
>
<TransitionIcon />
@ -1579,11 +1593,21 @@ export function SmartEditEditor({
(transitionsOpen || c.overlap > 0) && (
<button
key={c.id}
className="se-junction"
className={`se-junction ${c.overlap ? "has-transition" : ""}`}
aria-label={`片段 ${i + 1} 与 ${i + 2} 的转场`}
style={{ left: ((c.start + c.overlap / 2) / SECOND) * pps }}
style={{
left: ((c.transitionStart + c.overlap / 2) / SECOND) * pps,
width: c.overlap ? Math.max(24, (c.overlap / SECOND) * pps) : 24,
}}
title={
c.overlap
? `${transitionNames[c.transitionType]} ${(c.overlap / SECOND).toFixed(1)}s`
: "添加转场"
}
onClick={() => {
setTransitionIndex(i + 1);
setLoopTransition(true);
setPlaying(false);
setTransitionsOpen(true);
setPanel("transition");
}}
@ -1777,6 +1801,63 @@ function StyleColor({
);
}
function TransitionDuration({
value,
disabled,
onChange,
}: {
value: number;
disabled: boolean;
onChange: (value: number) => void;
}) {
const [text, setText] = useState(value.toFixed(1));
useEffect(() => setText(value.toFixed(1)), [value]);
const commit = () => {
const n = Number(text);
if (text.trim() && Number.isFinite(n)) {
const v = clamp(Math.round(n * 10) / 10, 0.2, 4);
setText(v.toFixed(1));
onChange(v);
} else setText(value.toFixed(1));
};
return (
<div className="se-transition-duration">
<button
aria-label="减少转场时长"
disabled={disabled || value <= 0.2}
onClick={() => onChange(Math.max(0.2, Math.round((value - 0.1) * 10) / 10))}
>
−
</button>
<div>
<input
id="se-transition-length"
aria-label="转场时长(秒)"
type="number"
min={0.2}
max={4}
step={0.1}
disabled={disabled}
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
}}
/>
<span>s</span>
</div>
<button
aria-label="增加转场时长"
disabled={disabled || value >= 4}
onClick={() => onChange(Math.min(4, Math.round((value + 0.1) * 10) / 10))}
>
+
</button>
</div>
);
}
function ValueField({
label,
suffix,

View File

@ -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 (
<div className="se-transition-preview" aria-label="当前片段转场动态预览">
<EditPreview
plan={preview}
time={time}
playing={visible}
externalClock
onTime={setTime}
onPlaying={() => {}}
onError={setError}
fit
cover={cover}
/>
{error && <div className="se-transition-preview-error">{error}</div>}
</div>
);
}

View File

@ -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参数已恢复;临时新建字幕已清理;没有扣费生成或发布。
- 字体、人声分离、字幕细节和正增益这些差异仍然存在,**不能标记“全部功能已齐/全站像素级一致”**。服务器更新继续冻结。

View File

@ -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),不要把此前概括性“已实现”解释为参考站所有子功能均已一致。该批继续保持本地,服务器未更新。

View File

@ -46,7 +46,9 @@ export type TimelineCaption = z.infer<typeof timelineCaptionSchema>;
export type CaptionStyle = z.infer<typeof captionStyleSchema>;
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<typeof audioClipSchema>;
@ -158,32 +162,57 @@ export function editOutputSize(plan: Pick<EditPlan, "ratio" | "resolution">) {
export function planTimeline(input: EditPlan) {
const plan = editPlanSchema.parse(input),
ids = new Set<string>();
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",
};
});

View File

@ -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++;

View File

@ -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 |
卡片图片只用于展示效果类别。右侧预览与成片合成使用用户选中的相邻素材,不把示例图作为用户成片素材。

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -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,

View File

@ -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) => ({