Match smart edit transition previews and centered effects to reference
This commit is contained in:
parent
048d50c357
commit
431ca03fac
@ -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: (() => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
75
components/canvas/smart-edit/TransitionPreview.tsx
Normal file
75
components/canvas/smart-edit/TransitionPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
docs/smart-edit-subfeature-audit-2026-09-17.md
Normal file
57
docs/smart-edit-subfeature-audit-2026-09-17.md
Normal 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参数已恢复;临时新建字幕已清理;没有扣费生成或发布。
|
||||
- 字体、人声分离、字幕细节和正增益这些差异仍然存在,**不能标记“全部功能已齐/全站像素级一致”**。服务器更新继续冻结。
|
||||
@ -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),不要把此前概括性“已实现”解释为参考站所有子功能均已一致。该批继续保持本地,服务器未更新。
|
||||
|
||||
@ -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",
|
||||
};
|
||||
});
|
||||
|
||||
@ -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++;
|
||||
|
||||
13
public/video-canvas/transitions/README.md
Normal file
13
public/video-canvas/transitions/README.md
Normal 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 |
|
||||
|
||||
卡片图片只用于展示效果类别。右侧预览与成片合成使用用户选中的相邻素材,不把示例图作为用户成片素材。
|
||||
BIN
public/video-canvas/transitions/fade.webp
Normal file
BIN
public/video-canvas/transitions/fade.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
BIN
public/video-canvas/transitions/fadeblack.webp
Normal file
BIN
public/video-canvas/transitions/fadeblack.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
public/video-canvas/transitions/fadewhite.webp
Normal file
BIN
public/video-canvas/transitions/fadewhite.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@ -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,
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user