Add timeline junction transitions (#15)

* Improve editor timeline and media UX

* Add timeline junction transitions

---------

Co-authored-by: haixin.yang <haixin.yang@weimob.com>
This commit is contained in:
Martin Delophy
2026-07-17 14:42:02 +08:00
committed by GitHub
co-authored by haixin.yang
parent 00c454dbb7
commit 51fecbbfc9
9 changed files with 207 additions and 46 deletions
+3 -1
View File
@@ -199,7 +199,7 @@ export function App() {
captionTargetDuration, captionTimeline, currentCaption, currentCaptionSegment,
currentSegmentIndex, currentStickerSegment, currentStickerSegmentIndex,
currentVisualRange, currentVisualSegment, currentVisualSegmentIndex, estimatedDuration,
focusedSegmentIndex, getStickerDragAsset, peaks, previewSticker, previewStickers,
focusedSegmentIndex, getStickerDragAsset, peaks, previewSticker, previewStickers, previewTransition,
previewVisionBaseAnalysis, previewVisionKey, previewVisionRecord, previewVisualLocalTime,
previewVisualRange, previewVisualSegment, previewVisualSegmentIndex,
previewVisualSourceTime, previewVisualSrc, previewVisualType, ratio, segments,
@@ -723,6 +723,7 @@ export function App() {
previewVisualRenderSrc={previewVisualRenderSrc}
previewVisionMaskUrl={previewVisionMaskUrl}
previewVisualType={previewVisualType}
previewTransition={previewTransition}
visualEffects={visualAnimationPreview?.segmentId && visualAnimationPreview.segmentId === previewVisualSegment?.id
? { ...previewVisualSegment, animation: visualAnimationPreview.animation }
: previewVisualSegment}
@@ -908,6 +909,7 @@ export function App() {
setSelectedStickerSegmentId={setSelectedStickerSegmentId}
imageSrc={imageSrc}
displayedVisualSegments={displayedVisualSegments}
setVisualSegments={setVisualSegments}
renderedVisualTimeline={renderedVisualTimeline}
visualType={visualType}
currentVisualSegment={currentVisualSegment}
+15 -1
View File
@@ -5,6 +5,7 @@ import {
FrameCorners,
Pause,
Play,
Resize,
SkipBack,
SkipForward,
} from "@phosphor-icons/react";
@@ -25,6 +26,7 @@ export function PreviewStage({
previewVisualRenderSrc,
previewVisionMaskUrl = "",
previewVisualType,
previewTransition = null,
previewRatio,
previewFrameStyle,
previewFrameSize,
@@ -275,6 +277,16 @@ export function PreviewStage({
/> : null}
</div>
) : null}
{previewTransition?.next?.src && trackVisibility.image ? (
<div className={`preview-transition-layer type-${previewTransition.id}`} style={{ "--transition-progress": previewTransition.progress }}>
{previewTransition.next.type === "video" ? (
<video src={previewTransition.next.src} muted playsInline autoPlay preload="auto" style={{ objectFit: activeObjectFit, objectPosition: activeObjectPosition }} />
) : (
<img src={previewTransition.next.src} alt="" style={{ objectFit: activeObjectFit, objectPosition: activeObjectPosition }} />
)}
{previewTransition.id === "flash" ? <i /> : null}
</div>
) : null}
{showVisionOverlays
? visionOverlayBoxes.map((detection, index) => (
<div
@@ -334,7 +346,9 @@ export function PreviewStage({
<img className="sticker-overlay-image" src={sticker.src} alt="" draggable={false} />
{isEditable ? <>
<button className="sticker-rotate-handle" type="button" aria-label={t("visualRotation", "旋转")} onPointerDown={(event) => startStickerTransform(event, "rotate", sticker)} />
<button className="sticker-scale-handle" type="button" aria-label={t("visualScale", "缩放")} onPointerDown={(event) => startStickerTransform(event, "scale", sticker)} />
<button className="sticker-scale-handle" type="button" aria-label={t("visualScale", "缩放")} onPointerDown={(event) => startStickerTransform(event, "scale", sticker)}>
<Resize size={12} weight="bold" aria-hidden="true" />
</button>
</> : null}
</div>
) : sticker.text ? (
+50 -1
View File
@@ -25,7 +25,7 @@ import {
Waveform,
} from "@phosphor-icons/react";
import { IMAGE_SEGMENT_SECONDS, MAX_IMAGE_THUMBNAILS } from "../config/editor.js";
import { IMAGE_SEGMENT_SECONDS, MAX_IMAGE_THUMBNAILS, TRANSITIONS } from "../config/editor.js";
import { formatClock, formatTime, getSegmentStartTime, packTimedSegmentsIntoLanes } from "../lib/timeline.js";
import { sliceSourceAudioPeaks } from "../lib/sourceAudioSync.js";
import {
@@ -163,6 +163,7 @@ export function Timeline({
setSelectedStickerSegmentId,
imageSrc,
displayedVisualSegments,
setVisualSegments,
renderedVisualTimeline,
visualType,
currentVisualSegment,
@@ -205,6 +206,13 @@ export function Timeline({
musicDuration,
startMusicMove,
}) {
const [transitionEditor, setTransitionEditor] = useState(null);
const updateJunctionTransition = (index, patch) => {
setVisualSegments((items) => items.map((item, itemIndex) => itemIndex === index
? { ...item, transition: { id: item.transition?.id || "none", duration: item.transition?.duration || 0.5, ...patch } }
: item));
};
const draggingVisualSegment =
activeTimelineClipDrag?.track === "image"
? displayedVisualSegments.find((segment) => segment.id === activeTimelineClipDrag.segmentId)
@@ -937,6 +945,26 @@ export function Timeline({
);
})
: null}
{displayedVisualSegments.slice(0, -1).map((segment, index) => {
const range = renderedVisualTimeline[index];
const transition = segment.transition || { id: "none", duration: 0.5 };
return (
<button
className={`visual-junction ${transition.id !== "none" ? "has-transition" : ""}`}
key={`junction-${segment.id}`}
type="button"
aria-label={`转场:${TRANSITIONS.find((item) => item.id === transition.id)?.name || "无转场"}`}
title="设置转场"
style={{ left: `${((range?.end || 0) / Math.max(0.01, timelineDuration)) * 100}%` }}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
const rect = event.currentTarget.getBoundingClientRect();
setTransitionEditor({ index, x: rect.left + rect.width / 2, y: rect.top });
}}
><span></span></button>
);
})}
{renderAssetDropSlot("image")}
</div>
{showStickerTrack ? stickerLanes.map((lane, laneIndex) => renderStickerTrack(lane, laneIndex)) : null}
@@ -1189,6 +1217,27 @@ export function Timeline({
)}
</div>
) : null}
{transitionEditor ? (() => {
const segment = displayedVisualSegments[transitionEditor.index];
const transition = segment?.transition || { id: "none", duration: 0.5 };
const maxDuration = Math.max(0.1, Math.min(2, (segment?.duration || 0.5) / 2, (displayedVisualSegments[transitionEditor.index + 1]?.duration || 0.5) / 2));
return (
<div className="transition-popover" role="dialog" aria-label="转场设置" style={{ left: transitionEditor.x, top: transitionEditor.y }} onPointerDown={(event) => event.stopPropagation()}>
<div className="transition-popover-head"><strong>转场</strong><button type="button" onClick={() => setTransitionEditor(null)} aria-label="关闭">×</button></div>
<div className="transition-presets">
{TRANSITIONS.map((option) => (
<button type="button" className={transition.id === option.id ? "is-selected" : ""} key={option.id} onClick={() => updateJunctionTransition(transitionEditor.index, { id: option.id })}>
<i className={`transition-preview preview-${option.id}`} /><span>{option.name}</span>
</button>
))}
</div>
<label className="transition-duration-control">
<span><b>时长</b><em>{Math.min(maxDuration, transition.duration || 0.5).toFixed(1)}s</em></span>
<input type="range" min="0.1" max={maxDuration} step="0.1" value={Math.min(maxDuration, transition.duration || 0.5)} disabled={transition.id === "none"} onChange={(event) => updateJunctionTransition(transitionEditor.index, { duration: Number(event.target.value) })} />
</label>
</div>
);
})() : null}
</section>
);
}
-18
View File
@@ -28,7 +28,6 @@ import {
STICKERS,
STICKER_CATEGORIES,
STICKER_PAGE_SIZE,
TRANSITIONS,
VOICES,
} from "../config/editor.js";
import { APP_LANGUAGES } from "../i18n.js";
@@ -509,23 +508,6 @@ export function ToolPanel(props) {
);
}
if (activeTool === "transition") {
return (
<VisualChoicePanel
title={t("transition")}
kind="transition"
options={TRANSITIONS}
selectedId={selectedTransitionId}
trOption={trOption}
sourceAudioLinked={sourceAudioLinked}
onSelect={(id) => {
setSelectedTransitionId(id);
notify(t("transitionApplied"));
}}
/>
);
}
if (activeTool === "effects") {
return (
<VisualEffectsPanel
-2
View File
@@ -1,5 +1,4 @@
import {
CirclesThree,
ClosedCaptioning,
ImageSquare,
MusicNote,
@@ -162,7 +161,6 @@ export const TOOL_RAIL = [
{ id: "caption", label: "字幕", icon: ClosedCaptioning },
{ id: "smart", label: "智能", icon: Scan },
{ id: "audio", label: "音频", icon: MusicNote },
{ id: "transition", label: "转场", icon: CirclesThree },
{ id: "stickers", label: "贴纸", icon: Sticker },
];
+10 -1
View File
@@ -177,6 +177,15 @@ export function useTimelineModel(d) {
const previewVisualSourceTime = previewVisualType === "video"
? getVisualSourceTime(previewVisualSegment, previewVisualLocalTime)
: previewVisualLocalTime;
const previewTransition = (() => {
const junction = previewVisualSegment?.transition;
const next = d.visualSegments[previewVisualSegmentIndex + 1];
if (!next || !junction?.id || junction.id === "none" || !previewVisualRange) return null;
const duration = Math.max(0.1, Math.min(Number(junction.duration) || 0.5, previewVisualSegment.duration || 0.5, next.duration || 0.5));
const start = previewVisualRange.end - duration;
if (d.currentTime < start || d.currentTime >= previewVisualRange.end) return null;
return { id: junction.id, duration, progress: (d.currentTime - start) / duration, next };
})();
const previewVisionKey = getVisionKey(previewVisualSegment ?? (previewVisualSrc ? {
id: "visual-fallback",
src: previewVisualSrc,
@@ -207,7 +216,7 @@ export function useTimelineModel(d) {
currentStickerSegmentIndex, currentVisualRange, currentVisualSegment,
currentVisualSegmentIndex, estimatedDuration, focusedSegmentIndex, getStickerDragAsset,
peaks, previewSticker, previewStickers, previewVisionBaseAnalysis, previewVisionKey,
previewVisionRecord, previewVisualLocalTime, previewVisualRange, previewVisualSegment,
previewTransition, previewVisionRecord, previewVisualLocalTime, previewVisualRange, previewVisualSegment,
previewVisualSegmentIndex, previewVisualSourceTime, previewVisualSrc, previewVisualType,
ratio, segments, selectedAudioSegment, selectedCaptionSegment, selectedFilter,
selectedSegmentIndex, selectedSticker, selectedStickerSegmentIndex,
+1 -1
View File
@@ -39,7 +39,7 @@ export function useVideoExport(d) {
: { width: (360 * d.ratio.width) / d.ratio.height, height: 360 },
sticker: d.stickerSegments.length ? null : d.selectedSticker,
stickerSegments: d.trackVisibility.sticker ? d.stickerSegments : [],
transitionId: d.selectedTransitionId, exportSettings: d.exportSettings, onProgress: progress,
transitionId: "none", exportSettings: d.exportSettings, onProgress: progress,
});
const name = `ai-voiceover-${d.ratio.id.replace(":", "x")}`;
if (d.exportSettings.codec !== "h264") {
+44 -16
View File
@@ -539,7 +539,6 @@ function drawFittedVisual(context, visual, canvas, fitMode, filter, vision = nul
function drawPreviewFrame(context, visual, canvas, options) {
const {
subtitle,
progress = 0,
fitMode = "contain",
filter = "none",
captionsEnabled = true,
@@ -553,6 +552,8 @@ function drawPreviewFrame(context, visual, canvas, options) {
stickers = [],
stickerImages = [],
transitionId = "none",
transitionNext = null,
transitionProgress = 0,
vision = null,
visualEffects = null,
visualTime = 0,
@@ -616,6 +617,33 @@ function drawPreviewFrame(context, visual, canvas, options) {
context.restore();
}
if (transitionNext?.visual && transitionId !== "none" && transitionProgress > 0) {
const amount = Math.max(0, Math.min(1, transitionProgress));
context.save();
if (transitionId === "wipe-left") context.rect(width * (1 - amount), 0, width * amount, height);
else if (transitionId === "wipe-up") context.rect(0, height * (1 - amount), width, height * amount);
else if (transitionId === "split") {
context.rect(width * (0.5 - amount / 2), 0, width * amount, height);
}
if (["wipe-left", "wipe-up", "split"].includes(transitionId)) context.clip();
context.globalAlpha = transitionId === "flash" ? Math.max(0, (amount - 0.35) / 0.65) : amount;
if (transitionId === "zoom") {
const scale = 1.12 - amount * 0.12;
context.translate(width / 2, height / 2); context.scale(scale, scale); context.translate(-width / 2, -height / 2);
}
const nextFilter = transitionId === "blur" ? `blur(${Math.max(0, (1 - amount) * 14)}px)` : transitionNext.filter || filter;
drawFittedVisual(context, transitionNext.visual, canvas, fitMode, nextFilter, transitionNext.vision || null);
context.restore();
if (transitionId === "flash") {
context.fillStyle = `rgba(255,255,255,${Math.max(0, 1 - Math.abs(amount - 0.5) * 2) * 0.7})`;
context.fillRect(0, 0, width, height);
}
if (transitionId === "glitch") {
context.fillStyle = `rgba(53,234,217,${Math.sin(amount * Math.PI * 8) * 0.12})`;
context.fillRect(0, 0, width, height);
}
}
if (captionsEnabled && subtitle) {
const captionLayout = getCaptionTextLayout({
context,
@@ -679,20 +707,6 @@ function drawPreviewFrame(context, visual, canvas, options) {
}
});
if (transitionId === "fade") {
const fadeAlpha =
progress < 0.12 ? 1 - progress / 0.12 : progress > 0.88 ? (progress - 0.88) / 0.12 : 0;
if (fadeAlpha > 0) {
context.fillStyle = `rgba(0, 0, 0, ${Math.min(0.72, fadeAlpha * 0.72)})`;
context.fillRect(0, 0, width, height);
}
}
if (transitionId === "flash" && progress < 0.08) {
context.fillStyle = `rgba(255, 255, 255, ${0.45 * (1 - progress / 0.08)})`;
context.fillRect(0, 0, width, height);
}
}
export function getSupportedRecordingFormat() {
@@ -1051,6 +1065,18 @@ export async function exportBrowserVideo({
const visualSourceTime = syncVideoItem(visualItem, localTime);
const exportStickers = getStickersAtTime(elapsed);
const exportVisual = visualItem.cutoutVisual || visualItem.visual;
const visualIndex = visualItems.indexOf(visualItem);
const junction = visualItem.segment.transition;
const transitionDuration = junction?.id && junction.id !== "none"
? Math.max(0.1, Math.min(Number(junction.duration) || 0.5, (visualRange?.end || 0) - (visualRange?.start || 0)))
: 0;
const transitionStart = (visualRange?.end || 0) - transitionDuration;
const nextVisualItem = transitionDuration > 0 && elapsed >= transitionStart ? visualItems[visualIndex + 1] : null;
const transitionProgress = nextVisualItem ? (elapsed - transitionStart) / transitionDuration : 0;
if (nextVisualItem?.segment.type === "video") {
const nextTime = Math.max(0, Number(nextVisualItem.segment.sourceStart) || 0) + transitionProgress * transitionDuration;
if (!nextVisualItem.visual.seeking && Math.abs(nextVisualItem.visual.currentTime - nextTime) > 0.05) nextVisualItem.visual.currentTime = nextTime;
}
const resolvedVision = resolveVisionAnalysisAtTime(
visualItem.segment.vision ?? null,
visualSourceTime,
@@ -1077,7 +1103,9 @@ export async function exportBrowserVideo({
captionReferenceSize,
stickers: exportStickers,
stickerImages: exportStickers.map((item) => item?.src ? stickerImageMap.get(item.src) : null),
transitionId,
transitionId: nextVisualItem ? junction.id : "none",
transitionNext: nextVisualItem ? { visual: nextVisualItem.cutoutVisual || nextVisualItem.visual } : null,
transitionProgress,
vision: frameVision,
visualEffects: visualItem.segment,
visualTime: localTime,
+84 -5
View File
@@ -2508,8 +2508,8 @@ button:disabled {
}
.visual-media-layer > .remaster-preview-frame { z-index: 2; }
.preview-frame img,
.preview-frame video {
.visual-media-layer > img,
.visual-media-layer > video {
display: block;
width: 100%;
height: 100%;
@@ -2518,8 +2518,8 @@ button:disabled {
background: #080a0e;
}
.preview-canvas.fit-cover .preview-frame img,
.preview-canvas.fit-cover .preview-frame video {
.preview-canvas.fit-cover .visual-media-layer > img,
.preview-canvas.fit-cover .visual-media-layer > video {
object-fit: cover;
}
@@ -2701,7 +2701,8 @@ button:disabled {
.sticker-scale-handle { position: absolute; z-index: 2; width: 14px; height: 14px; border: 2px solid #081315; border-radius: 50%; padding: 0; background: #35ead9; box-shadow: 0 0 0 1px rgba(255,255,255,.72); }
.sticker-rotate-handle { top: -23px; left: 50%; cursor: grab; transform: translateX(-50%); }
.sticker-rotate-handle::after { position: absolute; top: 11px; left: 5px; width: 2px; height: 10px; background: #35ead9; content: ""; }
.sticker-scale-handle { right: -8px; bottom: -8px; cursor: nwse-resize; }
.sticker-scale-handle { right: -11px; bottom: -11px; display: grid; place-items: center; width: 22px; height: 22px; border-radius: 6px; color: #071311; cursor: nwse-resize; }
.sticker-scale-handle svg { pointer-events: none; }
.sticker-properties-panel { display: grid; gap: 14px; }
.sticker-properties-preview { display: grid; place-items: center; min-height: 92px; border: 1px solid rgba(255,255,255,.07); border-radius: 9px; background: rgba(255,255,255,.025); }
@@ -3734,11 +3735,89 @@ button:disabled {
}
.image-track {
position: relative;
gap: 2px;
overflow: hidden;
padding: 4px 0;
}
.visual-junction {
position: absolute;
top: 15px;
z-index: 8;
display: grid;
place-items: center;
width: 22px;
height: 22px;
margin: 0;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.42);
border-radius: 6px;
color: #e8f1f5;
background: #202933;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.42);
transform: translate(-50%, -50%);
cursor: pointer;
}
.visual-junction:hover,
.visual-junction.has-transition {
border-color: #35ead9;
color: #061513;
background: #35ead9;
}
.visual-junction span { font-size: 15px; line-height: 1; transform: rotate(45deg); }
.transition-popover {
position: fixed;
z-index: 80;
width: 320px;
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.13);
border-radius: 12px;
color: #edf7f8;
background: rgba(19, 25, 32, 0.98);
box-shadow: 0 18px 55px rgba(0, 0, 0, 0.55);
transform: translate(-50%, calc(-100% - 12px));
}
.transition-popover-head,
.transition-duration-control > span { display: flex; align-items: center; justify-content: space-between; }
.transition-popover-head { margin-bottom: 12px; }
.transition-popover-head strong { font-size: 14px; }
.transition-popover-head button { width: 26px; height: 26px; border: 0; border-radius: 6px; color: #9baab5; background: transparent; cursor: pointer; }
.transition-popover-head button:hover { color: #fff; background: rgba(255,255,255,.08); }
.transition-presets { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; }
.transition-presets > button { min-width: 0; padding: 8px 5px; border: 1px solid rgba(255,255,255,.08); border-radius: 8px; color: #aebbc4; background: rgba(255,255,255,.035); cursor: pointer; }
.transition-presets > button:hover,
.transition-presets > button.is-selected { border-color: rgba(53,234,217,.72); color: #eafffd; background: rgba(53,234,217,.09); }
.transition-presets span { display: block; margin-top: 5px; overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.transition-preview { display: block; width: 100%; height: 24px; border-radius: 4px; background: linear-gradient(90deg,#527eb0 0 48%,#c17c65 52% 100%); }
.transition-preview.preview-fade { background: linear-gradient(90deg,#527eb0,#20252b,#c17c65); }
.transition-preview.preview-flash { background: linear-gradient(90deg,#527eb0,#fff,#c17c65); }
.transition-preview.preview-wipe-left,
.transition-preview.preview-wipe-up { background: linear-gradient(120deg,#527eb0 0 45%,#c17c65 47% 100%); }
.transition-preview.preview-blur { filter: blur(1px); }
.transition-duration-control { display: block; margin-top: 13px; }
.transition-duration-control span { color: #b8c4cc; font-size: 12px; }
.transition-duration-control em { color: #35ead9; font-style: normal; font-variant-numeric: tabular-nums; }
.transition-duration-control input { width: 100%; margin-top: 8px; accent-color: #35ead9; }
.preview-transition-layer { position: absolute; inset: 0; z-index: 2; overflow: hidden; opacity: var(--transition-progress); pointer-events: none; }
.preview-transition-layer img,
.preview-transition-layer video { width: 100%; height: 100%; display: block; }
.preview-transition-layer.type-wipe-left { clip-path: inset(0 0 0 calc((1 - var(--transition-progress)) * 100%)); opacity: 1; }
.preview-transition-layer.type-wipe-up { clip-path: inset(calc((1 - var(--transition-progress)) * 100%) 0 0); opacity: 1; }
.preview-transition-layer.type-split { clip-path: inset(0 calc((1 - var(--transition-progress)) * 50%)); opacity: 1; }
.preview-transition-layer.type-zoom img,
.preview-transition-layer.type-zoom video { transform: scale(calc(1.12 - var(--transition-progress) * .12)); }
.preview-transition-layer.type-blur img,
.preview-transition-layer.type-blur video { filter: blur(calc((1 - var(--transition-progress)) * 12px)); }
.preview-transition-layer.type-flash i { position: absolute; inset: 0; opacity: .7; background: #fff; }
.image-track.is-reordering,
.caption-track.is-reordering {
cursor: grabbing;